This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor(ship): extract shared cap publisher (vit/cap.js)

- move org.v-it.cap record assembly + putRecord out of shipCap into new pure src/lib/cap.js (publishCap)
- expose it publicly via package.json exports as `vit/cap.js`; supports optional reply strong refs, app.bsky.embed.external, and caller-supplied rkey/swapCid for idempotent refreshes
- shipCap now builds+writes through publishCap; CLI contract (flags, help, output, caps.jsonl, verbose dump, error guidance) unchanged
- add unit tests (fake agent, exact-write + failure paths), an offline tarball resolve test, and a traversal invariant test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+358 -23
+3
package.json
··· 6 6 "bin": { 7 7 "vit": "bin/vit.js" 8 8 }, 9 + "exports": { 10 + "./cap.js": "./src/lib/cap.js" 11 + }, 9 12 "files": [ 10 13 "bin/", 11 14 "src/",
+18 -23
src/cmd/ship.js
··· 17 17 import { toBeacon } from '../lib/beacon.js'; 18 18 import { hashTo3Words } from '../lib/cap-ref.js'; 19 19 import { formatError } from '../lib/error-format.js'; 20 + import { publishCap } from '../lib/cap.js'; 20 21 21 22 const STOP_WORDS = new Set([ 22 23 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', ··· 345 346 } 346 347 } 347 348 348 - async function shipCap(opts) { 349 + export async function shipCap(opts) { 349 350 const gate = requireAgent(); 350 351 if (!gate.ok) { 351 352 if (opts.json) { ··· 548 549 } 549 550 } 550 551 551 - const record = { 552 - $type: CAP_COLLECTION, 553 - text: text || '', 552 + const rkey = TID.nextStr(); 553 + if (verbose) vlog(`[verbose] Record built, rkey: ${rkey}`); 554 + if (verbose) vlog(`[verbose] putRecord ${CAP_COLLECTION} rkey=${rkey}`); 555 + const { uri, cid, record, response } = await publishCap(agent, { 556 + repo: did, 557 + text, 554 558 title: opts.title, 555 559 description: opts.description, 556 560 ref, 557 561 createdAt: now, 558 - }; 559 - if (beacon) record.beacon = beacon; 560 - if (opts.kind) record.kind = opts.kind; 561 - if (opts.recap) record.recap = { uri: recapUri, ref: opts.recap }; 562 - const rkey = TID.nextStr(); 563 - if (verbose) vlog(`[verbose] Record built, rkey: ${rkey}`); 564 - const putArgs = { 565 - repo: did, 566 - collection: CAP_COLLECTION, 562 + beacon, 563 + kind: opts.kind, 564 + recap: opts.recap ? { uri: recapUri, ref: opts.recap } : undefined, 567 565 rkey, 568 - record, 569 - validate: false, 570 - }; 571 - if (verbose) vlog(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 572 - const putRes = await agent.com.atproto.repo.putRecord(putArgs); 566 + }); 573 567 try { 574 568 appendLog('caps.jsonl', { 575 569 ts: now, ··· 578 572 ref, 579 573 collection: CAP_COLLECTION, 580 574 pds: session.serverMetadata?.issuer, 581 - uri: putRes.data.uri, 582 - cid: putRes.data.cid, 575 + uri, 576 + cid, 583 577 }); 584 578 } catch (logErr) { 585 579 console.error('warning: failed to write caps.jsonl:', logErr.message); 586 580 } 587 581 if (verbose) vlog(`[verbose] Log written to caps.jsonl`); 588 582 if (opts.json) { 589 - const out = { ref, uri: putRes.data.uri }; 583 + const out = { ref, uri }; 590 584 if (opts.kind) out.kind = opts.kind; 591 585 jsonOk(out); 592 586 return; ··· 597 591 console.log(`anyone can implement this. share the ref to build demand.`); 598 592 } else { 599 593 console.log(`shipped: ${ref}`); 600 - console.log(`uri: ${putRes.data.uri}`); 594 + console.log(`uri: ${uri}`); 601 595 } 602 596 if (verbose) { 597 + const putArgs = { repo: did, collection: CAP_COLLECTION, rkey, record, validate: false }; 603 598 vlog( 604 599 JSON.stringify({ 605 600 ts: now, 606 601 pds: session.serverMetadata?.issuer, 607 602 xrpc: 'com.atproto.repo.putRecord', 608 603 request: putArgs, 609 - response: putRes.data, 604 + response, 610 605 }), 611 606 ); 612 607 }
+73
src/lib/cap.js
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { TID } from '@atproto/common-web'; 5 + import { CAP_COLLECTION } from './constants.js'; 6 + import { resolveRef } from './cap-ref.js'; 7 + 8 + export async function publishCap(agent, input) { 9 + if (input.repo == null || input.repo !== agent.did) { 10 + throw new Error('write target must match authenticated agent'); 11 + } 12 + if (input.swapCid != null && input.rkey == null) { 13 + throw new Error('swap CID requires an rkey'); 14 + } 15 + if (input.reply) { 16 + const { root, parent } = input.reply; 17 + if ( 18 + !root 19 + || !parent 20 + || typeof root.uri !== 'string' 21 + || typeof root.cid !== 'string' 22 + || typeof parent.uri !== 'string' 23 + || typeof parent.cid !== 'string' 24 + ) { 25 + throw new Error('reply must include valid root and parent references'); 26 + } 27 + } 28 + if (input.embed) { 29 + const external = input.embed.external; 30 + if ( 31 + !external 32 + || typeof external.uri !== 'string' 33 + || typeof external.title !== 'string' 34 + || typeof external.description !== 'string' 35 + ) { 36 + throw new Error('embed must include a valid external value'); 37 + } 38 + } 39 + 40 + const record = { 41 + $type: CAP_COLLECTION, 42 + text: input.text || '', 43 + title: input.title, 44 + description: input.description, 45 + ref: input.ref, 46 + createdAt: input.createdAt, 47 + }; 48 + if (input.beacon) record.beacon = input.beacon; 49 + if (input.kind) record.kind = input.kind; 50 + if (input.recap) record.recap = input.recap; 51 + if (input.reply) record.reply = input.reply; 52 + if (input.embed) record.embed = input.embed; 53 + 54 + const rkey = input.rkey ?? TID.nextStr(); 55 + const putArgs = { 56 + repo: input.repo, 57 + collection: CAP_COLLECTION, 58 + rkey, 59 + record, 60 + validate: false, 61 + }; 62 + if (input.swapCid != null) putArgs.swapRecord = input.swapCid; 63 + 64 + const putRes = await agent.com.atproto.repo.putRecord(putArgs); 65 + return { 66 + uri: putRes.data.uri, 67 + cid: putRes.data.cid, 68 + ref: resolveRef(record, putRes.data.cid), 69 + rkey, 70 + record, 71 + response: putRes.data, 72 + }; 73 + }
+199
test/cap.test.js
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { describe, test, expect } from 'bun:test'; 5 + import { publishCap } from '../src/lib/cap.js'; 6 + 7 + function makeAgent(did, putImpl) { 8 + const calls = []; 9 + const agent = { 10 + did, 11 + com: { 12 + atproto: { 13 + repo: { 14 + putRecord: async (args) => { 15 + calls.push(args); 16 + return putImpl 17 + ? putImpl(args) 18 + : { data: { uri: `at://${did}/org.v-it.cap/${args.rkey}`, cid: 'bafyTESTCID' } }; 19 + }, 20 + }, 21 + }, 22 + }, 23 + }; 24 + agent.calls = calls; 25 + return agent; 26 + } 27 + 28 + function makeInput(overrides = {}) { 29 + return { 30 + repo: 'did:plc:test', 31 + text: 'Cap body', 32 + title: 'Test cap', 33 + description: 'A test capability', 34 + ref: 'one-two-three', 35 + createdAt: '2026-07-11T12:00:00.000Z', 36 + ...overrides, 37 + }; 38 + } 39 + 40 + describe('publishCap', () => { 41 + test('creates an exact cap record', async () => { 42 + const agent = makeAgent('did:plc:test'); 43 + const input = makeInput({ 44 + beacon: 'vit:example.com/o/r', 45 + kind: 'feat', 46 + rkey: '3mtestcreate', 47 + }); 48 + 49 + const res = await publishCap(agent, input); 50 + 51 + expect(agent.calls).toHaveLength(1); 52 + expect(agent.calls[0]).toEqual({ 53 + repo: 'did:plc:test', 54 + collection: 'org.v-it.cap', 55 + rkey: '3mtestcreate', 56 + record: { 57 + $type: 'org.v-it.cap', 58 + text: 'Cap body', 59 + title: 'Test cap', 60 + description: 'A test capability', 61 + ref: 'one-two-three', 62 + createdAt: '2026-07-11T12:00:00.000Z', 63 + beacon: 'vit:example.com/o/r', 64 + kind: 'feat', 65 + }, 66 + validate: false, 67 + }); 68 + expect(agent.calls[0].swapRecord).toBeUndefined(); 69 + expect(res).toMatchObject({ 70 + uri: 'at://did:plc:test/org.v-it.cap/3mtestcreate', 71 + cid: 'bafyTESTCID', 72 + ref: 'one-two-three', 73 + rkey: '3mtestcreate', 74 + }); 75 + }); 76 + 77 + test('generates an rkey when omitted', async () => { 78 + const agent = makeAgent('did:plc:test'); 79 + 80 + const res = await publishCap(agent, makeInput()); 81 + 82 + expect(res.rkey).toBeString(); 83 + expect(res.rkey.length).toBeGreaterThan(0); 84 + expect(agent.calls[0].rkey).toBe(res.rkey); 85 + }); 86 + 87 + test('refreshes a record with compare-and-swap', async () => { 88 + const agent = makeAgent('did:plc:test'); 89 + const createdAt = '2026-07-11T13:00:00.000Z'; 90 + 91 + await publishCap(agent, makeInput({ 92 + createdAt, 93 + rkey: '3mtestrefresh', 94 + swapCid: 'bafyOLDCID', 95 + })); 96 + 97 + expect(agent.calls[0].swapRecord).toBe('bafyOLDCID'); 98 + expect(agent.calls[0].rkey).toBe('3mtestrefresh'); 99 + expect(agent.calls[0].record.createdAt).toBe(createdAt); 100 + }); 101 + 102 + test('includes a valid reply', async () => { 103 + const agent = makeAgent('did:plc:test'); 104 + const reply = { 105 + root: { uri: 'at://did:plc:root/org.v-it.cap/root', cid: 'bafyROOT' }, 106 + parent: { uri: 'at://did:plc:parent/org.v-it.cap/parent', cid: 'bafyPARENT' }, 107 + }; 108 + 109 + await publishCap(agent, makeInput({ reply })); 110 + 111 + expect(agent.calls[0].record.reply).toEqual(reply); 112 + }); 113 + 114 + test('includes a valid external embed', async () => { 115 + const agent = makeAgent('did:plc:test'); 116 + const embed = { 117 + $type: 'app.bsky.embed.external', 118 + external: { 119 + uri: 'https://example.com/cap', 120 + title: 'Example cap', 121 + description: 'External cap context', 122 + }, 123 + }; 124 + 125 + await publishCap(agent, makeInput({ embed })); 126 + 127 + expect(agent.calls[0].record.embed).toEqual(embed); 128 + }); 129 + 130 + test('rejects a mismatched repo before writing', async () => { 131 + const agent = makeAgent('did:plc:other'); 132 + 133 + await expect(publishCap(agent, makeInput())).rejects.toThrow( 134 + 'write target must match authenticated agent', 135 + ); 136 + expect(agent.calls).toHaveLength(0); 137 + }); 138 + 139 + test('rejects a swap CID without an rkey before writing', async () => { 140 + const agent = makeAgent('did:plc:test'); 141 + 142 + await expect(publishCap(agent, makeInput({ swapCid: 'bafyOLDCID' }))).rejects.toThrow( 143 + 'swap CID requires an rkey', 144 + ); 145 + expect(agent.calls).toHaveLength(0); 146 + }); 147 + 148 + test('rejects malformed replies before writing', async () => { 149 + const replies = [ 150 + { root: { uri: 'at://did:plc:root/org.v-it.cap/root', cid: 'bafyROOT' } }, 151 + { 152 + root: { uri: 'at://did:plc:root/org.v-it.cap/root' }, 153 + parent: { uri: 'at://did:plc:parent/org.v-it.cap/parent', cid: 'bafyPARENT' }, 154 + }, 155 + ]; 156 + 157 + for (const reply of replies) { 158 + const agent = makeAgent('did:plc:test'); 159 + await expect(publishCap(agent, makeInput({ reply }))).rejects.toThrow( 160 + 'reply must include valid root and parent references', 161 + ); 162 + expect(agent.calls).toHaveLength(0); 163 + } 164 + }); 165 + 166 + test('rejects a malformed embed before writing', async () => { 167 + const agent = makeAgent('did:plc:test'); 168 + const embed = { 169 + $type: 'app.bsky.embed.external', 170 + external: { uri: 'https://example.com/cap', description: 'Missing title' }, 171 + }; 172 + 173 + await expect(publishCap(agent, makeInput({ embed }))).rejects.toThrow( 174 + 'embed must include a valid external value', 175 + ); 176 + expect(agent.calls).toHaveLength(0); 177 + }); 178 + 179 + test('propagates a stale-CID conflict after attempting the write', async () => { 180 + const agent = makeAgent('did:plc:test', () => { 181 + throw new Error('InvalidSwap'); 182 + }); 183 + 184 + await expect(publishCap(agent, makeInput({ 185 + rkey: '3mtestrefresh', 186 + swapCid: 'bafySTALECID', 187 + }))).rejects.toThrow('InvalidSwap'); 188 + expect(agent.calls).toHaveLength(1); 189 + }); 190 + 191 + test('propagates an agent rejection after attempting the write', async () => { 192 + const agent = makeAgent('did:plc:test', () => { 193 + throw new Error('boom'); 194 + }); 195 + 196 + await expect(publishCap(agent, makeInput())).rejects.toThrow('boom'); 197 + expect(agent.calls).toHaveLength(1); 198 + }); 199 + });
+46
test/pack.test.js
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { afterEach, test, expect } from 'bun:test'; 5 + import { execFileSync } from 'node:child_process'; 6 + import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; 7 + import { tmpdir } from 'node:os'; 8 + import { join } from 'node:path'; 9 + 10 + const repoRoot = join(import.meta.dir, '..'); 11 + let workDir; 12 + 13 + afterEach(() => { 14 + if (workDir) rmSync(workDir, { recursive: true, force: true }); 15 + workDir = undefined; 16 + }); 17 + 18 + test('vit/cap.js resolves from the packed package', () => { 19 + workDir = mkdtempSync(join(tmpdir(), 'vit-pack-')); 20 + const out = execFileSync( 21 + 'npm', 22 + ['pack', '--ignore-scripts', '--json', '--pack-destination', workDir], 23 + { cwd: repoRoot, encoding: 'utf-8' }, 24 + ); 25 + const tarball = JSON.parse(out)[0].filename; 26 + 27 + const consumerDir = join(workDir, 'consumer'); 28 + const vitDir = join(consumerDir, 'node_modules', 'vit'); 29 + mkdirSync(vitDir, { recursive: true }); 30 + execFileSync('tar', [ 31 + '-xzf', 32 + join(workDir, tarball), 33 + '-C', 34 + vitDir, 35 + '--strip-components=1', 36 + ]); 37 + symlinkSync(join(repoRoot, 'node_modules'), join(vitDir, 'node_modules')); 38 + 39 + const script = "const url = import.meta.resolve('vit/cap.js'); if (!url.includes('/consumer/node_modules/vit/')) { console.error('WRONG '+url); process.exit(2); } const m = await import('vit/cap.js'); if (typeof m.publishCap !== 'function') { console.error('NOEXPORT'); process.exit(3); } console.log('OK '+url);"; 40 + const res = execFileSync('node', ['--input-type=module', '-e', script], { 41 + cwd: consumerDir, 42 + encoding: 'utf-8', 43 + }); 44 + 45 + expect(res).toContain('OK'); 46 + }, 30000);
+19
test/ship-cap.test.js
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { test, expect } from 'bun:test'; 5 + import { readFileSync } from 'node:fs'; 6 + import { join } from 'node:path'; 7 + 8 + test('shipCap traverses the shared cap publisher', () => { 9 + const source = readFileSync(join(import.meta.dir, '..', 'src', 'cmd', 'ship.js'), 'utf-8'); 10 + const shipCapStart = source.indexOf('export async function shipCap'); 11 + const shipCapEnd = source.indexOf('export default function register'); 12 + const shipCapBody = source.slice(shipCapStart, shipCapEnd); 13 + 14 + expect(source).toContain("import { publishCap } from '../lib/cap.js';"); 15 + expect(shipCapStart).toBeGreaterThan(-1); 16 + expect(shipCapEnd).toBeGreaterThan(shipCapStart); 17 + expect(shipCapBody).toContain('publishCap('); 18 + expect(source.match(/putRecord\(/g) || []).toHaveLength(1); 19 + });