@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,105 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * Package-scoped token auth (build-bus Phase 3, ISS-0140).
5
+ *
6
+ * Each token carries a SCOPE — either a single package name (the token may
7
+ * publish only that module) or the admin scope `*` (publish anything + mint
8
+ * scoped tokens). Tokens are stored SHA-256 hashed; cleartext never lives in
9
+ * the auth set once the server starts.
10
+ *
11
+ * PUBLISH_TOKENS env (newline-separated), per line:
12
+ * <token> → admin scope `*` (publish any package, mint tokens)
13
+ * <token> <package> → scoped to that one package
14
+ *
15
+ * A bare `<token>` line is the admin/bootstrap token — this preserves the
16
+ * pre-scoping behavior (any configured token publishes anything) AND is the
17
+ * privilege that the `registry_publish` capability uses to mint per-repo
18
+ * scoped tokens. Minted scoped tokens are added at runtime via {@link addHashed}
19
+ * (loaded from the persisted store) and removed via {@link removeHashed}.
20
+ */
21
+
22
+ /** Admin scope: publish any package + mint scoped tokens. */
23
+ export const ADMIN_SCOPE = '*';
24
+
25
+ export interface TokenSpec {
26
+ token: string;
27
+ /** Package name, or {@link ADMIN_SCOPE}. */
28
+ scope: string;
29
+ }
30
+
31
+ /** SHA-256 of a token (trimmed). The persisted scoped-token store hashes too. */
32
+ export function hashToken(token: string): string {
33
+ return createHash('sha256').update(token.trim()).digest('hex');
34
+ }
35
+
36
+ /** Strip an optional `Bearer ` prefix and surrounding whitespace from a header. */
37
+ export function bearerToken(header: string): string {
38
+ return header.replace(/^Bearer\s+/i, '').trim();
39
+ }
40
+
41
+ export class TokenAuth {
42
+ /** hash → scope. */
43
+ private readonly scopeByHash = new Map<string, string>();
44
+
45
+ constructor(specs: TokenSpec[]) {
46
+ for (const s of specs) this.addRaw(s.token, s.scope);
47
+ }
48
+
49
+ /**
50
+ * Parse the `PUBLISH_TOKENS` env format into the scoped auth set. Each line is
51
+ * `<token>` (admin scope) or `<token> <package>` (scoped to one package).
52
+ */
53
+ static fromEnv(): TokenAuth {
54
+ const raw = process.env.PUBLISH_TOKENS ?? '';
55
+ const specs: TokenSpec[] = [];
56
+ for (const line of raw.split('\n')) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed) continue;
59
+ const [token, scope] = trimmed.split(/\s+/, 2);
60
+ specs.push({ token, scope: scope || ADMIN_SCOPE });
61
+ }
62
+ return new TokenAuth(specs);
63
+ }
64
+
65
+ /** Add a cleartext token (hashes it). Empty tokens are ignored. */
66
+ addRaw(rawToken: string, scope: string): void {
67
+ const t = rawToken.trim();
68
+ if (!t) return;
69
+ this.scopeByHash.set(hashToken(t), scope.trim() || ADMIN_SCOPE);
70
+ }
71
+
72
+ /** Add an already-hashed token (used when loading the persisted minted-token store). */
73
+ addHashed(hash: string, scope: string): void {
74
+ this.scopeByHash.set(hash, scope);
75
+ }
76
+
77
+ /** Remove a token by its hash (token revocation). */
78
+ removeHashed(hash: string): void {
79
+ this.scopeByHash.delete(hash);
80
+ }
81
+
82
+ /** If no tokens are configured, the server treats every write as unauthorized. */
83
+ hasTokens(): boolean {
84
+ return this.scopeByHash.size > 0;
85
+ }
86
+
87
+ /** The scope of a token (from an Authorization header value), or null if unknown. */
88
+ scopeOf(header: string): string | null {
89
+ const t = bearerToken(header);
90
+ if (!t) return null;
91
+ return this.scopeByHash.get(hashToken(t)) ?? null;
92
+ }
93
+
94
+ /** True when the token may publish/yank the given package (admin or exact scope). */
95
+ authorize(header: string, pkg: string): boolean {
96
+ const scope = this.scopeOf(header);
97
+ if (scope === null) return false;
98
+ return scope === ADMIN_SCOPE || scope === pkg;
99
+ }
100
+
101
+ /** True when the token is an admin token (scope `*`) — required to mint scoped tokens. */
102
+ isAdmin(header: string): boolean {
103
+ return this.scopeOf(header) === ADMIN_SCOPE;
104
+ }
105
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Lockstep gate (ISS-0046): the registry-server's on-demand `walkDir` packager
3
+ * can't import the canonical node_modules-bundling rule at runtime (this server
4
+ * ships standalone, with no @celilo deps), so it carries a structural copy.
5
+ * This test imports the canonical rule directly (zero-dependency module, safe to
6
+ * pull in at test time from the workspace) and asserts walkDir's actual output
7
+ * conforms to it — so a drift in either copy fails here instead of shipping a
8
+ * broken `.netapp`.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
12
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ // Canonical rule lives in apps/celilo; importing it cross-package is fine in a
16
+ // workspace test (it's not bundled into the standalone registry-sim image).
17
+ import { includeNodeModulesPath } from '../../../apps/celilo/src/module/packaging/package-rules';
18
+ import { walkDir } from './bootstrap';
19
+
20
+ describe('bootstrap walkDir stays in lockstep with the canonical packaging rule', () => {
21
+ let dir: string;
22
+
23
+ // Representative node_modules paths spanning both branches of the rule.
24
+ const nmFiles = [
25
+ 'scripts/node_modules/@celilo/capabilities/src/dns-internal.ts',
26
+ 'scripts/node_modules/tldts/index.js',
27
+ 'scripts/node_modules/drizzle-orm/index.js',
28
+ 'scripts/node_modules/.bin/tldts',
29
+ 'node_modules/@celilo/capabilities/index.js',
30
+ 'node_modules/lodash/index.js',
31
+ 'node_modules/@celilo/e2e/index.js',
32
+ ];
33
+
34
+ function write(rel: string): void {
35
+ const full = join(dir, rel);
36
+ mkdirSync(join(full, '..'), { recursive: true });
37
+ writeFileSync(full, 'x');
38
+ }
39
+
40
+ beforeEach(() => {
41
+ dir = mkdtempSync(join(tmpdir(), 'celilo-bootstrap-rule-'));
42
+ write('manifest.yml');
43
+ write('scripts/on-install.ts');
44
+ for (const f of nmFiles) write(f);
45
+ });
46
+
47
+ afterEach(() => {
48
+ try {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ });
54
+
55
+ it("walkDir's node_modules decisions match includeNodeModulesPath", () => {
56
+ const bundled = new Set(walkDir(dir));
57
+ for (const rel of nmFiles) {
58
+ expect(bundled.has(rel)).toBe(includeNodeModulesPath(rel));
59
+ }
60
+ });
61
+
62
+ it('bundles scripts deps (tldts), drops .bin shims and non-scripts cruft', () => {
63
+ const bundled = new Set(walkDir(dir));
64
+ expect(bundled.has('scripts/node_modules/tldts/index.js')).toBe(true);
65
+ expect(bundled.has('scripts/node_modules/drizzle-orm/index.js')).toBe(true);
66
+ expect(bundled.has('scripts/node_modules/.bin/tldts')).toBe(false);
67
+ expect(bundled.has('node_modules/lodash/index.js')).toBe(false);
68
+ expect(bundled.has('node_modules/@celilo/e2e/index.js')).toBe(false);
69
+ expect(bundled.has('node_modules/@celilo/capabilities/index.js')).toBe(true);
70
+ });
71
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Bootstrap mode — optional behavior for the e2e stand-in registry.
3
+ *
4
+ * When BOOTSTRAP_MODULES_DIR is set, the server also serves modules whose
5
+ * source lives in that directory. Each immediate subdirectory containing a
6
+ * manifest.yml is treated as a module; its manifest's `id` and `version`
7
+ * become the sparse index entry. On download, the directory is packaged
8
+ * into a .netapp tarball on the fly (cached on disk).
9
+ *
10
+ * This preserves the existing packages/e2e/docker/Dockerfile.registry
11
+ * behavior where tests can import any module from the repo without having
12
+ * to explicitly publish each dependency first.
13
+ *
14
+ * In production, BOOTSTRAP_MODULES_DIR is unset and this whole layer is
15
+ * inert — storage.ts is the only code that touches disk.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import { basename, dirname, join, relative } from 'node:path';
20
+ import { createHash } from 'node:crypto';
21
+ import type { IndexEntry } from './storage';
22
+
23
+ export interface BootstrapEntry {
24
+ name: string;
25
+ version: string;
26
+ /** Source directory to package on demand (source mode). */
27
+ sourceDir?: string;
28
+ /** Pre-built .netapp to serve directly (upload mode). */
29
+ netappPath?: string;
30
+ /**
31
+ * Manifest's `description` field, when present. Used by the search
32
+ * endpoint to populate the module-list payload — see
33
+ * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
34
+ */
35
+ description?: string;
36
+ }
37
+
38
+ /**
39
+ * Scan a directory for modules. Each subdirectory is a module iff it contains
40
+ * a manifest.yml. Manifest parsing is line-regex (not full YAML) so we don't
41
+ * need a YAML dependency at runtime — this matches packages/e2e/src/registry-server.ts.
42
+ */
43
+ export function scanBootstrapDir(bootstrapDir: string): Map<string, BootstrapEntry> {
44
+ const entries = new Map<string, BootstrapEntry>();
45
+ if (!existsSync(bootstrapDir)) return entries;
46
+
47
+ for (const entry of readdirSync(bootstrapDir, { withFileTypes: true })) {
48
+ if (!entry.isDirectory()) continue;
49
+ const manifestPath = join(bootstrapDir, entry.name, 'manifest.yml');
50
+ if (!existsSync(manifestPath)) continue;
51
+ try {
52
+ const yaml = readFileSync(manifestPath, 'utf-8');
53
+ const idMatch = yaml.match(/^id:\s*['"]?([^\s'"#]+)['"]?/m);
54
+ const versionMatch = yaml.match(/^version:\s*['"]?([^\s'"#]+)['"]?/m);
55
+ // Description is the rest-of-line up to (but not including) the
56
+ // newline. The manifest schema allows multi-line scalars, but
57
+ // existing modules use the single-line form so the line-regex is
58
+ // sufficient. We strip surrounding quotes if present.
59
+ const descriptionMatch = yaml.match(/^description:\s*(.+?)\s*$/m);
60
+ const name = idMatch?.[1]?.trim() ?? entry.name;
61
+ const version = versionMatch?.[1]?.trim() ?? '0.0.0';
62
+ const description = descriptionMatch?.[1]?.trim().replace(/^['"]|['"]$/g, '');
63
+ entries.set(name, {
64
+ name,
65
+ version,
66
+ sourceDir: join(bootstrapDir, entry.name),
67
+ description,
68
+ });
69
+ } catch {
70
+ // skip malformed manifests — matches e2e server behavior
71
+ }
72
+ }
73
+ return entries;
74
+ }
75
+
76
+ /**
77
+ * Scan a directory for pre-built .netapp files. Filename (minus suffix) is
78
+ * the module name. Version defaults to the filename's version suffix or to
79
+ * 0.0.0+1 — real version data is in the archive's manifest but parsing that
80
+ * at startup is overkill for the e2e bootstrap use case.
81
+ *
82
+ * Used by the shared-infra stand-in: the e2e harness's publishModule helper
83
+ * copies .netapps into BOOTSTRAP_UPLOADS_DIR with `docker cp`, and the
84
+ * server picks them up without needing the full HTTP publish protocol.
85
+ */
86
+ export function scanUploadsDir(uploadsDir: string): Map<string, BootstrapEntry> {
87
+ const entries = new Map<string, BootstrapEntry>();
88
+ if (!existsSync(uploadsDir)) return entries;
89
+
90
+ for (const file of readdirSync(uploadsDir)) {
91
+ if (!file.endsWith('.netapp')) continue;
92
+ const name = file.slice(0, -7);
93
+ entries.set(name, {
94
+ name,
95
+ version: '0.0.0',
96
+ netappPath: join(uploadsDir, file),
97
+ });
98
+ }
99
+ return entries;
100
+ }
101
+
102
+ /** Render a bootstrap entry as an IndexEntry suitable for the sparse index. */
103
+ export function bootstrapIndexEntry(entry: BootstrapEntry): IndexEntry {
104
+ const vers = entry.version.includes('+') ? entry.version : `${entry.version}+1`;
105
+ return {
106
+ name: entry.name,
107
+ vers,
108
+ deps: [],
109
+ cksum: 'bootstrap',
110
+ yanked: false,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Walk a directory, skipping hidden entries, *.test.ts files, the
116
+ * module's e2e/ tree (tests + their deps don't ship with the module),
117
+ * and most of node_modules.
118
+ *
119
+ * `scripts/node_modules` (the module's hook runtime) is bundled IN FULL
120
+ * (ISS-0046): hook scripts run on the target, where the package registry can
121
+ * be unreachable, so the `.netapp` must carry @celilo/capabilities AND its
122
+ * third-party deps (tldts, drizzle-orm, …). Any OTHER node_modules ships only
123
+ * `@celilo/capabilities` — the framework SDK the module was authored against.
124
+ *
125
+ * This is the structural twin of the canonical `includeNodeModulesPath` rule in
126
+ * apps/celilo/src/module/packaging/package-rules.ts. We can't import that at
127
+ * runtime (this server ships in a standalone Docker image with no @celilo deps),
128
+ * so the two are held in lockstep by bootstrap-packaging.test.ts, which asserts
129
+ * `walkDir`'s output conforms to the canonical rule. `walkDir` is exported for
130
+ * that test.
131
+ */
132
+ export function walkDir(dir: string, base = dir): string[] {
133
+ const results: string[] = [];
134
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
135
+ if (entry.name.startsWith('.')) continue;
136
+ if (entry.name.endsWith('.test.ts')) continue;
137
+
138
+ const fullPath = join(dir, entry.name);
139
+
140
+ // Module's e2e/ directory at the root is tests + their devDependencies.
141
+ // Detected by being a sibling of manifest.yml.
142
+ if (entry.name === 'e2e' && existsSync(join(dir, 'manifest.yml'))) {
143
+ continue;
144
+ }
145
+
146
+ if (entry.name === 'node_modules') {
147
+ if (basename(dir) === 'scripts') {
148
+ // The hook-script runtime closure — bundle it whole (incl. nested deps).
149
+ // walkAll keeps the hidden-entry / *.test.ts skips, so .bin shims drop.
150
+ results.push(...walkAll(fullPath, base));
151
+ } else {
152
+ // Other node_modules: collect only @celilo/capabilities.
153
+ const capDir = join(fullPath, '@celilo', 'capabilities');
154
+ if (existsSync(capDir)) {
155
+ results.push(...walkDir(capDir, base));
156
+ }
157
+ }
158
+ continue;
159
+ }
160
+
161
+ if (entry.isDirectory()) {
162
+ results.push(...walkDir(fullPath, base));
163
+ } else {
164
+ results.push(relative(base, fullPath));
165
+ }
166
+ }
167
+ return results;
168
+ }
169
+
170
+ /**
171
+ * Collect every file under `dir` (recursing into nested node_modules too),
172
+ * skipping only hidden entries (`.bin` symlink shims) and `*.test.ts`. Used to
173
+ * bundle a module's full `scripts/node_modules` runtime closure.
174
+ */
175
+ function walkAll(dir: string, base: string): string[] {
176
+ const results: string[] = [];
177
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
178
+ if (entry.name.startsWith('.')) continue;
179
+ if (entry.name.endsWith('.test.ts')) continue;
180
+ const fullPath = join(dir, entry.name);
181
+ if (entry.isDirectory()) {
182
+ results.push(...walkAll(fullPath, base));
183
+ } else {
184
+ results.push(relative(base, fullPath));
185
+ }
186
+ }
187
+ return results;
188
+ }
189
+
190
+ /**
191
+ * Package a module directory into a .netapp tarball. Cached by module name
192
+ * in cacheDir; cache is keyed by name only (assume the source is immutable
193
+ * for the lifetime of the server — fine for e2e).
194
+ */
195
+ export async function packageBootstrapModule(
196
+ entry: BootstrapEntry,
197
+ cacheDir: string,
198
+ ): Promise<string> {
199
+ // Upload-mode entries are already .netapp files — serve as-is.
200
+ if (entry.netappPath) return entry.netappPath;
201
+ const sourceDir = entry.sourceDir;
202
+ if (!sourceDir) {
203
+ throw new Error(`bootstrap entry ${entry.name} has neither sourceDir nor netappPath`);
204
+ }
205
+ mkdirSync(cacheDir, { recursive: true });
206
+ const cachePath = join(cacheDir, `${entry.name}.netapp`);
207
+ if (existsSync(cachePath)) return cachePath;
208
+
209
+ const filePaths = walkDir(sourceDir);
210
+ const fileHashes: Record<string, string> = {};
211
+ for (const relPath of filePaths) {
212
+ const data = readFileSync(join(sourceDir, relPath));
213
+ fileHashes[relPath] = createHash('sha256').update(data).digest('hex');
214
+ }
215
+
216
+ const checksumsJson = JSON.stringify(
217
+ { version: '1', generated: new Date().toISOString(), files: fileHashes },
218
+ null,
219
+ 2,
220
+ );
221
+
222
+ // Source dir may be read-only (volume-mounted) — stage in /tmp first.
223
+ const stagingDir = join('/tmp', `celilo-bootstrap-${entry.name}-${Date.now()}`);
224
+ mkdirSync(stagingDir, { recursive: true });
225
+ try {
226
+ for (const relPath of filePaths) {
227
+ mkdirSync(dirname(join(stagingDir, relPath)), { recursive: true });
228
+ writeFileSync(join(stagingDir, relPath), readFileSync(join(sourceDir, relPath)));
229
+ }
230
+ writeFileSync(join(stagingDir, 'checksums.json'), checksumsJson);
231
+
232
+ const proc = Bun.spawn(['tar', '-czf', cachePath, '-C', stagingDir, '.'], {
233
+ stderr: 'inherit',
234
+ });
235
+ const exitCode = await proc.exited;
236
+ if (exitCode !== 0) throw new Error(`tar exited with code ${exitCode}`);
237
+ } finally {
238
+ try {
239
+ Bun.spawnSync(['rm', '-rf', stagingDir]);
240
+ } catch {
241
+ // best-effort cleanup
242
+ }
243
+ }
244
+
245
+ return cachePath;
246
+ }
@@ -0,0 +1,16 @@
1
+ export { RegistryStorage } from './storage';
2
+ export type { IndexEntry, ModuleVersion } from './storage';
3
+ export { TokenAuth } from './auth';
4
+ export { startServer, startFromEnv } from './server';
5
+ export {
6
+ scanBootstrapDir,
7
+ bootstrapIndexEntry,
8
+ packageBootstrapModule,
9
+ } from './bootstrap';
10
+ export type { BootstrapEntry } from './bootstrap';
11
+
12
+ import { startFromEnv } from './server';
13
+
14
+ if (import.meta.main) {
15
+ startFromEnv();
16
+ }
@@ -0,0 +1,247 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { ADMIN_SCOPE } from './auth';
3
+ import {
4
+ IntrospectionVerifier,
5
+ claimToScope,
6
+ introspectionConfigFromEnv,
7
+ } from './introspection';
8
+
9
+ const CONFIG = {
10
+ endpoint: 'https://idp.test/application/o/introspect/',
11
+ clientId: 'celilo-registry',
12
+ clientSecret: 'super-secret',
13
+ adminGroup: 'celilo-admins',
14
+ publisherGroup: 'celilo-authors',
15
+ };
16
+
17
+ /** Replace global fetch with a stub returning `body` at `status`; restore after. */
18
+ function stubFetch(handler: (url: string, init: RequestInit) => Response): void {
19
+ // @ts-expect-error - overriding the global for the duration of a test
20
+ globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) =>
21
+ handler(String(url), init ?? {});
22
+ }
23
+
24
+ const realFetch = globalThis.fetch;
25
+ afterEach(() => {
26
+ globalThis.fetch = realFetch;
27
+ });
28
+
29
+ function introspectResponse(payload: unknown, status = 200): Response {
30
+ return new Response(JSON.stringify(payload), {
31
+ status,
32
+ headers: { 'Content-Type': 'application/json' },
33
+ });
34
+ }
35
+
36
+ describe('claimToScope', () => {
37
+ test('admin-group member → ADMIN_SCOPE', () => {
38
+ expect(claimToScope({ active: true, groups: ['celilo-admins'] }, 'celilo-admins')).toBe(
39
+ ADMIN_SCOPE,
40
+ );
41
+ });
42
+
43
+ test('non-admin identity → null (owner table is ce-1ch)', () => {
44
+ expect(claimToScope({ active: true, groups: ['users'] }, 'celilo-admins')).toBeNull();
45
+ expect(claimToScope({ active: true }, 'celilo-admins')).toBeNull();
46
+ });
47
+ });
48
+
49
+ describe('introspectionConfigFromEnv', () => {
50
+ const KEYS = [
51
+ 'OIDC_INTROSPECTION_ENDPOINT',
52
+ 'OIDC_CLIENT_ID',
53
+ 'OIDC_CLIENT_SECRET',
54
+ 'REGISTRY_ADMIN_GROUP',
55
+ 'REGISTRY_PUBLISHER_GROUP',
56
+ ];
57
+ afterEach(() => {
58
+ for (const k of KEYS) delete process.env[k];
59
+ });
60
+
61
+ test('null when any required var is missing', () => {
62
+ expect(introspectionConfigFromEnv()).toBeNull();
63
+ process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
64
+ process.env.OIDC_CLIENT_ID = 'cid';
65
+ // secret still missing
66
+ expect(introspectionConfigFromEnv()).toBeNull();
67
+ });
68
+
69
+ test('config when all required vars present; admin group defaults', () => {
70
+ process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
71
+ process.env.OIDC_CLIENT_ID = 'cid';
72
+ process.env.OIDC_CLIENT_SECRET = 'sec';
73
+ expect(introspectionConfigFromEnv()).toEqual({
74
+ endpoint: 'https://idp.test/introspect/',
75
+ clientId: 'cid',
76
+ clientSecret: 'sec',
77
+ adminGroup: 'celilo-admins',
78
+ publisherGroup: 'celilo-authors',
79
+ });
80
+ process.env.REGISTRY_ADMIN_GROUP = 'ops';
81
+ expect(introspectionConfigFromEnv()?.adminGroup).toBe('ops');
82
+ process.env.REGISTRY_PUBLISHER_GROUP = 'authors';
83
+ expect(introspectionConfigFromEnv()?.publisherGroup).toBe('authors');
84
+ });
85
+ });
86
+
87
+ describe('IntrospectionVerifier.scopeOf', () => {
88
+ const verifier = new IntrospectionVerifier(CONFIG);
89
+
90
+ test('active admin-group token → ADMIN_SCOPE', async () => {
91
+ stubFetch(() =>
92
+ introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }),
93
+ );
94
+ expect(await verifier.scopeOf('Bearer idp-token')).toBe(ADMIN_SCOPE);
95
+ });
96
+
97
+ test('active non-admin token → null', async () => {
98
+ stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['users'] }));
99
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
100
+ });
101
+
102
+ test('active:false → null (revoked at idp)', async () => {
103
+ stubFetch(() => introspectResponse({ active: false }));
104
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
105
+ });
106
+
107
+ test('expired exp → null even if active', async () => {
108
+ const pastSec = Math.floor(Date.now() / 1000) - 60;
109
+ stubFetch(() =>
110
+ introspectResponse({ active: true, groups: ['celilo-admins'], exp: pastSec }),
111
+ );
112
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
113
+ });
114
+
115
+ test('future exp → honored', async () => {
116
+ const futureSec = Math.floor(Date.now() / 1000) + 3600;
117
+ stubFetch(() =>
118
+ introspectResponse({ active: true, groups: ['celilo-admins'], exp: futureSec }),
119
+ );
120
+ expect(await verifier.scopeOf('idp-token')).toBe(ADMIN_SCOPE);
121
+ });
122
+
123
+ test('non-2xx response → null (fail closed)', async () => {
124
+ stubFetch(() => introspectResponse({ error: 'boom' }, 500));
125
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
126
+ });
127
+
128
+ test('fetch throws → null (fail closed)', async () => {
129
+ stubFetch(() => {
130
+ throw new Error('connection refused');
131
+ });
132
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
133
+ });
134
+
135
+ test('malformed body (no active field) → null', async () => {
136
+ stubFetch(() => introspectResponse({ sub: 'alice' }));
137
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
138
+ });
139
+
140
+ test('empty header → null without calling the idp', async () => {
141
+ let called = false;
142
+ stubFetch(() => {
143
+ called = true;
144
+ return introspectResponse({ active: true, groups: ['celilo-admins'] });
145
+ });
146
+ expect(await verifier.scopeOf('')).toBeNull();
147
+ expect(called).toBe(false);
148
+ });
149
+
150
+ test('authenticates with HTTP Basic client creds; sends the token in the body', async () => {
151
+ let seenAuth = '';
152
+ let seenBody = '';
153
+ stubFetch((_url, init) => {
154
+ seenAuth = new Headers(init.headers).get('Authorization') ?? '';
155
+ seenBody = String(init.body ?? '');
156
+ return introspectResponse({ active: true, groups: ['celilo-admins'] });
157
+ });
158
+ await verifier.scopeOf('Bearer the-token');
159
+ const expected = `Basic ${Buffer.from('celilo-registry:super-secret').toString('base64')}`;
160
+ expect(seenAuth).toBe(expected);
161
+ expect(seenBody).toContain('token=the-token');
162
+ });
163
+
164
+ test('does not log the token or client secret on failure', async () => {
165
+ const logs: string[] = [];
166
+ const realErr = console.error;
167
+ console.error = (...args: unknown[]) => {
168
+ logs.push(args.map(String).join(' '));
169
+ };
170
+ try {
171
+ stubFetch(() => {
172
+ throw new Error('boom');
173
+ });
174
+ await verifier.scopeOf('Bearer top-secret-token');
175
+ } finally {
176
+ console.error = realErr;
177
+ }
178
+ const joined = logs.join('\n');
179
+ expect(joined).not.toContain('top-secret-token');
180
+ expect(joined).not.toContain('super-secret');
181
+ });
182
+ });
183
+
184
+ describe('IntrospectionVerifier.identify (ce-1ch)', () => {
185
+ const verifier = new IntrospectionVerifier(CONFIG);
186
+
187
+ test('admin-group member → isAdmin, group=adminGroup', async () => {
188
+ stubFetch(() => introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }));
189
+ expect(await verifier.identify('Bearer t')).toEqual({
190
+ sub: 'alice',
191
+ isAdmin: true,
192
+ isPublisher: false,
193
+ group: 'celilo-admins',
194
+ });
195
+ });
196
+
197
+ test('publisher-group member → isPublisher, group=publisherGroup', async () => {
198
+ stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['celilo-authors'] }));
199
+ expect(await verifier.identify('Bearer t')).toEqual({
200
+ sub: 'bob',
201
+ isAdmin: false,
202
+ isPublisher: true,
203
+ group: 'celilo-authors',
204
+ });
205
+ });
206
+
207
+ test('member of neither publish group → verified but no group', async () => {
208
+ stubFetch(() => introspectResponse({ active: true, sub: 'carol', groups: ['users'] }));
209
+ expect(await verifier.identify('Bearer t')).toEqual({
210
+ sub: 'carol',
211
+ isAdmin: false,
212
+ isPublisher: false,
213
+ group: undefined,
214
+ });
215
+ });
216
+
217
+ test('admin with no sub → identified (admins publish without an ownership anchor)', async () => {
218
+ stubFetch(() => introspectResponse({ active: true, groups: ['celilo-admins'] }));
219
+ expect(await verifier.identify('Bearer t')).toEqual({
220
+ sub: undefined,
221
+ isAdmin: true,
222
+ isPublisher: false,
223
+ group: 'celilo-admins',
224
+ });
225
+ });
226
+
227
+ test('active:false → null (fail closed)', async () => {
228
+ stubFetch(() => introspectResponse({ active: false }));
229
+ expect(await verifier.identify('Bearer t')).toBeNull();
230
+ });
231
+
232
+ test('expired exp → null even if active', async () => {
233
+ const pastSec = Math.floor(Date.now() / 1000) - 60;
234
+ stubFetch(() =>
235
+ introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'], exp: pastSec }),
236
+ );
237
+ expect(await verifier.identify('Bearer t')).toBeNull();
238
+ });
239
+
240
+ test('admin group is configurable, not a literal', async () => {
241
+ const custom = new IntrospectionVerifier({ ...CONFIG, adminGroup: 'ops', publisherGroup: 'devs' });
242
+ stubFetch(() => introspectResponse({ active: true, sub: 'dan', groups: ['ops'] }));
243
+ const id = await custom.identify('Bearer t');
244
+ expect(id?.isAdmin).toBe(true);
245
+ expect(id?.group).toBe('ops');
246
+ });
247
+ });