From ef95a7d39b84efdba825b625f08b32ff57186e59 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 6 Nov 2026 00:45:12 -0500 Subject: [PATCH] Resolve tangled repos to their knot and mint push tokens resolveRepo follows org.tangled.repo.declaration to the repo DID and its #tangled_knot service, falling back to legacy sh.tangled.repo records. pushToken asks the PDS for a 60-second receivePack token whose audience is the knot's did:web, which describeKnot on knot1.tangled.sh reports. --- packages/sdk/src/index.ts | 1 + packages/sdk/src/tangled/errors.ts | 18 +++ packages/sdk/src/tangled/index.ts | 13 ++ packages/sdk/src/tangled/resolve.ts | 146 +++++++++++++++++++++ packages/sdk/src/tangled/token.ts | 20 +++ packages/sdk/tests/tangled-resolve.test.ts | 122 +++++++++++++++++ packages/sdk/tests/tangled-token.test.ts | 46 +++++++ 7 files changed, 366 insertions(+) create mode 100644 packages/sdk/src/tangled/errors.ts create mode 100644 packages/sdk/src/tangled/index.ts create mode 100644 packages/sdk/src/tangled/resolve.ts create mode 100644 packages/sdk/src/tangled/token.ts create mode 100644 packages/sdk/tests/tangled-resolve.test.ts create mode 100644 packages/sdk/tests/tangled-token.test.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 047f67c..0191709 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1 +1,2 @@ export type { Session } from './session.ts'; +export * from './tangled/index.ts'; diff --git a/packages/sdk/src/tangled/errors.ts b/packages/sdk/src/tangled/errors.ts new file mode 100644 index 0000000..ac575b6 --- /dev/null +++ b/packages/sdk/src/tangled/errors.ts @@ -0,0 +1,18 @@ +export type TangledErrorCode = + | 'bad-ref' + | 'repo-not-found' + | 'no-knot' + | 'remote-moved' + | 'auth-failed' + | 'push-rejected' + | 'bad-change' + | 'token-failed'; + +export class TangledError extends Error { + readonly code: TangledErrorCode; + constructor(code: TangledErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'TangledError'; + this.code = code; + } +} diff --git a/packages/sdk/src/tangled/index.ts b/packages/sdk/src/tangled/index.ts new file mode 100644 index 0000000..f72a65a --- /dev/null +++ b/packages/sdk/src/tangled/index.ts @@ -0,0 +1,13 @@ +export { TangledError, type TangledErrorCode } from './errors.ts'; +export { + createResolver, + parseRepoRef, + resolveRepo, + serviceEndpoint, + type DidDocument, + type DidDocumentService, + type Resolver, + type ResolveOptions, + type TangledRepo, +} from './resolve.ts'; +export { knotServiceDid, pushToken } from './token.ts'; diff --git a/packages/sdk/src/tangled/resolve.ts b/packages/sdk/src/tangled/resolve.ts new file mode 100644 index 0000000..0c3c73b --- /dev/null +++ b/packages/sdk/src/tangled/resolve.ts @@ -0,0 +1,146 @@ +import type {} from '@atcute/atproto'; +import { Client, simpleFetchHandler } from '@atcute/client'; +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + XrpcHandleResolver, +} from '@atcute/identity-resolver'; +import type { Did, Handle } from '@atcute/lexicons'; +import { isDid, isHandle } from '@atcute/lexicons/syntax'; +import { TangledError } from './errors.ts'; + +export interface DidDocumentService { + id: string; + type: string | string[]; + serviceEndpoint: unknown; +} + +export interface DidDocument { + id: string; + service?: DidDocumentService[]; +} + +/** The identity lookups tangled needs. Swap in the shared identity module, or a fake in tests. */ +export interface Resolver { + resolvePds(did: Did): Promise; + resolveDidDoc(did: Did): Promise; + resolveHandle(handle: Handle): Promise; +} + +export interface TangledRepo { + owner: Did; + name: string; + repoDid?: Did; + /** Knot origin, e.g. `https://knot1.tangled.sh`. */ + knot: string; + cloneUrl: string; +} + +export interface ResolveOptions { + resolver?: Resolver; + fetch?: typeof fetch; +} + +export function serviceEndpoint(doc: DidDocument, fragment: '#tangled_knot' | '#atproto_pds'): string | undefined { + const entry = doc.service?.find((s) => s.id === fragment || s.id === `${doc.id}${fragment}`); + return typeof entry?.serviceEndpoint === 'string' ? entry.serviceEndpoint : undefined; +} + +export function createResolver({ fetch: fetchImpl = globalThis.fetch }: { fetch?: typeof fetch } = {}): Resolver { + const dids = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver({ fetch: fetchImpl }), + web: new WebDidDocumentResolver({ fetch: fetchImpl }), + }, + }); + const handles = new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app', fetch: fetchImpl }); + const resolveDidDoc = (did: Did) => dids.resolve(did as Did<'plc' | 'web'>) as Promise; + return { + resolveDidDoc, + async resolvePds(did) { + const pds = serviceEndpoint(await resolveDidDoc(did), '#atproto_pds'); + if (!pds) throw new TangledError('repo-not-found', `${did} names no PDS`); + return pds; + }, + resolveHandle: (handle) => handles.resolve(handle), + }; +} + +type ParsedRef = { owner: string; collection?: 'org.tangled.repo.declaration' | 'sh.tangled.repo'; name: string }; + +export function parseRepoRef(ref: string): ParsedRef { + const uri = /^at:\/\/([^/]+)\/(org\.tangled\.repo\.declaration|sh\.tangled\.repo)\/([^/]+)$/.exec(ref); + if (uri) return { owner: uri[1]!, collection: uri[2] as ParsedRef['collection'], name: uri[3]! }; + const path = /^@?([^/]+)\/([^/]+?)(?:\.git)?$/.exec(ref); + if (path && (isDid(path[1]) || isHandle(path[1]))) return { owner: path[1]!, name: path[2]! }; + throw new TangledError('bad-ref', `not a tangled repo reference: ${ref}`); +} + +interface LegacyRepoRecord { + knot: string; + name?: string; + repoDid?: string; +} + +/** Resolves `handle/name`, `did/name`, or a declaration (or legacy `sh.tangled.repo`) AT-URI to its knot. */ +export async function resolveRepo(ref: string, options: ResolveOptions = {}): Promise { + const fetchImpl = options.fetch ?? globalThis.fetch; + const resolver = options.resolver ?? createResolver({ fetch: fetchImpl }); + const parsed = parseRepoRef(ref); + const owner = isDid(parsed.owner) ? parsed.owner : await resolver.resolveHandle(parsed.owner as Handle); + const pds = new Client({ handler: simpleFetchHandler({ service: await resolver.resolvePds(owner), fetch: fetchImpl }) }); + + const knotOf = async (repoDid: Did) => { + const endpoint = serviceEndpoint(await resolver.resolveDidDoc(repoDid), '#tangled_knot'); + if (!endpoint) throw new TangledError('no-knot', `${repoDid} names no #tangled_knot service`); + return new URL(endpoint).origin; + }; + + if (parsed.collection !== 'sh.tangled.repo') { + const res = await pds.get('com.atproto.repo.getRecord', { + params: { repo: owner, collection: 'org.tangled.repo.declaration', rkey: parsed.name }, + }); + if (res.ok) { + const repoDid = (res.data.value as { repo?: unknown }).repo; + if (!isDid(repoDid)) throw new TangledError('repo-not-found', `${ref}: declaration has no repo DID`); + const knot = await knotOf(repoDid); + return { owner, name: parsed.name, repoDid, knot, cloneUrl: `${knot}/${repoDid}` }; + } + if (res.data.error !== 'RecordNotFound' || parsed.collection) { + throw new TangledError('repo-not-found', `${ref}: ${res.data.error}`); + } + } + + const legacy = await findLegacyRecord(pds, owner, parsed.name, parsed.collection === 'sh.tangled.repo'); + if (!legacy) throw new TangledError('repo-not-found', `${ref}: no org.tangled.repo.declaration or sh.tangled.repo record`); + const name = legacy.name ?? parsed.name; + if (isDid(legacy.repoDid)) { + const knot = await knotOf(legacy.repoDid); + return { owner, name, repoDid: legacy.repoDid, knot, cloneUrl: `${knot}/${legacy.repoDid}` }; + } + const knot = new URL(/^https?:\/\//.test(legacy.knot) ? legacy.knot : `https://${legacy.knot}`).origin; + return { owner, name, knot, cloneUrl: `${knot}/${owner}/${name}` }; +} + +// Legacy records are keyed by name (older repos) or by TID with a `name` field (newer ones). +async function findLegacyRecord(pds: Client, owner: Did, name: string, byRkeyOnly: boolean): Promise { + const direct = await pds.get('com.atproto.repo.getRecord', { + params: { repo: owner, collection: 'sh.tangled.repo', rkey: name }, + }); + if (direct.ok) return direct.data.value as unknown as LegacyRepoRecord; + if (byRkeyOnly) return undefined; + let cursor: string | undefined; + do { + const page = await pds.get('com.atproto.repo.listRecords', { + params: { repo: owner, collection: 'sh.tangled.repo', limit: 100, cursor }, + }); + if (!page.ok) return undefined; + for (const record of page.data.records) { + const value = record.value as unknown as LegacyRepoRecord; + if (value.name === name) return value; + } + cursor = page.data.cursor; + } while (cursor); + return undefined; +} diff --git a/packages/sdk/src/tangled/token.ts b/packages/sdk/src/tangled/token.ts new file mode 100644 index 0000000..e81ce5c --- /dev/null +++ b/packages/sdk/src/tangled/token.ts @@ -0,0 +1,20 @@ +import type {} from '@atcute/atproto'; +import type { Did } from '@atcute/lexicons'; +import type { Session } from '../session.ts'; +import { TangledError } from './errors.ts'; + +/** `did:web:`, which is what a knot's `org.tangled.knot.describeKnot` reports as its DID. */ +export function knotServiceDid(knot: string): Did<'web'> { + const host = new URL(/^https?:\/\//.test(knot) ? knot : `https://${knot}`).host; + return `did:web:${host.replace(':', '%3A')}`; +} + +/** Mints a 60-second service-auth token that authorizes a git push to `knot`. */ +export async function pushToken(session: Session, knot: string, options: { now?: () => number } = {}): Promise { + const now = options.now ?? Date.now; + const res = await session.client.get('com.atproto.server.getServiceAuth', { + params: { aud: knotServiceDid(knot), lxm: 'org.tangled.git.receivePack', exp: Math.floor(now() / 1000) + 60 }, + }); + if (!res.ok) throw new TangledError('token-failed', `getServiceAuth: ${res.data.error}${res.data.message ? ` (${res.data.message})` : ''}`); + return res.data.token; +} diff --git a/packages/sdk/tests/tangled-resolve.test.ts b/packages/sdk/tests/tangled-resolve.test.ts new file mode 100644 index 0000000..b1581a5 --- /dev/null +++ b/packages/sdk/tests/tangled-resolve.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { Did } from '@atcute/lexicons'; +import { parseRepoRef, resolveRepo, TangledError, type DidDocument, type Resolver } from '../src/tangled/index.ts'; + +const owner = 'did:plc:wshs7t2adsemcrrd4snkeqli'; +const pds = 'https://oysterling.us-west.host.bsky.network'; + +// Shapes copied from plc.directory and the owner's PDS on 2026-11-06. +const repoDoc = (id: string): DidDocument => ({ + id, + service: [ + { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://knot1.tangled.sh' }, + { id: '#tangled_knot', type: 'TangledKnot', serviceEndpoint: 'https://knot1.tangled.sh' }, + ], +}); + +const records: Record> = { + 'org.tangled.repo.declaration/knot-docker': { + $type: 'org.tangled.repo.declaration', + repo: 'did:plc:f5s5la5wlofsxidb3zemdune', + createdAt: '2025-06-04T16:11:08.000000Z', + }, + 'sh.tangled.repo/core': { + $type: 'sh.tangled.repo', + knot: 'knot1.tangled.sh', + repoDid: 'did:plc:j5hmlfdrwkvtxm7cjmu7j2is', + createdAt: '2025-02-23T18:43:18Z', + }, + 'sh.tangled.repo/3lzg6enurmo22': { $type: 'sh.tangled.repo', knot: 'localhost:5555', name: 'test', createdAt: '2025-09-22T10:14:23Z' }, +}; + +const resolver: Resolver = { + async resolvePds(did) { + assert.equal(did, owner); + return pds; + }, + async resolveDidDoc(did) { + if (did === 'did:plc:nosvc') return { id: did, service: [] }; + return repoDoc(did); + }, + async resolveHandle(handle) { + assert.equal(handle, 'tangled.org'); + return owner as Did; + }, +}; + +const requested: string[] = []; +const fakeFetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : input); + requested.push(url.pathname + url.search); + assert.equal(url.origin, pds); + const collection = url.searchParams.get('collection'); + const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + if (url.pathname === '/xrpc/com.atproto.repo.getRecord') { + const key = `${collection}/${url.searchParams.get('rkey')}`; + const value = records[key]; + if (!value) return json(400, { error: 'RecordNotFound', message: 'Could not locate record' }); + return json(200, { uri: `at://${owner}/${key}`, cid: 'bafyreidehocneckziff4uffajjqyhqgwxxze364qhcaxtfvv46t4avdqku', value }); + } + if (url.pathname === '/xrpc/com.atproto.repo.listRecords') { + const list = Object.entries(records) + .filter(([key]) => key.startsWith(`${collection}/`)) + .map(([key, value]) => ({ uri: `at://${owner}/${key}`, cid: 'bafyreidehocneckziff4uffajjqyhqgwxxze364qhcaxtfvv46t4avdqku', value })); + return json(200, { records: list }); + } + return json(404, { error: 'MethodNotImplemented' }); +}) as typeof fetch; + +const options = { resolver, fetch: fakeFetch }; + +test('parseRepoRef accepts handle/name, @handle/name, did/name and declaration URIs', () => { + assert.deepEqual(parseRepoRef('tangled.org/core'), { owner: 'tangled.org', name: 'core' }); + assert.deepEqual(parseRepoRef('@tangled.org/core'), { owner: 'tangled.org', name: 'core' }); + assert.deepEqual(parseRepoRef(`${owner}/site.git`), { owner, name: 'site' }); + assert.deepEqual(parseRepoRef(`at://${owner}/org.tangled.repo.declaration/core`), { + owner, + collection: 'org.tangled.repo.declaration', + name: 'core', + }); + assert.throws(() => parseRepoRef('core'), (e: unknown) => e instanceof TangledError && e.code === 'bad-ref'); + assert.throws(() => parseRepoRef('https://tangled.org/core'), (e: unknown) => e instanceof TangledError && e.code === 'bad-ref'); +}); + +test('resolveRepo follows the declaration to the repo DID and its #tangled_knot service', async () => { + requested.length = 0; + assert.deepEqual(await resolveRepo('tangled.org/knot-docker', options), { + owner, + name: 'knot-docker', + repoDid: 'did:plc:f5s5la5wlofsxidb3zemdune', + knot: 'https://knot1.tangled.sh', + cloneUrl: 'https://knot1.tangled.sh/did:plc:f5s5la5wlofsxidb3zemdune', + }); + assert.deepEqual(requested, [ + `/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(owner)}&collection=org.tangled.repo.declaration&rkey=knot-docker`, + ]); +}); + +test('resolveRepo falls back to a legacy sh.tangled.repo record keyed by name', async () => { + assert.deepEqual(await resolveRepo(`${owner}/core`, options), { + owner, + name: 'core', + repoDid: 'did:plc:j5hmlfdrwkvtxm7cjmu7j2is', + knot: 'https://knot1.tangled.sh', + cloneUrl: 'https://knot1.tangled.sh/did:plc:j5hmlfdrwkvtxm7cjmu7j2is', + }); +}); + +test('resolveRepo finds a TID-keyed legacy record by name and uses its knot host', async () => { + assert.deepEqual(await resolveRepo(`${owner}/test`, options), { + owner, + name: 'test', + knot: 'https://localhost:5555', + cloneUrl: `https://localhost:5555/${owner}/test`, + }); +}); + +test('resolveRepo reports a missing repo and a repo DID without a knot', async () => { + await assert.rejects(resolveRepo(`${owner}/nope`, options), (e: unknown) => e instanceof TangledError && e.code === 'repo-not-found'); + records['org.tangled.repo.declaration/orphan'] = { $type: 'org.tangled.repo.declaration', repo: 'did:plc:nosvc', createdAt: '2026-01-01T00:00:00Z' }; + await assert.rejects(resolveRepo(`${owner}/orphan`, options), (e: unknown) => e instanceof TangledError && e.code === 'no-knot'); +}); diff --git a/packages/sdk/tests/tangled-token.test.ts b/packages/sdk/tests/tangled-token.test.ts new file mode 100644 index 0000000..472198a --- /dev/null +++ b/packages/sdk/tests/tangled-token.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Client } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; +import { knotServiceDid, pushToken, TangledError } from '../src/tangled/index.ts'; +import type { Session } from '../src/session.ts'; + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +function stubSession(respond: () => Response) { + const calls: { pathname: string; method: string | undefined }[] = []; + const client = new Client({ + handler: async (pathname, init) => { + calls.push({ pathname, method: init.method }); + return respond(); + }, + }); + const session: Session = { did: 'did:plc:pusher' as Did, client }; + return { session, calls }; +} + +test('knotServiceDid derives did:web from the knot host, percent-encoding a port', () => { + assert.equal(knotServiceDid('https://knot1.tangled.sh'), 'did:web:knot1.tangled.sh'); + assert.equal(knotServiceDid('knot1.tangled.sh'), 'did:web:knot1.tangled.sh'); + assert.equal(knotServiceDid('http://127.0.0.1:5555'), 'did:web:127.0.0.1%3A5555'); +}); + +test('pushToken asks the PDS for a receivePack token audienced to the knot, expiring in 60 s', async () => { + const { session, calls } = stubSession(() => json(200, { token: 'header.payload.sig' })); + const token = await pushToken(session, 'https://knot1.tangled.sh', { now: () => 1_790_000_000_500 }); + assert.equal(token, 'header.payload.sig'); + assert.deepEqual(calls, [ + { + pathname: '/xrpc/com.atproto.server.getServiceAuth?aud=did%3Aweb%3Aknot1.tangled.sh&lxm=org.tangled.git.receivePack&exp=1790000060', + method: 'get', + }, + ]); +}); + +test('pushToken surfaces a PDS refusal as token-failed', async () => { + const { session } = stubSession(() => json(403, { error: 'InsufficientScope', message: 'missing rpc scope' })); + await assert.rejects( + pushToken(session, 'knot1.tangled.sh'), + (e: unknown) => e instanceof TangledError && e.code === 'token-failed' && e.message === 'getServiceAuth: InsufficientScope (missing rpc scope)', + ); +}); -- knotmirror 0.0.1