/** * Celilo Module Registry Server * * Implements the Cargo sparse registry protocol: * GET /index/config.json → sparse protocol config * GET /index/{path} → sparse index file (NDJSON) * GET /api/v1/modules → search modules * GET /api/v1/modules/{name} → module metadata * GET /api/v1/modules/{name}/{ver}/download → download .netapp * PUT /api/v1/modules/new → publish (token auth) * DELETE /api/v1/modules/{name}/{ver}/yank → yank * PUT /api/v1/modules/{name}/{ver}/unyank → unyank * POST /api/v1/modules/sweep → reclaim old revisions (admin) * * All routes can be prefixed with PATH_PREFIX (e.g. "/registry"). * * When BOOTSTRAP_MODULES_DIR is set, modules discovered there are exposed * through the same endpoints — see bootstrap.ts. Bootstrap entries are * shadowed by real publishes (same name): the stored package wins so tests * can publish-then-import against a live registry. */ import { join } from 'node:path'; import { ADMIN_SCOPE, TokenAuth } from './auth'; import { type BootstrapEntry, bootstrapIndexEntry, packageBootstrapModule, scanBootstrapDir, scanUploadsDir, } from './bootstrap'; import { IntrospectionVerifier } from './introspection'; import { landingResponse } from './landing'; import { ModuleOwnerStore, fileModuleOwnerPersistence } from './module-owner-store'; import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit'; import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store'; import { type IndexEntry, RegistryStorage } from './storage'; import { DEFAULT_KEEP_BUILD_REVISIONS, sweep } from './sweep'; import { isValidName, isValidVersion, validateNameAndVersion } from './validation'; /** Max total publish body size. Real modules are a few MB; 100MB is generous. */ const MAX_PUBLISH_BYTES = 100 * 1024 * 1024; /** Max JSON metadata blob size inside a publish body. Real metadata is ~hundreds of bytes. */ const MAX_META_BYTES = 64 * 1024; /** * RFC 6266-safe Content-Disposition value. Strips everything but * `[A-Za-z0-9_.+-]` for the quoted form (defense-in-depth: even if the * upstream name/version validator is bypassed, the header cannot inject * CRLF or break out of the quoted-string), and includes the original * UTF-8 value via `filename*=UTF-8''...` for compliant clients. */ function contentDispositionAttachment(filename: string): string { const quotedSafe = filename.replace(/[^A-Za-z0-9_.+\-]/g, '_'); return `attachment; filename="${quotedSafe}"; filename*=UTF-8''${encodeURIComponent(filename)}`; } interface ServerOptions { dataDir: string; port: number; pathPrefix: string; publicUrl: string; /** Scanned for module source dirs (with manifest.yml), packaged on demand. */ bootstrapModulesDir?: string; /** Scanned for pre-built .netapp files, served directly. */ bootstrapUploadsDir?: string; bootstrapCacheDir?: string; auth: TokenAuth; /** * Optional override for the write-endpoint rate limiter; defaults to * 30 events per IP per minute, which is generous for a registry (real * publishes happen on the order of once per feature) but tight enough * to slow a bad actor to a crawl. */ rateLimiter?: RateLimiter; /** * Store of minted, per-repo package-scoped tokens (build-bus Phase 3, * ISS-0140). Defaults to a JSON file in `dataDir`. Its persisted hashes are * loaded into `auth` at startup so minted tokens survive a restart. */ scopedTokenStore?: ScopedTokenStore; /** * RFC 7662 introspection verifier for idp identity tokens (ce-s7e). When * present, a publish token unknown to the opaque set is verified against the * idp and mapped to a scope. When absent, only the opaque-token path applies. */ introspection?: IntrospectionVerifier; /** * Module-owner table for the hybrid group + owner authorization (ce-1ch). * Defaults to a JSON file in `dataDir`. Records first-publish-claims so a * verified non-admin publisher may only publish modules it owns. */ moduleOwnerStore?: ModuleOwnerStore; } export function startServer(options: ServerOptions): ReturnType { const { dataDir, port, pathPrefix, publicUrl, auth, introspection } = options; const storage = new RegistryStorage(dataDir); const scopedTokenStore = options.scopedTokenStore ?? new ScopedTokenStore(fileScopedTokenPersistence(join(dataDir, 'scoped-tokens.json'))); const moduleOwnerStore = options.moduleOwnerStore ?? new ModuleOwnerStore(fileModuleOwnerPersistence(join(dataDir, 'module-owners.json'))); // Load persisted minted tokens into the in-memory authorizer. for (const entry of scopedTokenStore.list()) { auth.addHashed(entry.hash, entry.scope); } const bootstrapCacheDir = options.bootstrapCacheDir ?? join(dataDir, '.bootstrap-cache'); const rateLimiter = options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 }); /** * Rescan every request so .netapps dropped into bootstrapUploadsDir at * runtime (via `docker cp` from the e2e harness) are picked up without * restarting the server. Source dirs are less volatile so we only rescan * them when uploads also get rescanned — the cost is a readdir or two per * request, negligible in the e2e fixture context where this runs. * * In production neither env var is set and this function returns an empty * map without touching the disk. */ function resolveBootstrap(): Map { const merged = new Map(); if (options.bootstrapModulesDir) { for (const [name, entry] of scanBootstrapDir(options.bootstrapModulesDir)) { merged.set(name, entry); } } if (options.bootstrapUploadsDir) { // Uploads shadow source-dir entries for the same name. for (const [name, entry] of scanUploadsDir(options.bootstrapUploadsDir)) { merged.set(name, entry); } } return merged; } const sparseConfig = { dl: `${publicUrl}/api/v1/modules/{name}/{version}/download`, api: publicUrl, }; function err(detail: string, status = 400): Response { return Response.json({ errors: [{ detail }] }, { status }); } function notFound(): Response { return err('Not found', 404); } function unauthorized(): Response { return err('Unauthorized — provide a valid publish token in the Authorization header', 401); } /** * True when the request's token may publish/yank `pkg`. Layered: * 1. **Opaque publish token** (build-bus Phase 3): a locally-known token * authorizes synchronously by admin/exact scope. * 2. **idp identity token** (ce-s7e verify-bridge + ce-1ch owner table): a * token unknown to the opaque set is verified via RFC 7662 introspection * to a {@link VerifiedIdentity}, then gated by the hybrid group + owner * rule (see {@link authorizeIdentity}). Fails CLOSED (deny) on any error. */ async function authorizePackage(req: Request, pkg: string): Promise { const header = req.headers.get('Authorization') ?? ''; if (auth.authorize(header, pkg)) return true; if (!introspection) return false; const identity = await introspection.identify(header); if (!identity) return false; return authorizeIdentity(identity, pkg); } /** * Hybrid group + owner-table decision for a verified identity (ce-1ch, D-C): * - **admin group** → publish anything; first publish of an unclaimed name * records ownership (so a subsequent non-admin owner check has an anchor). * - **publisher group** → unclaimed name: claim it + allow; owned by this * `sub`: allow; owned by someone else: **DENY** (confused-deputy defense). * - anything else → deny. */ function authorizeIdentity( identity: { sub?: string; isAdmin: boolean; isPublisher: boolean; group?: string }, pkg: string, ): boolean { const owner = moduleOwnerStore.get(pkg); if (identity.isAdmin) { // Admins publish anything; claim an unclaimed name only when we have a // sub to attribute it to (an admin token without a sub still publishes). if (!owner && identity.sub) { moduleOwnerStore.claim(pkg, identity.sub, identity.group ?? ADMIN_SCOPE); } return true; } if (identity.isPublisher) { // Ownership can't be attributed without a stable subject → deny. if (!identity.sub) return false; if (!owner) { moduleOwnerStore.claim(pkg, identity.sub, identity.group ?? ''); return true; } return owner.ownerSub === identity.sub; } return false; } /** True when the request carries an admin (`*`-scoped) opaque token. */ function authorizeAdmin(req: Request): boolean { return auth.isAdmin(req.headers.get('Authorization') ?? ''); } /** * True when the request is admin — via an opaque `*`-scoped token OR a * verified idp identity in the admin group (ce-1ch). Used to gate the * owner-table management endpoints; the CLI drives these with the opaque * admin token, but an operator's idp admin token works too. */ async function authorizeAdminReq(req: Request): Promise { const header = req.headers.get('Authorization') ?? ''; if (auth.isAdmin(header)) return true; if (!introspection) return false; const identity = await introspection.identify(header); return identity?.isAdmin ?? false; } /** * Rate-limit check for write endpoints. Returns a 429 response when * exceeded (with Retry-After), null when allowed. */ function rateLimitOrNull( req: Request, srv: { requestIP(r: Request): { address: string } | null }, ): Response | null { const ip = clientIp(req, srv); const result = rateLimiter.take(ip); if (result.ok) return null; return new Response( JSON.stringify({ errors: [{ detail: 'Too many write requests — try again later' }] }), { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': String(result.retryAfterSec), }, }, ); } /** * Resolve a module name to (a) its published entries and (b) whether a * bootstrap fallback exists. Real publishes take precedence — once a name * has any published version, bootstrap for that name is ignored, so tests * that publish namecheap@1.0.0+1 don't keep picking up the repo's source. * * Caller MUST have validated `name` first. We also guard here as a cheap * backstop since the function is reachable from several handlers. */ function resolveEntries(name: string): IndexEntry[] { if (!isValidName(name)) return []; const published = storage.readIndex(name); if (published.length > 0) return published; const b = resolveBootstrap().get(name); return b ? [bootstrapIndexEntry(b)] : []; } async function serveBootstrapDownload(entry: BootstrapEntry): Promise { const netappPath = await packageBootstrapModule(entry, bootstrapCacheDir); return new Response(Bun.file(netappPath), { headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': contentDispositionAttachment(`${entry.name}.netapp`), }, }); } async function handlePublish(req: Request): Promise { // Refuse to even buffer oversized publishes. Content-Length can be spoofed // but a hostile client that lies to get past this still has to stream // through Caddy's per-connection read limits; defense-in-depth. const declaredLength = Number(req.headers.get('Content-Length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_PUBLISH_BYTES) { return err( `Publish body too large: ${declaredLength} bytes exceeds ${MAX_PUBLISH_BYTES}`, 413, ); } const buf = Buffer.from(await req.arrayBuffer()); if (buf.length > MAX_PUBLISH_BYTES) { return err(`Publish body too large: ${buf.length} bytes exceeds ${MAX_PUBLISH_BYTES}`, 413); } if (buf.length < 8) return err('Request body too short'); const metaLen = buf.readUInt32LE(0); // Cap the JSON metadata independently of the overall body. A malicious // payload could set metaLen close to MAX_PUBLISH_BYTES to force // multi-MB JSON.parse; the sparse protocol's metadata is always tiny // in practice (a few hundred bytes). if (metaLen > MAX_META_BYTES) { return err(`Metadata too large: ${metaLen} bytes exceeds ${MAX_META_BYTES}`, 413); } if (buf.length < 4 + metaLen + 4) return err('Request body truncated (metadata)'); let meta: Record; try { meta = JSON.parse(buf.subarray(4, 4 + metaLen).toString('utf-8')) as Record; } catch { return err('Invalid JSON metadata'); } const name = String(meta.name ?? ''); const vers = String(meta.vers ?? ''); // Description is optional, comes from the publishing client (the // celilo CLI reads manifest.yml#description and includes it). When // not provided we leave it undefined; the search endpoint falls // back to bootstrap data or empty. const description = typeof meta.description === 'string' ? meta.description : undefined; // Icon likewise, but capped at one Unicode scalar. The CLI already refines // it at manifest-parse time; this second check is here because the publish // metadata is arbitrary client JSON and the value lands in a fixed-width // slot on the browse page. const icon = typeof meta.icon === 'string' && [...meta.icon].length === 1 ? meta.icon : undefined; const validation = validateNameAndVersion(name, vers); if (!validation.ok) return err(validation.message); // Scope check AFTER the name is known: a package-scoped token may publish // only its own package; an admin token publishes anything (ISS-0140). if (!(await authorizePackage(req, name))) return unauthorized(); const fileOffset = 4 + metaLen; const fileLen = buf.readUInt32LE(fileOffset); if (buf.length < fileOffset + 4 + fileLen) return err('Request body truncated (file)'); const fileData = buf.subarray(fileOffset + 4, fileOffset + 4 + fileLen); if (storage.packageExists(name, vers)) { return err(`Version ${name}@${vers} already exists — versions are immutable`, 409); } // The registry refuses a glyph another module already holds (module-icons // D8, operator ruling 2026-09-01). The module's own earlier versions are // excluded: republishing with the icon you already have is the normal // case, not a collision. Before any mutation — a refusal must not leave a // stored payload behind, the same ordering discipline as the check above. if (icon) { const holder = [...storage.latestIcons()].find( ([heldName, heldIcon]) => heldName !== name && heldIcon === icon, ); if (holder) { return err( `icon '${icon}' is already used by module ${holder[0]} — declare a glyph no other module holds (openspec/changes/module-icons, D8)`, 409, ); } } const cksum = storage.storePackage(name, vers, fileData); storage.appendIndex({ name, vers, deps: [], cksum: `sha256:${cksum}`, yanked: false, description, icon, }); console.log(`[registry] published ${name}@${vers} (${fileLen} bytes, sha256:${cksum})`); return Response.json({ ok: true, name, vers }); } /** * Reclaim disk from superseded build revisions (admin-only). * * The counterpart yank never was: yanking flips a boolean and frees nothing, * so before this endpoint existed every revision ever published was retained * forever and the store grew without bound until the disk filled. * * Runs IN the process that owns the store, so it cannot race a concurrent * publish's index append. `dry_run` reports the same plan without touching * anything — the safe way to see what a policy would do on a live store. */ async function handleSweep(req: Request): Promise { if (!(await authorizeAdminReq(req))) return unauthorized(); let body: { keep_build_revisions?: unknown; dry_run?: unknown } = {}; try { const text = await req.text(); if (text.trim()) body = JSON.parse(text) as typeof body; } catch { return err('Invalid JSON body'); } const requested = body.keep_build_revisions; if (requested !== undefined && (!Number.isInteger(requested) || (requested as number) < 1)) { return err('keep_build_revisions must be an integer >= 1'); } const keepBuildRevisions = (requested as number | undefined) ?? DEFAULT_KEEP_BUILD_REVISIONS; const dryRun = body.dry_run === true; const report = sweep(storage, { keepBuildRevisions }, dryRun); console.log( `[registry] sweep${dryRun ? ' (dry run)' : ''} keep=${keepBuildRevisions}: ` + `${report.removedCount} superseded + ${report.orphanCount} orphaned revision(s), ` + `${Math.round(report.reclaimedBytes / 1024 / 1024)} MB`, ); // Both of these mean the store is damaged, not merely untidy, and the sweep // deliberately did not act on either. Say so where an operator will see it. if (report.danglingCount > 0) { console.warn( `[registry] ${report.danglingCount} index line(s) have no package file — left in place; download 404s until republished`, ); } if (report.unreadable.length > 0) { console.warn( `[registry] skipped entirely (no package files at all — check the mount and DATA_DIR): ${report.unreadable.join(', ')}`, ); } return Response.json({ ok: true, ...report }); } function setYanked(name: string, version: string, yanked: boolean): Response { const entries = storage.readIndex(name); const entry = entries.find((e) => e.vers === version); if (!entry) return notFound(); entry.yanked = yanked; storage.updateIndex(name, entries); return Response.json({ ok: true }); } /** * Mint a per-repo, package-scoped publish token (ISS-0140). Admin-only. The * `registry_publish` capability calls this on behalf of `source_forge. * registerRepo`. Reconciled: re-minting for the same repo rotates the token. * The raw token is returned ONCE — the caller sets it as the repo's Actions * secret; the server keeps only its hash. */ async function handleMintToken(req: Request): Promise { if (!authorizeAdmin(req)) return unauthorized(); let body: { repo?: unknown; scope?: unknown }; try { body = (await req.json()) as { repo?: unknown; scope?: unknown }; } catch { return err('Invalid JSON body'); } const repo = typeof body.repo === 'string' ? body.repo.trim() : ''; const scope = typeof body.scope === 'string' ? body.scope.trim() : ''; if (!repo) return err('repo is required'); if (!scope || !isValidName(scope)) return err('scope must be a valid package name'); const { token, entry, revokedHashes } = scopedTokenStore.mint(repo, scope); for (const h of revokedHashes) auth.removeHashed(h); auth.addHashed(entry.hash, entry.scope); console.log(`[registry] minted scoped token for ${repo} → ${scope}`); return Response.json({ ok: true, token, scope, repo }); } /** Revoke all scoped tokens for a repo (ISS-0140). Admin-only. */ async function handleRevokeToken(req: Request): Promise { if (!authorizeAdmin(req)) return unauthorized(); let body: { repo?: unknown }; try { body = (await req.json()) as { repo?: unknown }; } catch { return err('Invalid JSON body'); } const repo = typeof body.repo === 'string' ? body.repo.trim() : ''; if (!repo) return err('repo is required'); const removed = scopedTokenStore.revoke(repo); for (const h of removed) auth.removeHashed(h); console.log(`[registry] revoked ${removed.length} scoped token(s) for ${repo}`); return Response.json({ ok: true, repo, revoked: removed.length }); } /** List the whole module-owner table (ce-1ch). Admin-only. */ async function handleOwnerList(req: Request): Promise { if (!(await authorizeAdminReq(req))) return unauthorized(); return Response.json({ owners: moduleOwnerStore.list() }); } /** Show the owner of one module name (ce-1ch). Admin-only. */ async function handleOwnerShow(req: Request, name: string): Promise { if (!(await authorizeAdminReq(req))) return unauthorized(); if (!isValidName(name)) return err('Invalid module name'); const owner = moduleOwnerStore.get(name); if (!owner) return notFound(); return Response.json({ owner }); } /** Reassign ownership of a module name to a new subject (ce-1ch). Admin-only. */ async function handleOwnerSet(req: Request, name: string): Promise { if (!(await authorizeAdminReq(req))) return unauthorized(); if (!isValidName(name)) return err('Invalid module name'); let body: { ownerSub?: unknown }; try { body = (await req.json()) as { ownerSub?: unknown }; } catch { return err('Invalid JSON body'); } const ownerSub = typeof body.ownerSub === 'string' ? body.ownerSub.trim() : ''; if (!ownerSub) return err('ownerSub is required'); const owner = moduleOwnerStore.reassign(name, ownerSub, 'admin-reassign'); console.log(`[registry] reassigned owner of ${name} → ${ownerSub}`); return Response.json({ ok: true, owner }); } const server = Bun.serve({ port, async fetch(req, srv) { const url = new URL(req.url); const path = url.pathname; const method = req.method; // Strip the configured prefix (if any) before matching. if (pathPrefix && !path.startsWith(pathPrefix)) return notFound(); const suffix = pathPrefix ? path.slice(pathPrefix.length) : path; // Polite redirect for the bare registry root: serve a 301 → // /modules/ on the celilo.computer site, with a minimal HTML // body for clients that don't follow redirects (curl without // -L, bots, bare HTTP libraries). Matches three forms so it // works whether PATH_PREFIX is set or empty: // - suffix='' → request was exactly the prefix, no slash // - suffix='/' → request was prefix + '/', or just '/' // - suffix='/index.html' // See apps/celilo/designs/REGISTRY_BROWSE_UI.md (decision D2). if (method === 'GET' && (suffix === '' || suffix === '/' || suffix === '/index.html')) { return landingResponse(publicUrl); } // Sparse config if (method === 'GET' && suffix === '/index/config.json') { return Response.json(sparseConfig); } // Sparse index files — the module name is the last path segment. // Reject anything that isn't a valid module name so the storage layer // can never see a path-traversal string via the index endpoint. if (method === 'GET' && suffix.startsWith('/index/')) { const name = decodeURIComponent(suffix.slice(suffix.lastIndexOf('/') + 1)); if (!isValidName(name)) return notFound(); const entries = resolveEntries(name); if (entries.length === 0) return notFound(); const body = `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`; return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } const apiBase = '/api/v1/modules'; // Search if (method === 'GET' && suffix === apiBase) { const q = url.searchParams.get('q')?.toLowerCase() ?? ''; const perPage = Math.min(Number(url.searchParams.get('per_page') ?? 25), 100); // Sort options: 'name' (default, alphabetical) or 'downloads' // (most-downloaded first, ties broken alphabetically). Anything // unrecognized falls back to 'name'. const sort = url.searchParams.get('sort') === 'downloads' ? 'downloads' : 'name'; const publishedNames = new Set(storage.listModules().map((m) => m.name)); const bootstrap = resolveBootstrap(); const allNames = new Set([...publishedNames, ...bootstrap.keys()]); const filtered = [...allNames].filter((n) => !q || n.includes(q)); // Build the per-module records first so sorting can read the // download counts. Empty input → empty output, no work done. const records = filtered.map((name) => { const entries = resolveEntries(name); const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1); // Description preference order: // 1. Latest published version's stored description (best — // reflects the captured-at-publish-time manifest). // 2. Bootstrap entry's manifest description (when serving // a module from a source directory the registry hasn't // formally received via publish). // 3. Empty string (older publishes that pre-date the // description capture and haven't been republished). const bootstrapEntry = bootstrap.get(name); const description = latest?.description ?? bootstrapEntry?.description ?? ''; // Icon follows the same order, but has no empty-string tier: absent // means "this module declared none", and the consumer resolves it // from its own table or a placeholder (module-icons D5). const icon = latest?.icon ?? bootstrapEntry?.icon; return { name, max_version: latest?.vers ?? '0.0.0', description, icon, total_downloads: storage.getDownloads(name), }; }); if (sort === 'downloads') { records.sort((a, b) => { if (b.total_downloads !== a.total_downloads) { return b.total_downloads - a.total_downloads; } return a.name.localeCompare(b.name); }); } else { records.sort((a, b) => a.name.localeCompare(b.name)); } return Response.json({ modules: records.slice(0, perPage), total: records.length, }); } // Publish — rate-limit before even checking auth so a bad-token flood // can't crowd out legitimate traffic on the shared buckets. The scope // check happens inside handlePublish, once the package name is known. if (method === 'PUT' && suffix === `${apiBase}/new`) { const rl = rateLimitOrNull(req, srv); if (rl) return rl; return handlePublish(req); } // Mint / revoke per-repo scoped publish tokens (admin-only — ISS-0140). if (method === 'POST' && suffix === `${apiBase}/tokens/mint`) { const rl = rateLimitOrNull(req, srv); if (rl) return rl; return handleMintToken(req); } if (method === 'POST' && suffix === `${apiBase}/tokens/revoke`) { const rl = rateLimitOrNull(req, srv); if (rl) return rl; return handleRevokeToken(req); } // Reclaim disk from superseded build revisions (admin-only), grouped // with the other admin endpoints. A module NAMED "sweep" is fine here: // its metadata route is a GET, so the two never compete. if (method === 'POST' && suffix === `${apiBase}/sweep`) { const rl = rateLimitOrNull(req, srv); if (rl) return rl; return handleSweep(req); } // Module-owner table management (admin-only — ce-1ch). Matched before the // generic `${apiBase}/{name}` handlers so a name of "owners" can't shadow // them. if (method === 'GET' && suffix === `${apiBase}/owners`) { return handleOwnerList(req); } if (suffix.startsWith(`${apiBase}/owners/`)) { const name = decodeURIComponent(suffix.slice(`${apiBase}/owners/`.length)); if (method === 'GET') return handleOwnerShow(req, name); if (method === 'POST') { const rl = rateLimitOrNull(req, srv); if (rl) return rl; return handleOwnerSet(req, name); } } if (suffix.startsWith(`${apiBase}/`)) { const modulePath = suffix.slice(apiBase.length + 1); // URL-decode path segments — module versions use a literal '+' to // separate semver from pkg revision (e.g. 1.0.0+1), and clients // URL-encode that as %2B. Without decoding, storage lookups miss // because they'd use the raw escape string. const parts = modulePath.split('/').map((p) => decodeURIComponent(p)); // Module metadata if (method === 'GET' && parts.length === 1 && parts[0]) { const name = parts[0]; if (!isValidName(name)) return notFound(); const entries = resolveEntries(name); if (entries.length === 0) return notFound(); // Description: latest non-yanked version's stored description, // falling back to the bootstrap entry's manifest description // for source-mode modules. Same precedence as the search // endpoint — see notes there. const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1); const bootstrapEntry = resolveBootstrap().get(name); const description = latest?.description ?? bootstrapEntry?.description ?? ''; const icon = latest?.icon ?? bootstrapEntry?.icon; return Response.json({ name, description, icon, total_downloads: storage.getDownloads(name), versions: entries.map((v) => ({ num: v.vers, yanked: v.yanked, created_at: new Date(0).toISOString(), })), }); } // Download if (method === 'GET' && parts.length === 3 && parts[2] === 'download') { const [name, version] = parts; if (!isValidName(name) || !isValidVersion(version)) return notFound(); const data = storage.readPackage(name, version); if (data) { // Bump the per-module counter on every successful download. // Liberal counting: no User-Agent filter, no de-duplication. // CI/crawler traffic inflates the number but the simplicity // is worth it — see REGISTRY_BROWSE_UI.md (Phase 3). storage.incrementDownloads(name); return new Response(new Uint8Array(data), { headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': contentDispositionAttachment(`${name}-${version}.netapp`), }, }); } // Bootstrap fallback — published packages take precedence above. const b = resolveBootstrap().get(name); if (b) { storage.incrementDownloads(name); return serveBootstrapDownload(b); } return notFound(); } // Yank if (method === 'DELETE' && parts.length === 3 && parts[2] === 'yank') { const rl = rateLimitOrNull(req, srv); if (rl) return rl; const [name, version] = parts; if (!isValidName(name) || !isValidVersion(version)) return notFound(); if (!(await authorizePackage(req, name))) return unauthorized(); return setYanked(name, version, true); } // Unyank if (method === 'PUT' && parts.length === 3 && parts[2] === 'unyank') { const rl = rateLimitOrNull(req, srv); if (rl) return rl; const [name, version] = parts; if (!isValidName(name) || !isValidVersion(version)) return notFound(); if (!(await authorizePackage(req, name))) return unauthorized(); return setYanked(name, version, false); } } return notFound(); }, }); return server; } /** Env-var entry point used by bin/celilo-registry-server and the Dockerfile. */ export function startFromEnv(): void { const dataDir = process.env.DATA_DIR ?? '/var/lib/celilo-registry'; const port = Number(process.env.PORT ?? 3000); const pathPrefix = (process.env.PATH_PREFIX ?? '').replace(/\/$/, ''); const publicUrl = process.env.PUBLIC_URL ?? `https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`; const bootstrapModulesDir = process.env.BOOTSTRAP_MODULES_DIR || undefined; const bootstrapUploadsDir = process.env.BOOTSTRAP_UPLOADS_DIR || undefined; const bootstrapCacheDir = process.env.BOOTSTRAP_CACHE_DIR || undefined; const auth = TokenAuth.fromEnv(); const introspection = IntrospectionVerifier.fromEnv(); const server = startServer({ dataDir, port, pathPrefix, publicUrl, bootstrapModulesDir, bootstrapUploadsDir, bootstrapCacheDir, auth, introspection, }); const bootstrapBits = [ bootstrapModulesDir ? `modulesDir=${bootstrapModulesDir}` : '', bootstrapUploadsDir ? `uploadsDir=${bootstrapUploadsDir}` : '', ] .filter(Boolean) .join(', '); console.log( `[registry] listening on :${server.port} (prefix=${pathPrefix || '(none)'}, publicUrl=${publicUrl}${ bootstrapBits ? `, bootstrap=[${bootstrapBits}]` : '' }, auth=${auth.hasTokens() ? 'enabled' : 'disabled (publish will always 401)'}, introspection=${ introspection ? 'enabled' : 'disabled' })`, ); } // Invoke when run as an entry point (`bun run src/server.ts`). if (import.meta.main) { startFromEnv(); }