@openparachute/hub 0.5.2 → 0.5.9-rc.6
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/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -9,8 +9,13 @@ import {
|
|
|
9
9
|
REFRESH_TOKEN_TTL_MS,
|
|
10
10
|
RefreshTokenInsertError,
|
|
11
11
|
findRefreshToken,
|
|
12
|
+
findTokenRowByJti,
|
|
13
|
+
listActiveRevocations,
|
|
14
|
+
recordTokenMint,
|
|
15
|
+
revokeTokenByJti,
|
|
12
16
|
signAccessToken,
|
|
13
17
|
signRefreshToken,
|
|
18
|
+
tokenRowIdentity,
|
|
14
19
|
validateAccessToken,
|
|
15
20
|
} from "../jwt-sign.ts";
|
|
16
21
|
import { getActiveSigningKey, rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -359,3 +364,203 @@ describe("validateAccessToken", () => {
|
|
|
359
364
|
}
|
|
360
365
|
});
|
|
361
366
|
});
|
|
367
|
+
|
|
368
|
+
// closes #212 Phase 1 — unified token registry helpers (recordTokenMint,
|
|
369
|
+
// revokeTokenByJti, listActiveRevocations) and the v6 schema shape.
|
|
370
|
+
describe("token registry (hub#212 Phase 1)", () => {
|
|
371
|
+
test("v6 schema: tokens has user_id NULLABLE + permissions/created_via/subject", () => {
|
|
372
|
+
const { db, cleanup } = makeDb();
|
|
373
|
+
try {
|
|
374
|
+
// SQLite PRAGMA table_info reports column nullability + defaults; the
|
|
375
|
+
// bun:sqlite driver maps the row shape onto our type. The columns are
|
|
376
|
+
// (cid, name, type, notnull, dflt_value, pk) per SQLite docs.
|
|
377
|
+
type ColInfo = {
|
|
378
|
+
cid: number;
|
|
379
|
+
name: string;
|
|
380
|
+
type: string;
|
|
381
|
+
notnull: number;
|
|
382
|
+
dflt_value: string | null;
|
|
383
|
+
pk: number;
|
|
384
|
+
};
|
|
385
|
+
const cols = db.query<ColInfo, []>("PRAGMA table_info(tokens)").all();
|
|
386
|
+
const byName = new Map(cols.map((c) => [c.name, c]));
|
|
387
|
+
// Pre-v6: user_id NOT NULL. Post-v6: user_id NULLABLE.
|
|
388
|
+
expect(byName.get("user_id")?.notnull).toBe(0);
|
|
389
|
+
// New columns.
|
|
390
|
+
expect(byName.has("permissions")).toBe(true);
|
|
391
|
+
expect(byName.has("created_via")).toBe(true);
|
|
392
|
+
expect(byName.has("subject")).toBe(true);
|
|
393
|
+
// created_via has the back-compat default for pre-v6 rows.
|
|
394
|
+
expect(byName.get("created_via")?.dflt_value).toMatch(/oauth_refresh/);
|
|
395
|
+
} finally {
|
|
396
|
+
cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("recordTokenMint inserts a registry row matching the inputs", () => {
|
|
401
|
+
const { db, cleanup } = makeDb();
|
|
402
|
+
try {
|
|
403
|
+
const expiresAt = new Date(Date.now() + 86400_000).toISOString();
|
|
404
|
+
recordTokenMint(db, {
|
|
405
|
+
jti: "jti-cli-1",
|
|
406
|
+
createdVia: "cli_mint",
|
|
407
|
+
subject: "operator",
|
|
408
|
+
clientId: "parachute-hub",
|
|
409
|
+
scopes: ["vault:read", "scribe:transcribe"],
|
|
410
|
+
expiresAt,
|
|
411
|
+
permissions: '{"vault":{"default":{"read_tags":["public"]}}}',
|
|
412
|
+
});
|
|
413
|
+
const row = findTokenRowByJti(db, "jti-cli-1");
|
|
414
|
+
expect(row).not.toBeNull();
|
|
415
|
+
expect(row?.userId).toBeNull();
|
|
416
|
+
expect(row?.subject).toBe("operator");
|
|
417
|
+
expect(row?.createdVia).toBe("cli_mint");
|
|
418
|
+
expect(row?.scopes).toEqual(["vault:read", "scribe:transcribe"]);
|
|
419
|
+
expect(row?.expiresAt).toBe(expiresAt);
|
|
420
|
+
expect(row?.permissions).toBe('{"vault":{"default":{"read_tags":["public"]}}}');
|
|
421
|
+
expect(row?.revokedAt).toBeNull();
|
|
422
|
+
} finally {
|
|
423
|
+
cleanup();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("recordTokenMint with a duplicate jti throws RefreshTokenInsertError", () => {
|
|
428
|
+
const { db, cleanup } = makeDb();
|
|
429
|
+
try {
|
|
430
|
+
const expiresAt = new Date(Date.now() + 86400_000).toISOString();
|
|
431
|
+
recordTokenMint(db, {
|
|
432
|
+
jti: "jti-dup",
|
|
433
|
+
createdVia: "operator_mint",
|
|
434
|
+
subject: "operator",
|
|
435
|
+
clientId: "parachute-hub",
|
|
436
|
+
scopes: ["hub:admin"],
|
|
437
|
+
expiresAt,
|
|
438
|
+
});
|
|
439
|
+
expect(() =>
|
|
440
|
+
recordTokenMint(db, {
|
|
441
|
+
jti: "jti-dup",
|
|
442
|
+
createdVia: "cli_mint",
|
|
443
|
+
subject: "operator",
|
|
444
|
+
clientId: "parachute-hub",
|
|
445
|
+
scopes: ["vault:read"],
|
|
446
|
+
expiresAt,
|
|
447
|
+
}),
|
|
448
|
+
).toThrow(RefreshTokenInsertError);
|
|
449
|
+
} finally {
|
|
450
|
+
cleanup();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("revokeTokenByJti flips revoked_at; second call returns false (idempotent)", () => {
|
|
455
|
+
const { db, cleanup } = makeDb();
|
|
456
|
+
try {
|
|
457
|
+
const expiresAt = new Date(Date.now() + 86400_000).toISOString();
|
|
458
|
+
recordTokenMint(db, {
|
|
459
|
+
jti: "jti-rev",
|
|
460
|
+
createdVia: "cli_mint",
|
|
461
|
+
subject: "operator",
|
|
462
|
+
clientId: "parachute-hub",
|
|
463
|
+
scopes: ["vault:read"],
|
|
464
|
+
expiresAt,
|
|
465
|
+
});
|
|
466
|
+
const now = new Date();
|
|
467
|
+
expect(revokeTokenByJti(db, "jti-rev", now)).toBe(true);
|
|
468
|
+
expect(revokeTokenByJti(db, "jti-rev", now)).toBe(false);
|
|
469
|
+
const row = findTokenRowByJti(db, "jti-rev");
|
|
470
|
+
expect(row?.revokedAt).toBe(now.toISOString());
|
|
471
|
+
} finally {
|
|
472
|
+
cleanup();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("listActiveRevocations filters by revoked_at AND expires_at>now", () => {
|
|
477
|
+
const { db, cleanup } = makeDb();
|
|
478
|
+
try {
|
|
479
|
+
const past = new Date(Date.now() - 86400_000).toISOString();
|
|
480
|
+
const future = new Date(Date.now() + 86400_000).toISOString();
|
|
481
|
+
// Two revoked rows: one expired, one active.
|
|
482
|
+
recordTokenMint(db, {
|
|
483
|
+
jti: "jti-revoked-expired",
|
|
484
|
+
createdVia: "cli_mint",
|
|
485
|
+
subject: "operator",
|
|
486
|
+
clientId: "parachute-hub",
|
|
487
|
+
scopes: ["vault:read"],
|
|
488
|
+
expiresAt: past,
|
|
489
|
+
});
|
|
490
|
+
recordTokenMint(db, {
|
|
491
|
+
jti: "jti-revoked-active",
|
|
492
|
+
createdVia: "cli_mint",
|
|
493
|
+
subject: "operator",
|
|
494
|
+
clientId: "parachute-hub",
|
|
495
|
+
scopes: ["vault:read"],
|
|
496
|
+
expiresAt: future,
|
|
497
|
+
});
|
|
498
|
+
// One non-revoked active row (control — must NOT appear).
|
|
499
|
+
recordTokenMint(db, {
|
|
500
|
+
jti: "jti-not-revoked",
|
|
501
|
+
createdVia: "cli_mint",
|
|
502
|
+
subject: "operator",
|
|
503
|
+
clientId: "parachute-hub",
|
|
504
|
+
scopes: ["vault:read"],
|
|
505
|
+
expiresAt: future,
|
|
506
|
+
});
|
|
507
|
+
const now = new Date();
|
|
508
|
+
revokeTokenByJti(db, "jti-revoked-expired", now);
|
|
509
|
+
revokeTokenByJti(db, "jti-revoked-active", now);
|
|
510
|
+
const list = listActiveRevocations(db, now);
|
|
511
|
+
expect(list).toEqual(["jti-revoked-active"]);
|
|
512
|
+
} finally {
|
|
513
|
+
cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("tokenRowIdentity returns userId when present, else subject", async () => {
|
|
518
|
+
const { db, cleanup } = makeDb();
|
|
519
|
+
try {
|
|
520
|
+
rotateSigningKey(db);
|
|
521
|
+
const u = await createUser(db, "owner", "pw");
|
|
522
|
+
// OAuth refresh row: userId set, subject NULL.
|
|
523
|
+
const refresh = signRefreshToken(db, {
|
|
524
|
+
jti: "jti-oauth",
|
|
525
|
+
userId: u.id,
|
|
526
|
+
clientId: "parachute-hub",
|
|
527
|
+
scopes: ["vault:read"],
|
|
528
|
+
});
|
|
529
|
+
expect(refresh.familyId).toBeDefined();
|
|
530
|
+
const oauthRow = findTokenRowByJti(db, "jti-oauth")!;
|
|
531
|
+
expect(tokenRowIdentity(oauthRow)).toBe(u.id);
|
|
532
|
+
|
|
533
|
+
// CLI mint row: userId NULL, subject set.
|
|
534
|
+
recordTokenMint(db, {
|
|
535
|
+
jti: "jti-cli",
|
|
536
|
+
createdVia: "cli_mint",
|
|
537
|
+
subject: "operator",
|
|
538
|
+
clientId: "parachute-hub",
|
|
539
|
+
scopes: ["vault:read"],
|
|
540
|
+
expiresAt: new Date(Date.now() + 86400_000).toISOString(),
|
|
541
|
+
});
|
|
542
|
+
const cliRow = findTokenRowByJti(db, "jti-cli")!;
|
|
543
|
+
expect(tokenRowIdentity(cliRow)).toBe("operator");
|
|
544
|
+
} finally {
|
|
545
|
+
cleanup();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("signRefreshToken explicitly stamps created_via='oauth_refresh'", async () => {
|
|
550
|
+
const { db, cleanup } = makeDb();
|
|
551
|
+
try {
|
|
552
|
+
rotateSigningKey(db);
|
|
553
|
+
const u = await createUser(db, "owner", "pw");
|
|
554
|
+
signRefreshToken(db, {
|
|
555
|
+
jti: "jti-oauth-stamped",
|
|
556
|
+
userId: u.id,
|
|
557
|
+
clientId: "parachute-hub",
|
|
558
|
+
scopes: ["vault:read"],
|
|
559
|
+
});
|
|
560
|
+
const row = findTokenRowByJti(db, "jti-oauth-stamped");
|
|
561
|
+
expect(row?.createdVia).toBe("oauth_refresh");
|
|
562
|
+
} finally {
|
|
563
|
+
cleanup();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
});
|
|
@@ -248,7 +248,11 @@ describe("parachute start", () => {
|
|
|
248
248
|
configDir: h.configDir,
|
|
249
249
|
manifestPath: h.manifestPath,
|
|
250
250
|
spawner,
|
|
251
|
-
alive
|
|
251
|
+
// Stale 4242 is dead; the freshly spawned 7777 is alive — the
|
|
252
|
+
// post-spawn settle (hub#194) calls alive(pid) on the new pid,
|
|
253
|
+
// so we differentiate per-pid rather than blanket-false.
|
|
254
|
+
alive: (pid) => pid === 7777,
|
|
255
|
+
sleep: async () => {},
|
|
252
256
|
log: () => {},
|
|
253
257
|
});
|
|
254
258
|
expect(code).toBe(0);
|
|
@@ -629,6 +633,94 @@ describe("parachute start", () => {
|
|
|
629
633
|
}
|
|
630
634
|
});
|
|
631
635
|
|
|
636
|
+
test("hub#194: reports failure when child dies before the settle window", async () => {
|
|
637
|
+
// The bug: `parachute start notes` reported `✓ notes started (pid X)`
|
|
638
|
+
// but notes-serve crashed milliseconds later on a Bun.resolveSync
|
|
639
|
+
// failure, leaving tailnet `/notes/` 502'ing. Fix: after spawn, sleep
|
|
640
|
+
// ~250ms then re-check alive(pid). If dead, clear pidfile, log
|
|
641
|
+
// failure, return non-zero. This regression test pins the post-fix
|
|
642
|
+
// shape with a stub alive that always reports dead and a fast settle.
|
|
643
|
+
const h = makeHarness();
|
|
644
|
+
try {
|
|
645
|
+
seedVault(h.manifestPath);
|
|
646
|
+
const spawner = makeSpawner([4242]);
|
|
647
|
+
const lines: string[] = [];
|
|
648
|
+
const code = await start("vault", {
|
|
649
|
+
configDir: h.configDir,
|
|
650
|
+
manifestPath: h.manifestPath,
|
|
651
|
+
spawner,
|
|
652
|
+
alive: () => false, // child dies immediately after spawn
|
|
653
|
+
sleep: async () => {}, // skip the real wait in tests
|
|
654
|
+
startSettleMs: 1, // any non-zero value engages the check
|
|
655
|
+
log: (l) => lines.push(l),
|
|
656
|
+
});
|
|
657
|
+
expect(code).toBe(1);
|
|
658
|
+
expect(spawner.calls).toHaveLength(1);
|
|
659
|
+
// pidfile is cleared so a follow-up `start` doesn't report
|
|
660
|
+
// already-running against a corpse.
|
|
661
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
662
|
+
const out = lines.join("\n");
|
|
663
|
+
expect(out).toMatch(/✗ vault failed to start/);
|
|
664
|
+
expect(out).toMatch(/exited within 1ms/);
|
|
665
|
+
expect(out).toMatch(/Tail the log/);
|
|
666
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
667
|
+
} finally {
|
|
668
|
+
h.cleanup();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("hub#194: settle path passes when child stays alive past the window", async () => {
|
|
673
|
+
// Companion to the above — verifies the success-path shape doesn't
|
|
674
|
+
// regress. Stub alive returns true so the post-spawn check passes,
|
|
675
|
+
// and we still see the `✓ ... started` line.
|
|
676
|
+
const h = makeHarness();
|
|
677
|
+
try {
|
|
678
|
+
seedVault(h.manifestPath);
|
|
679
|
+
const spawner = makeSpawner([4242]);
|
|
680
|
+
const lines: string[] = [];
|
|
681
|
+
const code = await start("vault", {
|
|
682
|
+
configDir: h.configDir,
|
|
683
|
+
manifestPath: h.manifestPath,
|
|
684
|
+
spawner,
|
|
685
|
+
alive: () => true,
|
|
686
|
+
sleep: async () => {},
|
|
687
|
+
startSettleMs: 1,
|
|
688
|
+
log: (l) => lines.push(l),
|
|
689
|
+
});
|
|
690
|
+
expect(code).toBe(0);
|
|
691
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
692
|
+
expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
|
|
693
|
+
} finally {
|
|
694
|
+
h.cleanup();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("hub#194: settle skipped when startSettleMs is 0", async () => {
|
|
699
|
+
// Defense — don't regress the test-default policy. With a stub
|
|
700
|
+
// spawner and no `alive` override, the resolved settle is 0 (see
|
|
701
|
+
// resolve() in lifecycle.ts), so the post-spawn check is bypassed
|
|
702
|
+
// entirely and even an `alive: () => false` doesn't matter.
|
|
703
|
+
const h = makeHarness();
|
|
704
|
+
try {
|
|
705
|
+
seedVault(h.manifestPath);
|
|
706
|
+
const spawner = makeSpawner([4242]);
|
|
707
|
+
const code = await start("vault", {
|
|
708
|
+
configDir: h.configDir,
|
|
709
|
+
manifestPath: h.manifestPath,
|
|
710
|
+
spawner,
|
|
711
|
+
startSettleMs: 0,
|
|
712
|
+
// intentionally omit alive — defaultAlive against a fake pid
|
|
713
|
+
// would normally report dead, but startSettleMs: 0 skips the
|
|
714
|
+
// call entirely.
|
|
715
|
+
log: () => {},
|
|
716
|
+
});
|
|
717
|
+
expect(code).toBe(0);
|
|
718
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
719
|
+
} finally {
|
|
720
|
+
h.cleanup();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
632
724
|
test("third-party with no startCmd in module.json reports lifecycle-unsupported", async () => {
|
|
633
725
|
const h = makeHarness();
|
|
634
726
|
try {
|
|
@@ -802,7 +894,10 @@ describe("parachute restart", () => {
|
|
|
802
894
|
manifestPath: h.manifestPath,
|
|
803
895
|
spawner,
|
|
804
896
|
kill: (pid, sig) => killed.push([pid, sig]),
|
|
805
|
-
|
|
897
|
+
// Stale 4242 is dead (stop's stale-pid path skips the kill);
|
|
898
|
+
// freshly spawned 7777 is alive past the post-spawn settle
|
|
899
|
+
// (hub#194). Per-pid differentiation rather than blanket-false.
|
|
900
|
+
alive: (pid) => pid === 7777,
|
|
806
901
|
sleep: async () => {},
|
|
807
902
|
log: () => {},
|
|
808
903
|
});
|
|
@@ -126,6 +126,54 @@ describe("validateModuleManifest", () => {
|
|
|
126
126
|
).toThrow(/http:.*https:/);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("uiUrl accepts a leading-slash path (Phase D)", () => {
|
|
130
|
+
const m = validateModuleManifest({ ...VALID, uiUrl: "/notes" }, "x");
|
|
131
|
+
expect(m.uiUrl).toBe("/notes");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("uiUrl accepts an absolute https URL", () => {
|
|
135
|
+
const m = validateModuleManifest({ ...VALID, uiUrl: "https://app.example.com/" }, "x");
|
|
136
|
+
expect(m.uiUrl).toBe("https://app.example.com/");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("uiUrl rejects empty / non-string / non-url-or-path (mirrors managementUrl)", () => {
|
|
140
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "" }, "x")).toThrow(/uiUrl/);
|
|
141
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: 7 }, "x")).toThrow(/uiUrl/);
|
|
142
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "no-slash" }, "x")).toThrow(
|
|
143
|
+
/path starting with "\/" or a full http\(s\) URL/,
|
|
144
|
+
);
|
|
145
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "ftp://example.com" }, "x")).toThrow(
|
|
146
|
+
/http:.*https:/,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uiUrl absent stays absent", () => {
|
|
151
|
+
const m = validateModuleManifest(VALID, "x");
|
|
152
|
+
expect(m.uiUrl).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Open-redirect regression: protocol-relative paths like "//evil.com" pass
|
|
156
|
+
// a naive `startsWith("/")` check but `new URL("//evil.com", base)` resolves
|
|
157
|
+
// to the foreign origin. A malicious third-party module could plant such a
|
|
158
|
+
// value in module.json:uiUrl and turn a discovery tile into an off-origin
|
|
159
|
+
// redirect. Both uiUrl and managementUrl are validated by the shared
|
|
160
|
+
// asPathOrUrl helper, so cover both.
|
|
161
|
+
test("uiUrl rejects protocol-relative paths (open-redirect regression)", () => {
|
|
162
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com" }, "x")).toThrow(/uiUrl/);
|
|
163
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com/path" }, "x")).toThrow(
|
|
164
|
+
/uiUrl/,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("managementUrl rejects protocol-relative paths (open-redirect regression)", () => {
|
|
169
|
+
expect(() => validateModuleManifest({ ...VALID, managementUrl: "//evil.com" }, "x")).toThrow(
|
|
170
|
+
/managementUrl/,
|
|
171
|
+
);
|
|
172
|
+
expect(() =>
|
|
173
|
+
validateModuleManifest({ ...VALID, managementUrl: "//evil.com/admin" }, "x"),
|
|
174
|
+
).toThrow(/managementUrl/);
|
|
175
|
+
});
|
|
176
|
+
|
|
129
177
|
test("managementUrl absent stays absent", () => {
|
|
130
178
|
const m = validateModuleManifest(VALID, "x");
|
|
131
179
|
expect(m.managementUrl).toBeUndefined();
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizeMount,
|
|
7
|
+
notesDistCandidates,
|
|
8
|
+
notesFetch,
|
|
9
|
+
resolveNotesDistFrom,
|
|
10
|
+
} from "../notes-serve.ts";
|
|
6
11
|
|
|
7
12
|
interface Harness {
|
|
8
13
|
dir: string;
|
|
@@ -133,3 +138,150 @@ describe("notesFetch with empty mount (root deployment)", () => {
|
|
|
133
138
|
}
|
|
134
139
|
});
|
|
135
140
|
});
|
|
141
|
+
|
|
142
|
+
describe("notesDistCandidates", () => {
|
|
143
|
+
test("returns cwd, then global node_modules, then global root", () => {
|
|
144
|
+
const cands = notesDistCandidates("/some/cwd", "/home/user");
|
|
145
|
+
expect(cands).toEqual([
|
|
146
|
+
"/some/cwd",
|
|
147
|
+
"/home/user/.bun/install/global/node_modules",
|
|
148
|
+
"/home/user/.bun/install/global",
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `resolveNotesDistFrom` is the hub#194 fix — when the cwd-relative resolve
|
|
155
|
+
* fails (hub repo dir doesn't depend on @openparachute/notes), we walk down
|
|
156
|
+
* to bun's global install dirs before giving up. Tests use a stub
|
|
157
|
+
* `resolveSync` so we can drive the candidate order without writing real
|
|
158
|
+
* fixtures into `~/.bun/install/global`.
|
|
159
|
+
*/
|
|
160
|
+
describe("resolveNotesDistFrom (hub#194)", () => {
|
|
161
|
+
function makeFixture(): { home: string; cleanup: () => void; pkgRoot: string; dist: string } {
|
|
162
|
+
// realpathSync — on macOS `mkdtempSync` returns a /var/folders path
|
|
163
|
+
// that resolves to /private/var/folders; we want the resolved form so
|
|
164
|
+
// string comparisons against `Bun.resolveSync` output line up.
|
|
165
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "pcli-notes-resolve-")));
|
|
166
|
+
const home = join(root, "home");
|
|
167
|
+
const pkgRoot = join(home, ".bun/install/global/node_modules/@openparachute/notes");
|
|
168
|
+
mkdirSync(pkgRoot, { recursive: true });
|
|
169
|
+
const dist = join(pkgRoot, "dist");
|
|
170
|
+
mkdirSync(dist, { recursive: true });
|
|
171
|
+
writeFileSync(join(pkgRoot, "package.json"), '{"name":"@openparachute/notes"}');
|
|
172
|
+
return { home, pkgRoot, dist, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
test("first-candidate (cwd) hit returns its dist immediately", () => {
|
|
176
|
+
const f = makeFixture();
|
|
177
|
+
try {
|
|
178
|
+
const calls: string[] = [];
|
|
179
|
+
const out = resolveNotesDistFrom({
|
|
180
|
+
cwd: "/cwd-with-notes",
|
|
181
|
+
home: f.home,
|
|
182
|
+
resolveSync: (specifier, base) => {
|
|
183
|
+
calls.push(base);
|
|
184
|
+
if (base === "/cwd-with-notes") {
|
|
185
|
+
return "/cwd-with-notes/node_modules/@openparachute/notes/package.json";
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`unexpected base: ${base}`);
|
|
188
|
+
},
|
|
189
|
+
existsSync: (p) => p === "/cwd-with-notes/node_modules/@openparachute/notes/dist",
|
|
190
|
+
});
|
|
191
|
+
expect(out).toBe("/cwd-with-notes/node_modules/@openparachute/notes/dist");
|
|
192
|
+
// Only the cwd candidate should be probed — we short-circuit on hit.
|
|
193
|
+
expect(calls).toEqual(["/cwd-with-notes"]);
|
|
194
|
+
} finally {
|
|
195
|
+
f.cleanup();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("falls through to global node_modules when cwd resolve fails (hub#194 root cause)", () => {
|
|
200
|
+
// The exact scenario from hub#194: hub repo's cwd has no dependency on
|
|
201
|
+
// notes, so the first candidate throws ResolveMessage. Bun does NOT
|
|
202
|
+
// auto-consult ~/.bun/install/global, so we have to try it explicitly.
|
|
203
|
+
const f = makeFixture();
|
|
204
|
+
try {
|
|
205
|
+
const calls: string[] = [];
|
|
206
|
+
const out = resolveNotesDistFrom({
|
|
207
|
+
cwd: "/hub-repo-cwd-without-notes",
|
|
208
|
+
home: f.home,
|
|
209
|
+
resolveSync: (specifier, base) => {
|
|
210
|
+
calls.push(base);
|
|
211
|
+
if (base === "/hub-repo-cwd-without-notes") {
|
|
212
|
+
throw new Error(`Cannot find module '${specifier}' from '${base}'`);
|
|
213
|
+
}
|
|
214
|
+
// Real Bun.resolveSync against the global node_modules dir
|
|
215
|
+
// resolves into the package's package.json.
|
|
216
|
+
return Bun.resolveSync(specifier, base);
|
|
217
|
+
},
|
|
218
|
+
// Use real existsSync — the fixture has dist/ on disk.
|
|
219
|
+
});
|
|
220
|
+
expect(out).toBe(f.dist);
|
|
221
|
+
// Both candidates probed, in order.
|
|
222
|
+
expect(calls[0]).toBe("/hub-repo-cwd-without-notes");
|
|
223
|
+
expect(calls[1]).toBe(join(f.home, ".bun/install/global/node_modules"));
|
|
224
|
+
} finally {
|
|
225
|
+
f.cleanup();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("falls through past global node_modules to the older global root layout", () => {
|
|
230
|
+
// Defensive: older Bun versions used a flatter global layout. We probe
|
|
231
|
+
// both. This test forces the first two candidates to fail and pins
|
|
232
|
+
// that the third is reached.
|
|
233
|
+
const probed: string[] = [];
|
|
234
|
+
expect(() =>
|
|
235
|
+
resolveNotesDistFrom({
|
|
236
|
+
cwd: "/cwd",
|
|
237
|
+
home: "/h",
|
|
238
|
+
resolveSync: (_specifier, base) => {
|
|
239
|
+
probed.push(base);
|
|
240
|
+
throw new Error(`Cannot find module from '${base}'`);
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
).toThrow(/Could not resolve @openparachute\/notes from any of/);
|
|
244
|
+
expect(probed).toEqual([
|
|
245
|
+
"/cwd",
|
|
246
|
+
"/h/.bun/install/global/node_modules",
|
|
247
|
+
"/h/.bun/install/global",
|
|
248
|
+
]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("error message names every candidate that was tried", () => {
|
|
252
|
+
let caught: unknown;
|
|
253
|
+
try {
|
|
254
|
+
resolveNotesDistFrom({
|
|
255
|
+
cwd: "/probe-cwd",
|
|
256
|
+
home: "/probe-home",
|
|
257
|
+
resolveSync: (_specifier, base) => {
|
|
258
|
+
throw new Error(`Cannot find module from '${base}'`);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
caught = err;
|
|
263
|
+
}
|
|
264
|
+
expect(caught).toBeInstanceOf(Error);
|
|
265
|
+
const msg = (caught as Error).message;
|
|
266
|
+
expect(msg).toContain("/probe-cwd");
|
|
267
|
+
expect(msg).toContain("/probe-home/.bun/install/global/node_modules");
|
|
268
|
+
expect(msg).toContain("/probe-home/.bun/install/global");
|
|
269
|
+
// Hint operators at the actionable next step.
|
|
270
|
+
expect(msg).toMatch(/bun add -g @openparachute\/notes|parachute install notes/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("resolved package without dist/ throws a hard error (no fallthrough)", () => {
|
|
274
|
+
// If the package resolves but lacks a dist/ directory, that's a
|
|
275
|
+
// packaging issue — falling through to other candidates would just
|
|
276
|
+
// re-resolve the same package. Surface the problem with the resolved
|
|
277
|
+
// path so the operator can file the right issue against the package.
|
|
278
|
+
expect(() =>
|
|
279
|
+
resolveNotesDistFrom({
|
|
280
|
+
cwd: "/cwd-with-notes",
|
|
281
|
+
home: "/h",
|
|
282
|
+
resolveSync: () => "/cwd-with-notes/node_modules/@openparachute/notes/package.json",
|
|
283
|
+
existsSync: () => false,
|
|
284
|
+
}),
|
|
285
|
+
).toThrow(/has no dist\/ directory/);
|
|
286
|
+
});
|
|
287
|
+
});
|