@openparachute/hub 0.6.3-rc.2 → 0.6.3-rc.4

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.
@@ -296,6 +296,7 @@ describe("migrate — interactive + flag behavior", () => {
296
296
  throw new Error("prompt must not be called");
297
297
  },
298
298
  isTty: true,
299
+ hubUnitState: () => ({ state: "inactive" }),
299
300
  });
300
301
  expect(code).toBe(0);
301
302
  expect(logs.join("\n")).toMatch(/nothing to archive/i);
@@ -325,6 +326,7 @@ describe("migrate — interactive + flag behavior", () => {
325
326
  },
326
327
  list: true,
327
328
  isTty: true,
329
+ hubUnitState: () => ({ state: "inactive" }),
328
330
  });
329
331
  expect(code).toBe(0);
330
332
  expect(logs.join("\n")).toMatch(/--list — no changes made/);
@@ -351,6 +353,7 @@ describe("migrate — interactive + flag behavior", () => {
351
353
  },
352
354
  dryRun: true,
353
355
  isTty: true,
356
+ hubUnitState: () => ({ state: "inactive" }),
354
357
  });
355
358
  expect(code).toBe(0);
356
359
  expect(logs.join("\n")).toMatch(/dry-run/);
@@ -383,6 +386,7 @@ describe("migrate — interactive + flag behavior", () => {
383
386
  },
384
387
  yes: true,
385
388
  isTty: false,
389
+ hubUnitState: () => ({ state: "inactive" }),
386
390
  });
387
391
  expect(code).toBe(0);
388
392
  const archive = join(h.configDir, ".archive-2026-04-19");
@@ -510,6 +514,7 @@ describe("migrate — interactive + flag behavior", () => {
510
514
  throw new Error("prompt must not be called");
511
515
  },
512
516
  isTty: false,
517
+ hubUnitState: () => ({ state: "inactive" }),
513
518
  });
514
519
  expect(code).toBe(1);
515
520
  expect(logs.join("\n")).toMatch(/refusing to sweep without a TTY/i);
@@ -533,6 +538,7 @@ describe("migrate — interactive + flag behavior", () => {
533
538
  log: () => {},
534
539
  prompt: async () => answers.shift() ?? "n",
535
540
  isTty: true,
541
+ hubUnitState: () => ({ state: "inactive" }),
536
542
  });
537
543
  expect(code).toBe(1);
538
544
  // Aborted before any rename — daily.db still there.
@@ -557,6 +563,7 @@ describe("migrate — interactive + flag behavior", () => {
557
563
  log: () => {},
558
564
  prompt: async () => answers.shift() ?? "y",
559
565
  isTty: true,
566
+ hubUnitState: () => ({ state: "inactive" }),
560
567
  });
561
568
  expect(code).toBe(0);
562
569
  const archive = join(h.configDir, ".archive-2026-04-19");
@@ -588,6 +595,7 @@ describe("migrate — interactive + flag behavior", () => {
588
595
  },
589
596
  yes: true,
590
597
  isTty: false,
598
+ hubUnitState: () => ({ state: "inactive" }),
591
599
  });
592
600
  expect(code).toBe(0);
593
601
  const archivedLink = join(h.configDir, ".archive-2026-04-19", "logs");
@@ -621,6 +629,7 @@ describe("migrate — interactive + flag behavior", () => {
621
629
  },
622
630
  yes: true,
623
631
  isTty: false,
632
+ hubUnitState: () => ({ state: "inactive" }),
624
633
  });
625
634
  expect(code).toBe(0);
626
635
  // The "nothing recognized" exit branch — no archive directory created.
@@ -647,6 +656,7 @@ describe("migrate — interactive + flag behavior", () => {
647
656
  log: (l) => logs.push(l),
648
657
  prompt: async () => "n",
649
658
  isTty: true,
659
+ hubUnitState: () => ({ state: "inactive" }),
650
660
  });
651
661
  expect(code).toBe(1);
652
662
  expect(logs.join("\n")).toMatch(/aborted/i);
@@ -668,6 +678,7 @@ describe("migrate — interactive + flag behavior", () => {
668
678
  log: () => {},
669
679
  prompt: async () => "y",
670
680
  isTty: true,
681
+ hubUnitState: () => ({ state: "inactive" }),
671
682
  });
672
683
  expect(code).toBe(0);
673
684
  expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
@@ -687,6 +698,7 @@ describe("migrate — interactive + flag behavior", () => {
687
698
  log: () => {},
688
699
  yes: true,
689
700
  isTty: false,
701
+ hubUnitState: () => ({ state: "inactive" }),
690
702
  });
691
703
  // Add more cruft and sweep again the same day
692
704
  touch(join(h.configDir, "channel.log"), "2");
@@ -696,6 +708,7 @@ describe("migrate — interactive + flag behavior", () => {
696
708
  log: () => {},
697
709
  yes: true,
698
710
  isTty: false,
711
+ hubUnitState: () => ({ state: "inactive" }),
699
712
  });
700
713
  const archive = join(h.configDir, ".archive-2026-04-19");
701
714
  expect(existsSync(join(archive, "server.yaml"))).toBe(true);
@@ -719,6 +732,7 @@ describe("migrate — interactive + flag behavior", () => {
719
732
  log: () => {},
720
733
  yes: true,
721
734
  isTty: false,
735
+ hubUnitState: () => ({ state: "inactive" }),
722
736
  });
723
737
  touch(join(h.configDir, "channel.log"), "2");
724
738
  await migrate({
@@ -727,6 +741,7 @@ describe("migrate — interactive + flag behavior", () => {
727
741
  log: () => {},
728
742
  yes: true,
729
743
  isTty: false,
744
+ hubUnitState: () => ({ state: "inactive" }),
730
745
  });
731
746
  expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
732
747
  expect(existsSync(join(h.configDir, ".archive-2026-04-20", "channel.log"))).toBe(true);
@@ -749,6 +764,7 @@ describe("migrate — interactive + flag behavior", () => {
749
764
  log: () => {},
750
765
  yes: true,
751
766
  isTty: false,
767
+ hubUnitState: () => ({ state: "inactive" }),
752
768
  });
753
769
  const archive = join(h.configDir, ".archive-2026-04-19");
754
770
  const contents = readdirSync(archive);
@@ -3,6 +3,8 @@ import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import type { ExposeState } from "../expose-state.ts";
7
+ import { writeExposeState } from "../expose-state.ts";
6
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
9
  import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
8
10
  import {
@@ -14,6 +16,7 @@ import {
14
16
  OPERATOR_TOKEN_SCOPE_SETS,
15
17
  OPERATOR_TOKEN_SCOPE_SET_CLAIM,
16
18
  OPERATOR_TOKEN_TTL_SECONDS,
19
+ buildKnownIssuersForOperatorToken,
17
20
  issueOperatorToken,
18
21
  mintOperatorToken,
19
22
  operatorTokenPath,
@@ -478,6 +481,280 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
478
481
  });
479
482
  });
480
483
 
484
+ // hub#516 — the operator token's `iss` is the hub's PUBLIC origin after
485
+ // `parachute expose`, but callers resolve `issuer` inconsistently (status →
486
+ // loopback, lifecycle → public). The client-side validation now accepts the
487
+ // token if its `iss` is ANY of the hub's known origins (loopback aliases ∪
488
+ // expose-state public origin ∪ env), gated FIRST on the JWKS signature. These
489
+ // tests pin the four-corner matrix: public-iss accepted with loopback config
490
+ // (the status bug), loopback-iss accepted, foreign-iss rejected, and
491
+ // foreign-SIGNATURE rejected even when its iss is in the known set.
492
+ const PUBLIC_ISSUER = "https://parachute.taildf9ce2.ts.net";
493
+
494
+ /** Minimal valid expose-state advertising `hubOrigin` (the public origin). */
495
+ function exposeStateForOrigin(hubOrigin: string): ExposeState {
496
+ return {
497
+ version: 1,
498
+ layer: "tailnet",
499
+ mode: "path",
500
+ canonicalFqdn: new URL(hubOrigin).host,
501
+ port: 1939,
502
+ funnel: false,
503
+ entries: [],
504
+ hubOrigin,
505
+ };
506
+ }
507
+
508
+ describe("useOperatorTokenWithAutoRotate known-issuer set (hub#516)", () => {
509
+ // The PARACHUTE_HUB_ORIGIN / RENDER_EXTERNAL_URL / FLY_APP_NAME env vars feed
510
+ // the platform-origin seed of the known-issuer set. Tests that assert a
511
+ // public-iss is REJECTED when expose-state is absent must not have a stray
512
+ // env public origin leaking the iss back in — clear them around each test.
513
+ function withCleanPlatformEnv<T>(fn: () => T): T {
514
+ const saved = {
515
+ hub: process.env.PARACHUTE_HUB_ORIGIN,
516
+ render: process.env.RENDER_EXTERNAL_URL,
517
+ fly: process.env.FLY_APP_NAME,
518
+ };
519
+ // Computed-key delete (not `delete process.env.FOO`) so biome's noDelete
520
+ // doesn't fire — matches spawn-env-propagation.test.ts. A `= undefined`
521
+ // assignment would coerce to the string "undefined" and leak a bogus
522
+ // origin into the known-issuer set, so a real delete is required here.
523
+ for (const k of ["PARACHUTE_HUB_ORIGIN", "RENDER_EXTERNAL_URL", "FLY_APP_NAME"]) {
524
+ delete process.env[k];
525
+ }
526
+ try {
527
+ return fn();
528
+ } finally {
529
+ if (saved.hub !== undefined) process.env.PARACHUTE_HUB_ORIGIN = saved.hub;
530
+ if (saved.render !== undefined) process.env.RENDER_EXTERNAL_URL = saved.render;
531
+ if (saved.fly !== undefined) process.env.FLY_APP_NAME = saved.fly;
532
+ }
533
+ }
534
+
535
+ test("accepts a PUBLIC-iss operator token when config resolves loopback (the status bug)", async () => {
536
+ await withCleanPlatformEnv(async () => {
537
+ const h = makeHarness();
538
+ try {
539
+ const db = openHubDb(hubDbPath(h.dir));
540
+ try {
541
+ rotateSigningKey(db);
542
+ // Mint the operator token under the hub's PUBLIC origin — what
543
+ // happens on an exposed box (selfHealOperatorTokenIssuer re-mints to
544
+ // the public iss).
545
+ const issued = await issueOperatorToken(db, "user-abc", {
546
+ dir: h.dir,
547
+ issuer: PUBLIC_ISSUER,
548
+ });
549
+ // Expose-state advertises the public origin (so it lands in the
550
+ // known set). The CALLER resolves loopback (status's hardcoded path).
551
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
552
+
553
+ const used = await useOperatorTokenWithAutoRotate(db, {
554
+ configDir: h.dir,
555
+ issuer: TEST_ISSUER, // loopback — the status scenario
556
+ });
557
+ expect(used).not.toBeNull();
558
+ expect(used?.status.kind).toBe("fresh");
559
+ expect(used?.token).toBe(issued.token);
560
+ expect(used?.payload.iss).toBe(PUBLIC_ISSUER);
561
+ } finally {
562
+ db.close();
563
+ }
564
+ } finally {
565
+ h.cleanup();
566
+ }
567
+ });
568
+ });
569
+
570
+ test("accepts a loopback-iss operator token with loopback config", async () => {
571
+ await withCleanPlatformEnv(async () => {
572
+ const h = makeHarness();
573
+ try {
574
+ const db = openHubDb(hubDbPath(h.dir));
575
+ try {
576
+ rotateSigningKey(db);
577
+ const issued = await issueOperatorToken(db, "user-abc", {
578
+ dir: h.dir,
579
+ issuer: TEST_ISSUER,
580
+ });
581
+ // No expose-state — known set is loopback aliases only.
582
+ const used = await useOperatorTokenWithAutoRotate(db, {
583
+ configDir: h.dir,
584
+ issuer: TEST_ISSUER,
585
+ });
586
+ expect(used).not.toBeNull();
587
+ expect(used?.status.kind).toBe("fresh");
588
+ expect(used?.token).toBe(issued.token);
589
+ } finally {
590
+ db.close();
591
+ }
592
+ } finally {
593
+ h.cleanup();
594
+ }
595
+ });
596
+ });
597
+
598
+ test("rejects a token whose iss is FOREIGN to the known set", async () => {
599
+ await withCleanPlatformEnv(async () => {
600
+ const h = makeHarness();
601
+ try {
602
+ const db = openHubDb(hubDbPath(h.dir));
603
+ try {
604
+ rotateSigningKey(db);
605
+ // Hub-SIGNED (so the signature gate passes) but stamped with an iss
606
+ // that's neither loopback nor in expose-state nor env. Must reject.
607
+ const issued = await issueOperatorToken(db, "user-abc", {
608
+ dir: h.dir,
609
+ issuer: "https://evil.example.com",
610
+ });
611
+ expect(issued.token.length).toBeGreaterThan(0);
612
+ // expose-state advertises the PUBLIC origin (not the evil one), so
613
+ // the foreign iss is not in the known set.
614
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
615
+
616
+ await expect(
617
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
618
+ ).rejects.toThrow(/unexpected "iss" claim value/);
619
+ } finally {
620
+ db.close();
621
+ }
622
+ } finally {
623
+ h.cleanup();
624
+ }
625
+ });
626
+ });
627
+
628
+ test("rejects a FOREIGN-SIGNED token even when its iss is in the known set (signature gate first)", async () => {
629
+ await withCleanPlatformEnv(async () => {
630
+ const h = makeHarness();
631
+ const foreign = makeHarness();
632
+ try {
633
+ const db = openHubDb(hubDbPath(h.dir));
634
+ // A DIFFERENT hub (different signing key) mints a token stamped with an
635
+ // iss that IS in our known set. The signature won't verify against our
636
+ // JWKS, so it must be rejected at the signature gate regardless of iss.
637
+ const foreignDb = openHubDb(hubDbPath(foreign.dir));
638
+ try {
639
+ rotateSigningKey(db);
640
+ rotateSigningKey(foreignDb);
641
+ const foreignToken = await mintOperatorToken(foreignDb, "user-abc", {
642
+ issuer: PUBLIC_ISSUER,
643
+ });
644
+ await writeOperatorTokenFile(foreignToken.token, h.dir);
645
+ // Our expose-state advertises PUBLIC_ISSUER — so the iss WOULD pass
646
+ // the belt-and-suspenders check. The signature gate must still reject.
647
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
648
+
649
+ await expect(
650
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
651
+ ).rejects.toThrow();
652
+ } finally {
653
+ db.close();
654
+ foreignDb.close();
655
+ }
656
+ } finally {
657
+ h.cleanup();
658
+ foreign.cleanup();
659
+ }
660
+ });
661
+ });
662
+
663
+ test("expose-state absent: loopback-iss accepted, public-iss rejected (no public origin known)", async () => {
664
+ await withCleanPlatformEnv(async () => {
665
+ const h = makeHarness();
666
+ try {
667
+ const db = openHubDb(hubDbPath(h.dir));
668
+ try {
669
+ rotateSigningKey(db);
670
+ // A loopback-iss token is accepted (loopback alias is always in the set).
671
+ const loopbackTok = await issueOperatorToken(db, "user-abc", {
672
+ dir: h.dir,
673
+ issuer: TEST_ISSUER,
674
+ });
675
+ const usedLoopback = await useOperatorTokenWithAutoRotate(db, {
676
+ configDir: h.dir,
677
+ issuer: TEST_ISSUER,
678
+ });
679
+ expect(usedLoopback?.token).toBe(loopbackTok.token);
680
+
681
+ // Overwrite with a public-iss token. No expose-state, no env public
682
+ // origin → the public iss is NOT known → reject. (Correct: with no
683
+ // exposure configured, the hub doesn't legitimately answer on it.)
684
+ const publicTok = await issueOperatorToken(db, "user-abc", {
685
+ dir: h.dir,
686
+ issuer: PUBLIC_ISSUER,
687
+ });
688
+ expect(publicTok.token.length).toBeGreaterThan(0);
689
+ await expect(
690
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
691
+ ).rejects.toThrow(/unexpected "iss" claim value/);
692
+ } finally {
693
+ db.close();
694
+ }
695
+ } finally {
696
+ h.cleanup();
697
+ }
698
+ });
699
+ });
700
+
701
+ test("auto-rotate still fires for a near-expiry token validated via the known set", async () => {
702
+ await withCleanPlatformEnv(async () => {
703
+ const h = makeHarness();
704
+ try {
705
+ const db = openHubDb(hubDbPath(h.dir));
706
+ try {
707
+ rotateSigningKey(db);
708
+ // Public-iss + 1-day TTL (below the 7d threshold). Validates via the
709
+ // known set (expose-state public origin), then auto-rotates.
710
+ const original = await issueOperatorToken(db, "user-abc", {
711
+ dir: h.dir,
712
+ issuer: PUBLIC_ISSUER,
713
+ scopeSet: "start",
714
+ ttlSeconds: 24 * 60 * 60,
715
+ });
716
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
717
+
718
+ const used = await useOperatorTokenWithAutoRotate(db, {
719
+ configDir: h.dir,
720
+ issuer: PUBLIC_ISSUER, // lifecycle's public-origin scenario
721
+ });
722
+ expect(used).not.toBeNull();
723
+ expect(used?.status.kind).toBe("rotated");
724
+ expect(used?.rotated?.scopeSet).toBe("start");
725
+ expect(used?.token).not.toBe(original.token);
726
+ // Re-mint stamps opts.issuer as the new iss; still validates.
727
+ const validated = await validateAccessToken(db, used!.token, PUBLIC_ISSUER);
728
+ expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
729
+ } finally {
730
+ db.close();
731
+ }
732
+ } finally {
733
+ h.cleanup();
734
+ }
735
+ });
736
+ });
737
+
738
+ test("buildKnownIssuersForOperatorToken includes loopback aliases + expose-state public origin", async () => {
739
+ await withCleanPlatformEnv(() => {
740
+ const h = makeHarness();
741
+ try {
742
+ writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
743
+ const set = buildKnownIssuersForOperatorToken(h.dir, TEST_ISSUER);
744
+ expect(set).toContain("http://127.0.0.1:1939");
745
+ expect(set).toContain("http://localhost:1939");
746
+ expect(set).toContain(PUBLIC_ISSUER);
747
+ // The seed issuer is included too.
748
+ expect(set).toContain(TEST_ISSUER);
749
+ // A foreign origin is NOT present.
750
+ expect(set).not.toContain("https://evil.example.com");
751
+ } finally {
752
+ h.cleanup();
753
+ }
754
+ });
755
+ });
756
+ });
757
+
481
758
  // closes #212 Phase 1 — operator-mint paths write to the unified token
482
759
  // registry so they show up in the revocation list and admin UI alongside
483
760
  // OAuth refresh tokens and CLI mints.