@openparachute/hub 0.7.5 → 0.7.6-rc.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-module-token.test.ts +40 -3
  3. package/src/__tests__/api-modules-ops.test.ts +8 -3
  4. package/src/__tests__/api-modules.test.ts +26 -18
  5. package/src/__tests__/connections-store.test.ts +84 -0
  6. package/src/__tests__/doctor.test.ts +131 -0
  7. package/src/__tests__/git-notify.test.ts +29 -1
  8. package/src/__tests__/grants-store.test.ts +33 -1
  9. package/src/__tests__/hub-instance.test.ts +297 -0
  10. package/src/__tests__/hub-server.test.ts +169 -0
  11. package/src/__tests__/install.test.ts +28 -0
  12. package/src/__tests__/serve-boot.test.ts +60 -0
  13. package/src/__tests__/service-spec-discovery.test.ts +32 -9
  14. package/src/__tests__/setup.test.ts +64 -16
  15. package/src/__tests__/stale-module-units.test.ts +1 -1
  16. package/src/__tests__/status-supervisor.test.ts +112 -0
  17. package/src/admin-connections.ts +5 -1
  18. package/src/admin-module-token.ts +2 -2
  19. package/src/api-modules-ops.ts +3 -3
  20. package/src/api-modules.ts +13 -13
  21. package/src/commands/doctor.ts +167 -4
  22. package/src/commands/install.ts +29 -3
  23. package/src/commands/migrate.ts +5 -0
  24. package/src/commands/serve.ts +52 -0
  25. package/src/commands/setup.ts +10 -9
  26. package/src/commands/status.ts +42 -1
  27. package/src/connections-store.ts +15 -2
  28. package/src/git-notify.ts +34 -5
  29. package/src/grants-store.ts +15 -2
  30. package/src/help.ts +3 -3
  31. package/src/hub-instance.ts +365 -0
  32. package/src/hub-server.ts +89 -1
  33. package/src/install-source.ts +1 -1
  34. package/src/service-spec.ts +36 -44
  35. package/src/services-manifest.ts +1 -1
  36. package/src/stale-module-units.ts +2 -2
  37. package/src/well-known.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.5",
3
+ "version": "0.7.6-rc.3",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -7,7 +7,7 @@
7
7
  * - 401 when the cookie names a deleted session.
8
8
  * - 405 on POST.
9
9
  * - 200 + JWT carrying `aud: "<short>"` and `<short>:admin` for known modules
10
- * (scribe / runner / surface).
10
+ * (scribe / surface / agent).
11
11
  * - 400 for `vault` (per-instance — points at /admin/vault-admin-token/<name>).
12
12
  * - 404 for an unknown short.
13
13
  * - First-admin gate: 403 for a signed-in non-first-admin (friend).
@@ -111,8 +111,10 @@ describe("handleModuleToken", () => {
111
111
  });
112
112
 
113
113
  // The known single-audience modules the generic mint serves. Each gets
114
- // `<short>:admin` with `aud: <short>`.
115
- for (const short of ["scribe", "runner", "surface", "agent"]) {
114
+ // `<short>:admin` with `aud: <short>`. (runner left this set with its
115
+ // 2026-07-01 registry removal a LEGACY install still mints via the
116
+ // self-registration gate, pinned below.)
117
+ for (const short of ["scribe", "surface", "agent"]) {
116
118
  test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
117
119
  const { cookie, userId } = await withSession();
118
120
  rotateSigningKey(harness.db);
@@ -201,6 +203,41 @@ describe("handleModuleToken", () => {
201
203
  return dir;
202
204
  }
203
205
 
206
+ test("200 mints for a LEGACY runner install via the self-registration gate (registry removal 2026-07-01)", async () => {
207
+ // runner is no longer a known short, so path 2 (bootstrap registry) is
208
+ // gone — but an existing install that self-registered (row + module.json)
209
+ // keeps minting `runner:admin` through path 1, exactly like a third-party
210
+ // module. Its config UI keeps working post-removal.
211
+ const { cookie } = await withSession();
212
+ rotateSigningKey(harness.db);
213
+ const installDir = writeManifestDir("parachute-runner");
214
+ try {
215
+ const services: ServiceEntry[] = [
216
+ {
217
+ name: "parachute-runner",
218
+ port: 1945,
219
+ paths: ["/runner"],
220
+ health: "/runner/healthz",
221
+ version: "0.2.0",
222
+ installDir,
223
+ },
224
+ ];
225
+ // The row matches by its literal services.json name (the third-party
226
+ // convention) — `runner` the bare short no longer resolves anywhere.
227
+ const req = new Request(urlFor("parachute-runner"), { headers: { cookie } });
228
+ const res = await handleModuleToken(req, "parachute-runner", depsWith(services));
229
+ expect(res.status).toBe(200);
230
+ const body = (await res.json()) as { scopes: string[] };
231
+ expect(body.scopes).toEqual(["parachute-runner:admin"]);
232
+ // The bare `runner` short 404s — nothing known + no row named "runner".
233
+ const bareReq = new Request(urlFor("runner"), { headers: { cookie } });
234
+ const bareRes = await handleModuleToken(bareReq, "runner", depsWith(services));
235
+ expect(bareRes.status).toBe(404);
236
+ } finally {
237
+ rmSync(installDir, { recursive: true, force: true });
238
+ }
239
+ });
240
+
204
241
  test("200 mints for a self-registered third-party module (row + readable module.json)", async () => {
205
242
  const { cookie, userId } = await withSession();
206
243
  rotateSigningKey(harness.db);
@@ -218,9 +218,9 @@ describe("parseModulesPath", () => {
218
218
  short: "agent",
219
219
  rest: "install",
220
220
  });
221
- // Other known modules (runner / surface) resolve too.
222
- expect(parseModulesPath("/api/modules/runner/install")).toEqual({
223
- short: "runner",
221
+ // Other known modules (surface) resolve too.
222
+ expect(parseModulesPath("/api/modules/surface/install")).toEqual({
223
+ short: "surface",
224
224
  rest: "install",
225
225
  });
226
226
  });
@@ -231,6 +231,11 @@ describe("parseModulesPath", () => {
231
231
  // module-ops switch never acts on them.
232
232
  expect(parseModulesPath("/api/modules/hub/install")).toBeUndefined();
233
233
  expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
234
+ // runner left the registries on 2026-07-01 — module-ops no longer
235
+ // addresses it by short, same as any third-party module. (A legacy
236
+ // install still boots under `parachute serve` via its installDir; see
237
+ // serve-boot tests.)
238
+ expect(parseModulesPath("/api/modules/runner/install")).toBeUndefined();
234
239
  });
235
240
 
236
241
  test("rejects malformed paths", () => {
@@ -222,10 +222,11 @@ describe("GET /api/modules", () => {
222
222
  // by the UNION of the bootstrap registries (KNOWN_MODULES ∪
223
223
  // FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
224
224
  // surfaces — core (vault/scribe/surface) in the headline tier, agent as
225
- // `experimental`, and notes/runner as `deprecated` (2026-06-25, still
225
+ // `experimental`, and notes as `deprecated` (2026-06-25, still
226
226
  // resolvable but not offered for fresh installs) — so the agent-not-installed
227
227
  // class (running but invisible) can't recur while deprecated modules stop
228
- // being pushed on a fresh box.
228
+ // being pushed on a fresh box. runner left the registries entirely on
229
+ // 2026-07-01 and must NOT surface on a fresh catalog.
229
230
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
230
231
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
231
232
  db: h.db,
@@ -250,31 +251,31 @@ describe("GET /api/modules", () => {
250
251
  // ahead of every experimental module, which lead the deprecated ones.
251
252
  expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
252
253
  expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
253
- // agent (experimental) sorts ahead of notes/runner (deprecated).
254
- expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("runner"));
254
+ // agent (experimental) sorts ahead of notes (deprecated).
255
255
  expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
256
256
  // Every known module is discoverable — vault/scribe/surface (core),
257
- // agent (experimental), notes/runner (deprecated).
258
- for (const s of ["vault", "scribe", "surface", "agent", "runner", "notes"]) {
257
+ // agent (experimental), notes (deprecated). runner is gone (2026-07-01
258
+ // registry removal) a fresh box never sees it.
259
+ for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
259
260
  expect(shorts).toContain(s);
260
261
  }
262
+ expect(shorts).not.toContain("runner");
263
+ expect(shorts).not.toContain("parachute-runner");
261
264
  // Focus tier resolves from the default map.
262
265
  const byShort = new Map(body.modules.map((m) => [m.short, m]));
263
266
  expect(byShort.get("vault")?.focus).toBe("core");
264
267
  expect(byShort.get("scribe")?.focus).toBe("core");
265
268
  expect(byShort.get("surface")?.focus).toBe("core");
266
269
  expect(byShort.get("agent")?.focus).toBe("experimental");
267
- expect(byShort.get("runner")?.focus).toBe("deprecated");
268
270
  expect(byShort.get("notes")?.focus).toBe("deprecated");
269
271
  // `available` stays true for every known module (re-installable), but the
270
272
  // fresh-install OFFER (`available_to_install`) drops the deprecated tier —
271
- // notes/runner aren't pushed on a fresh box; agent (experimental) still is.
273
+ // notes isn't pushed on a fresh box; agent (experimental) still is.
272
274
  expect(body.modules.every((m) => m.available)).toBe(true);
273
275
  expect(byShort.get("vault")?.available_to_install).toBe(true);
274
276
  expect(byShort.get("scribe")?.available_to_install).toBe(true);
275
277
  expect(byShort.get("surface")?.available_to_install).toBe(true);
276
278
  expect(byShort.get("agent")?.available_to_install).toBe(true);
277
- expect(byShort.get("runner")?.available_to_install).toBe(false);
278
279
  expect(byShort.get("notes")?.available_to_install).toBe(false);
279
280
  expect(body.modules.every((m) => !m.installed)).toBe(true);
280
281
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -282,11 +283,13 @@ describe("GET /api/modules", () => {
282
283
  expect(body.supervisor_available).toBe(false);
283
284
  });
284
285
 
285
- test("an installed deprecated module (runner) still surfaces for management but is not offered for fresh install (2026-06-25)", async () => {
286
- // A legacy operator with runner on disk: the row must remain visible
287
- // (installed: true) + manageable, in the `deprecated` tier but
288
- // `available_to_install` is false so the SPA's install catalog won't push
289
- // it. Mirrors the notes-daemon back-compat posture.
286
+ test("an installed LEGACY runner row still surfaces as a third-party row post-registry-removal (2026-07-01)", async () => {
287
+ // A legacy operator with runner on disk: post-removal the row no longer
288
+ // resolves to a known short, so it surfaces under its own manifest name
289
+ // (`parachute-runner`) exactly like a third-party module visible
290
+ // (installed: true), never offered (`available` false: no install package
291
+ // known to the hub), and NEVER crashes the catalog. This is the graceful
292
+ // existing-install posture the removal preserves.
290
293
  writeManifest(h.manifestPath, [
291
294
  {
292
295
  name: "parachute-runner",
@@ -313,13 +316,18 @@ describe("GET /api/modules", () => {
313
316
  available_to_install: boolean;
314
317
  }>;
315
318
  };
316
- const runner = body.modules.find((m) => m.short === "runner");
319
+ // No known short resolves anymore the row surfaces under its own
320
+ // manifest name, the third-party fallback convention.
321
+ expect(body.modules.find((m) => m.short === "runner")).toBeUndefined();
322
+ const runner = body.modules.find((m) => m.short === "parachute-runner");
317
323
  expect(runner).toBeDefined();
318
324
  expect(runner?.installed).toBe(true);
319
325
  expect(runner?.installed_version).toBe("0.2.0");
320
- expect(runner?.focus).toBe("deprecated");
321
- // Still hub-installable (re-install path) but NOT offered fresh.
322
- expect(runner?.available).toBe(true);
326
+ // Unlisted shorts default to the experimental tier (no special-casing
327
+ // survives the removal).
328
+ expect(runner?.focus).toBe("experimental");
329
+ // No install package known to the hub → not installable, never offered.
330
+ expect(runner?.available).toBe(false);
323
331
  expect(runner?.available_to_install).toBe(false);
324
332
  });
325
333
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for the connections registry store (`connections.json`,
3
+ * `connections-store.ts`). The store's behavior is otherwise exercised
4
+ * indirectly through `admin-connections.test.ts`; this file pins the on-disk
5
+ * shape directly — specifically the `version` field added 2026-07-01 (the
6
+ * future-migration seam) and the legacy-file tolerance that makes it safe.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ CONNECTIONS_FILE_VERSION,
14
+ type ConnectionRecord,
15
+ getConnection,
16
+ putConnection,
17
+ readConnections,
18
+ removeConnection,
19
+ } from "../connections-store.ts";
20
+
21
+ let dir: string;
22
+ let storePath: string;
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), "phub-connections-store-"));
25
+ storePath = join(dir, "connections.json");
26
+ });
27
+ afterEach(() => {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ });
30
+
31
+ function rec(over: Partial<ConnectionRecord> = {}): ConnectionRecord {
32
+ return {
33
+ id: over.id ?? "conn-1",
34
+ source: over.source ?? { module: "vault", vault: "default", event: "note.created" },
35
+ sink: over.sink ?? { module: "agent", action: "message.deliver" },
36
+ provisioned: over.provisioned ?? { type: "vault-trigger", triggerName: "t1", vault: "default" },
37
+ createdAt: over.createdAt ?? "2026-07-01T00:00:00.000Z",
38
+ };
39
+ }
40
+
41
+ describe("file versioning (2026-07-01 — future-migration seam)", () => {
42
+ test("every write stamps version: 1 and put → read round-trips", () => {
43
+ putConnection(storePath, rec());
44
+ const onDisk = JSON.parse(readFileSync(storePath, "utf8")) as { version?: unknown };
45
+ expect(onDisk.version).toBe(CONNECTIONS_FILE_VERSION);
46
+ expect(onDisk.version).toBe(1);
47
+ const back = readConnections(storePath);
48
+ expect(back).toHaveLength(1);
49
+ expect(back[0]).toEqual(rec());
50
+ // Rewrites (upsert + remove) keep stamping it.
51
+ putConnection(storePath, rec({ id: "conn-2" }));
52
+ removeConnection(storePath, "conn-1");
53
+ const after = JSON.parse(readFileSync(storePath, "utf8")) as {
54
+ version?: unknown;
55
+ connections: unknown[];
56
+ };
57
+ expect(after.version).toBe(1);
58
+ expect(after.connections).toHaveLength(1);
59
+ });
60
+
61
+ test("a LEGACY file without version loads fine (treated as v1)", () => {
62
+ const legacy = rec();
63
+ writeFileSync(storePath, JSON.stringify({ connections: [legacy] }));
64
+ const back = readConnections(storePath);
65
+ expect(back).toHaveLength(1);
66
+ expect(back[0]).toEqual(legacy);
67
+ expect(getConnection(storePath, legacy.id)?.id).toBe(legacy.id);
68
+ // First write-through upgrades the file to the versioned shape without
69
+ // losing the legacy record.
70
+ putConnection(storePath, rec({ id: "conn-2" }));
71
+ const upgraded = JSON.parse(readFileSync(storePath, "utf8")) as {
72
+ version?: unknown;
73
+ connections: unknown[];
74
+ };
75
+ expect(upgraded.version).toBe(1);
76
+ expect(upgraded.connections).toHaveLength(2);
77
+ });
78
+
79
+ test("a missing or garbage file still reads as empty (fresh hub)", () => {
80
+ expect(readConnections(storePath)).toEqual([]);
81
+ writeFileSync(storePath, "not-json{");
82
+ expect(readConnections(storePath)).toEqual([]);
83
+ });
84
+ });
@@ -753,3 +753,134 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
753
753
  }
754
754
  });
755
755
  });
756
+
757
+ describe("doctor — loopback-hijack check (hub#737)", () => {
758
+ test("no hub-instance.json → PASS (benign; the Hub check owns 'down') — #717", async () => {
759
+ const h = makeHarness();
760
+ try {
761
+ seedCurrentManifest(h.manifestPath);
762
+ seedOperatorToken(h.configDir);
763
+ // No instance file seeded, no seams overridden — defaults read the empty
764
+ // sandbox and short-circuit before any real network/lsof.
765
+ const { code, checks } = await runDoctor(h, healthyDeps());
766
+ expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
767
+ expect(code).toBe(0);
768
+ } finally {
769
+ h.cleanup();
770
+ }
771
+ });
772
+
773
+ test("loopback nonce matches ours + single listener → PASS", async () => {
774
+ const h = makeHarness();
775
+ try {
776
+ seedCurrentManifest(h.manifestPath);
777
+ seedOperatorToken(h.configDir);
778
+ const { code, checks } = await runDoctor(
779
+ h,
780
+ healthyDeps({
781
+ readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
782
+ probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
783
+ countHubListeners: () => 1,
784
+ }),
785
+ );
786
+ const c = byName(checks, "loopback-hijack");
787
+ expect(c?.status).toBe("pass");
788
+ expect(c?.detail).toContain("instance nonce");
789
+ expect(code).toBe(0);
790
+ } finally {
791
+ h.cleanup();
792
+ }
793
+ });
794
+
795
+ test("loopback nonce MISMATCH → FAIL with lsof/orb remediation + incident ref", async () => {
796
+ const h = makeHarness();
797
+ try {
798
+ seedCurrentManifest(h.manifestPath);
799
+ seedOperatorToken(h.configDir);
800
+ const { code, checks } = await runDoctor(
801
+ h,
802
+ healthyDeps({
803
+ readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
804
+ probeLoopbackInstance: async () => ({
805
+ reachable: true,
806
+ status: 200,
807
+ instance: "rogue-hub",
808
+ }),
809
+ countHubListeners: () => 2,
810
+ }),
811
+ );
812
+ const c = byName(checks, "loopback-hijack");
813
+ expect(c?.status).toBe("fail");
814
+ expect(c?.detail).toContain("rogue-hub");
815
+ expect(c?.detail).toContain("2 listeners");
816
+ expect(c?.detail).toContain("hub#737");
817
+ expect(c?.fix).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
818
+ expect(c?.fix).toContain("orb list");
819
+ expect(code).toBe(1);
820
+ } finally {
821
+ h.cleanup();
822
+ }
823
+ });
824
+
825
+ test("foreign process answering with NO nonce → FAIL (the OrbStack container-hub shape)", async () => {
826
+ const h = makeHarness();
827
+ try {
828
+ seedCurrentManifest(h.manifestPath);
829
+ seedOperatorToken(h.configDir);
830
+ const { checks } = await runDoctor(
831
+ h,
832
+ healthyDeps({
833
+ readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
834
+ probeLoopbackInstance: async () => ({ reachable: true, status: 200 }),
835
+ countHubListeners: () => undefined, // lsof indeterminate — still FAILs on the nonce alone
836
+ }),
837
+ );
838
+ const c = byName(checks, "loopback-hijack");
839
+ expect(c?.status).toBe("fail");
840
+ expect(c?.detail).toContain("foreign process");
841
+ } finally {
842
+ h.cleanup();
843
+ }
844
+ });
845
+
846
+ test("nonce matches but a SECOND listener exists → WARN (latent shadow, not FAIL)", async () => {
847
+ const h = makeHarness();
848
+ try {
849
+ seedCurrentManifest(h.manifestPath);
850
+ seedOperatorToken(h.configDir);
851
+ const { code, checks } = await runDoctor(
852
+ h,
853
+ healthyDeps({
854
+ readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
855
+ probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
856
+ countHubListeners: () => 2,
857
+ }),
858
+ );
859
+ const c = byName(checks, "loopback-hijack");
860
+ expect(c?.status).toBe("warn");
861
+ expect(c?.detail).toContain("2 listeners");
862
+ // WARN never fails the exit code.
863
+ expect(code).toBe(0);
864
+ } finally {
865
+ h.cleanup();
866
+ }
867
+ });
868
+
869
+ test("record present but loopback unreachable → PASS (defers to the Hub check)", async () => {
870
+ const h = makeHarness();
871
+ try {
872
+ seedCurrentManifest(h.manifestPath);
873
+ seedOperatorToken(h.configDir);
874
+ const { checks } = await runDoctor(
875
+ h,
876
+ healthyDeps({
877
+ readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
878
+ probeLoopbackInstance: async () => ({ reachable: false }),
879
+ }),
880
+ );
881
+ expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
882
+ } finally {
883
+ h.cleanup();
884
+ }
885
+ });
886
+ });
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { notifySurfacePushed } from "../git-notify.ts";
5
+ import { REGISTERED_MINT_TTL_THRESHOLD_SECONDS } from "../admin-connections.ts";
6
+ import {
7
+ NOTIFY_TTL_SECONDS,
8
+ PULL_TTL_SECONDS,
9
+ assertUnregisteredMintTtl,
10
+ notifySurfacePushed,
11
+ } from "../git-notify.ts";
6
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
13
  import { validateAccessToken } from "../jwt-sign.ts";
8
14
  import { rotateSigningKey } from "../signing-keys.ts";
@@ -37,6 +43,28 @@ function fetchSpy(status = 200, body = '{"ok":true}') {
37
43
  return { impl, calls };
38
44
  }
39
45
 
46
+ describe("unregistered-mint TTL policy guard", () => {
47
+ test("the shipped TTLs sit strictly under the registered-mint threshold", () => {
48
+ // The import of git-notify.ts at the top of this file already ran the
49
+ // module-load asserts — reaching here means they passed. Pin the values
50
+ // explicitly too, so a bumped TTL fails with a readable diff.
51
+ expect(NOTIFY_TTL_SECONDS).toBeLessThan(REGISTERED_MINT_TTL_THRESHOLD_SECONDS);
52
+ expect(PULL_TTL_SECONDS).toBeLessThan(REGISTERED_MINT_TTL_THRESHOLD_SECONDS);
53
+ });
54
+
55
+ test("the guard throws at/above the threshold and passes just under it", () => {
56
+ expect(() =>
57
+ assertUnregisteredMintTtl("PULL_TTL_SECONDS", REGISTERED_MINT_TTL_THRESHOLD_SECONDS),
58
+ ).toThrow(/registered-mint/);
59
+ expect(() =>
60
+ assertUnregisteredMintTtl("PULL_TTL_SECONDS", REGISTERED_MINT_TTL_THRESHOLD_SECONDS + 1),
61
+ ).toThrow(/PULL_TTL_SECONDS/);
62
+ expect(() =>
63
+ assertUnregisteredMintTtl("NOTIFY_TTL_SECONDS", REGISTERED_MINT_TTL_THRESHOLD_SECONDS - 1),
64
+ ).not.toThrow();
65
+ });
66
+ });
67
+
40
68
  describe("notifySurfacePushed", () => {
41
69
  test("no surface module installed → no-op, no fetch", async () => {
42
70
  const h = makeHarness();
@@ -7,11 +7,12 @@
7
7
  * the lenient-read posture.
8
8
  */
9
9
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
- import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import {
14
14
  type ConnectionSpec,
15
+ GRANTS_FILE_VERSION,
15
16
  type GrantRecord,
16
17
  connectionKey,
17
18
  getGrant,
@@ -93,6 +94,37 @@ describe("round-trip", () => {
93
94
  });
94
95
  });
95
96
 
97
+ describe("file versioning (2026-07-01 — future-migration seam)", () => {
98
+ test("every write stamps version: 1 and a rewrite preserves it", () => {
99
+ putGrant(storePath, rec());
100
+ const first = JSON.parse(readFileSync(storePath, "utf8")) as { version?: unknown };
101
+ expect(first.version).toBe(GRANTS_FILE_VERSION);
102
+ expect(first.version).toBe(1);
103
+ // Round-trip through the store API keeps stamping it.
104
+ putGrant(storePath, rec({ id: "second", agent: "agent2" }));
105
+ const second = JSON.parse(readFileSync(storePath, "utf8")) as { version?: unknown };
106
+ expect(second.version).toBe(1);
107
+ expect(readGrants(storePath)).toHaveLength(2);
108
+ });
109
+
110
+ test("a LEGACY file without version loads fine (treated as v1)", () => {
111
+ const legacy = rec();
112
+ writeFileSync(storePath, JSON.stringify({ grants: [legacy] }));
113
+ const back = readGrants(storePath);
114
+ expect(back).toHaveLength(1);
115
+ expect(back[0]).toEqual(legacy);
116
+ // First write-through upgrades the file to the versioned shape without
117
+ // losing the legacy record.
118
+ putGrant(storePath, rec({ id: "new-row", agent: "agent2" }));
119
+ const upgraded = JSON.parse(readFileSync(storePath, "utf8")) as {
120
+ version?: unknown;
121
+ grants: unknown[];
122
+ };
123
+ expect(upgraded.version).toBe(1);
124
+ expect(upgraded.grants).toHaveLength(2);
125
+ });
126
+ });
127
+
96
128
  describe("0600 perms (the file holds secrets)", () => {
97
129
  test("the store file is created mode 0600", () => {
98
130
  putGrant(