@celilo/e2e 0.9.2 → 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 (46) 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/proxy/squid.conf +7 -2
  11. package/config/resolver/unbound.conf +39 -3
  12. package/config/routing/management-routes.sh +9 -4
  13. package/config/routing/resolver-internal-routes.sh +10 -0
  14. package/docker/Dockerfile.cpanel-host-sim +70 -0
  15. package/docker/Dockerfile.firewall +12 -0
  16. package/docker/Dockerfile.management +6 -0
  17. package/docker/Dockerfile.minio +1 -1
  18. package/docker/Dockerfile.observer +11 -0
  19. package/docker/Dockerfile.signal-cli +60 -0
  20. package/docker/Dockerfile.signal-release +36 -0
  21. package/docker/Dockerfile.signal-sim +17 -0
  22. package/docker/Dockerfile.target-machine +10 -1
  23. package/docker/Dockerfile.target-machine-docker +1 -1
  24. package/docker/Dockerfile.target-technitium +1 -1
  25. package/package.json +1 -1
  26. package/registry-server/src/auth.ts +2 -2
  27. package/registry-server/src/introspection.test.ts +247 -0
  28. package/registry-server/src/introspection.ts +204 -0
  29. package/registry-server/src/module-owner-store.test.ts +85 -0
  30. package/registry-server/src/module-owner-store.ts +108 -0
  31. package/registry-server/src/server.test.ts +262 -0
  32. package/registry-server/src/server.ts +139 -10
  33. package/scripts/pack-celilo-packages.ts +79 -51
  34. package/scripts/stage-libsignal.ts +116 -0
  35. package/simulators/signal-cli/server.ts +272 -0
  36. package/src/cli/build.ts +35 -9
  37. package/src/cli/command-registry.ts +34 -6
  38. package/src/cli/index.ts +59 -21
  39. package/src/container-manager.ts +6 -2
  40. package/src/docker-compose-generator.ts +108 -4
  41. package/src/network-builder.ts +52 -0
  42. package/src/runner.ts +128 -57
  43. package/src/simulator-ips.ts +9 -0
  44. package/src/types.ts +34 -0
  45. package/config/dns/example.net.zone +0 -16
  46. package/config/dns/iamtheinternet.org.zone +0 -16
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { ADMIN_SCOPE, TokenAuth } from './auth';
6
+ import { IntrospectionVerifier } from './introspection';
6
7
  import { createRateLimiter } from './rate-limit';
7
8
  import { startServer } from './server';
8
9
 
@@ -507,3 +508,264 @@ async function publish(
507
508
  body,
508
509
  });
509
510
  }
511
+
512
+ // ── idp introspection verify-bridge (ce-s7e) ─────────────────────────────────
513
+ // The real publish path with an idp identity token: the token is unknown to
514
+ // the opaque set, so authorizePackage() verifies it via RFC 7662 introspection
515
+ // against the idp. A fake idp server stands in for authentik; revoking a token
516
+ // there (active:false) denies the next publish.
517
+
518
+ describe('publish via idp introspection (ce-s7e)', () => {
519
+ let idp: ReturnType<typeof Bun.serve> | null = null;
520
+ let idpServer: ReturnType<typeof startServer> | null = null;
521
+ let idpDataDir: string;
522
+ let idpBaseUrl: string;
523
+ // token → introspection claims the fake idp reports for it.
524
+ const claimsByToken = new Map<string, Record<string, unknown>>();
525
+
526
+ beforeEach(() => {
527
+ claimsByToken.clear();
528
+ idp = Bun.serve({
529
+ port: 0,
530
+ async fetch(req) {
531
+ const form = new URLSearchParams(await req.text());
532
+ const token = form.get('token') ?? '';
533
+ const claims = claimsByToken.get(token) ?? { active: false };
534
+ return Response.json(claims);
535
+ },
536
+ });
537
+ idpDataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-idp-test-'));
538
+ idpServer = startServer({
539
+ dataDir: idpDataDir,
540
+ port: 0,
541
+ pathPrefix: '',
542
+ publicUrl: 'https://example.test',
543
+ // Opaque admin token still works → backward-compat coverage below.
544
+ auth: new TokenAuth([{ token: 'opaque-admin', scope: ADMIN_SCOPE }]),
545
+ introspection: new IntrospectionVerifier({
546
+ endpoint: `http://localhost:${idp.port}/introspect`,
547
+ clientId: 'celilo-registry',
548
+ clientSecret: 'sec',
549
+ adminGroup: 'celilo-admins',
550
+ publisherGroup: 'celilo-authors',
551
+ }),
552
+ });
553
+ idpBaseUrl = `http://localhost:${idpServer.port}`;
554
+ });
555
+
556
+ afterEach(() => {
557
+ idpServer?.stop();
558
+ idpServer = null;
559
+ idp?.stop();
560
+ idp = null;
561
+ rmSync(idpDataDir, { recursive: true, force: true });
562
+ });
563
+
564
+ function publishTo(token: string, name = 'homebridge', vers = '1.0.0+1'): Promise<Response> {
565
+ const meta = Buffer.from(JSON.stringify({ name, vers }), 'utf-8');
566
+ const file = Buffer.from('fake netapp bytes');
567
+ const metaLen = Buffer.alloc(4);
568
+ metaLen.writeUInt32LE(meta.length, 0);
569
+ const fileLen = Buffer.alloc(4);
570
+ fileLen.writeUInt32LE(file.length, 0);
571
+ const body = Buffer.concat([metaLen, meta, fileLen, file]);
572
+ return fetch(`${idpBaseUrl}/api/v1/modules/new`, {
573
+ method: 'PUT',
574
+ headers: { Authorization: `Bearer ${token}` },
575
+ body,
576
+ });
577
+ }
578
+
579
+ test('active idp token (admin group) authorizes publish', async () => {
580
+ claimsByToken.set('idp-tok', { active: true, sub: 'alice', groups: ['celilo-admins'] });
581
+ const res = await publishTo('idp-tok');
582
+ expect(res.status).toBe(200);
583
+ });
584
+
585
+ test('active idp token not in admin group → 401', async () => {
586
+ claimsByToken.set('idp-tok', { active: true, sub: 'bob', groups: ['users'] });
587
+ const res = await publishTo('idp-tok');
588
+ expect(res.status).toBe(401);
589
+ });
590
+
591
+ test('revoked-at-idp (active:false) → next publish denied 401', async () => {
592
+ claimsByToken.set('idp-tok', { active: true, groups: ['celilo-admins'] });
593
+ expect((await publishTo('idp-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
594
+ // Revoke at the idp — the token is now inactive.
595
+ claimsByToken.set('idp-tok', { active: false });
596
+ expect((await publishTo('idp-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
597
+ });
598
+
599
+ test('opaque publish token still works (backward compat)', async () => {
600
+ const res = await publishTo('opaque-admin');
601
+ // 'opaque-admin' is the raw token; it authorizes via the local set,
602
+ // never touching introspection.
603
+ expect(res.status).toBe(200);
604
+ });
605
+
606
+ test('idp down (introspection throws) → fails closed 401', async () => {
607
+ idp?.stop();
608
+ idp = null;
609
+ claimsByToken.set('idp-tok', { active: true, groups: ['celilo-admins'] });
610
+ const res = await publishTo('idp-tok');
611
+ expect(res.status).toBe(401);
612
+ });
613
+ });
614
+
615
+ // ── module-owner table: hybrid group + owner authorization (ce-1ch) ──────────
616
+ // A verified idp publisher may publish only module names it owns. The first
617
+ // verified publisher of an unclaimed name claims it (first-publish-claims);
618
+ // another publisher is then DENIED (confused-deputy defense). Admins publish or
619
+ // reassign anything.
620
+
621
+ describe('module-owner authorization (ce-1ch)', () => {
622
+ let idp: ReturnType<typeof Bun.serve> | null = null;
623
+ let srv: ReturnType<typeof startServer> | null = null;
624
+ let dir: string;
625
+ let url: string;
626
+ const claimsByToken = new Map<string, Record<string, unknown>>();
627
+
628
+ beforeEach(() => {
629
+ claimsByToken.clear();
630
+ idp = Bun.serve({
631
+ port: 0,
632
+ async fetch(req) {
633
+ const form = new URLSearchParams(await req.text());
634
+ const token = form.get('token') ?? '';
635
+ return Response.json(claimsByToken.get(token) ?? { active: false });
636
+ },
637
+ });
638
+ dir = mkdtempSync(join(tmpdir(), 'celilo-registry-owner-test-'));
639
+ srv = startServer({
640
+ dataDir: dir,
641
+ port: 0,
642
+ pathPrefix: '',
643
+ publicUrl: 'https://example.test',
644
+ auth: new TokenAuth([{ token: 'opaque-admin', scope: ADMIN_SCOPE }]),
645
+ introspection: new IntrospectionVerifier({
646
+ endpoint: `http://localhost:${idp.port}/introspect`,
647
+ clientId: 'celilo-registry',
648
+ clientSecret: 'sec',
649
+ adminGroup: 'celilo-admins',
650
+ publisherGroup: 'celilo-authors',
651
+ }),
652
+ });
653
+ url = `http://localhost:${srv.port}`;
654
+ });
655
+
656
+ afterEach(() => {
657
+ srv?.stop();
658
+ srv = null;
659
+ idp?.stop();
660
+ idp = null;
661
+ rmSync(dir, { recursive: true, force: true });
662
+ });
663
+
664
+ function publish(token: string, name: string, vers: string): Promise<Response> {
665
+ const meta = Buffer.from(JSON.stringify({ name, vers }), 'utf-8');
666
+ const file = Buffer.from('fake netapp bytes');
667
+ const metaLen = Buffer.alloc(4);
668
+ metaLen.writeUInt32LE(meta.length, 0);
669
+ const fileLen = Buffer.alloc(4);
670
+ fileLen.writeUInt32LE(file.length, 0);
671
+ const body = Buffer.concat([metaLen, meta, fileLen, file]);
672
+ return fetch(`${url}/api/v1/modules/new`, {
673
+ method: 'PUT',
674
+ headers: { Authorization: `Bearer ${token}` },
675
+ body,
676
+ });
677
+ }
678
+
679
+ test('publisher claims an unclaimed name on first publish', async () => {
680
+ claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
681
+ expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
682
+ // The owner endpoint (admin) now shows alice owns it.
683
+ const res = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
684
+ headers: { Authorization: 'Bearer opaque-admin' },
685
+ });
686
+ expect(res.status).toBe(200);
687
+ const body = (await res.json()) as { owner: { ownerSub: string; sourceGroup: string } };
688
+ expect(body.owner.ownerSub).toBe('alice');
689
+ expect(body.owner.sourceGroup).toBe('celilo-authors');
690
+ });
691
+
692
+ test('owner may publish more versions of a name it owns', async () => {
693
+ claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
694
+ expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
695
+ expect((await publish('alice-tok', 'homebridge', '1.0.0+2')).status).toBe(200);
696
+ });
697
+
698
+ test('CONFUSED DEPUTY: a different publisher cannot publish someone else’s name', async () => {
699
+ claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
700
+ claimsByToken.set('bob-tok', { active: true, sub: 'bob', groups: ['celilo-authors'] });
701
+ expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
702
+ // Bob is a valid publisher but does NOT own homebridge → denied.
703
+ expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
704
+ });
705
+
706
+ test('an identity in neither publish group is denied even when verified', async () => {
707
+ claimsByToken.set('carol-tok', { active: true, sub: 'carol', groups: ['users'] });
708
+ expect((await publish('carol-tok', 'anything', '1.0.0+1')).status).toBe(401);
709
+ });
710
+
711
+ test('admin group may publish any name and claims it', async () => {
712
+ claimsByToken.set('admin-tok', { active: true, sub: 'ops', groups: ['celilo-admins'] });
713
+ expect((await publish('admin-tok', 'caddy', '1.0.0+1')).status).toBe(200);
714
+ const res = await fetch(`${url}/api/v1/modules/owners/caddy`, {
715
+ headers: { Authorization: 'Bearer opaque-admin' },
716
+ });
717
+ expect((await res.json() as { owner: { ownerSub: string } }).owner.ownerSub).toBe('ops');
718
+ });
719
+
720
+ test('admin reassign lets a new owner publish the name', async () => {
721
+ claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
722
+ claimsByToken.set('bob-tok', { active: true, sub: 'bob', groups: ['celilo-authors'] });
723
+ expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
724
+ // Bob denied before reassignment.
725
+ expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
726
+ // Admin reassigns homebridge to bob.
727
+ const reassign = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
728
+ method: 'POST',
729
+ headers: { Authorization: 'Bearer opaque-admin', 'Content-Type': 'application/json' },
730
+ body: JSON.stringify({ ownerSub: 'bob' }),
731
+ });
732
+ expect(reassign.status).toBe(200);
733
+ // Now bob may publish.
734
+ expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(200);
735
+ });
736
+
737
+ test('owner list endpoint requires admin', async () => {
738
+ const anon = await fetch(`${url}/api/v1/modules/owners`);
739
+ expect(anon.status).toBe(401);
740
+ const ok = await fetch(`${url}/api/v1/modules/owners`, {
741
+ headers: { Authorization: 'Bearer opaque-admin' },
742
+ });
743
+ expect(ok.status).toBe(200);
744
+ expect((await ok.json() as { owners: unknown[] }).owners).toEqual([]);
745
+ });
746
+
747
+ test('owner reassign requires admin (a plain publisher cannot)', async () => {
748
+ claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
749
+ const res = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
750
+ method: 'POST',
751
+ headers: { Authorization: 'Bearer alice-tok', 'Content-Type': 'application/json' },
752
+ body: JSON.stringify({ ownerSub: 'alice' }),
753
+ });
754
+ expect(res.status).toBe(401);
755
+ });
756
+
757
+ test('owner show for an unclaimed name → 404', async () => {
758
+ const res = await fetch(`${url}/api/v1/modules/owners/never-claimed`, {
759
+ headers: { Authorization: 'Bearer opaque-admin' },
760
+ });
761
+ expect(res.status).toBe(404);
762
+ });
763
+
764
+ test('an idp admin token may drive the owner endpoints too', async () => {
765
+ claimsByToken.set('admin-tok', { active: true, sub: 'ops', groups: ['celilo-admins'] });
766
+ const res = await fetch(`${url}/api/v1/modules/owners`, {
767
+ headers: { Authorization: 'Bearer admin-tok' },
768
+ });
769
+ expect(res.status).toBe(200);
770
+ });
771
+ });
@@ -20,7 +20,8 @@
20
20
  */
21
21
 
22
22
  import { join } from 'node:path';
23
- import { TokenAuth } from './auth';
23
+ import { ADMIN_SCOPE, TokenAuth } from './auth';
24
+ import { IntrospectionVerifier } from './introspection';
24
25
  import {
25
26
  type BootstrapEntry,
26
27
  bootstrapIndexEntry,
@@ -29,6 +30,7 @@ import {
29
30
  scanUploadsDir,
30
31
  } from './bootstrap';
31
32
  import { landingResponse } from './landing';
33
+ import { ModuleOwnerStore, fileModuleOwnerPersistence } from './module-owner-store';
32
34
  import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
33
35
  import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
34
36
  import { type IndexEntry, RegistryStorage } from './storage';
@@ -76,14 +78,29 @@ interface ServerOptions {
76
78
  * loaded into `auth` at startup so minted tokens survive a restart.
77
79
  */
78
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;
79
93
  }
80
94
 
81
95
  export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve> {
82
- const { dataDir, port, pathPrefix, publicUrl, auth } = options;
96
+ const { dataDir, port, pathPrefix, publicUrl, auth, introspection } = options;
83
97
  const storage = new RegistryStorage(dataDir);
84
98
  const scopedTokenStore =
85
99
  options.scopedTokenStore ??
86
100
  new ScopedTokenStore(fileScopedTokenPersistence(join(dataDir, 'scoped-tokens.json')));
101
+ const moduleOwnerStore =
102
+ options.moduleOwnerStore ??
103
+ new ModuleOwnerStore(fileModuleOwnerPersistence(join(dataDir, 'module-owners.json')));
87
104
  // Load persisted minted tokens into the in-memory authorizer.
88
105
  for (const entry of scopedTokenStore.list()) {
89
106
  auth.addHashed(entry.hash, entry.scope);
@@ -134,16 +151,76 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
134
151
  return err('Unauthorized — provide a valid publish token in the Authorization header', 401);
135
152
  }
136
153
 
137
- /** True when the request's token may publish/yank `pkg` (admin or exact scope). */
138
- function authorizePackage(req: Request, pkg: string): boolean {
139
- return auth.authorize(req.headers.get('Authorization') ?? '', pkg);
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);
140
170
  }
141
171
 
142
- /** True when the request carries an admin (`*`-scoped) token — required to mint. */
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. */
143
206
  function authorizeAdmin(req: Request): boolean {
144
207
  return auth.isAdmin(req.headers.get('Authorization') ?? '');
145
208
  }
146
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
+
147
224
  /**
148
225
  * Rate-limit check for write endpoints. Returns a 429 response when
149
226
  * exceeded (with Retry-After), null when allowed.
@@ -242,7 +319,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
242
319
 
243
320
  // Scope check AFTER the name is known: a package-scoped token may publish
244
321
  // only its own package; an admin token publishes anything (ISS-0140).
245
- if (!authorizePackage(req, name)) return unauthorized();
322
+ if (!(await authorizePackage(req, name))) return unauthorized();
246
323
 
247
324
  const fileOffset = 4 + metaLen;
248
325
  const fileLen = buf.readUInt32LE(fileOffset);
@@ -322,6 +399,38 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
322
399
  return Response.json({ ok: true, repo, revoked: removed.length });
323
400
  }
324
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
+
325
434
  const server = Bun.serve({
326
435
  port,
327
436
  async fetch(req, srv) {
@@ -442,6 +551,22 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
442
551
  return handleRevokeToken(req);
443
552
  }
444
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
+
445
570
  if (suffix.startsWith(`${apiBase}/`)) {
446
571
  const modulePath = suffix.slice(apiBase.length + 1);
447
572
  // URL-decode path segments — module versions use a literal '+' to
@@ -508,7 +633,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
508
633
  if (rl) return rl;
509
634
  const [name, version] = parts;
510
635
  if (!isValidName(name) || !isValidVersion(version)) return notFound();
511
- if (!authorizePackage(req, name)) return unauthorized();
636
+ if (!(await authorizePackage(req, name))) return unauthorized();
512
637
  return setYanked(name, version, true);
513
638
  }
514
639
 
@@ -518,7 +643,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
518
643
  if (rl) return rl;
519
644
  const [name, version] = parts;
520
645
  if (!isValidName(name) || !isValidVersion(version)) return notFound();
521
- if (!authorizePackage(req, name)) return unauthorized();
646
+ if (!(await authorizePackage(req, name))) return unauthorized();
522
647
  return setYanked(name, version, false);
523
648
  }
524
649
  }
@@ -541,6 +666,7 @@ export function startFromEnv(): void {
541
666
  const bootstrapUploadsDir = process.env.BOOTSTRAP_UPLOADS_DIR || undefined;
542
667
  const bootstrapCacheDir = process.env.BOOTSTRAP_CACHE_DIR || undefined;
543
668
  const auth = TokenAuth.fromEnv();
669
+ const introspection = IntrospectionVerifier.fromEnv();
544
670
 
545
671
  const server = startServer({
546
672
  dataDir,
@@ -551,6 +677,7 @@ export function startFromEnv(): void {
551
677
  bootstrapUploadsDir,
552
678
  bootstrapCacheDir,
553
679
  auth,
680
+ introspection,
554
681
  });
555
682
 
556
683
  const bootstrapBits = [
@@ -562,7 +689,9 @@ export function startFromEnv(): void {
562
689
  console.log(
563
690
  `[registry] listening on :${server.port} (prefix=${pathPrefix || '(none)'}, publicUrl=${publicUrl}${
564
691
  bootstrapBits ? `, bootstrap=[${bootstrapBits}]` : ''
565
- }, auth=${auth.hasTokens() ? 'enabled' : 'disabled (publish will always 401)'})`,
692
+ }, auth=${auth.hasTokens() ? 'enabled' : 'disabled (publish will always 401)'}, introspection=${
693
+ introspection ? 'enabled' : 'disabled'
694
+ })`,
566
695
  );
567
696
  }
568
697