@celilo/e2e 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/resolver/unbound.conf +26 -0
  11. package/config/routing/management-routes.sh +9 -4
  12. package/config/routing/resolver-internal-routes.sh +10 -0
  13. package/docker/Dockerfile.cpanel-host-sim +70 -0
  14. package/docker/Dockerfile.firewall +12 -0
  15. package/docker/Dockerfile.management +6 -0
  16. package/docker/Dockerfile.observer +11 -0
  17. package/docker/Dockerfile.signal-cli +60 -0
  18. package/docker/Dockerfile.signal-release +36 -0
  19. package/docker/Dockerfile.signal-sim +17 -0
  20. package/docker/Dockerfile.target-machine +9 -0
  21. package/package.json +3 -3
  22. package/registry-server/package.json +17 -0
  23. package/registry-server/src/auth.test.ts +76 -0
  24. package/registry-server/src/auth.ts +105 -0
  25. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  26. package/registry-server/src/bootstrap.ts +246 -0
  27. package/registry-server/src/index.ts +16 -0
  28. package/registry-server/src/introspection.test.ts +247 -0
  29. package/registry-server/src/introspection.ts +204 -0
  30. package/registry-server/src/landing.ts +90 -0
  31. package/registry-server/src/module-owner-store.test.ts +85 -0
  32. package/registry-server/src/module-owner-store.ts +108 -0
  33. package/registry-server/src/rate-limit.test.ts +62 -0
  34. package/registry-server/src/rate-limit.ts +83 -0
  35. package/registry-server/src/scoped-token-store.test.ts +93 -0
  36. package/registry-server/src/scoped-token-store.ts +110 -0
  37. package/registry-server/src/server.test.ts +771 -0
  38. package/registry-server/src/server.ts +701 -0
  39. package/registry-server/src/storage.test.ts +148 -0
  40. package/registry-server/src/storage.ts +150 -0
  41. package/registry-server/src/validation.test.ts +86 -0
  42. package/registry-server/src/validation.ts +60 -0
  43. package/registry-server/tsconfig.json +15 -0
  44. package/scripts/stage-libsignal.ts +116 -0
  45. package/simulators/signal-cli/server.ts +272 -0
  46. package/src/cli/build.ts +7 -0
  47. package/src/cli/index.ts +6 -0
  48. package/src/container-manager.ts +5 -1
  49. package/src/docker-compose-generator.ts +108 -4
  50. package/src/network-builder.ts +52 -0
  51. package/src/simulator-ips.ts +9 -0
  52. package/src/types.ts +34 -0
@@ -0,0 +1,701 @@
1
+ /**
2
+ * Celilo Module Registry Server
3
+ *
4
+ * Implements the Cargo sparse registry protocol:
5
+ * GET /index/config.json → sparse protocol config
6
+ * GET /index/{path} → sparse index file (NDJSON)
7
+ * GET /api/v1/modules → search modules
8
+ * GET /api/v1/modules/{name} → module metadata
9
+ * GET /api/v1/modules/{name}/{ver}/download → download .netapp
10
+ * PUT /api/v1/modules/new → publish (token auth)
11
+ * DELETE /api/v1/modules/{name}/{ver}/yank → yank
12
+ * PUT /api/v1/modules/{name}/{ver}/unyank → unyank
13
+ *
14
+ * All routes can be prefixed with PATH_PREFIX (e.g. "/registry").
15
+ *
16
+ * When BOOTSTRAP_MODULES_DIR is set, modules discovered there are exposed
17
+ * through the same endpoints — see bootstrap.ts. Bootstrap entries are
18
+ * shadowed by real publishes (same name): the stored package wins so tests
19
+ * can publish-then-import against a live registry.
20
+ */
21
+
22
+ import { join } from 'node:path';
23
+ import { ADMIN_SCOPE, TokenAuth } from './auth';
24
+ import { IntrospectionVerifier } from './introspection';
25
+ import {
26
+ type BootstrapEntry,
27
+ bootstrapIndexEntry,
28
+ packageBootstrapModule,
29
+ scanBootstrapDir,
30
+ scanUploadsDir,
31
+ } from './bootstrap';
32
+ import { landingResponse } from './landing';
33
+ import { ModuleOwnerStore, fileModuleOwnerPersistence } from './module-owner-store';
34
+ import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
35
+ import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
36
+ import { type IndexEntry, RegistryStorage } from './storage';
37
+ import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
38
+
39
+ /** Max total publish body size. Real modules are a few MB; 100MB is generous. */
40
+ const MAX_PUBLISH_BYTES = 100 * 1024 * 1024;
41
+
42
+ /** Max JSON metadata blob size inside a publish body. Real metadata is ~hundreds of bytes. */
43
+ const MAX_META_BYTES = 64 * 1024;
44
+
45
+ /**
46
+ * RFC 6266-safe Content-Disposition value. Strips everything but
47
+ * `[A-Za-z0-9_.+-]` for the quoted form (defense-in-depth: even if the
48
+ * upstream name/version validator is bypassed, the header cannot inject
49
+ * CRLF or break out of the quoted-string), and includes the original
50
+ * UTF-8 value via `filename*=UTF-8''...` for compliant clients.
51
+ */
52
+ function contentDispositionAttachment(filename: string): string {
53
+ const quotedSafe = filename.replace(/[^A-Za-z0-9_.+\-]/g, '_');
54
+ return `attachment; filename="${quotedSafe}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
55
+ }
56
+
57
+ interface ServerOptions {
58
+ dataDir: string;
59
+ port: number;
60
+ pathPrefix: string;
61
+ publicUrl: string;
62
+ /** Scanned for module source dirs (with manifest.yml), packaged on demand. */
63
+ bootstrapModulesDir?: string;
64
+ /** Scanned for pre-built .netapp files, served directly. */
65
+ bootstrapUploadsDir?: string;
66
+ bootstrapCacheDir?: string;
67
+ auth: TokenAuth;
68
+ /**
69
+ * Optional override for the write-endpoint rate limiter; defaults to
70
+ * 30 events per IP per minute, which is generous for a registry (real
71
+ * publishes happen on the order of once per feature) but tight enough
72
+ * to slow a bad actor to a crawl.
73
+ */
74
+ rateLimiter?: RateLimiter;
75
+ /**
76
+ * Store of minted, per-repo package-scoped tokens (build-bus Phase 3,
77
+ * ISS-0140). Defaults to a JSON file in `dataDir`. Its persisted hashes are
78
+ * loaded into `auth` at startup so minted tokens survive a restart.
79
+ */
80
+ scopedTokenStore?: ScopedTokenStore;
81
+ /**
82
+ * RFC 7662 introspection verifier for idp identity tokens (ce-s7e). When
83
+ * present, a publish token unknown to the opaque set is verified against the
84
+ * idp and mapped to a scope. When absent, only the opaque-token path applies.
85
+ */
86
+ introspection?: IntrospectionVerifier;
87
+ /**
88
+ * Module-owner table for the hybrid group + owner authorization (ce-1ch).
89
+ * Defaults to a JSON file in `dataDir`. Records first-publish-claims so a
90
+ * verified non-admin publisher may only publish modules it owns.
91
+ */
92
+ moduleOwnerStore?: ModuleOwnerStore;
93
+ }
94
+
95
+ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve> {
96
+ const { dataDir, port, pathPrefix, publicUrl, auth, introspection } = options;
97
+ const storage = new RegistryStorage(dataDir);
98
+ const scopedTokenStore =
99
+ options.scopedTokenStore ??
100
+ new ScopedTokenStore(fileScopedTokenPersistence(join(dataDir, 'scoped-tokens.json')));
101
+ const moduleOwnerStore =
102
+ options.moduleOwnerStore ??
103
+ new ModuleOwnerStore(fileModuleOwnerPersistence(join(dataDir, 'module-owners.json')));
104
+ // Load persisted minted tokens into the in-memory authorizer.
105
+ for (const entry of scopedTokenStore.list()) {
106
+ auth.addHashed(entry.hash, entry.scope);
107
+ }
108
+ const bootstrapCacheDir = options.bootstrapCacheDir ?? join(dataDir, '.bootstrap-cache');
109
+ const rateLimiter = options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 });
110
+
111
+ /**
112
+ * Rescan every request so .netapps dropped into bootstrapUploadsDir at
113
+ * runtime (via `docker cp` from the e2e harness) are picked up without
114
+ * restarting the server. Source dirs are less volatile so we only rescan
115
+ * them when uploads also get rescanned — the cost is a readdir or two per
116
+ * request, negligible in the e2e fixture context where this runs.
117
+ *
118
+ * In production neither env var is set and this function returns an empty
119
+ * map without touching the disk.
120
+ */
121
+ function resolveBootstrap(): Map<string, BootstrapEntry> {
122
+ const merged = new Map<string, BootstrapEntry>();
123
+ if (options.bootstrapModulesDir) {
124
+ for (const [name, entry] of scanBootstrapDir(options.bootstrapModulesDir)) {
125
+ merged.set(name, entry);
126
+ }
127
+ }
128
+ if (options.bootstrapUploadsDir) {
129
+ // Uploads shadow source-dir entries for the same name.
130
+ for (const [name, entry] of scanUploadsDir(options.bootstrapUploadsDir)) {
131
+ merged.set(name, entry);
132
+ }
133
+ }
134
+ return merged;
135
+ }
136
+
137
+ const sparseConfig = {
138
+ dl: `${publicUrl}/api/v1/modules/{name}/{version}/download`,
139
+ api: publicUrl,
140
+ };
141
+
142
+ function err(detail: string, status = 400): Response {
143
+ return Response.json({ errors: [{ detail }] }, { status });
144
+ }
145
+
146
+ function notFound(): Response {
147
+ return err('Not found', 404);
148
+ }
149
+
150
+ function unauthorized(): Response {
151
+ return err('Unauthorized — provide a valid publish token in the Authorization header', 401);
152
+ }
153
+
154
+ /**
155
+ * True when the request's token may publish/yank `pkg`. Layered:
156
+ * 1. **Opaque publish token** (build-bus Phase 3): a locally-known token
157
+ * authorizes synchronously by admin/exact scope.
158
+ * 2. **idp identity token** (ce-s7e verify-bridge + ce-1ch owner table): a
159
+ * token unknown to the opaque set is verified via RFC 7662 introspection
160
+ * to a {@link VerifiedIdentity}, then gated by the hybrid group + owner
161
+ * rule (see {@link authorizeIdentity}). Fails CLOSED (deny) on any error.
162
+ */
163
+ async function authorizePackage(req: Request, pkg: string): Promise<boolean> {
164
+ const header = req.headers.get('Authorization') ?? '';
165
+ if (auth.authorize(header, pkg)) return true;
166
+ if (!introspection) return false;
167
+ const identity = await introspection.identify(header);
168
+ if (!identity) return false;
169
+ return authorizeIdentity(identity, pkg);
170
+ }
171
+
172
+ /**
173
+ * Hybrid group + owner-table decision for a verified identity (ce-1ch, D-C):
174
+ * - **admin group** → publish anything; first publish of an unclaimed name
175
+ * records ownership (so a subsequent non-admin owner check has an anchor).
176
+ * - **publisher group** → unclaimed name: claim it + allow; owned by this
177
+ * `sub`: allow; owned by someone else: **DENY** (confused-deputy defense).
178
+ * - anything else → deny.
179
+ */
180
+ function authorizeIdentity(
181
+ identity: { sub?: string; isAdmin: boolean; isPublisher: boolean; group?: string },
182
+ pkg: string,
183
+ ): boolean {
184
+ const owner = moduleOwnerStore.get(pkg);
185
+ if (identity.isAdmin) {
186
+ // Admins publish anything; claim an unclaimed name only when we have a
187
+ // sub to attribute it to (an admin token without a sub still publishes).
188
+ if (!owner && identity.sub) {
189
+ moduleOwnerStore.claim(pkg, identity.sub, identity.group ?? ADMIN_SCOPE);
190
+ }
191
+ return true;
192
+ }
193
+ if (identity.isPublisher) {
194
+ // Ownership can't be attributed without a stable subject → deny.
195
+ if (!identity.sub) return false;
196
+ if (!owner) {
197
+ moduleOwnerStore.claim(pkg, identity.sub, identity.group ?? '');
198
+ return true;
199
+ }
200
+ return owner.ownerSub === identity.sub;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** True when the request carries an admin (`*`-scoped) opaque token. */
206
+ function authorizeAdmin(req: Request): boolean {
207
+ return auth.isAdmin(req.headers.get('Authorization') ?? '');
208
+ }
209
+
210
+ /**
211
+ * True when the request is admin — via an opaque `*`-scoped token OR a
212
+ * verified idp identity in the admin group (ce-1ch). Used to gate the
213
+ * owner-table management endpoints; the CLI drives these with the opaque
214
+ * admin token, but an operator's idp admin token works too.
215
+ */
216
+ async function authorizeAdminReq(req: Request): Promise<boolean> {
217
+ const header = req.headers.get('Authorization') ?? '';
218
+ if (auth.isAdmin(header)) return true;
219
+ if (!introspection) return false;
220
+ const identity = await introspection.identify(header);
221
+ return identity?.isAdmin ?? false;
222
+ }
223
+
224
+ /**
225
+ * Rate-limit check for write endpoints. Returns a 429 response when
226
+ * exceeded (with Retry-After), null when allowed.
227
+ */
228
+ function rateLimitOrNull(
229
+ req: Request,
230
+ srv: { requestIP(r: Request): { address: string } | null },
231
+ ): Response | null {
232
+ const ip = clientIp(req, srv);
233
+ const result = rateLimiter.take(ip);
234
+ if (result.ok) return null;
235
+ return new Response(
236
+ JSON.stringify({ errors: [{ detail: 'Too many write requests — try again later' }] }),
237
+ {
238
+ status: 429,
239
+ headers: {
240
+ 'Content-Type': 'application/json',
241
+ 'Retry-After': String(result.retryAfterSec),
242
+ },
243
+ },
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Resolve a module name to (a) its published entries and (b) whether a
249
+ * bootstrap fallback exists. Real publishes take precedence — once a name
250
+ * has any published version, bootstrap for that name is ignored, so tests
251
+ * that publish namecheap@1.0.0+1 don't keep picking up the repo's source.
252
+ *
253
+ * Caller MUST have validated `name` first. We also guard here as a cheap
254
+ * backstop since the function is reachable from several handlers.
255
+ */
256
+ function resolveEntries(name: string): IndexEntry[] {
257
+ if (!isValidName(name)) return [];
258
+ const published = storage.readIndex(name);
259
+ if (published.length > 0) return published;
260
+ const b = resolveBootstrap().get(name);
261
+ return b ? [bootstrapIndexEntry(b)] : [];
262
+ }
263
+
264
+ async function serveBootstrapDownload(entry: BootstrapEntry): Promise<Response> {
265
+ const netappPath = await packageBootstrapModule(entry, bootstrapCacheDir);
266
+ return new Response(Bun.file(netappPath), {
267
+ headers: {
268
+ 'Content-Type': 'application/octet-stream',
269
+ 'Content-Disposition': contentDispositionAttachment(`${entry.name}.netapp`),
270
+ },
271
+ });
272
+ }
273
+
274
+ async function handlePublish(req: Request): Promise<Response> {
275
+ // Refuse to even buffer oversized publishes. Content-Length can be spoofed
276
+ // but a hostile client that lies to get past this still has to stream
277
+ // through Caddy's per-connection read limits; defense-in-depth.
278
+ const declaredLength = Number(req.headers.get('Content-Length'));
279
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_PUBLISH_BYTES) {
280
+ return err(
281
+ `Publish body too large: ${declaredLength} bytes exceeds ${MAX_PUBLISH_BYTES}`,
282
+ 413,
283
+ );
284
+ }
285
+
286
+ const buf = Buffer.from(await req.arrayBuffer());
287
+ if (buf.length > MAX_PUBLISH_BYTES) {
288
+ return err(`Publish body too large: ${buf.length} bytes exceeds ${MAX_PUBLISH_BYTES}`, 413);
289
+ }
290
+ if (buf.length < 8) return err('Request body too short');
291
+
292
+ const metaLen = buf.readUInt32LE(0);
293
+ // Cap the JSON metadata independently of the overall body. A malicious
294
+ // payload could set metaLen close to MAX_PUBLISH_BYTES to force
295
+ // multi-MB JSON.parse; the sparse protocol's metadata is always tiny
296
+ // in practice (a few hundred bytes).
297
+ if (metaLen > MAX_META_BYTES) {
298
+ return err(`Metadata too large: ${metaLen} bytes exceeds ${MAX_META_BYTES}`, 413);
299
+ }
300
+ if (buf.length < 4 + metaLen + 4) return err('Request body truncated (metadata)');
301
+
302
+ let meta: Record<string, unknown>;
303
+ try {
304
+ meta = JSON.parse(buf.subarray(4, 4 + metaLen).toString('utf-8')) as Record<string, unknown>;
305
+ } catch {
306
+ return err('Invalid JSON metadata');
307
+ }
308
+
309
+ const name = String(meta.name ?? '');
310
+ const vers = String(meta.vers ?? '');
311
+ // Description is optional, comes from the publishing client (the
312
+ // celilo CLI reads manifest.yml#description and includes it). When
313
+ // not provided we leave it undefined; the search endpoint falls
314
+ // back to bootstrap data or empty.
315
+ const description = typeof meta.description === 'string' ? meta.description : undefined;
316
+
317
+ const validation = validateNameAndVersion(name, vers);
318
+ if (!validation.ok) return err(validation.message);
319
+
320
+ // Scope check AFTER the name is known: a package-scoped token may publish
321
+ // only its own package; an admin token publishes anything (ISS-0140).
322
+ if (!(await authorizePackage(req, name))) return unauthorized();
323
+
324
+ const fileOffset = 4 + metaLen;
325
+ const fileLen = buf.readUInt32LE(fileOffset);
326
+ if (buf.length < fileOffset + 4 + fileLen) return err('Request body truncated (file)');
327
+
328
+ const fileData = buf.subarray(fileOffset + 4, fileOffset + 4 + fileLen);
329
+
330
+ if (storage.packageExists(name, vers)) {
331
+ return err(`Version ${name}@${vers} already exists — versions are immutable`, 409);
332
+ }
333
+
334
+ const cksum = storage.storePackage(name, vers, fileData);
335
+ storage.appendIndex({
336
+ name,
337
+ vers,
338
+ deps: [],
339
+ cksum: `sha256:${cksum}`,
340
+ yanked: false,
341
+ description,
342
+ });
343
+
344
+ console.log(`[registry] published ${name}@${vers} (${fileLen} bytes, sha256:${cksum})`);
345
+ return Response.json({ ok: true, name, vers });
346
+ }
347
+
348
+ function setYanked(name: string, version: string, yanked: boolean): Response {
349
+ const entries = storage.readIndex(name);
350
+ const entry = entries.find((e) => e.vers === version);
351
+ if (!entry) return notFound();
352
+ entry.yanked = yanked;
353
+ storage.updateIndex(name, entries);
354
+ return Response.json({ ok: true });
355
+ }
356
+
357
+ /**
358
+ * Mint a per-repo, package-scoped publish token (ISS-0140). Admin-only. The
359
+ * `registry_publish` capability calls this on behalf of `source_forge.
360
+ * registerRepo`. Reconciled: re-minting for the same repo rotates the token.
361
+ * The raw token is returned ONCE — the caller sets it as the repo's Actions
362
+ * secret; the server keeps only its hash.
363
+ */
364
+ async function handleMintToken(req: Request): Promise<Response> {
365
+ if (!authorizeAdmin(req)) return unauthorized();
366
+ let body: { repo?: unknown; scope?: unknown };
367
+ try {
368
+ body = (await req.json()) as { repo?: unknown; scope?: unknown };
369
+ } catch {
370
+ return err('Invalid JSON body');
371
+ }
372
+ const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
373
+ const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
374
+ if (!repo) return err('repo is required');
375
+ if (!scope || !isValidName(scope)) return err('scope must be a valid package name');
376
+
377
+ const { token, entry, revokedHashes } = scopedTokenStore.mint(repo, scope);
378
+ for (const h of revokedHashes) auth.removeHashed(h);
379
+ auth.addHashed(entry.hash, entry.scope);
380
+ console.log(`[registry] minted scoped token for ${repo} → ${scope}`);
381
+ return Response.json({ ok: true, token, scope, repo });
382
+ }
383
+
384
+ /** Revoke all scoped tokens for a repo (ISS-0140). Admin-only. */
385
+ async function handleRevokeToken(req: Request): Promise<Response> {
386
+ if (!authorizeAdmin(req)) return unauthorized();
387
+ let body: { repo?: unknown };
388
+ try {
389
+ body = (await req.json()) as { repo?: unknown };
390
+ } catch {
391
+ return err('Invalid JSON body');
392
+ }
393
+ const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
394
+ if (!repo) return err('repo is required');
395
+
396
+ const removed = scopedTokenStore.revoke(repo);
397
+ for (const h of removed) auth.removeHashed(h);
398
+ console.log(`[registry] revoked ${removed.length} scoped token(s) for ${repo}`);
399
+ return Response.json({ ok: true, repo, revoked: removed.length });
400
+ }
401
+
402
+ /** List the whole module-owner table (ce-1ch). Admin-only. */
403
+ async function handleOwnerList(req: Request): Promise<Response> {
404
+ if (!(await authorizeAdminReq(req))) return unauthorized();
405
+ return Response.json({ owners: moduleOwnerStore.list() });
406
+ }
407
+
408
+ /** Show the owner of one module name (ce-1ch). Admin-only. */
409
+ async function handleOwnerShow(req: Request, name: string): Promise<Response> {
410
+ if (!(await authorizeAdminReq(req))) return unauthorized();
411
+ if (!isValidName(name)) return err('Invalid module name');
412
+ const owner = moduleOwnerStore.get(name);
413
+ if (!owner) return notFound();
414
+ return Response.json({ owner });
415
+ }
416
+
417
+ /** Reassign ownership of a module name to a new subject (ce-1ch). Admin-only. */
418
+ async function handleOwnerSet(req: Request, name: string): Promise<Response> {
419
+ if (!(await authorizeAdminReq(req))) return unauthorized();
420
+ if (!isValidName(name)) return err('Invalid module name');
421
+ let body: { ownerSub?: unknown };
422
+ try {
423
+ body = (await req.json()) as { ownerSub?: unknown };
424
+ } catch {
425
+ return err('Invalid JSON body');
426
+ }
427
+ const ownerSub = typeof body.ownerSub === 'string' ? body.ownerSub.trim() : '';
428
+ if (!ownerSub) return err('ownerSub is required');
429
+ const owner = moduleOwnerStore.reassign(name, ownerSub, 'admin-reassign');
430
+ console.log(`[registry] reassigned owner of ${name} → ${ownerSub}`);
431
+ return Response.json({ ok: true, owner });
432
+ }
433
+
434
+ const server = Bun.serve({
435
+ port,
436
+ async fetch(req, srv) {
437
+ const url = new URL(req.url);
438
+ const path = url.pathname;
439
+ const method = req.method;
440
+
441
+ // Strip the configured prefix (if any) before matching.
442
+ if (pathPrefix && !path.startsWith(pathPrefix)) return notFound();
443
+ const suffix = pathPrefix ? path.slice(pathPrefix.length) : path;
444
+
445
+ // Polite redirect for the bare registry root: serve a 301 →
446
+ // /modules/ on the celilo.computer site, with a minimal HTML
447
+ // body for clients that don't follow redirects (curl without
448
+ // -L, bots, bare HTTP libraries). Matches three forms so it
449
+ // works whether PATH_PREFIX is set or empty:
450
+ // - suffix='' → request was exactly the prefix, no slash
451
+ // - suffix='/' → request was prefix + '/', or just '/'
452
+ // - suffix='/index.html'
453
+ // See apps/celilo/designs/REGISTRY_BROWSE_UI.md (decision D2).
454
+ if (method === 'GET' && (suffix === '' || suffix === '/' || suffix === '/index.html')) {
455
+ return landingResponse(publicUrl);
456
+ }
457
+
458
+ // Sparse config
459
+ if (method === 'GET' && suffix === '/index/config.json') {
460
+ return Response.json(sparseConfig);
461
+ }
462
+
463
+ // Sparse index files — the module name is the last path segment.
464
+ // Reject anything that isn't a valid module name so the storage layer
465
+ // can never see a path-traversal string via the index endpoint.
466
+ if (method === 'GET' && suffix.startsWith('/index/')) {
467
+ const name = decodeURIComponent(suffix.slice(suffix.lastIndexOf('/') + 1));
468
+ if (!isValidName(name)) return notFound();
469
+ const entries = resolveEntries(name);
470
+ if (entries.length === 0) return notFound();
471
+ const body = `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`;
472
+ return new Response(body, {
473
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
474
+ });
475
+ }
476
+
477
+ const apiBase = '/api/v1/modules';
478
+
479
+ // Search
480
+ if (method === 'GET' && suffix === apiBase) {
481
+ const q = url.searchParams.get('q')?.toLowerCase() ?? '';
482
+ const perPage = Math.min(Number(url.searchParams.get('per_page') ?? 25), 100);
483
+ // Sort options: 'name' (default, alphabetical) or 'downloads'
484
+ // (most-downloaded first, ties broken alphabetically). Anything
485
+ // unrecognized falls back to 'name'.
486
+ const sort = url.searchParams.get('sort') === 'downloads' ? 'downloads' : 'name';
487
+
488
+ const publishedNames = new Set(storage.listModules().map((m) => m.name));
489
+ const bootstrap = resolveBootstrap();
490
+ const allNames = new Set([...publishedNames, ...bootstrap.keys()]);
491
+ const filtered = [...allNames].filter((n) => !q || n.includes(q));
492
+
493
+ // Build the per-module records first so sorting can read the
494
+ // download counts. Empty input → empty output, no work done.
495
+ const records = filtered.map((name) => {
496
+ const entries = resolveEntries(name);
497
+ const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
498
+ // Description preference order:
499
+ // 1. Latest published version's stored description (best —
500
+ // reflects the captured-at-publish-time manifest).
501
+ // 2. Bootstrap entry's manifest description (when serving
502
+ // a module from a source directory the registry hasn't
503
+ // formally received via publish).
504
+ // 3. Empty string (older publishes that pre-date the
505
+ // description capture and haven't been republished).
506
+ const bootstrapEntry = bootstrap.get(name);
507
+ const description = latest?.description ?? bootstrapEntry?.description ?? '';
508
+ return {
509
+ name,
510
+ max_version: latest?.vers ?? '0.0.0',
511
+ description,
512
+ total_downloads: storage.getDownloads(name),
513
+ };
514
+ });
515
+
516
+ if (sort === 'downloads') {
517
+ records.sort((a, b) => {
518
+ if (b.total_downloads !== a.total_downloads) {
519
+ return b.total_downloads - a.total_downloads;
520
+ }
521
+ return a.name.localeCompare(b.name);
522
+ });
523
+ } else {
524
+ records.sort((a, b) => a.name.localeCompare(b.name));
525
+ }
526
+
527
+ return Response.json({
528
+ modules: records.slice(0, perPage),
529
+ total: records.length,
530
+ });
531
+ }
532
+
533
+ // Publish — rate-limit before even checking auth so a bad-token flood
534
+ // can't crowd out legitimate traffic on the shared buckets. The scope
535
+ // check happens inside handlePublish, once the package name is known.
536
+ if (method === 'PUT' && suffix === `${apiBase}/new`) {
537
+ const rl = rateLimitOrNull(req, srv);
538
+ if (rl) return rl;
539
+ return handlePublish(req);
540
+ }
541
+
542
+ // Mint / revoke per-repo scoped publish tokens (admin-only — ISS-0140).
543
+ if (method === 'POST' && suffix === `${apiBase}/tokens/mint`) {
544
+ const rl = rateLimitOrNull(req, srv);
545
+ if (rl) return rl;
546
+ return handleMintToken(req);
547
+ }
548
+ if (method === 'POST' && suffix === `${apiBase}/tokens/revoke`) {
549
+ const rl = rateLimitOrNull(req, srv);
550
+ if (rl) return rl;
551
+ return handleRevokeToken(req);
552
+ }
553
+
554
+ // Module-owner table management (admin-only — ce-1ch). Matched before the
555
+ // generic `${apiBase}/{name}` handlers so a name of "owners" can't shadow
556
+ // them.
557
+ if (method === 'GET' && suffix === `${apiBase}/owners`) {
558
+ return handleOwnerList(req);
559
+ }
560
+ if (suffix.startsWith(`${apiBase}/owners/`)) {
561
+ const name = decodeURIComponent(suffix.slice(`${apiBase}/owners/`.length));
562
+ if (method === 'GET') return handleOwnerShow(req, name);
563
+ if (method === 'POST') {
564
+ const rl = rateLimitOrNull(req, srv);
565
+ if (rl) return rl;
566
+ return handleOwnerSet(req, name);
567
+ }
568
+ }
569
+
570
+ if (suffix.startsWith(`${apiBase}/`)) {
571
+ const modulePath = suffix.slice(apiBase.length + 1);
572
+ // URL-decode path segments — module versions use a literal '+' to
573
+ // separate semver from pkg revision (e.g. 1.0.0+1), and clients
574
+ // URL-encode that as %2B. Without decoding, storage lookups miss
575
+ // because they'd use the raw escape string.
576
+ const parts = modulePath.split('/').map((p) => decodeURIComponent(p));
577
+
578
+ // Module metadata
579
+ if (method === 'GET' && parts.length === 1 && parts[0]) {
580
+ const name = parts[0];
581
+ if (!isValidName(name)) return notFound();
582
+ const entries = resolveEntries(name);
583
+ if (entries.length === 0) return notFound();
584
+ // Description: latest non-yanked version's stored description,
585
+ // falling back to the bootstrap entry's manifest description
586
+ // for source-mode modules. Same precedence as the search
587
+ // endpoint — see notes there.
588
+ const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
589
+ const bootstrapEntry = resolveBootstrap().get(name);
590
+ const description = latest?.description ?? bootstrapEntry?.description ?? '';
591
+ return Response.json({
592
+ name,
593
+ description,
594
+ total_downloads: storage.getDownloads(name),
595
+ versions: entries.map((v) => ({
596
+ num: v.vers,
597
+ yanked: v.yanked,
598
+ created_at: new Date(0).toISOString(),
599
+ })),
600
+ });
601
+ }
602
+
603
+ // Download
604
+ if (method === 'GET' && parts.length === 3 && parts[2] === 'download') {
605
+ const [name, version] = parts;
606
+ if (!isValidName(name) || !isValidVersion(version)) return notFound();
607
+ const data = storage.readPackage(name, version);
608
+ if (data) {
609
+ // Bump the per-module counter on every successful download.
610
+ // Liberal counting: no User-Agent filter, no de-duplication.
611
+ // CI/crawler traffic inflates the number but the simplicity
612
+ // is worth it — see REGISTRY_BROWSE_UI.md (Phase 3).
613
+ storage.incrementDownloads(name);
614
+ return new Response(new Uint8Array(data), {
615
+ headers: {
616
+ 'Content-Type': 'application/octet-stream',
617
+ 'Content-Disposition': contentDispositionAttachment(`${name}-${version}.netapp`),
618
+ },
619
+ });
620
+ }
621
+ // Bootstrap fallback — published packages take precedence above.
622
+ const b = resolveBootstrap().get(name);
623
+ if (b) {
624
+ storage.incrementDownloads(name);
625
+ return serveBootstrapDownload(b);
626
+ }
627
+ return notFound();
628
+ }
629
+
630
+ // Yank
631
+ if (method === 'DELETE' && parts.length === 3 && parts[2] === 'yank') {
632
+ const rl = rateLimitOrNull(req, srv);
633
+ if (rl) return rl;
634
+ const [name, version] = parts;
635
+ if (!isValidName(name) || !isValidVersion(version)) return notFound();
636
+ if (!(await authorizePackage(req, name))) return unauthorized();
637
+ return setYanked(name, version, true);
638
+ }
639
+
640
+ // Unyank
641
+ if (method === 'PUT' && parts.length === 3 && parts[2] === 'unyank') {
642
+ const rl = rateLimitOrNull(req, srv);
643
+ if (rl) return rl;
644
+ const [name, version] = parts;
645
+ if (!isValidName(name) || !isValidVersion(version)) return notFound();
646
+ if (!(await authorizePackage(req, name))) return unauthorized();
647
+ return setYanked(name, version, false);
648
+ }
649
+ }
650
+
651
+ return notFound();
652
+ },
653
+ });
654
+
655
+ return server;
656
+ }
657
+
658
+ /** Env-var entry point used by bin/celilo-registry-server and the Dockerfile. */
659
+ export function startFromEnv(): void {
660
+ const dataDir = process.env.DATA_DIR ?? '/var/lib/celilo-registry';
661
+ const port = Number(process.env.PORT ?? 3000);
662
+ const pathPrefix = (process.env.PATH_PREFIX ?? '').replace(/\/$/, '');
663
+ const publicUrl =
664
+ process.env.PUBLIC_URL ?? `https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`;
665
+ const bootstrapModulesDir = process.env.BOOTSTRAP_MODULES_DIR || undefined;
666
+ const bootstrapUploadsDir = process.env.BOOTSTRAP_UPLOADS_DIR || undefined;
667
+ const bootstrapCacheDir = process.env.BOOTSTRAP_CACHE_DIR || undefined;
668
+ const auth = TokenAuth.fromEnv();
669
+ const introspection = IntrospectionVerifier.fromEnv();
670
+
671
+ const server = startServer({
672
+ dataDir,
673
+ port,
674
+ pathPrefix,
675
+ publicUrl,
676
+ bootstrapModulesDir,
677
+ bootstrapUploadsDir,
678
+ bootstrapCacheDir,
679
+ auth,
680
+ introspection,
681
+ });
682
+
683
+ const bootstrapBits = [
684
+ bootstrapModulesDir ? `modulesDir=${bootstrapModulesDir}` : '',
685
+ bootstrapUploadsDir ? `uploadsDir=${bootstrapUploadsDir}` : '',
686
+ ]
687
+ .filter(Boolean)
688
+ .join(', ');
689
+ console.log(
690
+ `[registry] listening on :${server.port} (prefix=${pathPrefix || '(none)'}, publicUrl=${publicUrl}${
691
+ bootstrapBits ? `, bootstrap=[${bootstrapBits}]` : ''
692
+ }, auth=${auth.hasTokens() ? 'enabled' : 'disabled (publish will always 401)'}, introspection=${
693
+ introspection ? 'enabled' : 'disabled'
694
+ })`,
695
+ );
696
+ }
697
+
698
+ // Invoke when run as an entry point (`bun run src/server.ts`).
699
+ if (import.meta.main) {
700
+ startFromEnv();
701
+ }