import { PLAYLIST_COLLECTION, isPlaylistRecord, toPlaylist } from './playlistSync'; import type { Playlist } from './playlists'; const DEFAULT_PLC_DIRECTORY = 'https://plc.directory'; const PLC_PREFIX = 'did:plc:'; const WEB_PREFIX = 'did:web:'; /** * Maps a DID to the URL of its DID document — the did:plc/did:web rules from * `packages/gateway/src/did-resolver.ts` `didDocumentUrl()` in * `/home/dev/workspace/szzt` (read for reference, not imported: that package * is a server verifying signatures on someone else's behalf and SSRF-guards * the URL before fetching it; this runs in the visitor's own browser fetching * on its own behalf, and needs none of the signing-key or PLC-audit-log * machinery that package also carries). */ function didDocumentUrl(did: string, plcDirectory: string = DEFAULT_PLC_DIRECTORY): string { if (did.startsWith(PLC_PREFIX)) { if (did.length <= PLC_PREFIX.length) throw new Error('empty did:plc id'); return `${plcDirectory.replace(/\/$/, '')}/${did}`; } if (did.startsWith(WEB_PREFIX)) { const msi = did.slice(WEB_PREFIX.length); if (msi === '') throw new Error('empty did:web id'); const hasPath = msi.includes(':'); if (msi.startsWith(':') || msi.endsWith(':') || msi.includes('::')) { throw new Error('empty did:web segment'); } const decoded = decodeURIComponent(msi.replace(/:/g, '/')); return `https://${decoded}${hasPath ? '/did.json' : '/.well-known/did.json'}`; } throw new Error(`unsupported DID method: ${did}`); } /** * Extracts the atproto PDS base URL from a DID document — mirrors * `packages/gateway/src/pds-resolver.ts` `extractAtprotoPds()` in the same * reference project. */ function extractPds(doc: unknown, did: string): string { if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) { throw new Error('DID document is not an object'); } const document = doc as { id?: unknown; service?: unknown }; if (typeof document.id !== 'string' || document.id !== did) { throw new Error("DID document id does not match the requested DID"); } const services = document.service; if (!Array.isArray(services)) throw new Error('DID document has no service entries'); for (const entry of services) { if (typeof entry !== 'object' || entry === null) continue; const service = entry as { id?: unknown; type?: unknown; serviceEndpoint?: unknown }; const isPds = service.type === 'AtprotoPersonalDataServer' || (typeof service.id === 'string' && service.id.endsWith('#atproto_pds')); if (!isPds) continue; if (typeof service.serviceEndpoint !== 'string' || service.serviceEndpoint === '') { throw new Error('atproto_pds service entry has no serviceEndpoint'); } return service.serviceEndpoint; } throw new Error('no #atproto_pds service entry'); } async function fetchJson(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`fetch failed: ${res.status} ${url}`); return res.json(); } /** * What opening a share link found, distinguishing the four ways it can fail * to show a playlist — an identifier that doesn't resolve, a record that * isn't there, a record that is a tombstone, and a record whose shape * doesn't match — from the one way it can succeed. */ export type ShareResolution = | { status: 'ok'; playlist: Playlist } | { status: 'not-found' } | { status: 'deleted' } | { status: 'invalid' } | { status: 'unresolvable'; message: string }; /** * Resolves a share link's (author DID, playlist id) to the playlist record it * points at. Needs no sign-in and no atproto package: `com.atproto.repo. * getRecord` against a public repo takes no auth, so identity resolution plus * one record fetch is plain `fetch()` calls, the same way `src/atproto.ts` * `loadOAuthClient()` keeps `@atproto/api` and the OAuth client out of a * visitor's bundle until something actually needs them — opening a share * link and never signing in is not one of those somethings. */ export async function resolveSharedPlaylist(did: string, id: string): Promise { let pds: string; try { const doc = await fetchJson(didDocumentUrl(did)); pds = extractPds(doc, did); } catch (e) { return { status: 'unresolvable', message: e instanceof Error ? e.message : String(e) }; } const recordUrl = `${pds.replace(/\/$/, '')}/xrpc/com.atproto.repo.getRecord?${new URLSearchParams({ repo: did, collection: PLAYLIST_COLLECTION, rkey: id, })}`; let res: Response; try { res = await fetch(recordUrl); } catch (e) { return { status: 'unresolvable', message: e instanceof Error ? e.message : String(e) }; } let body: unknown; try { body = await res.json(); } catch { body = undefined; } if (!res.ok) { // XRPC reports "no such record" as an application error rather than a // fixed HTTP status, so the error code is what distinguishes "not there" // from an actual resolution failure worth a different message. if ((body as { error?: unknown })?.error === 'RecordNotFound') return { status: 'not-found' }; return { status: 'unresolvable', message: `record fetch failed: ${res.status}` }; } const value = (body as { value?: unknown } | undefined)?.value; if (!isPlaylistRecord(value)) return { status: 'invalid' }; if (value.deletedAt !== undefined) return { status: 'deleted' }; return { status: 'ok', playlist: toPlaylist(id, value) }; }