From 175ec554b893f879614c8e60e196e14c16e47fa0 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 11 Jul 2026 02:43:31 -0600 Subject: [PATCH] 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) --- package.json | 3 + src/cmd/ship.js | 41 ++++----- src/lib/cap.js | 73 ++++++++++++++++ test/cap.test.js | 199 ++++++++++++++++++++++++++++++++++++++++++ test/pack.test.js | 46 ++++++++++ test/ship-cap.test.js | 19 ++++ 6 files changed, 358 insertions(+), 23 deletions(-) create mode 100644 src/lib/cap.js create mode 100644 test/cap.test.js create mode 100644 test/pack.test.js create mode 100644 test/ship-cap.test.js diff --git a/package.json b/package.json index 590db96..740b0bd 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "bin": { "vit": "bin/vit.js" }, + "exports": { + "./cap.js": "./src/lib/cap.js" + }, "files": [ "bin/", "src/", diff --git a/src/cmd/ship.js b/src/cmd/ship.js index 1941b6b..52ee861 100644 --- a/src/cmd/ship.js +++ b/src/cmd/ship.js @@ -17,6 +17,7 @@ import { jsonOk, jsonError } from '../lib/json-output.js'; import { toBeacon } from '../lib/beacon.js'; import { hashTo3Words } from '../lib/cap-ref.js'; import { formatError } from '../lib/error-format.js'; +import { publishCap } from '../lib/cap.js'; const STOP_WORDS = new Set([ 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', @@ -345,7 +346,7 @@ async function shipSkill(opts) { } } -async function shipCap(opts) { +export async function shipCap(opts) { const gate = requireAgent(); if (!gate.ok) { if (opts.json) { @@ -548,28 +549,21 @@ async function shipCap(opts) { } } - const record = { - $type: CAP_COLLECTION, - text: text || '', + const rkey = TID.nextStr(); + if (verbose) vlog(`[verbose] Record built, rkey: ${rkey}`); + if (verbose) vlog(`[verbose] putRecord ${CAP_COLLECTION} rkey=${rkey}`); + const { uri, cid, record, response } = await publishCap(agent, { + repo: did, + text, title: opts.title, description: opts.description, ref, createdAt: now, - }; - if (beacon) record.beacon = beacon; - if (opts.kind) record.kind = opts.kind; - if (opts.recap) record.recap = { uri: recapUri, ref: opts.recap }; - const rkey = TID.nextStr(); - if (verbose) vlog(`[verbose] Record built, rkey: ${rkey}`); - const putArgs = { - repo: did, - collection: CAP_COLLECTION, + beacon, + kind: opts.kind, + recap: opts.recap ? { uri: recapUri, ref: opts.recap } : undefined, rkey, - record, - validate: false, - }; - if (verbose) vlog(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); - const putRes = await agent.com.atproto.repo.putRecord(putArgs); + }); try { appendLog('caps.jsonl', { ts: now, @@ -578,15 +572,15 @@ async function shipCap(opts) { ref, collection: CAP_COLLECTION, pds: session.serverMetadata?.issuer, - uri: putRes.data.uri, - cid: putRes.data.cid, + uri, + cid, }); } catch (logErr) { console.error('warning: failed to write caps.jsonl:', logErr.message); } if (verbose) vlog(`[verbose] Log written to caps.jsonl`); if (opts.json) { - const out = { ref, uri: putRes.data.uri }; + const out = { ref, uri }; if (opts.kind) out.kind = opts.kind; jsonOk(out); return; @@ -597,16 +591,17 @@ async function shipCap(opts) { console.log(`anyone can implement this. share the ref to build demand.`); } else { console.log(`shipped: ${ref}`); - console.log(`uri: ${putRes.data.uri}`); + console.log(`uri: ${uri}`); } if (verbose) { + const putArgs = { repo: did, collection: CAP_COLLECTION, rkey, record, validate: false }; vlog( JSON.stringify({ ts: now, pds: session.serverMetadata?.issuer, xrpc: 'com.atproto.repo.putRecord', request: putArgs, - response: putRes.data, + response, }), ); } diff --git a/src/lib/cap.js b/src/lib/cap.js new file mode 100644 index 0000000..5fbc4ef --- /dev/null +++ b/src/lib/cap.js @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { TID } from '@atproto/common-web'; +import { CAP_COLLECTION } from './constants.js'; +import { resolveRef } from './cap-ref.js'; + +export async function publishCap(agent, input) { + if (input.repo == null || input.repo !== agent.did) { + throw new Error('write target must match authenticated agent'); + } + if (input.swapCid != null && input.rkey == null) { + throw new Error('swap CID requires an rkey'); + } + if (input.reply) { + const { root, parent } = input.reply; + if ( + !root + || !parent + || typeof root.uri !== 'string' + || typeof root.cid !== 'string' + || typeof parent.uri !== 'string' + || typeof parent.cid !== 'string' + ) { + throw new Error('reply must include valid root and parent references'); + } + } + if (input.embed) { + const external = input.embed.external; + if ( + !external + || typeof external.uri !== 'string' + || typeof external.title !== 'string' + || typeof external.description !== 'string' + ) { + throw new Error('embed must include a valid external value'); + } + } + + const record = { + $type: CAP_COLLECTION, + text: input.text || '', + title: input.title, + description: input.description, + ref: input.ref, + createdAt: input.createdAt, + }; + if (input.beacon) record.beacon = input.beacon; + if (input.kind) record.kind = input.kind; + if (input.recap) record.recap = input.recap; + if (input.reply) record.reply = input.reply; + if (input.embed) record.embed = input.embed; + + const rkey = input.rkey ?? TID.nextStr(); + const putArgs = { + repo: input.repo, + collection: CAP_COLLECTION, + rkey, + record, + validate: false, + }; + if (input.swapCid != null) putArgs.swapRecord = input.swapCid; + + const putRes = await agent.com.atproto.repo.putRecord(putArgs); + return { + uri: putRes.data.uri, + cid: putRes.data.cid, + ref: resolveRef(record, putRes.data.cid), + rkey, + record, + response: putRes.data, + }; +} diff --git a/test/cap.test.js b/test/cap.test.js new file mode 100644 index 0000000..5eceb96 --- /dev/null +++ b/test/cap.test.js @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { describe, test, expect } from 'bun:test'; +import { publishCap } from '../src/lib/cap.js'; + +function makeAgent(did, putImpl) { + const calls = []; + const agent = { + did, + com: { + atproto: { + repo: { + putRecord: async (args) => { + calls.push(args); + return putImpl + ? putImpl(args) + : { data: { uri: `at://${did}/org.v-it.cap/${args.rkey}`, cid: 'bafyTESTCID' } }; + }, + }, + }, + }, + }; + agent.calls = calls; + return agent; +} + +function makeInput(overrides = {}) { + return { + repo: 'did:plc:test', + text: 'Cap body', + title: 'Test cap', + description: 'A test capability', + ref: 'one-two-three', + createdAt: '2026-07-11T12:00:00.000Z', + ...overrides, + }; +} + +describe('publishCap', () => { + test('creates an exact cap record', async () => { + const agent = makeAgent('did:plc:test'); + const input = makeInput({ + beacon: 'vit:example.com/o/r', + kind: 'feat', + rkey: '3mtestcreate', + }); + + const res = await publishCap(agent, input); + + expect(agent.calls).toHaveLength(1); + expect(agent.calls[0]).toEqual({ + repo: 'did:plc:test', + collection: 'org.v-it.cap', + rkey: '3mtestcreate', + record: { + $type: 'org.v-it.cap', + text: 'Cap body', + title: 'Test cap', + description: 'A test capability', + ref: 'one-two-three', + createdAt: '2026-07-11T12:00:00.000Z', + beacon: 'vit:example.com/o/r', + kind: 'feat', + }, + validate: false, + }); + expect(agent.calls[0].swapRecord).toBeUndefined(); + expect(res).toMatchObject({ + uri: 'at://did:plc:test/org.v-it.cap/3mtestcreate', + cid: 'bafyTESTCID', + ref: 'one-two-three', + rkey: '3mtestcreate', + }); + }); + + test('generates an rkey when omitted', async () => { + const agent = makeAgent('did:plc:test'); + + const res = await publishCap(agent, makeInput()); + + expect(res.rkey).toBeString(); + expect(res.rkey.length).toBeGreaterThan(0); + expect(agent.calls[0].rkey).toBe(res.rkey); + }); + + test('refreshes a record with compare-and-swap', async () => { + const agent = makeAgent('did:plc:test'); + const createdAt = '2026-07-11T13:00:00.000Z'; + + await publishCap(agent, makeInput({ + createdAt, + rkey: '3mtestrefresh', + swapCid: 'bafyOLDCID', + })); + + expect(agent.calls[0].swapRecord).toBe('bafyOLDCID'); + expect(agent.calls[0].rkey).toBe('3mtestrefresh'); + expect(agent.calls[0].record.createdAt).toBe(createdAt); + }); + + test('includes a valid reply', async () => { + const agent = makeAgent('did:plc:test'); + const reply = { + root: { uri: 'at://did:plc:root/org.v-it.cap/root', cid: 'bafyROOT' }, + parent: { uri: 'at://did:plc:parent/org.v-it.cap/parent', cid: 'bafyPARENT' }, + }; + + await publishCap(agent, makeInput({ reply })); + + expect(agent.calls[0].record.reply).toEqual(reply); + }); + + test('includes a valid external embed', async () => { + const agent = makeAgent('did:plc:test'); + const embed = { + $type: 'app.bsky.embed.external', + external: { + uri: 'https://example.com/cap', + title: 'Example cap', + description: 'External cap context', + }, + }; + + await publishCap(agent, makeInput({ embed })); + + expect(agent.calls[0].record.embed).toEqual(embed); + }); + + test('rejects a mismatched repo before writing', async () => { + const agent = makeAgent('did:plc:other'); + + await expect(publishCap(agent, makeInput())).rejects.toThrow( + 'write target must match authenticated agent', + ); + expect(agent.calls).toHaveLength(0); + }); + + test('rejects a swap CID without an rkey before writing', async () => { + const agent = makeAgent('did:plc:test'); + + await expect(publishCap(agent, makeInput({ swapCid: 'bafyOLDCID' }))).rejects.toThrow( + 'swap CID requires an rkey', + ); + expect(agent.calls).toHaveLength(0); + }); + + test('rejects malformed replies before writing', async () => { + const replies = [ + { root: { uri: 'at://did:plc:root/org.v-it.cap/root', cid: 'bafyROOT' } }, + { + root: { uri: 'at://did:plc:root/org.v-it.cap/root' }, + parent: { uri: 'at://did:plc:parent/org.v-it.cap/parent', cid: 'bafyPARENT' }, + }, + ]; + + for (const reply of replies) { + const agent = makeAgent('did:plc:test'); + await expect(publishCap(agent, makeInput({ reply }))).rejects.toThrow( + 'reply must include valid root and parent references', + ); + expect(agent.calls).toHaveLength(0); + } + }); + + test('rejects a malformed embed before writing', async () => { + const agent = makeAgent('did:plc:test'); + const embed = { + $type: 'app.bsky.embed.external', + external: { uri: 'https://example.com/cap', description: 'Missing title' }, + }; + + await expect(publishCap(agent, makeInput({ embed }))).rejects.toThrow( + 'embed must include a valid external value', + ); + expect(agent.calls).toHaveLength(0); + }); + + test('propagates a stale-CID conflict after attempting the write', async () => { + const agent = makeAgent('did:plc:test', () => { + throw new Error('InvalidSwap'); + }); + + await expect(publishCap(agent, makeInput({ + rkey: '3mtestrefresh', + swapCid: 'bafySTALECID', + }))).rejects.toThrow('InvalidSwap'); + expect(agent.calls).toHaveLength(1); + }); + + test('propagates an agent rejection after attempting the write', async () => { + const agent = makeAgent('did:plc:test', () => { + throw new Error('boom'); + }); + + await expect(publishCap(agent, makeInput())).rejects.toThrow('boom'); + expect(agent.calls).toHaveLength(1); + }); +}); diff --git a/test/pack.test.js b/test/pack.test.js new file mode 100644 index 0000000..9c8e47c --- /dev/null +++ b/test/pack.test.js @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { afterEach, test, expect } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '..'); +let workDir; + +afterEach(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); + workDir = undefined; +}); + +test('vit/cap.js resolves from the packed package', () => { + workDir = mkdtempSync(join(tmpdir(), 'vit-pack-')); + const out = execFileSync( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', workDir], + { cwd: repoRoot, encoding: 'utf-8' }, + ); + const tarball = JSON.parse(out)[0].filename; + + const consumerDir = join(workDir, 'consumer'); + const vitDir = join(consumerDir, 'node_modules', 'vit'); + mkdirSync(vitDir, { recursive: true }); + execFileSync('tar', [ + '-xzf', + join(workDir, tarball), + '-C', + vitDir, + '--strip-components=1', + ]); + symlinkSync(join(repoRoot, 'node_modules'), join(vitDir, 'node_modules')); + + 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);"; + const res = execFileSync('node', ['--input-type=module', '-e', script], { + cwd: consumerDir, + encoding: 'utf-8', + }); + + expect(res).toContain('OK'); +}, 30000); diff --git a/test/ship-cap.test.js b/test/ship-cap.test.js new file mode 100644 index 0000000..cbcbcd2 --- /dev/null +++ b/test/ship-cap.test.js @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { test, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +test('shipCap traverses the shared cap publisher', () => { + const source = readFileSync(join(import.meta.dir, '..', 'src', 'cmd', 'ship.js'), 'utf-8'); + const shipCapStart = source.indexOf('export async function shipCap'); + const shipCapEnd = source.indexOf('export default function register'); + const shipCapBody = source.slice(shipCapStart, shipCapEnd); + + expect(source).toContain("import { publishCap } from '../lib/cap.js';"); + expect(shipCapStart).toBeGreaterThan(-1); + expect(shipCapEnd).toBeGreaterThan(shipCapStart); + expect(shipCapBody).toContain('publishCap('); + expect(source.match(/putRecord\(/g) || []).toHaveLength(1); +}); -- 2.51.2