@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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +121 -0
- package/src/__tests__/api-modules.test.ts +67 -0
- package/src/__tests__/host-admin-token-validation.test.ts +218 -0
- package/src/__tests__/managed-unit.test.ts +23 -3
- package/src/__tests__/migrate-cutover.test.ts +60 -1
- package/src/__tests__/migrate.test.ts +16 -0
- package/src/__tests__/operator-token.test.ts +277 -0
- package/src/__tests__/stale-module-units.test.ts +286 -0
- package/src/api-modules-ops.ts +28 -2
- package/src/api-modules.ts +25 -2
- package/src/cloudflare/connector-service.ts +13 -2
- package/src/commands/migrate-cutover.ts +48 -0
- package/src/host-admin-token-validation.ts +96 -0
- package/src/hub-server.ts +19 -3
- package/src/managed-unit.ts +24 -4
- package/src/operator-token.ts +96 -5
- package/src/origin-check.ts +10 -0
- package/src/stale-module-units.ts +374 -0
|
@@ -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.
|