@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
@@ -65,7 +65,7 @@ export type Runner = (cmd: readonly string[]) => Promise<number>;
65
65
  * Rationale: the canonical Render deploy ships the hub container from
66
66
  * `main` (which tracks the rc chain per governance rule 2). Without this
67
67
  * env var the supervisor's `/admin/modules` install API would still
68
- * resolve `@latest` for vault / app / scribe / runner — leaving a hub-on-rc
68
+ * resolve `@latest` for vault / surface / scribe — leaving a hub-on-rc
69
69
  * cluster bootstrapping its other modules on stable, which silently
70
70
  * fragments the cluster's version axis. Setting `PARACHUTE_INSTALL_CHANNEL=rc`
71
71
  * at the platform level cascades the rc-ness across every module install,
@@ -137,6 +137,22 @@ const SERVICE_ALIASES: Record<string, string> = {
137
137
  channel: "agent",
138
138
  };
139
139
 
140
+ /**
141
+ * Former first-party shorts that were RETIRED from the registries (not
142
+ * renamed — no alias target). Without this guard the bare short would fall
143
+ * through resolveInstallTarget's "anything else is npm" arm and `bun add -g`
144
+ * an UNRELATED npm package that happens to share the name (`runner` is a
145
+ * real, non-Parachute package on npm). Install refuses with the message
146
+ * instead.
147
+ */
148
+ const RETIRED_INSTALL_SHORTS: Record<string, string> = {
149
+ runner:
150
+ "parachute-runner was retired from the hub's module registry on 2026-07-01 " +
151
+ "(the module set of record is vault, hub, agent, scribe, surface). " +
152
+ "An existing install keeps running under `parachute serve`; to install it anyway, " +
153
+ "pass the explicit npm package name (@openparachute/runner) or a local checkout path.",
154
+ };
155
+
140
156
  export interface InstallOpts {
141
157
  runner?: Runner;
142
158
  manifestPath?: string;
@@ -625,7 +641,8 @@ async function readInstalledManifest(
625
641
  * - **fallback**: notes / channel still ship a vendored manifest + extras
626
642
  * in FIRST_PARTY_FALLBACKS. Missing `module.json` is non-fatal — the
627
643
  * embedded manifest carries the install through.
628
- * - **known**: vault / scribe / runner have retired their FALLBACK entries.
644
+ * - **known**: vault / scribe / agent / surface have retired their FALLBACK
645
+ * entries (runner had too, before its 2026-07-01 registry removal).
629
646
  * We know the package + manifestName + imperative extras (init,
630
647
  * postInstallFooter, urlForEntry, hasAuth) but NOT the static manifest;
631
648
  * `module.json` is the contract and a missing one is a hard error,
@@ -672,6 +689,15 @@ function resolveInstallTarget(
672
689
  const aliased = SERVICE_ALIASES[input];
673
690
  const candidate = aliased ?? input;
674
691
 
692
+ // Retired shorts refuse BEFORE the npm fallback: `install runner` must not
693
+ // `bun add -g` npm's unrelated `runner` package. Explicit package names /
694
+ // paths still pass through the arms below.
695
+ const retiredMessage = RETIRED_INSTALL_SHORTS[candidate];
696
+ if (retiredMessage !== undefined) {
697
+ log(`✗ ${retiredMessage}`);
698
+ return null;
699
+ }
700
+
675
701
  const fb = FIRST_PARTY_FALLBACKS[candidate];
676
702
  if (fb) {
677
703
  if (aliased !== undefined) {
@@ -943,7 +969,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
943
969
  manifest = installedManifest ?? target.fallback.manifest;
944
970
  extras = target.fallback.extras;
945
971
  } else if (target.kind === "known-module") {
946
- // KNOWN_MODULES shorts (vault / scribe / runner) carry no vendored
972
+ // KNOWN_MODULES shorts (vault / scribe / agent / surface) carry no vendored
947
973
  // manifest (hub#310). The module's own `.parachute/module.json` is the
948
974
  // canonical source. When it's unreadable (legacy installs from before
949
975
  // module.json shipped, or test fixtures that mock the disk path without
@@ -77,6 +77,11 @@ export const ARCHIVE_PREFIX = ".archive-";
77
77
  export function safelistEntries(): Set<string> {
78
78
  return new Set<string>([
79
79
  ...knownServices(),
80
+ // `runner` left the registries on 2026-07-01 (module set of record:
81
+ // vault / hub / agent / scribe / surface) but legacy installs still have
82
+ // a `~/.parachute/runner/` dir — keep it safelisted so it doesn't count
83
+ // as unrecognized-root noise in `migrateNotice`.
84
+ "runner",
80
85
  "hub",
81
86
  "services.json",
82
87
  "expose-state.json",
@@ -36,6 +36,14 @@ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
36
36
  import { readExposeState } from "../expose-state.ts";
37
37
  import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../hub-db-liveness.ts";
38
38
  import { hubDbPath, openHubDb } from "../hub-db.ts";
39
+ import {
40
+ type HubInstanceRecord,
41
+ type HubSelfProbe,
42
+ armHubSelfProbe,
43
+ clearHubInstanceFile,
44
+ generateInstanceNonce,
45
+ writeHubInstanceFile,
46
+ } from "../hub-instance.ts";
39
47
  import { hubFetch } from "../hub-server.ts";
40
48
  import { getHubOrigin } from "../hub-settings.ts";
41
49
  import { writeHubFile } from "../hub.ts";
@@ -513,6 +521,13 @@ export async function serve(opts: ServeOpts = {}): Promise<{
513
521
 
514
522
  const supervisor = opts.supervisor ?? new Supervisor();
515
523
 
524
+ // Per-boot instance nonce (hub#737). Minted BEFORE the listener so it can be
525
+ // threaded into `/health`; written to disk AFTER a successful bind (below) so
526
+ // a bind failure never leaves a stale identity file. It's the linchpin of
527
+ // loopback-hijack detection: `/health` echoes it, and external tools compare
528
+ // the disk copy to what a loopback `/health` actually returns.
529
+ const instanceNonce = generateInstanceNonce();
530
+
516
531
  // Claim the hub port FIRST — before booting a single supervised module. If
517
532
  // another hub/supervisor already owns it, `Bun.serve` throws here and we
518
533
  // exit immediately. The prior order (boot modules, *then* bind) let a
@@ -534,6 +549,7 @@ export async function serve(opts: ServeOpts = {}): Promise<{
534
549
  probeDbPath: () => dbHolder.probePath(),
535
550
  issuer,
536
551
  loopbackPort: port,
552
+ instanceNonce,
537
553
  supervisor,
538
554
  }),
539
555
  }),
@@ -544,6 +560,38 @@ export async function serve(opts: ServeOpts = {}): Promise<{
544
560
  throw err;
545
561
  }
546
562
 
563
+ // We own the listener now — record this process's identity on disk (0644) so
564
+ // `parachute status` / `parachute doctor` can detect a loopback hijack by
565
+ // comparing this nonce to what a loopback `/health` returns. Best-effort:
566
+ // a write failure only degrades external detection, never blocks the hub.
567
+ const instanceRecord: HubInstanceRecord = {
568
+ instance: instanceNonce,
569
+ pid: process.pid,
570
+ port,
571
+ startedAt: new Date().toISOString(),
572
+ };
573
+ writeHubInstanceFile(instanceRecord, { configDir: CONFIG_DIR, log });
574
+
575
+ // Arm the loopback self-probe (hub#737): an immediate check right after the
576
+ // bind catches a hijack that's ALREADY present at boot (the OrbStack VM that
577
+ // relaunched at reboot and grabbed 127.0.0.1:<port> before us), then a
578
+ // low-frequency re-probe catches one that appears later. It logs loudly on a
579
+ // mismatch and records the verdict into hub-instance.json for external tools.
580
+ // Skipped alongside module boot in tests (`skipModuleBoot`) so the test path
581
+ // never spawns a real 5-minute timer / loopback fetch.
582
+ let selfProbe: HubSelfProbe | undefined;
583
+ if (!opts.skipModuleBoot) {
584
+ selfProbe = armHubSelfProbe(
585
+ { port, nonce: instanceNonce, record: instanceRecord, configDir: CONFIG_DIR },
586
+ { log },
587
+ );
588
+ // Fire the startup check without blocking serve's return — a hijack present
589
+ // at boot surfaces within the probe's bounded timeout, not on the next tick.
590
+ // `probeOnce` is non-throwing in production, but guard the floating promise
591
+ // against ever surfacing as an unhandled rejection.
592
+ void selfProbe.probeOnce().catch(() => {});
593
+ }
594
+
547
595
  log(
548
596
  formatListeningBanner({
549
597
  hostname,
@@ -618,8 +666,12 @@ export async function serve(opts: ServeOpts = {}): Promise<{
618
666
  for (const state of supervisor.list()) {
619
667
  await supervisor.stop(state.short);
620
668
  }
669
+ selfProbe?.stop();
621
670
  livenessTimer.stop();
622
671
  await server.stop();
672
+ // Clear our on-disk identity so a cleanly-stopped hub leaves no stale
673
+ // self-probe verdict for `status` / `doctor` to read (hub#737 review).
674
+ clearHubInstanceFile(CONFIG_DIR);
623
675
  dbHolder.get().close();
624
676
  },
625
677
  };
@@ -66,7 +66,7 @@ export interface SetupOpts {
66
66
  * Survey row. Pre-install we know manifestName + the optional
67
67
  * `urlForEntry` quirk (vault wants `/mcp`, scribe wants the bare port); the
68
68
  * full ServiceSpec only exists post-install for KNOWN_MODULES shorts
69
- * (vault / scribe / runner — hub#310). The survey uses just these two
69
+ * (vault / scribe / — hub#310). The survey uses just these two
70
70
  * fields, so a minimal shape avoids the spec round-trip pre-install.
71
71
  */
72
72
  interface ServiceChoice {
@@ -110,17 +110,19 @@ function defaultAvailability(): InteractiveAvailability {
110
110
 
111
111
  /**
112
112
  * Survey ALL known first-party shortnames (vault / notes / scribe / agent /
113
- * runner / surface) regardless of tier — `installed` is true when the service
113
+ * surface) regardless of tier — `installed` is true when the service
114
114
  * has a row in services.json. The fresh-install OFFER is narrowed downstream
115
115
  * by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
116
- * notes / runner); agent (`experimental`) is flagged exploratory in its blurb
116
+ * notes); agent (`experimental`) is flagged exploratory in its blurb
117
117
  * but stays offered. Surveying everything keeps `installed` detection complete
118
118
  * (the "already installed" banner still lists a deprecated module an operator
119
- * has on disk).
119
+ * has on disk). A legacy `parachute-runner` row is NOT surveyed (runner left
120
+ * the registries 2026-07-01 — see the KNOWN_MODULES note in service-spec.ts);
121
+ * like any unknown row it neither blocks setup nor appears in the offer.
120
122
  *
121
123
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
122
124
  * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
123
- * (vault / scribe / runner / agent / surface) ship `.parachute/module.json`
125
+ * (vault / scribe / agent / surface) ship `.parachute/module.json`
124
126
  * and self-register; pre-install we know manifestName + the urlForEntry quirk
125
127
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
126
128
  */
@@ -157,7 +159,7 @@ function surveyServices(manifestPath: string): ServiceChoice[] {
157
159
  /**
158
160
  * A surveyed service is OFFERED on a fresh setup iff it is not already
159
161
  * installed AND its discovery tier is not `deprecated` (2026-06-25). The
160
- * deprecated tier (notes-daemon, runner) stays resolvable + manageable for an
162
+ * deprecated tier (notes-daemon) stays resolvable + manageable for an
161
163
  * existing install — it just isn't pushed on a fresh box. `agent`
162
164
  * (`experimental`) is still offered. Exported so the setup tests can pin the
163
165
  * exclusion directly.
@@ -175,11 +177,10 @@ const BLURBS: Record<string, string> = {
175
177
  surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
176
178
  // `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
177
179
  app: "Parachute UI host — auto-installs Notes on first boot (recommended over notes-daemon)",
178
- // notes / runner are `deprecated` (not offered on a fresh setup) — these
179
- // blurbs only render if a legacy install surfaces them in the survey.
180
+ // notes is `deprecated` (not offered on a fresh setup) — this blurb only
181
+ // renders if a legacy install surfaces it in the survey.
180
182
  notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
181
183
  scribe: "audio transcription for dictation + recordings",
182
- runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
183
184
  agent:
184
185
  "(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
185
186
  };
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
2
2
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
3
3
  import { readHubPort } from "../hub-control.ts";
4
4
  import { hubDbPath, openHubDb } from "../hub-db.ts";
5
+ import { type SelfProbeState, readHubInstanceFile } from "../hub-instance.ts";
5
6
  import {
6
7
  HUB_UNIT_DEFAULT_PORT,
7
8
  type HubUnitDeps,
@@ -90,6 +91,16 @@ export interface StatusOpts {
90
91
  openDb?: (configDir: string) => Database;
91
92
  /** Loopback hub base URL override (default derives from the hub port). */
92
93
  baseUrl?: string;
94
+ /**
95
+ * Read the running serve process's last loopback self-probe verdict from
96
+ * `hub-instance.json` (hub#737). Read from DISK, not over loopback — during
97
+ * a hijack the loopback /health (and the module-ops API) reach the WRONG
98
+ * hub, so the on-disk verdict the real serve wrote is the only trustworthy
99
+ * source. A `hijacked` verdict overrides the hub row (which would otherwise
100
+ * read `active` off the rogue's 200). Default {@link readHubInstanceFile}'s
101
+ * `selfProbe`; tests inject a state (or undefined).
102
+ */
103
+ readInstanceState?: (configDir: string) => SelfProbeState | undefined;
93
104
  };
94
105
  }
95
106
 
@@ -386,6 +397,7 @@ interface ResolvedStatusSupervisor {
386
397
  probeModuleHealth: (port: number, health: string) => Promise<boolean>;
387
398
  openDb: (configDir: string) => Database;
388
399
  baseUrl: string | undefined;
400
+ readInstanceState: (configDir: string) => SelfProbeState | undefined;
389
401
  }
390
402
 
391
403
  /**
@@ -402,6 +414,8 @@ function resolveStatusSupervisor(opts: StatusOpts["supervisor"]): ResolvedStatus
402
414
  probeModuleHealth: opts?.probeModuleHealth ?? defaultProbeModuleHealth,
403
415
  openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
404
416
  baseUrl: opts?.baseUrl,
417
+ readInstanceState:
418
+ opts?.readInstanceState ?? ((configDir) => readHubInstanceFile(configDir)?.selfProbe),
405
419
  };
406
420
  }
407
421
 
@@ -671,8 +685,35 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
671
685
  port,
672
686
  hubHealthy,
673
687
  });
688
+ // Loopback-hijack override (hub#737). During a hijack the loopback `/health`
689
+ // the hub row's liveness probe hit belongs to the ROGUE hub (a 200 → the row
690
+ // reads `active`), so trust the running serve's own on-disk self-probe verdict
691
+ // instead — read from disk, never over the hijacked loopback. A `hijacked`
692
+ // verdict flips the row to `failing` with the loud, actionable note.
693
+ //
694
+ // GATED ON `hubHealthy` (review fix): the instance file is written per-boot
695
+ // and only cleared on a *graceful* stop, so a hard-killed hub can leave a
696
+ // stale `hijacked` verdict on disk. `hubHealthy` is true exactly when
697
+ // SOMETHING is answering the loopback port right now — which is precisely the
698
+ // live-hijack condition (the rogue keeps answering 200), so gating here never
699
+ // suppresses a real hijack, but it does keep a stopped hub (nothing answering)
700
+ // from rendering a phantom hijack over its normal down-hub row.
701
+ let selfProbe: SelfProbeState | undefined;
702
+ try {
703
+ selfProbe = sup.readInstanceState(configDir);
704
+ } catch {
705
+ selfProbe = undefined;
706
+ }
707
+ if (hubHealthy && selfProbe?.status === "hijacked") {
708
+ hub.stateLabel = "failing";
709
+ hub.healthy = false;
710
+ hub.skipped = false;
711
+ hub.healthDetail = "loopback hijacked — /health answered by a foreign process";
712
+ hub.managerNote = `LOOPBACK HIJACK on :${port} — module JWKS/API calls are NOT reaching this hub. Run \`parachute doctor\` and \`lsof -nP -iTCP:${port} -sTCP:LISTEN\`.`;
713
+ }
674
714
  // If the degraded-read note never landed on a module row (empty manifest),
675
- // surface it on the hub row so the operator still sees the actionable hint.
715
+ // surface it on the hub row so the operator still sees the actionable hint
716
+ // unless the hijack note already claimed it (the hijack is the bigger signal).
676
717
  if (moduleReadNote && !hub.managerNote) hub.managerNote = moduleReadNote;
677
718
  rows.push(hub);
678
719
  return rows;
@@ -113,12 +113,22 @@ export interface ConnectionRecord {
113
113
  readonly requestedBy?: string;
114
114
  }
115
115
 
116
+ /**
117
+ * On-disk schema version stamped on every write (2026-07-01). Readers treat a
118
+ * file WITHOUT a `version` field as v1 — every connections.json written before
119
+ * this field existed is a v1 file, so absence tolerance is the whole
120
+ * back-compat story. No migration logic exists today; the field is here so a
121
+ * FUTURE shape change can branch on it instead of sniffing record shapes.
122
+ */
123
+ export const CONNECTIONS_FILE_VERSION = 1;
124
+
116
125
  interface ConnectionsFile {
126
+ version: number;
117
127
  connections: ConnectionRecord[];
118
128
  }
119
129
 
120
130
  function emptyFile(): ConnectionsFile {
121
- return { connections: [] };
131
+ return { version: CONNECTIONS_FILE_VERSION, connections: [] };
122
132
  }
123
133
 
124
134
  /** Read the store. A missing/garbage file reads as empty (fresh hub). */
@@ -136,6 +146,9 @@ export function readConnections(storePath: string): ConnectionRecord[] {
136
146
  return [];
137
147
  }
138
148
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
149
+ // `version` is deliberately NOT validated here: an absent field is a legacy
150
+ // v1 file (see CONNECTIONS_FILE_VERSION) and there is only one version
151
+ // today. When v2 lands, this is where the migration branches.
139
152
  const arr = (parsed as { connections?: unknown }).connections;
140
153
  if (!Array.isArray(arr)) return [];
141
154
  // Lenient: drop any malformed row rather than failing the whole read, so one
@@ -156,7 +169,7 @@ export function readConnections(storePath: string): ConnectionRecord[] {
156
169
 
157
170
  function writeAll(storePath: string, records: ConnectionRecord[]): void {
158
171
  mkdirSync(dirname(storePath), { recursive: true });
159
- const file: ConnectionsFile = { connections: records };
172
+ const file: ConnectionsFile = { version: CONNECTIONS_FILE_VERSION, connections: records };
160
173
  // Written WITHOUT 0o600 because this file holds NO secrets — the provisioned
161
174
  // webhook bearer lives only in the vault trigger's row, never here; records
162
175
  // carry source/sink/trigger-name metadata only. Consistent with the default
package/src/git-notify.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  * validates when it comes back in over loopback.
32
32
  */
33
33
  import type { Database } from "bun:sqlite";
34
+ import { REGISTERED_MINT_TTL_THRESHOLD_SECONDS } from "./admin-connections.ts";
34
35
  import { signAccessToken } from "./jwt-sign.ts";
35
36
 
36
37
  /** Provenance identity stamped on the hub-internal notify + pull tokens. */
@@ -43,18 +44,46 @@ const SURFACE_AUDIENCE = "surface";
43
44
  /**
44
45
  * notify-auth TTL. The POST is fired immediately; a small window covers a
45
46
  * momentarily-busy loopback without leaving a usable credential lying around.
47
+ *
48
+ * Exported for the TTL-policy guard test only.
46
49
  */
47
- const NOTIFY_TTL_SECONDS = 120;
50
+ export const NOTIFY_TTL_SECONDS = 120;
48
51
 
49
52
  /**
50
53
  * pull-token TTL. Long enough for surface-host to `git clone --depth 1` a
51
54
  * source surface right after the notify lands, short enough that a leaked
52
55
  * token is near-useless. Both TTLs here MUST stay well under the hub's
53
- * registered-mint threshold (admin-connections REGISTERED_MINT_TTL_THRESHOLD,
54
- * 600s) so these fire-and-forget tokens remain unregistered-by-policy bumping
55
- * either past it without registering them would leak unrevocable tokens.
56
+ * registered-mint threshold (`REGISTERED_MINT_TTL_THRESHOLD_SECONDS`, 600s —
57
+ * imported from admin-connections.ts, where the policy lives) so these
58
+ * fire-and-forget tokens remain unregistered-by-policy bumping either past
59
+ * it without registering them would leak unrevocable tokens. Enforced by
60
+ * {@link assertUnregisteredMintTtl} at module load, not just this comment.
61
+ *
62
+ * Exported for the TTL-policy guard test only.
56
63
  */
57
- const PULL_TTL_SECONDS = 300;
64
+ export const PULL_TTL_SECONDS = 300;
65
+
66
+ /**
67
+ * Registered-mint policy guard (hub-module-boundary charter): a TTL minted
68
+ * WITHOUT a tokens-table registration must stay strictly under the
69
+ * registered-mint threshold. Throws at module load when a future edit bumps
70
+ * one of this file's fire-and-forget TTLs to/past the line — turning a silent
71
+ * "unrevocable token" policy leak into an immediate boot failure.
72
+ *
73
+ * Exported for tests; not for reuse as a general validator (registered mint
74
+ * sites legitimately exceed the threshold — they register the jti instead).
75
+ */
76
+ export function assertUnregisteredMintTtl(name: string, ttlSeconds: number): void {
77
+ if (ttlSeconds >= REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
78
+ throw new Error(
79
+ `git-notify: ${name} (${ttlSeconds}s) must stay under the registered-mint threshold (${REGISTERED_MINT_TTL_THRESHOLD_SECONDS}s). Tokens minted here are fire-and-forget and never registered in the tokens table — at/above the threshold they'd be long-lived AND unrevocable. Either shorten the TTL or register the mint (see admin-connections.ts registered-mint rule).`,
80
+ );
81
+ }
82
+ }
83
+
84
+ // Module-load enforcement of the policy the comments above describe.
85
+ assertUnregisteredMintTtl("NOTIFY_TTL_SECONDS", NOTIFY_TTL_SECONDS);
86
+ assertUnregisteredMintTtl("PULL_TTL_SECONDS", PULL_TTL_SECONDS);
58
87
 
59
88
  /** Bound the notify HTTP call so a wedged surface-host can't hang the caller. */
60
89
  const NOTIFY_FETCH_TIMEOUT_MS = 10_000;
@@ -143,7 +143,17 @@ export interface GrantRecord {
143
143
  readonly approvedAt?: string;
144
144
  }
145
145
 
146
+ /**
147
+ * On-disk schema version stamped on every write (2026-07-01). Readers treat a
148
+ * file WITHOUT a `version` field as v1 — every agent-grants.json written
149
+ * before this field existed is a v1 file, so absence tolerance is the whole
150
+ * back-compat story. No migration logic exists today; the field is here so a
151
+ * FUTURE shape change can branch on it instead of sniffing record shapes.
152
+ */
153
+ export const GRANTS_FILE_VERSION = 1;
154
+
146
155
  interface GrantsFile {
156
+ version: number;
147
157
  grants: GrantRecord[];
148
158
  }
149
159
 
@@ -194,7 +204,7 @@ export function grantId(agent: string, spec: ConnectionSpec): string {
194
204
  }
195
205
 
196
206
  function emptyFile(): GrantsFile {
197
- return { grants: [] };
207
+ return { version: GRANTS_FILE_VERSION, grants: [] };
198
208
  }
199
209
 
200
210
  function isConnectionSpec(v: unknown): v is ConnectionSpec {
@@ -221,6 +231,9 @@ export function readGrants(storePath: string): GrantRecord[] {
221
231
  return [];
222
232
  }
223
233
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
234
+ // `version` is deliberately NOT validated here: an absent field is a legacy
235
+ // v1 file (see GRANTS_FILE_VERSION) and there is only one version today.
236
+ // When v2 lands, this is where the migration branches.
224
237
  const arr = (parsed as { grants?: unknown }).grants;
225
238
  if (!Array.isArray(arr)) return [];
226
239
  // Lenient: drop a malformed row rather than failing the whole read (mirrors
@@ -245,7 +258,7 @@ export function readGrants(storePath: string): GrantRecord[] {
245
258
 
246
259
  function writeAll(storePath: string, records: GrantRecord[]): void {
247
260
  mkdirSync(dirname(storePath), { recursive: true });
248
- const file: GrantsFile = { grants: records };
261
+ const file: GrantsFile = { version: GRANTS_FILE_VERSION, grants: records };
249
262
  // 0600 — UNLIKE connections.json, this file holds the granted secrets
250
263
  // (minted vault tokens + pasted service creds in `material`). `writeFileSync`'s
251
264
  // `mode` applies at CREATE time (passed to open(O_CREAT)), so a fresh file is
package/src/help.ts CHANGED
@@ -103,8 +103,8 @@ Flags:
103
103
  Environment:
104
104
  PARACHUTE_INSTALL_CHANNEL=rc|latest
105
105
  cluster-wide default channel. Lets a Render deploy
106
- running the hub at \`@rc\` cascade rc to vault / app /
107
- scribe / runner installed via the admin SPA — without
106
+ running the hub at \`@rc\` cascade rc to vault / surface /
107
+ scribe installed via the admin SPA — without
108
108
  an explicit \`--channel\` per call. Loses to \`--channel\`
109
109
  and \`--tag\`. Defaults to \`latest\` when unset.
110
110
 
@@ -144,7 +144,7 @@ What it does:
144
144
  Fresh-install front door, one command for both laptops AND remote
145
145
  servers (EC2, DigitalOcean, Hetzner, any VPS). The admin SPA already
146
146
  walks operators through the rest (install vault, set up the admin
147
- user, install scribe / runner / app); this command's only job is to
147
+ user, install scribe / surface); this command's only job is to
148
148
  get you to that wizard.
149
149
 
150
150
  Idempotent — every re-run is safe: