@celilo/e2e 0.9.2-alpha.0 → 0.9.3

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.
package/src/runner.ts CHANGED
@@ -17,11 +17,11 @@
17
17
  * Countdown goes negative when over estimate.
18
18
  */
19
19
 
20
- import { spawn, spawnSync, execSync } from 'node:child_process';
20
+ import { execSync, spawn, spawnSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
22
22
  import { basename, dirname, join, resolve } from 'node:path';
23
- import { parse as parseYaml } from 'yaml';
24
23
  import { type DisplayMode, ProgressDisplay } from '@celilo/cli-display';
24
+ import { parse as parseYaml } from 'yaml';
25
25
  import {
26
26
  emitRunCompleted,
27
27
  emitRunFailed,
@@ -30,7 +30,7 @@ import {
30
30
  emitTestStarted,
31
31
  } from './bus-events';
32
32
  import { parseLine } from './parse-line';
33
- import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
33
+ import { E2eBusyError, acquireRunLock, markKept } from './run-lock';
34
34
  import { SIMULATOR_IPS } from './simulator-ips';
35
35
 
36
36
  // ─── Config ──────────────────────────────────────────────────────────
@@ -50,7 +50,9 @@ function resolveModuleTestsDir(dir: string): string {
50
50
  const manifestPath = join(dir, 'manifest.yml');
51
51
  if (existsSync(manifestPath)) {
52
52
  try {
53
- const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { e2e?: { tests_dir?: string } };
53
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
54
+ e2e?: { tests_dir?: string };
55
+ };
54
56
  if (parsed?.e2e?.tests_dir) return resolve(dir, parsed.e2e.tests_dir);
55
57
  } catch {}
56
58
  }
@@ -84,11 +86,14 @@ const flagCi = rawArgs.includes('--ci');
84
86
  const flagNoCi = rawArgs.includes('--no-ci');
85
87
  const ci = flagCi || (!flagNoCi && !!process.env.CI);
86
88
  const patterns = rawArgs.filter((a) => !a.startsWith('--'));
87
- // --ci-safe: skip tests marked `cele2e-ci-unsafe` in their file (quarantined —
88
- // known-failing or builder-flaky, each tracked by an ISS). `--complete`/`--all`
89
- // run everything. Passed through from the CLI (not stripped) so the runner can
90
- // apply the exclusion.
91
- const flagCiSafe = rawArgs.includes('--ci-safe');
89
+ // Quarantine: tests whose file contains the marker `cele2e-ci-unsafe` (a comment
90
+ // naming the reason + ISS) are known-failing / builder-flaky and tracked
91
+ // separately, so they must never turn a broad run red. They are SKIPPED by
92
+ // default in every broad run (`--all`, `--all-modules`, `--ci-safe`, bare). Two
93
+ // ways to run one anyway: name it explicitly (`cele2e run <name>`), or pass
94
+ // `--complete` (the deliberate "run everything, including quarantined" opt-in).
95
+ // `--complete` is passed through from the CLI (not stripped) so the runner sees it.
96
+ const flagComplete = rawArgs.includes('--complete');
92
97
  // --notify: fire a desktop notification when the run finishes (handy for long
93
98
  // detached/background runs). Best-effort — never affects the exit code.
94
99
  const flagNotify = rawArgs.includes('--notify');
@@ -123,9 +128,20 @@ function notify(title: string, message: string): void {
123
128
  if (!flagNotify) return;
124
129
  try {
125
130
  if (process.platform === 'darwin') {
126
- const r = spawnSync('terminal-notifier', ['-title', title, '-message', message, '-sound', 'Glass'], { stdio: 'ignore' });
131
+ const r = spawnSync(
132
+ 'terminal-notifier',
133
+ ['-title', title, '-message', message, '-sound', 'Glass'],
134
+ { stdio: 'ignore' },
135
+ );
127
136
  if (r.error) {
128
- spawnSync('osascript', ['-e', `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`], { stdio: 'ignore' });
137
+ spawnSync(
138
+ 'osascript',
139
+ [
140
+ '-e',
141
+ `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`,
142
+ ],
143
+ { stdio: 'ignore' },
144
+ );
129
145
  }
130
146
  spawnSync('say', [message], { stdio: 'ignore' });
131
147
  } else if (process.platform === 'linux') {
@@ -182,7 +198,10 @@ function extractFailureMessage(lines: string[], exitCode: number): string {
182
198
  // bun's own test failure: "✗ test name [Xms]" — extract the failing test name
183
199
  const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
184
200
  if (bunFailIdx >= 0) {
185
- const testName = plain[bunFailIdx].replace(/^\s*✗\s+/, '').replace(/\s*\[\d+ms\].*$/, '').trim();
201
+ const testName = plain[bunFailIdx]
202
+ .replace(/^\s*✗\s+/, '')
203
+ .replace(/\s*\[\d+ms\].*$/, '')
204
+ .trim();
186
205
  // Grab the error detail on the next few lines (bun indents it under the test)
187
206
  const detail = plain
188
207
  .slice(bunFailIdx + 1, bunFailIdx + 6)
@@ -215,7 +234,13 @@ function extractFailureMessage(lines: string[], exitCode: number): string {
215
234
 
216
235
  // Last resort: print the last few non-empty, non-progress lines from the output
217
236
  const tail = plain
218
- .filter((l) => l.length > 0 && !l.startsWith('[progress') && !l.startsWith('[e2e:') && !l.startsWith('[ansible:'))
237
+ .filter(
238
+ (l) =>
239
+ l.length > 0 &&
240
+ !l.startsWith('[progress') &&
241
+ !l.startsWith('[e2e:') &&
242
+ !l.startsWith('[ansible:'),
243
+ )
219
244
  .slice(-3)
220
245
  .map((l) => l.trim())
221
246
  .join(' | ');
@@ -270,7 +295,9 @@ async function runTest(
270
295
  testEnv.CELILO_E2E_REUSE = '1';
271
296
  testEnv.CELILO_E2E_PROJECT = data.projectName;
272
297
  } catch {
273
- console.error(`${red}✗ --reuse: no persistent network found. Run with --keep first.${reset}`);
298
+ console.error(
299
+ `${red}✗ --reuse: no persistent network found. Run with --keep first.${reset}`,
300
+ );
274
301
  process.exit(1);
275
302
  }
276
303
  }
@@ -298,23 +325,32 @@ async function runTest(
298
325
  process.stdout.write(`${bold}\x1b[33m║${reset} Container: ${bold}${container}${reset}\n`);
299
326
  process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
300
327
  process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Run commands:${reset}\n`);
301
- process.stdout.write(`${bold}\x1b[33m║${reset} docker compose -f docker-compose.test.yml \\\n`);
302
- process.stdout.write(`${bold}\x1b[33m║${reset} -p ${project} exec ${container} bash -c '<cmd>'\n`);
328
+ process.stdout.write(
329
+ `${bold}\x1b[33m║${reset} docker compose -f docker-compose.test.yml \\\n`,
330
+ );
331
+ process.stdout.write(
332
+ `${bold}\x1b[33m║${reset} -p ${project} exec ${container} bash -c '<cmd>'\n`,
333
+ );
303
334
  process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
304
335
 
305
336
  if (interactive) {
306
- process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Or get a shell: type 'exit' to resume the test.${reset}\n`);
337
+ process.stdout.write(
338
+ `${bold}\x1b[33m║${reset} ${dim}Or get a shell: type 'exit' to resume the test.${reset}\n`,
339
+ );
307
340
  process.stdout.write(`${bold}\x1b[33m╚${border}${reset}\n\n`);
308
341
 
309
- spawnSync('docker', [
310
- 'compose', '-f', 'docker-compose.test.yml', '-p', project,
311
- 'exec', container, 'bash',
312
- ], {
313
- cwd: PKG_DIR,
314
- stdio: 'inherit',
315
- });
342
+ spawnSync(
343
+ 'docker',
344
+ ['compose', '-f', 'docker-compose.test.yml', '-p', project, 'exec', container, 'bash'],
345
+ {
346
+ cwd: PKG_DIR,
347
+ stdio: 'inherit',
348
+ },
349
+ );
316
350
  } else {
317
- process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Resume: touch ${signalFile}${reset}\n`);
351
+ process.stdout.write(
352
+ `${bold}\x1b[33m║${reset} ${dim}Resume: touch ${signalFile}${reset}\n`,
353
+ );
318
354
  process.stdout.write(`${bold}\x1b[33m╚${border}${reset}\n\n`);
319
355
 
320
356
  // Non-interactive: just wait for the signal file (no shell)
@@ -376,7 +412,7 @@ async function runTest(
376
412
  writeFileSync(logFile, stdout + '\n---STDERR---\n' + stderr);
377
413
 
378
414
  const expectMatch = stdout.match(/(\d+) expect\(\) calls/);
379
- const expectCount = expectMatch ? parseInt(expectMatch[1], 10) : undefined;
415
+ const expectCount = expectMatch ? Number.parseInt(expectMatch[1], 10) : undefined;
380
416
 
381
417
  if (code === 0) {
382
418
  if (expectCount === 0) {
@@ -389,7 +425,13 @@ async function runTest(
389
425
  error: '0 assertions — test likely exited before reaching assertions',
390
426
  });
391
427
  } else {
392
- resolveTest({ name, status: 'pass', duration, hadDebugPause, projectName: capturedProjectName });
428
+ resolveTest({
429
+ name,
430
+ status: 'pass',
431
+ duration,
432
+ hadDebugPause,
433
+ projectName: capturedProjectName,
434
+ });
393
435
  }
394
436
  } else {
395
437
  // Collect a raw tail: strip ANSI, drop internal protocol lines, keep last 30
@@ -397,12 +439,13 @@ async function runTest(
397
439
  .split('\n')
398
440
  .map(stripAnsi)
399
441
  .map((l) => l.trimEnd())
400
- .filter((l) =>
401
- l.length > 0 &&
402
- !l.startsWith('[progress') &&
403
- !l.startsWith('[e2e:') &&
404
- !l.startsWith('[ansible:') &&
405
- !l.startsWith('[debug:'),
442
+ .filter(
443
+ (l) =>
444
+ l.length > 0 &&
445
+ !l.startsWith('[progress') &&
446
+ !l.startsWith('[e2e:') &&
447
+ !l.startsWith('[ansible:') &&
448
+ !l.startsWith('[debug:'),
406
449
  )
407
450
  .slice(-30)
408
451
  .join('\n');
@@ -424,10 +467,12 @@ async function runTest(
424
467
 
425
468
  function dockerComposeShared(cmd: string): string {
426
469
  try {
427
- return execSync(
428
- `docker compose -f docker-compose.shared.yml -p celilo-e2e-shared ${cmd}`,
429
- { cwd: PKG_DIR, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60_000 },
430
- ).trim();
470
+ return execSync(`docker compose -f docker-compose.shared.yml -p celilo-e2e-shared ${cmd}`, {
471
+ cwd: PKG_DIR,
472
+ encoding: 'utf-8',
473
+ stdio: ['pipe', 'pipe', 'pipe'],
474
+ timeout: 60_000,
475
+ }).trim();
431
476
  } catch {
432
477
  return '';
433
478
  }
@@ -435,7 +480,9 @@ function dockerComposeShared(cmd: string): string {
435
480
 
436
481
  function stopSharedInfra(): void {
437
482
  dockerComposeShared('down --volumes --remove-orphans');
438
- try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
483
+ try {
484
+ execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 });
485
+ } catch {}
439
486
  }
440
487
 
441
488
  /**
@@ -486,14 +533,20 @@ async function main() {
486
533
  // docker mutation so a competing run can't wipe our shared infra mid-setup.
487
534
  const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
488
535
  try {
489
- acquireRunLock({ test: lockLabel, runId: process.env.CELE2E_RUN_ID ?? ambientRunId, allowKept: flagReuse });
536
+ acquireRunLock({
537
+ test: lockLabel,
538
+ runId: process.env.CELE2E_RUN_ID ?? ambientRunId,
539
+ allowKept: flagReuse,
540
+ });
490
541
  // A --reuse run keeps using a kept stack that stays up afterwards — hold the
491
542
  // lock in kept state so it isn't freed out from under the reused network.
492
543
  if (flagReuse) markKept();
493
544
  } catch (err) {
494
545
  if (err instanceof E2eBusyError) {
495
546
  console.error(`\n${red}${err.message}${reset}`);
496
- console.error(`${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`);
547
+ console.error(
548
+ `${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`,
549
+ );
497
550
  process.exit(3);
498
551
  }
499
552
  throw err;
@@ -511,7 +564,9 @@ async function main() {
511
564
  const tp = resolveModuleTestsDir(dir);
512
565
  if (!existsSync(tp)) {
513
566
  console.error(`${red}No tests directory found at ${tp}${reset}`);
514
- console.error(`${red}Set e2e.tests_dir in ${join(dir, 'manifest.yml')} to point to your test files.${reset}`);
567
+ console.error(
568
+ `${red}Set e2e.tests_dir in ${join(dir, 'manifest.yml')} to point to your test files.${reset}`,
569
+ );
515
570
  process.exit(1);
516
571
  }
517
572
  // Auto-install e2e/ dependencies if package.json exists but node_modules is missing
@@ -573,11 +628,10 @@ async function main() {
573
628
  testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
574
629
  }
575
630
 
576
- // --ci-safe: drop quarantined tests — those whose file contains the marker
577
- // `cele2e-ci-unsafe` (a comment naming the reason + ISS). Keeps the nightly
578
- // green on the CI-safe subset while the quarantined tests are tracked + fixed
579
- // separately. `--complete`/`--all` run everything (no exclusion).
580
- if (flagCiSafe) {
631
+ // Drop quarantined tests (file contains `cele2e-ci-unsafe`) from broad runs so
632
+ // they never turn a full run red. Skipped by default; bypassed only when the
633
+ // user explicitly named tests (`patterns.length > 0`) or passed `--complete`.
634
+ if (!flagComplete && patterns.length === 0) {
581
635
  const before = testFiles.length;
582
636
  testFiles = testFiles.filter((f) => {
583
637
  try {
@@ -588,7 +642,9 @@ async function main() {
588
642
  });
589
643
  const skipped = before - testFiles.length;
590
644
  if (skipped > 0) {
591
- console.log(`${dim}--ci-safe: skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s)${reset}`);
645
+ console.log(
646
+ `${dim}skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s) — pass --complete or name one to run it${reset}`,
647
+ );
592
648
  }
593
649
  }
594
650
 
@@ -637,7 +693,9 @@ async function main() {
637
693
  console.log(`${bold}╔══════════════════════════════════════════════════════════════${reset}`);
638
694
  console.log(`${bold}║ Celilo E2E Tests${moduleLabel}${reset}`);
639
695
  if (hasHistory) {
640
- console.log(`${bold}║ ${testFiles.length} test(s) — expected ~${formatDuration(suiteExpected)}${reset}`);
696
+ console.log(
697
+ `${bold}║ ${testFiles.length} test(s) — expected ~${formatDuration(suiteExpected)}${reset}`,
698
+ );
641
699
  } else {
642
700
  console.log(`${bold}║ ${testFiles.length} test(s) — first run, no timing history${reset}`);
643
701
  }
@@ -741,7 +799,8 @@ async function main() {
741
799
  emitTestCompleted({
742
800
  runId,
743
801
  name,
744
- status: result.status === 'pass' ? 'pass' : result.status === 'suspicious' ? 'suspicious' : 'fail',
802
+ status:
803
+ result.status === 'pass' ? 'pass' : result.status === 'suspicious' ? 'suspicious' : 'fail',
745
804
  durationMs: result.duration * 1000,
746
805
  error: result.error,
747
806
  logDir,
@@ -757,11 +816,15 @@ async function main() {
757
816
  if (flagKeep && result.projectName) {
758
817
  writeFileSync(
759
818
  persistentFile,
760
- JSON.stringify({
761
- projectName: result.projectName,
762
- testFile: file,
763
- createdAt: new Date().toISOString(),
764
- }, null, 2) + '\n',
819
+ JSON.stringify(
820
+ {
821
+ projectName: result.projectName,
822
+ testFile: file,
823
+ createdAt: new Date().toISOString(),
824
+ },
825
+ null,
826
+ 2,
827
+ ) + '\n',
765
828
  );
766
829
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
767
830
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
@@ -800,7 +863,9 @@ async function main() {
800
863
  JSON.stringify({ status: result.status, duration: result.duration, error: result.error }),
801
864
  );
802
865
 
803
- try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
866
+ try {
867
+ execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 });
868
+ } catch {}
804
869
  }
805
870
 
806
871
  saveTiming(history);
@@ -820,7 +885,9 @@ async function main() {
820
885
  for (const r of results) {
821
886
  const icon = r.status === 'pass' ? '✓' : '✗';
822
887
  const color = r.status === 'pass' ? green : red;
823
- console.log(`${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration + 's').padStart(6)}${reset}`);
888
+ console.log(
889
+ `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration + 's').padStart(6)}${reset}`,
890
+ );
824
891
  if (r.error) {
825
892
  console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
826
893
  }
@@ -832,7 +899,11 @@ async function main() {
832
899
 
833
900
  writeFileSync(
834
901
  join(resultsDir, 'summary.json'),
835
- JSON.stringify({ timestamp, total: testFiles.length, passed, failed, duration: suiteDuration }, null, 2),
902
+ JSON.stringify(
903
+ { timestamp, total: testFiles.length, passed, failed, duration: suiteDuration },
904
+ null,
905
+ 2,
906
+ ),
836
907
  );
837
908
 
838
909
  const gitignore = join(testDir, '.gitignore');
@@ -1,17 +0,0 @@
1
- {
2
- "name": "@celilo/registry-server",
3
- "version": "1.0.0",
4
- "private": true,
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "scripts": {
8
- "start": "bun run src/index.ts",
9
- "dev": "bun --watch src/index.ts",
10
- "build:binary": "bun build --compile --target=bun-linux-x64 --outfile=dist/celilo-registry-server src/index.ts",
11
- "test": "bun test"
12
- },
13
- "devDependencies": {
14
- "bun-types": "latest",
15
- "typescript": "^5"
16
- }
17
- }
@@ -1,76 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { ADMIN_SCOPE, TokenAuth, hashToken } from './auth';
3
-
4
- describe('TokenAuth (package-scoped — ISS-0140)', () => {
5
- test('empty specs → no tokens; nothing authorizes', () => {
6
- const auth = new TokenAuth([]);
7
- expect(auth.hasTokens()).toBe(false);
8
- expect(auth.authorize('anything', 'caddy')).toBe(false);
9
- expect(auth.isAdmin('anything')).toBe(false);
10
- });
11
-
12
- test('admin-scoped token publishes ANY package', () => {
13
- const auth = new TokenAuth([{ token: 'admin-tok', scope: ADMIN_SCOPE }]);
14
- expect(auth.authorize('admin-tok', 'caddy')).toBe(true);
15
- expect(auth.authorize('admin-tok', 'lunacycle')).toBe(true);
16
- expect(auth.isAdmin('admin-tok')).toBe(true);
17
- });
18
-
19
- test('package-scoped token publishes ONLY its package', () => {
20
- const auth = new TokenAuth([{ token: 'luna-tok', scope: 'lunacycle' }]);
21
- expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
22
- expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
23
- // A scoped token is NOT an admin token — it cannot mint.
24
- expect(auth.isAdmin('luna-tok')).toBe(false);
25
- });
26
-
27
- test('unknown token never authorizes', () => {
28
- const auth = new TokenAuth([{ token: 'known', scope: 'lunacycle' }]);
29
- expect(auth.authorize('unknown', 'lunacycle')).toBe(false);
30
- });
31
-
32
- test('tokens are stored hashed, not in cleartext', () => {
33
- const auth = new TokenAuth([{ token: 'secret-token', scope: ADMIN_SCOPE }]);
34
- expect(JSON.stringify(auth)).not.toContain('secret-token');
35
- });
36
-
37
- test('tolerates a Bearer prefix and surrounding whitespace', () => {
38
- const auth = new TokenAuth([{ token: 'clean', scope: ADMIN_SCOPE }]);
39
- expect(auth.authorize(' clean ', 'caddy')).toBe(true);
40
- expect(auth.authorize('Bearer clean', 'caddy')).toBe(true);
41
- });
42
-
43
- test('empty header rejected', () => {
44
- const auth = new TokenAuth([{ token: 'secret', scope: ADMIN_SCOPE }]);
45
- expect(auth.authorize('', 'caddy')).toBe(false);
46
- expect(auth.scopeOf('')).toBe(null);
47
- });
48
-
49
- test('addHashed / removeHashed manage minted tokens at runtime', () => {
50
- const auth = new TokenAuth([]);
51
- const hash = hashToken('minted-tok');
52
- auth.addHashed(hash, 'lunacycle');
53
- expect(auth.authorize('minted-tok', 'lunacycle')).toBe(true);
54
- auth.removeHashed(hash);
55
- expect(auth.authorize('minted-tok', 'lunacycle')).toBe(false);
56
- });
57
- });
58
-
59
- describe('TokenAuth.fromEnv (PUBLISH_TOKENS format)', () => {
60
- test('bare line → admin scope; "token pkg" line → scoped', () => {
61
- process.env.PUBLISH_TOKENS = 'admin-tok\nluna-tok lunacycle\n';
62
- const auth = TokenAuth.fromEnv();
63
- expect(auth.isAdmin('admin-tok')).toBe(true);
64
- expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
65
- expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
66
- process.env.PUBLISH_TOKENS = '';
67
- });
68
-
69
- test('blank lines ignored', () => {
70
- process.env.PUBLISH_TOKENS = '\n \nt1\n';
71
- const auth = TokenAuth.fromEnv();
72
- expect(auth.hasTokens()).toBe(true);
73
- expect(auth.isAdmin('t1')).toBe(true);
74
- process.env.PUBLISH_TOKENS = '';
75
- });
76
- });
@@ -1,105 +0,0 @@
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
- function bareToken(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 = bareToken(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
- }
@@ -1,71 +0,0 @@
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
- });