/** * OCI distribution HTTP API served from dev.pdsjs.oci.repo records, so * stock docker can push and pull: * * docker pull /: * docker push /: * * A layer is one blob: its OCI digest and its blob CID hold the same * SHA-256 bytes, so a blob GET is a digest re-encoding plus a getBlob. * Manifest bytes are stored as blobs untouched, because a digest covers the * exact serialization. The record keeps a blob reference for every stored * manifest, config and layer, which is what holds them against orphan * cleanup. * * 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 pulls only. * * This module must stay free of node-only imports; it runs on Cloudflare * Workers. */ import { cborEncodeDagCbor, cidToString, createCid } from '@pdsjs/core/repo'; import { createSha256 } from '@pdsjs/core/sha256'; import { digestFromHashBytes, digestToCid, isValidDigest } from './digest.js'; import { isValidName, isValidTag, nameToRkey, OCI_REPO_COLLECTION, } from './lexicon.js'; import { MAX_MANIFEST_SIZE, parseManifest } from './manifest.js'; import { blobRef, parseRepoRecord, recordBlobCids, removeManifest, serializeRepoRecord, } from './record.js'; const API_VERSION_HEADER = 'Docker-Distribution-API-Version'; const API_VERSION = 'registry/2.0'; const BLOB_MIME_TYPE = 'application/octet-stream'; /** How long an upload session waits between requests before it is swept. */ const UPLOAD_SESSION_TTL = 60 * 60 * 1000; /** How long one verified credential pair stays good without re-checking. */ const AUTH_CACHE_TTL = 5 * 60 * 1000; /** Largest accepted layer, when the caller sets no other bound. */ const DEFAULT_MAX_BLOB_SIZE = 10 * 1024 * 1024 * 1024; /** * @typedef {Object} RegistryContext * @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] - pushes need it * @property {import('@pdsjs/core/ports').BlobPort} [blobs] - pushes need it * @property {(did: string, ops: Array<{action: string, uri: string, cid?: string}>) => Promise} [createCommit] - pushes need it * @property {import('@pdsjs/core/ports').HasherFactory} [hasher] - SHA-256 factory; absent, the plain JavaScript one * @property {number} [maxBlobSize] - largest accepted layer, in bytes * @property {string} [collection] - record collection, for lexicon forks * @property {string} [realm] - realm named in the Basic challenge */ /** * @typedef {Object} UploadSession * @property {string} name * @property {import('@pdsjs/core/ports').BlobWriter} writer * @property {import('@pdsjs/core/ports').Hasher} hash * @property {number} size * @property {number} touched * @property {boolean} busy */ /** * @param {string} code - error code from the distribution spec * @param {string} message * @param {number} status * @param {Record} [headers] * @returns {Response} */ function ociError(code, message, status, headers = {}) { return Response.json( { errors: [{ code, message }] }, { status, headers: { [API_VERSION_HEADER]: API_VERSION, ...headers } }, ); } /** * @param {Uint8Array[]} chunks * @param {number} size * @returns {Uint8Array} */ function joinChunks(chunks, size) { const joined = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.length; } return joined; } /** * The `start-end` byte range of an upload PATCH. The `bytes ` prefix some * clients send is accepted. * @param {string|null} header * @returns {{start: number, end: number}|null} */ function parseContentRange(header) { if (!header) return null; const match = /^(?:bytes )?(\d+)-(\d+)/.exec(header); if (!match) return null; return { start: Number(match[1]), end: Number(match[2]) }; } /** * @param {RegistryContext} ctx * @returns {{handle: (request: Request) => Promise}} */ export function createRegistry(ctx) { if (!ctx || typeof ctx.xrpc !== 'function') { throw new Error('createRegistry requires an xrpc fetch function'); } if (typeof ctx.getDid !== 'function') { throw new Error('createRegistry requires getDid'); } const { xrpc, getDid, actorStorage, blobs, createCommit, hasher = createSha256, maxBlobSize = DEFAULT_MAX_BLOB_SIZE, collection = OCI_REPO_COLLECTION, realm = 'registry', } = ctx; const pushEnabled = 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 ───────────────────────────────────────────────────── // // Docker only pulls anonymously through the Bearer token flow: a Basic // challenge with no stored credentials fails outright, where a Bearer // challenge sends the client to the token endpoint, which grants pull to // anyone and push to valid Basic credentials. Requests carrying Basic // credentials directly are accepted too, so curl works without a token. /** Verified credential digests, value = epoch ms the entry expires. */ const authCache = new Map(); /** Issued Bearer tokens, value = {push, expires}. */ const tokens = new Map(); /** How long one issued token stays good. */ const TOKEN_TTL = 30 * 60 * 1000; /** * @param {string} decoded - `identifier:password` * @returns {Promise} */ async function credentialKey(decoded) { const hash = hasher(); await hash.update(new TextEncoder().encode(decoded)); return digestFromHashBytes(await hash.digest()); } /** * True when the request carries Basic credentials createSession accepts. * The check goes through createSession itself, so account passwords and * app passwords both work, exactly as docker login sends them. * @param {string} header - the Authorization header * @returns {Promise} */ async function basicAuthorized(header) { let decoded; try { decoded = atob(header.slice(5).trim()); } catch { return false; } // The username may be a DID, which holds colons of its own, so the // split runs after the DID prefix when one is present. const didMatch = /^(did:[a-z0-9]+:[^:]+):(.*)$/s.exec(decoded); const at = decoded.indexOf(':'); if (!didMatch && at < 0) return false; const identifier = didMatch ? didMatch[1] : decoded.slice(0, at); const password = didMatch ? didMatch[2] : decoded.slice(at + 1); const key = await credentialKey(decoded); 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; } /** * What the request may do: null for no valid credentials at all. * @param {Request} request * @returns {Promise<{push: boolean}|null>} */ async function resolveAccess(request) { const header = request.headers.get('authorization') ?? ''; if (/^basic\s/i.test(header)) { return (await basicAuthorized(header)) ? { push: true } : null; } if (/^bearer\s/i.test(header)) { const grant = tokens.get(header.slice(6).trim()); if (!grant || grant.expires < Date.now()) return null; return { push: grant.push }; } return null; } /** * GET /v2/token, the realm the Bearer challenge names. Basic credentials * grant push and pull; a request without credentials gets pull only, * which is what makes anonymous docker pulls work. Bad credentials are * refused rather than downgraded, so docker login fails loudly. * @param {Request} request * @returns {Promise} */ async function serveToken(request) { const header = request.headers.get('authorization') ?? ''; let push = false; if (/^basic\s/i.test(header)) { if (!(await basicAuthorized(header))) { return ociError('UNAUTHORIZED', 'invalid credentials', 401); } push = true; } for (const [token, grant] of tokens) { if (grant.expires < Date.now()) tokens.delete(token); } const token = crypto.randomUUID(); tokens.set(token, { push, expires: Date.now() + TOKEN_TTL }); return Response.json( { token, access_token: token, expires_in: Math.floor(TOKEN_TTL / 1000), }, { headers: { [API_VERSION_HEADER]: API_VERSION } }, ); } /** * @param {URL} url * @returns {Response} */ function challenge(url) { return ociError('UNAUTHORIZED', 'authentication required', 401, { 'WWW-Authenticate': `Bearer realm="${url.origin}/v2/token",service="${realm}"`, }); } // ── Reads ────────────────────────────────────────────────────────────── /** * @param {string} did * @param {string} name * @returns {Promise} */ async function loadRepo(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 {{value: unknown}} */ (await res.json()); return parseRepoRecord(body.value); } /** * Size and type of one stored blob, or null when it is absent. The * metadata row answers when the port is present; otherwise a one-byte * ranged getBlob reads the size out of Content-Range. * @param {string} did * @param {string} digest * @returns {Promise<{size: number, mimeType: string}|null>} */ async function blobMeta(did, digest) { const cid = digestToCid(digest); if (actorStorage?.getBlob) { const meta = await actorStorage.getBlob(cid); return meta ? { size: meta.size, mimeType: meta.mimeType } : null; } const params = new URLSearchParams({ did, cid }); const res = await xrpcFetch(`/xrpc/com.atproto.sync.getBlob?${params}`, { headers: { Range: 'bytes=0-0' }, }); if (res.status === 206) { await res.body?.cancel(); const match = /\/(\d+)$/.exec(res.headers.get('content-range') ?? ''); return match ? { size: Number(match[1]), mimeType: res.headers.get('content-type') ?? BLOB_MIME_TYPE, } : null; } if (res.status === 416) { // An empty blob satisfies no byte range; the 416 still proves it // exists. return { size: 0, mimeType: BLOB_MIME_TYPE }; } if (res.ok) { const data = await res.arrayBuffer(); return { size: data.byteLength, mimeType: res.headers.get('content-type') ?? BLOB_MIME_TYPE, }; } return 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 or HEAD one blob, streaming and honouring Range. * @param {Request} request * @param {string} did * @param {string} digest * @returns {Promise} */ async function serveBlob(request, did, digest) { if (!isValidDigest(digest)) { return ociError('DIGEST_INVALID', `unsupported digest: ${digest}`, 400); } const meta = await blobMeta(did, digest); if (!meta) { return ociError('BLOB_UNKNOWN', `blob unknown: ${digest}`, 404); } /** @type {Record} */ const headers = { [API_VERSION_HEADER]: API_VERSION, 'Docker-Content-Digest': digest, 'Content-Type': BLOB_MIME_TYPE, 'Accept-Ranges': 'bytes', ETag: `"${digest}"`, }; if (request.method === 'HEAD') { headers['Content-Length'] = String(meta.size); return new Response(null, { status: 200, headers }); } const params = new URLSearchParams({ did, cid: digestToCid(digest) }); /** @type {HeadersInit} */ const forward = {}; const range = request.headers.get('range'); if (range) forward.Range = range; const res = await xrpcFetch(`/xrpc/com.atproto.sync.getBlob?${params}`, { headers: forward, }); if (!res.ok && res.status !== 206 && res.status !== 416) { return ociError('BLOB_UNKNOWN', `blob unknown: ${digest}`, 404); } for (const name of ['Content-Length', 'Content-Range']) { const value = res.headers.get(name); if (value) headers[name] = value; } return new Response(res.body, { status: res.status, headers }); } /** * The manifest entry a reference names: a digest directly, a tag through * the tag list. * @param {import('./record.js').OciRepoRecord} record * @param {string} reference * @returns {import('./record.js').ManifestEntry|null} */ function resolveManifest(record, reference) { const digest = reference.includes(':') ? reference : record.tags.find((t) => t.tag === reference)?.digest; if (!digest) return null; return record.manifests.find((m) => m.digest === digest) ?? null; } /** * @param {Request} request * @param {string} did * @param {string} name * @param {string} reference - tag or digest * @returns {Promise} */ async function serveManifest(request, did, name, reference) { const record = await loadRepo(did, name); if (!record) { return ociError('NAME_UNKNOWN', `repository unknown: ${name}`, 404); } const entry = resolveManifest(record, reference); if (!entry) { return ociError( 'MANIFEST_UNKNOWN', `manifest unknown: ${reference}`, 404, ); } /** @type {Record} */ const headers = { [API_VERSION_HEADER]: API_VERSION, 'Docker-Content-Digest': entry.digest, 'Content-Type': entry.mediaType, ETag: `"${entry.digest}"`, }; const ifNoneMatch = request.headers.get('if-none-match'); if (ifNoneMatch?.includes(entry.digest)) { return new Response(null, { status: 304, headers }); } const bytes = await readBlob(did, entry.blob.ref.$link); if (!bytes) { return ociError( 'MANIFEST_UNKNOWN', `manifest unknown: ${reference}`, 404, ); } headers['Content-Length'] = String(bytes.length); return new Response( request.method === 'HEAD' ? null : /** @type {BodyInit} */ (bytes), { status: 200, headers }, ); } /** * @param {URL} url * @param {string} did * @param {string} name * @returns {Promise} */ async function serveTagsList(url, did, name) { const record = await loadRepo(did, name); if (!record) { return ociError('NAME_UNKNOWN', `repository unknown: ${name}`, 404); } let tags = record.tags.map((t) => t.tag).sort(); const last = url.searchParams.get('last'); if (last) tags = tags.filter((t) => t > last); const rawN = url.searchParams.get('n'); const n = rawN === null ? Number.NaN : Number(rawN); if (Number.isFinite(n) && n >= 0) tags = tags.slice(0, n); return Response.json( { name, tags }, { headers: { [API_VERSION_HEADER]: API_VERSION } }, ); } /** * @param {string} did * @returns {Promise} */ async function serveCatalog(did) { const params = new URLSearchParams({ repo: did, collection, limit: '100', }); const res = await xrpcFetch(`/xrpc/com.atproto.repo.listRecords?${params}`); /** @type {string[]} */ let repositories = []; if (res.ok) { const body = /** @type {{records: Array<{value: unknown}>}} */ ( await res.json() ); repositories = body.records .map((r) => parseRepoRecord(r.value)?.name) .filter((name) => typeof name === 'string') .sort(); } return Response.json( { repositories }, { headers: { [API_VERSION_HEADER]: API_VERSION } }, ); } // ── Writes ───────────────────────────────────────────────────────────── /** * Multi-request uploads in flight, keyed by session id. In memory: a * restart loses them, and the client starts the upload over. * @type {Map} */ const uploads = new Map(); /** * One write to a repository record at a time, so concurrent pushes * 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; } /** * @param {string} id * @returns {UploadSession} */ function takeUpload(id) { const session = uploads.get(id); if (!session || session.busy) { throw new Error('upload unknown'); } return session; } /** @param {string} id */ async function dropUpload(id) { const session = uploads.get(id); if (!session) return; uploads.delete(id); try { await session.writer.abort(); } catch { // an abort that fails leaves staged bytes for the platform to expire } } /** Abandon every session that has waited past the TTL. */ async function sweepUploads() { const cutoff = Date.now() - UPLOAD_SESSION_TTL; for (const [id, session] of uploads) { if (session.touched < cutoff && !session.busy) await dropUpload(id); } } /** * Feed one request body into a session. A body that fails partway has * already fed the hash, so the session ends and the client starts over. * @param {UploadSession} session * @param {string} id * @param {ReadableStream|null} body * @returns {Promise} an error response, or null on success */ async function appendBody(session, id, body) { if (!body) return null; session.busy = true; try { const reader = body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); session.size += chunk.length; if (session.size > maxBlobSize) { await reader.cancel(); await dropUpload(id); return ociError( 'SIZE_INVALID', `blob exceeds the maximum size of ${maxBlobSize} bytes`, 413, ); } await session.hash.update(chunk); await session.writer.write(chunk); } session.busy = false; session.touched = Date.now(); return null; } catch (err) { await dropUpload(id); const message = err instanceof Error ? err.message : String(err); return ociError('BLOB_UPLOAD_INVALID', message, 400); } } /** * Land a finished session as the blob its digest names. * @param {UploadSession} session * @param {string} id * @param {string} digest - declared digest the bytes must hash to * @returns {Promise} */ async function finishUpload(session, id, digest) { uploads.delete(id); if (!isValidDigest(digest)) { await session.writer.abort(); return ociError('DIGEST_INVALID', `unsupported digest: ${digest}`, 400); } const computed = digestFromHashBytes(await session.hash.digest()); if (computed !== digest) { await session.writer.abort(); return ociError( 'DIGEST_INVALID', `digest mismatch: body hashes to ${computed}`, 400, ); } const cid = digestToCid(digest); await /** @type {NonNullable} */ ( actorStorage ).putBlob(cid, BLOB_MIME_TYPE, session.size); await session.writer.commit(cid, BLOB_MIME_TYPE); return new Response(null, { status: 201, headers: { [API_VERSION_HEADER]: API_VERSION, Location: `/v2/${session.name}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, }); } /** * @param {string} name * @returns {Promise<{id: string, session: UploadSession}>} */ async function createUpload(name) { await sweepUploads(); const id = crypto.randomUUID(); const session = { name, writer: await /** @type {NonNullable} */ ( /** @type {NonNullable} */ (blobs).createWriter )(/** @type {string} */ (await getDid())), hash: hasher(), size: 0, touched: Date.now(), busy: false, }; uploads.set(id, session); return { id, session }; } /** * @param {string} name * @param {string} id * @param {UploadSession} session * @returns {Response} */ function uploadAccepted(name, id, session) { return new Response(null, { status: 202, headers: { [API_VERSION_HEADER]: API_VERSION, Location: `/v2/${name}/blobs/uploads/${id}`, Range: `0-${Math.max(0, session.size - 1)}`, 'Docker-Upload-UUID': id, }, }); } /** * 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').OciRepoRecord} record * @param {boolean} existed */ async function writeRepoRecord(did, name, record, existed) { const storage = /** @type {NonNullable} */ ( actorStorage ); const rkey = nameToRkey(name); const uri = `at://${did}/${collection}/${rkey}`; const encoded = cborEncodeDagCbor(serializeRepoRecord(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 {Request} request * @param {string} did * @param {string} name * @param {string} reference - tag or digest * @returns {Promise} */ async function putManifest(request, did, name, reference) { const declared = request.headers.get('content-type') ?? BLOB_MIME_TYPE; const chunks = []; let size = 0; if (request.body) { 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 > MAX_MANIFEST_SIZE) { await reader.cancel(); return ociError('MANIFEST_INVALID', 'manifest too large', 400); } chunks.push(chunk); } } const bytes = joinChunks(chunks, size); /** @type {import('./manifest.js').ParsedManifest} */ let parsed; try { parsed = parseManifest(bytes, declared); } catch (err) { const message = err instanceof Error ? err.message : String(err); return ociError('MANIFEST_INVALID', message, 400); } const hash = hasher(); await hash.update(bytes); const digest = digestFromHashBytes(await hash.digest()); if (reference.includes(':')) { if (reference !== digest) { return ociError( 'DIGEST_INVALID', `manifest hashes to ${digest}, not ${reference}`, 400, ); } } else if (!isValidTag(reference)) { return ociError('TAG_INVALID', `invalid tag: ${reference}`, 400); } return withRecordLock(name, async () => { const existing = await loadRepo(did, name); // Everything the manifest references must already be here. Layers // with external urls or a foreign media type are distributed outside // this registry, so their absence is expected. /** @type {import('./record.js').LayerEntry[]} */ const layerEntries = []; for (const descriptor of parsed.blobs) { if (!isValidDigest(descriptor.digest)) { return ociError( 'DIGEST_INVALID', `unsupported digest: ${descriptor.digest}`, 400, ); } if ( (descriptor.urls?.length ?? 0) > 0 || descriptor.mediaType?.includes('foreign') ) { continue; } const meta = await blobMeta(did, descriptor.digest); if (!meta) { return ociError( 'MANIFEST_BLOB_UNKNOWN', `blob unknown: ${descriptor.digest}`, 404, ); } layerEntries.push({ digest: descriptor.digest, blob: blobRef( digestToCid(descriptor.digest), descriptor.mediaType ?? meta.mimeType, meta.size, ), }); } for (const child of parsed.children) { if (!existing?.manifests.some((m) => m.digest === child.digest)) { return ociError( 'MANIFEST_BLOB_UNKNOWN', `manifest unknown: ${child.digest}`, 404, ); } } const manifestCid = digestToCid(digest); const storage = /** @type {NonNullable} */ ( actorStorage ); await /** @type {NonNullable} */ (blobs).put( /** @type {string} */ (did), manifestCid, bytes, parsed.mediaType, ); await storage.putBlob(manifestCid, parsed.mediaType, bytes.length); const now = new Date().toISOString(); /** @type {import('./record.js').OciRepoRecord} */ const record = existing ?? { name, tags: [], manifests: [], layers: [], createdAt: now, }; record.updatedAt = now; if (!record.manifests.some((m) => m.digest === digest)) { record.manifests.push({ digest, mediaType: parsed.mediaType, blob: blobRef(manifestCid, parsed.mediaType, bytes.length), }); } for (const entry of layerEntries) { if (!record.layers.some((l) => l.digest === entry.digest)) { record.layers.push(entry); } } if (!reference.includes(':')) { const tag = record.tags.find((t) => t.tag === reference); if (tag) tag.digest = digest; else record.tags.push({ tag: reference, digest }); } await writeRepoRecord(did, name, record, Boolean(existing)); return new Response(null, { status: 201, headers: { [API_VERSION_HEADER]: API_VERSION, Location: `/v2/${name}/manifests/${digest}`, 'Docker-Content-Digest': digest, }, }); }); } /** * Delete a tag, or a manifest with every tag naming it. Removing a * manifest drops the layer references no remaining manifest holds, and * the orphan sweep frees the bytes. * @param {string} did * @param {string} name * @param {string} reference - tag or digest * @returns {Promise} */ async function deleteManifest(did, name, reference) { return withRecordLock(name, async () => { const record = await loadRepo(did, name); if (!record) { return ociError('NAME_UNKNOWN', `repository unknown: ${name}`, 404); } if (!reference.includes(':')) { if (!record.tags.some((t) => t.tag === reference)) { return ociError('MANIFEST_UNKNOWN', `tag unknown: ${reference}`, 404); } record.tags = record.tags.filter((t) => t.tag !== reference); record.updatedAt = new Date().toISOString(); await writeRepoRecord(did, name, record, true); return new Response(null, { status: 202, headers: { [API_VERSION_HEADER]: API_VERSION }, }); } if (!record.manifests.some((m) => m.digest === reference)) { return ociError( 'MANIFEST_UNKNOWN', `manifest unknown: ${reference}`, 404, ); } // The record does not hold which layers each manifest references, so // the remaining manifests are re-read to compute what stays. const stillReferenced = new Set(); for (const entry of record.manifests) { if (entry.digest === reference) continue; const bytes = await readBlob(did, entry.blob.ref.$link); if (!bytes) continue; const parsed = parseManifest(bytes, entry.mediaType); for (const descriptor of parsed.blobs) { stillReferenced.add(descriptor.digest); } } const next = removeManifest(record, reference, stillReferenced); next.updatedAt = new Date().toISOString(); await writeRepoRecord(did, name, next, true); return new Response(null, { status: 202, headers: { [API_VERSION_HEADER]: API_VERSION }, }); }); } // ── Dispatch ─────────────────────────────────────────────────────────── /** * @param {string[]} segments - path segments after /v2/ * @returns {{route: string, name: string, arg: string}|null} */ function matchRoute(segments) { const s = segments; const n = s.length; if (n >= 3 && s[n - 2] === 'manifests') { return { route: 'manifests', name: s.slice(0, n - 2).join('/'), arg: s[n - 1], }; } if (n >= 3 && s[n - 2] === 'tags' && s[n - 1] === 'list') { return { route: 'tags', name: s.slice(0, n - 2).join('/'), arg: '' }; } if (n >= 4 && s[n - 3] === 'blobs' && s[n - 2] === 'uploads') { return { route: 'upload', name: s.slice(0, n - 3).join('/'), arg: s[n - 1], }; } if (n >= 3 && s[n - 2] === 'blobs' && s[n - 1] === 'uploads') { return { route: 'uploads', name: s.slice(0, n - 2).join('/'), arg: '' }; } if (n >= 3 && s[n - 2] === 'blobs') { return { route: 'blobs', name: s.slice(0, n - 2).join('/'), arg: s[n - 1], }; } if (n >= 3 && s[n - 2] === 'referrers') { return { route: 'referrers', name: s.slice(0, n - 2).join('/'), arg: s[n - 1], }; } return null; } 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 !== '/v2' && !pathname.startsWith('/v2/')) return null; try { const did = await getDid(); if (!did) { return ociError('NAME_UNKNOWN', 'server not initialized', 404); } const segments = pathname.split('/').filter((part) => part !== ''); // ['v2', ...]. The ping answers the Bearer challenge to a request // without valid credentials: docker reads its auth scheme from this // response, and without the challenge it never attaches credentials // to a push. A client with no credentials takes a pull-only token // from the realm, which is how anonymous pulls work. if (segments.length === 1) { if (!(await resolveAccess(request))) return challenge(url); return Response.json( {}, { headers: { [API_VERSION_HEADER]: API_VERSION } }, ); } if (segments.length === 2 && segments[1] === 'token') { return await serveToken(request); } if (segments.length === 2 && segments[1] === '_catalog') { return await serveCatalog(did); } const match = matchRoute(segments.slice(1)); if (!match) { return ociError('UNSUPPORTED', 'unsupported route', 404); } const { route, name, arg } = match; if (!isValidName(name)) { return ociError('NAME_INVALID', `invalid name: ${name}`, 400); } const mutating = !['GET', 'HEAD'].includes(request.method); if (mutating) { if (!pushEnabled) { return ociError('UNSUPPORTED', 'registry is read-only', 405); } const access = await resolveAccess(request); if (!access) return challenge(url); if (!access.push) { return ociError('DENIED', 'pull-only credentials', 403); } } if (route === 'blobs') { if (mutating) { return ociError( 'UNSUPPORTED', 'blobs are removed with their manifests', 405, ); } return await serveBlob(request, did, arg); } if (route === 'manifests') { if (request.method === 'PUT') { return await putManifest(request, did, name, arg); } if (request.method === 'DELETE') { return await deleteManifest(did, name, arg); } if (mutating) { return ociError('UNSUPPORTED', 'unsupported method', 405); } return await serveManifest(request, did, name, arg); } if (route === 'tags') { if (mutating) { return ociError('UNSUPPORTED', 'unsupported method', 405); } return await serveTagsList(url, did, name); } if (route === 'referrers') { // The referrers API is not implemented; clients fall back to the // tag schema on 404. return ociError('UNSUPPORTED', 'referrers not supported', 404); } if (route === 'uploads' && request.method === 'POST') { const mount = url.searchParams.get('mount'); if (mount && isValidDigest(mount)) { // Blobs are stored per account rather than per repository, so a // mount is an existence check. if (await blobMeta(did, mount)) { return new Response(null, { status: 201, headers: { [API_VERSION_HEADER]: API_VERSION, Location: `/v2/${name}/blobs/${mount}`, 'Docker-Content-Digest': mount, }, }); } } const { id, session } = await createUpload(name); const digest = url.searchParams.get('digest'); if (digest) { const failed = await appendBody(session, id, request.body); if (failed) return failed; return await finishUpload(session, id, digest); } return uploadAccepted(name, id, session); } if (route === 'upload') { /** @type {UploadSession} */ let session; try { session = takeUpload(arg); } catch { return ociError('BLOB_UPLOAD_UNKNOWN', 'upload unknown', 404); } if (request.method === 'PATCH') { const range = parseContentRange( request.headers.get('content-range'), ); if (range && range.start !== session.size) { return ociError( 'BLOB_UPLOAD_INVALID', `next chunk starts at ${session.size}`, 416, ); } const failed = await appendBody(session, arg, request.body); if (failed) return failed; return uploadAccepted(name, arg, session); } if (request.method === 'PUT') { const digest = url.searchParams.get('digest'); if (!digest) { return ociError('DIGEST_INVALID', 'digest missing', 400); } const failed = await appendBody(session, arg, request.body); if (failed) return failed; return await finishUpload(session, arg, digest); } if (request.method === 'DELETE') { await dropUpload(arg); return new Response(null, { status: 204, headers: { [API_VERSION_HEADER]: API_VERSION }, }); } if (request.method === 'GET') { return new Response(null, { status: 204, headers: { [API_VERSION_HEADER]: API_VERSION, Range: `0-${Math.max(0, session.size - 1)}`, 'Docker-Upload-UUID': arg, }, }); } return ociError('UNSUPPORTED', 'unsupported method', 405); } return ociError('UNSUPPORTED', 'unsupported route', 404); } catch (err) { const message = err instanceof Error ? err.message : String(err); return ociError('UNKNOWN', message, 500); } }, }; }