/** * npm registry HTTP API served from dev.pdsjs.npm.package records, so the * stock npm client can publish and install: * * npm config set registry https:///npm/ * npm publish * npm install * * The surface lives under /npm/ because the npm protocol wants the URL * root, which the PDS owns. Tarball and manifest bytes are stored as blobs * exactly as published; the record holds the dist-tags, the integrity * strings and a blob reference per version, which is what holds the blobs * against orphan cleanup. The packument is assembled from the manifest * blobs, with each tarball URL rewritten to the requesting origin. * * Reads go through an injected fetch, typically the server's own fetch * function. Writes go straight to the ports, the way the drive writer * writes. Without the write ports the registry serves installs only. * * Authentication is the account's own: `npm login` verifies through * createSession and answers the password back as the token, so the stored * `_authToken` is the app password itself and nothing server-side has to * survive a restart. Installs are anonymous. * * This module must stay free of node-only imports; it runs on Cloudflare * Workers. */ import { cborEncodeDagCbor, cidToString, createBlobCid, createCid, } from '@pdsjs/core/repo'; import { createSha256 } from '@pdsjs/core/sha256'; import { fromBase64, tarballHashes } from './integrity.js'; import { isValidName, NPM_PACKAGE_COLLECTION, nameToRkey } from './lexicon.js'; import { blobRef, parsePackageRecord, recordBlobCids, serializePackageRecord, } from './record.js'; const TARBALL_MIME_TYPE = 'application/octet-stream'; const MANIFEST_MIME_TYPE = 'application/json'; /** Largest accepted tarball, when the caller sets no other bound. */ const DEFAULT_MAX_TARBALL_SIZE = 64 * 1024 * 1024; /** How long one verified credential pair stays good without re-checking. */ const AUTH_CACHE_TTL = 5 * 60 * 1000; const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,256}$/; const TAG_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; /** * @typedef {Object} NpmRegistryContext * @property {(request: Request) => Promise} xrpc - the PDS fetch * function; requests are self-addressed XRPC calls * @property {() => Promise} getDid * @property {import('@pdsjs/core/ports').ActorStoragePort} [actorStorage] - publishes need it * @property {import('@pdsjs/core/ports').BlobPort} [blobs] - publishes need it * @property {(did: string, ops: Array<{action: string, uri: string, cid?: string}>) => Promise} [createCommit] - publishes need it * @property {import('@pdsjs/core/ports').HasherFactory} [hasher] - SHA-256 factory; absent, the plain JavaScript one * @property {number} [maxTarballSize] - largest accepted tarball, in bytes * @property {string} [collection] - record collection, for lexicon forks */ /** * @param {string} message * @param {number} status * @returns {Response} */ function npmError(message, status) { return Response.json({ error: message }, { status }); } /** * The name without its scope, which names the tarball file. * @param {string} name * @returns {string} */ function basename(name) { return name.includes('/') ? name.split('/')[1] : name; } /** * @param {URL} url * @param {string} name * @param {string} version * @returns {string} */ function tarballUrl(url, name, version) { return `${url.origin}/npm/${name}/-/${basename(name)}-${version}.tgz`; } /** * @param {NpmRegistryContext} ctx * @returns {{handle: (request: Request) => Promise}} */ export function createNpmRegistry(ctx) { if (!ctx || typeof ctx.xrpc !== 'function') { throw new Error('createNpmRegistry requires an xrpc fetch function'); } if (typeof ctx.getDid !== 'function') { throw new Error('createNpmRegistry requires getDid'); } const { xrpc, getDid, actorStorage, blobs, createCommit, hasher = createSha256, maxTarballSize = DEFAULT_MAX_TARBALL_SIZE, collection = NPM_PACKAGE_COLLECTION, } = ctx; const publishEnabled = Boolean(actorStorage && blobs && createCommit); /** * @param {string} path * @param {RequestInit} [init] * @returns {Promise} */ const xrpcFetch = (path, init) => xrpc(new Request(`https://pds.internal${path}`, init)); // ── Authentication ───────────────────────────────────────────────────── /** Verified credential digests, value = epoch ms the entry expires. */ const authCache = new Map(); /** * @param {string} identifier * @param {string} password * @returns {Promise} */ async function credentialsValid(identifier, password) { const hash = hasher(); await hash.update(new TextEncoder().encode(`${identifier}\0${password}`)); const digest = await hash.digest(); let key = ''; for (const byte of digest) key += byte.toString(16).padStart(2, '0'); const expires = authCache.get(key); if (expires && expires > Date.now()) return true; authCache.delete(key); const res = await xrpcFetch('/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }), }); if (!res.ok) return false; authCache.set(key, Date.now() + AUTH_CACHE_TTL); return true; } /** * True when the request carries credentials createSession accepts. A * Bearer token is the password alone; the account is the identifier, * since this server holds exactly one. * @param {Request} request * @returns {Promise} */ async function authorized(request) { const header = request.headers.get('authorization') ?? ''; if (/^bearer\s/i.test(header)) { const did = await getDid(); if (!did) return false; return credentialsValid(did, header.slice(6).trim()); } if (/^basic\s/i.test(header)) { let decoded; try { decoded = atob(header.slice(5).trim()); } catch { return false; } const didMatch = /^(did:[a-z0-9]+:[^:]+):(.*)$/s.exec(decoded); const at = decoded.indexOf(':'); if (!didMatch && at < 0) return false; return credentialsValid( didMatch ? didMatch[1] : decoded.slice(0, at), didMatch ? didMatch[2] : decoded.slice(at + 1), ); } return false; } // ── Reads ────────────────────────────────────────────────────────────── /** * @param {string} did * @param {string} name * @returns {Promise<{cid: string, record: import('./record.js').NpmPackageRecord}|null>} */ async function loadPackage(did, name) { const params = new URLSearchParams({ repo: did, collection, rkey: nameToRkey(name), }); const res = await xrpcFetch(`/xrpc/com.atproto.repo.getRecord?${params}`); if (!res.ok) return null; const body = /** @type {{cid: string, value: unknown}} */ ( await res.json() ); const record = parsePackageRecord(body.value); return record ? { cid: body.cid, record } : null; } /** * @param {string} did * @param {string} cid * @returns {Promise} */ async function readBlob(did, cid) { const params = new URLSearchParams({ did, cid }); const res = await xrpcFetch(`/xrpc/com.atproto.sync.getBlob?${params}`); if (!res.ok) return null; return new Uint8Array(await res.arrayBuffer()); } /** * GET /npm/: the packument, assembled from the manifest blobs with * tarball URLs rewritten to the requesting origin. * @param {Request} request * @param {URL} url * @param {string} did * @param {string} name * @returns {Promise} */ async function servePackument(request, url, did, name) { const found = await loadPackage(did, name); if (!found) return npmError(`package not found: ${name}`, 404); const { cid, record } = found; const ifNoneMatch = request.headers.get('if-none-match'); if (ifNoneMatch?.includes(cid)) { return new Response(null, { status: 304, headers: { ETag: `"${cid}"` } }); } /** @type {Record} */ const versions = {}; /** @type {Record} */ const time = {}; for (const entry of record.versions) { const bytes = await readBlob(did, entry.manifest.ref.$link); if (!bytes) continue; /** @type {Record} */ let manifest; try { manifest = JSON.parse(new TextDecoder().decode(bytes)); } catch { continue; } manifest.dist = { .../** @type {Record} */ (manifest.dist ?? {}), tarball: tarballUrl(url, name, entry.version), integrity: entry.integrity, ...(entry.shasum ? { shasum: entry.shasum } : {}), }; versions[entry.version] = manifest; if (entry.publishedAt) time[entry.version] = entry.publishedAt; } /** @type {Record} */ const distTags = {}; for (const tag of record.distTags) distTags[tag.tag] = tag.version; return Response.json( { name, 'dist-tags': distTags, versions, time: { created: record.createdAt, modified: record.updatedAt ?? record.createdAt, ...time, }, }, { headers: { ETag: `"${cid}"` } }, ); } /** * GET /npm//-/.tgz: the version's tarball, streamed from * blob storage. * @param {URL} _url * @param {string} did * @param {string} name * @param {string} file * @returns {Promise} */ async function serveTarball(_url, did, name, file) { const prefix = `${basename(name)}-`; if (!file.startsWith(prefix) || !file.endsWith('.tgz')) { return npmError(`tarball not found: ${file}`, 404); } const version = file.slice(prefix.length, -4); const found = await loadPackage(did, name); const entry = found?.record.versions.find((v) => v.version === version); if (!entry) return npmError(`tarball not found: ${file}`, 404); const params = new URLSearchParams({ did, cid: entry.tarball.ref.$link }); const res = await xrpcFetch(`/xrpc/com.atproto.sync.getBlob?${params}`); if (!res.ok) return npmError(`tarball not found: ${file}`, 404); return new Response(res.body, { headers: { 'Content-Type': TARBALL_MIME_TYPE, 'Content-Length': res.headers.get('content-length') ?? String(entry.tarball.size), }, }); } // ── Writes ───────────────────────────────────────────────────────────── /** * One write to a package record at a time, so concurrent publishes * read-modify-write in sequence rather than losing updates. * @type {Map>} */ const recordLocks = new Map(); /** * @template T * @param {string} name * @param {() => Promise} fn * @returns {Promise} */ function withRecordLock(name, fn) { const previous = recordLocks.get(name) ?? Promise.resolve(); const next = previous.then(fn, fn); recordLocks.set( name, next.catch(() => {}), ); return next; } /** * Store the record, link its blobs and commit, the way the drive writer * does. The link runs before the commit, so a reap between the two * cannot take a blob the record already holds. * @param {string} did * @param {string} name * @param {import('./record.js').NpmPackageRecord} record * @param {boolean} existed */ async function writePackageRecord(did, name, record, existed) { const storage = /** @type {NonNullable} */ ( actorStorage ); const rkey = nameToRkey(name); const uri = `at://${did}/${collection}/${rkey}`; const encoded = cborEncodeDagCbor(serializePackageRecord(record)); const cid = cidToString(await createCid(encoded)); await storage.putBlock(cid, encoded); await storage.putRecord(uri, cid, collection, rkey, encoded); await storage.unlinkBlobsFromRecord(uri); for (const blobCid of recordBlobCids(record)) { await storage.linkBlobToRecord(blobCid, uri, Date.now()); } await /** @type {NonNullable} */ (createCommit)(did, [ { action: existed ? 'update' : 'create', uri, cid }, ]); } /** * @param {string} did * @param {Uint8Array} bytes * @param {string} mimeType * @returns {Promise} the blob's CID */ async function storeBlob(did, bytes, mimeType) { const cid = cidToString(await createBlobCid(bytes)); await /** @type {NonNullable} */ (blobs).put( did, cid, bytes, mimeType, ); await /** @type {NonNullable} */ ( actorStorage ).putBlob(cid, mimeType, bytes.length); return cid; } /** * Read the whole request body, refusing one past the cap. The publish * body carries the tarball base64-encoded, so the cap allows for the * encoding overhead. * @param {Request} request * @param {number} cap * @returns {Promise} null when the body exceeds the cap */ async function readBody(request, cap) { if (!request.body) return new Uint8Array(0); const chunks = []; let size = 0; const reader = request.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); size += chunk.length; if (size > cap) { await reader.cancel(); return null; } chunks.push(chunk); } const joined = new Uint8Array(size); let at = 0; for (const chunk of chunks) { joined.set(chunk, at); at += chunk.length; } return joined; } /** * PUT /npm/: publish. The body is the package document npm sends: * version manifests plus the tarballs base64-encoded in `_attachments`. * @param {Request} request * @param {URL} url * @param {string} did * @param {string} name * @returns {Promise} */ async function publish(request, url, did, name) { // base64 inflates the tarball by a third; the slack covers manifests. const body = await readBody( request, Math.ceil(maxTarballSize * 1.4) + 1024 * 1024, ); if (!body) { return npmError( `package exceeds the maximum size of ${maxTarballSize} bytes`, 413, ); } /** @type {Record} */ let doc; try { doc = JSON.parse(new TextDecoder().decode(body)); } catch { return npmError('the publish body is not valid JSON', 400); } if (doc.name !== name) { return npmError('the document name does not match the URL', 400); } const versions = /** @type {Record>} */ ( doc.versions && typeof doc.versions === 'object' ? doc.versions : {} ); const attachments = /** @type {Record} */ ( doc._attachments && typeof doc._attachments === 'object' ? doc._attachments : {} ); const docTags = /** @type {Record} */ ( doc['dist-tags'] && typeof doc['dist-tags'] === 'object' ? doc['dist-tags'] : {} ); if (Object.keys(versions).length === 0) { return npmError('the publish body names no versions', 400); } return withRecordLock(name, async () => { const existing = await loadPackage(did, name); const now = new Date().toISOString(); /** @type {import('./record.js').NpmPackageRecord} */ const record = existing?.record ?? { name, distTags: [], versions: [], createdAt: now, }; for (const [version, manifest] of Object.entries(versions)) { if (!VERSION_PATTERN.test(version)) { return npmError(`invalid version: ${version}`, 400); } if (record.versions.some((v) => v.version === version)) { // npm expects 403 here and reports the version conflict itself. return npmError( `cannot publish over the previously published version ${version}`, 403, ); } const attachment = attachments[`${basename(name)}-${version}.tgz`] ?? Object.values(attachments)[0]; if (typeof attachment?.data !== 'string') { return npmError(`no tarball attached for version ${version}`, 400); } /** @type {Uint8Array} */ let tarball; try { tarball = fromBase64(attachment.data); } catch { return npmError('the tarball attachment is not valid base64', 400); } if (tarball.length > maxTarballSize) { return npmError( `package exceeds the maximum size of ${maxTarballSize} bytes`, 413, ); } const hashes = await tarballHashes(tarball); const declared = /** @type {{integrity?: unknown}} */ ( manifest.dist ?? {} ).integrity; if (typeof declared === 'string' && declared !== hashes.integrity) { return npmError( `the tarball hashes to ${hashes.integrity}, not the declared integrity`, 400, ); } const tarballCid = await storeBlob(did, tarball, TARBALL_MIME_TYPE); const manifestBytes = new TextEncoder().encode( JSON.stringify(manifest), ); const manifestCid = await storeBlob( did, manifestBytes, MANIFEST_MIME_TYPE, ); record.versions.push({ version, tarball: blobRef(tarballCid, TARBALL_MIME_TYPE, tarball.length), manifest: blobRef( manifestCid, MANIFEST_MIME_TYPE, manifestBytes.length, ), integrity: hashes.integrity, shasum: hashes.shasum, publishedAt: now, }); } for (const [tag, version] of Object.entries(docTags)) { if (typeof version !== 'string' || !TAG_PATTERN.test(tag)) continue; const entry = record.distTags.find((t) => t.tag === tag); if (entry) entry.version = version; else record.distTags.push({ tag, version }); } record.updatedAt = now; await writePackageRecord(did, name, record, Boolean(existing)); return Response.json({ ok: true, id: name }, { status: 201 }); }); } /** * Change one dist-tag: PUT with the version as a JSON string, or DELETE. * @param {Request} request * @param {string} did * @param {string} name * @param {string} tag * @returns {Promise} */ async function changeDistTag(request, did, name, tag) { return withRecordLock(name, async () => { const found = await loadPackage(did, name); if (!found) return npmError(`package not found: ${name}`, 404); const { record } = found; if (request.method === 'DELETE') { if (!record.distTags.some((t) => t.tag === tag)) { return npmError(`tag not found: ${tag}`, 404); } record.distTags = record.distTags.filter((t) => t.tag !== tag); } else { let version; try { version = JSON.parse(await request.text()); } catch { return npmError('the body is not a JSON version string', 400); } if ( typeof version !== 'string' || !record.versions.some((v) => v.version === version) ) { return npmError(`version not found: ${version}`, 404); } const entry = record.distTags.find((t) => t.tag === tag); if (entry) entry.version = version; else record.distTags.push({ tag, version }); } record.updatedAt = new Date().toISOString(); await writePackageRecord(did, name, record, true); return Response.json({ ok: true }); }); } // ── Dispatch ─────────────────────────────────────────────────────────── /** * @param {string[]} segments - path segments after /npm/ * @returns {{name: string, rest: string[]}|null} */ function parseName(segments) { if (segments.length === 0) return null; if (segments[0].startsWith('@')) { if (segments.length < 2) return null; return { name: `${segments[0]}/${segments[1]}`, rest: segments.slice(2), }; } return { name: segments[0], rest: segments.slice(1) }; } return { /** * @param {Request} request * @returns {Promise} null when the path is not a * registry route */ async handle(request) { const url = new URL(request.url); const pathname = decodeURIComponent(url.pathname); if (pathname !== '/npm' && !pathname.startsWith('/npm/')) return null; try { const did = await getDid(); if (!did) return npmError('server not initialized', 404); const tail = pathname.slice('/npm'.length); const segments = tail.split('/').filter((part) => part !== ''); if (segments.length === 0) { return Response.json({}); } // Meta routes, the ones npm serves under /-/. if (segments[0] === '-') { if (segments[1] === 'ping') return Response.json({}); // PUT /-/user/org.couchdb.user:, the npm login shape. if ( request.method === 'PUT' && segments[1] === 'user' && segments.length === 3 ) { /** @type {{name?: unknown, password?: unknown}} */ let body; try { body = JSON.parse(await request.text()); } catch { return npmError('the login body is not valid JSON', 400); } if ( typeof body.name !== 'string' || typeof body.password !== 'string' || !(await credentialsValid(body.name, body.password)) ) { return npmError('invalid credentials', 401); } // The token is the password itself: createSession verifies it on // every use, so nothing server-side has to survive a restart. return Response.json( { ok: true, id: `org.couchdb.user:${body.name}`, token: body.password, }, { status: 201 }, ); } if (segments[1] === 'whoami') { if (!(await authorized(request))) { return npmError('authentication required', 401); } const res = await xrpcFetch( `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`, ); const handle = res.ok ? /** @type {{handle?: string}} */ (await res.json()).handle : null; return Response.json({ username: handle || did }); } if (segments[1] === 'package') { const parsed = parseName(segments.slice(2)); if (!parsed || !isValidName(parsed.name)) { return npmError('invalid package name', 400); } if (parsed.rest[0] !== 'dist-tags') { return npmError('unsupported route', 404); } if (request.method === 'GET') { const found = await loadPackage(did, parsed.name); if (!found) { return npmError(`package not found: ${parsed.name}`, 404); } /** @type {Record} */ const tags = {}; for (const tag of found.record.distTags) { tags[tag.tag] = tag.version; } return Response.json(tags); } const tag = parsed.rest[1]; if (!tag || !TAG_PATTERN.test(tag)) { return npmError('invalid tag', 400); } if (!publishEnabled) { return npmError('this registry is read-only', 405); } if (!(await authorized(request))) { return npmError('authentication required', 401); } return await changeDistTag(request, did, parsed.name, tag); } // npm probes audit and search endpoints during installs; a plain // 404 makes the client skip the feature. return npmError('unsupported route', 404); } const parsed = parseName(segments); if (!parsed || !isValidName(parsed.name)) { return npmError('invalid package name', 400); } const { name, rest } = parsed; if (rest.length === 2 && rest[0] === '-') { if (request.method !== 'GET') { return npmError('unsupported method', 405); } return await serveTarball(url, did, name, rest[1]); } if (rest.length === 0) { if (request.method === 'GET') { return await servePackument(request, url, did, name); } if (request.method === 'PUT') { if (!publishEnabled) { return npmError('this registry is read-only', 405); } if (!(await authorized(request))) { return npmError('authentication required', 401); } return await publish(request, url, did, name); } if (request.method === 'DELETE') { return npmError( 'unpublish is not supported; delete the dev.pdsjs.npm.package record instead', 403, ); } } return npmError('unsupported route', 404); } catch (err) { const message = err instanceof Error ? err.message : String(err); return npmError(message, 500); } }, }; }