@openparachute/hub 0.7.5 → 0.7.6-rc.1

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.
@@ -119,9 +119,12 @@ describe("isOfferable (fresh-install OFFER, 2026-06-25)", () => {
119
119
  expect(isOfferable({ short: "agent", installed: false })).toBe(true);
120
120
  });
121
121
 
122
- test("does NOT offer a deprecated module (notes / runner) on a fresh install", () => {
122
+ test("does NOT offer a deprecated module (notes) on a fresh install", () => {
123
123
  expect(isOfferable({ short: "notes", installed: false })).toBe(false);
124
- expect(isOfferable({ short: "runner", installed: false })).toBe(false);
124
+ // runner is stronger than deprecated now: it left the registries entirely
125
+ // (2026-07-01), so it never reaches the survey → isOfferable never sees
126
+ // it. (The bare predicate would say true for an unknown short — the OFFER
127
+ // gate for runner is `knownServices()` no longer containing it.)
125
128
  });
126
129
 
127
130
  test("never offers an already-installed module regardless of tier", () => {
@@ -137,6 +140,9 @@ describe("setup", () => {
137
140
  // Pre-seed every first-party shortname so survey returns all-installed.
138
141
  // Distinct canonical ports per service — services-manifest.ts now
139
142
  // rejects duplicate ports between distinct services (hub#195).
143
+ // parachute-runner rides along as a LEGACY row (runner left the
144
+ // registries 2026-07-01): it must neither block the all-installed exit
145
+ // nor crash the survey.
140
146
  const seeds: Array<{ name: string; port: number }> = [
141
147
  { name: "parachute-vault", port: 1940 },
142
148
  { name: "parachute-notes", port: 1942 },
@@ -175,12 +181,13 @@ describe("setup", () => {
175
181
  }
176
182
  });
177
183
 
178
- test("fresh box: the offered 'Available to install' list excludes deprecated notes/runner (2026-06-25)", async () => {
184
+ test("fresh box: the offered 'Available to install' list excludes deprecated notes + removed runner", async () => {
179
185
  const h = makeHarness();
180
186
  try {
181
187
  // 'all' picks every OFFERED service. With a clean services.json the survey
182
- // sees every known short; the offered filter must drop notes + runner
183
- // (deprecated) while keeping vault/scribe/surface/agent. Only vault +
188
+ // sees every known short; the offered filter must drop notes (deprecated,
189
+ // 2026-06-25) while runner never even reaches the survey (registry
190
+ // removal, 2026-07-01) — keeping vault/scribe/surface/agent. Only vault +
184
191
  // scribe have pre-install follow-up prompts (vault name, scribe provider);
185
192
  // surface + agent have none — so the scripted answers below are complete.
186
193
  const availability = scriptedAvailability([
@@ -221,10 +228,57 @@ describe("setup", () => {
221
228
  test("an already-installed deprecated module still shows in 'Already installed' + isn't re-offered (back-compat)", async () => {
222
229
  const h = makeHarness();
223
230
  try {
224
- // Legacy operator with runner (deprecated) on disk. It must surface in the
225
- // "Already installed" banner (so they know it's there + can manage it via
226
- // `parachute <verb> runner`), and must NOT reappear in the fresh-install
227
- // OFFER list.
231
+ // Legacy operator with notes-daemon (deprecated) on disk. It must surface
232
+ // in the "Already installed" banner (so they know it's there + can manage
233
+ // it via `parachute <verb> notes`), and must NOT reappear in the
234
+ // fresh-install OFFER list.
235
+ upsertService(
236
+ {
237
+ name: "parachute-notes",
238
+ version: "0.3.15",
239
+ port: 1942,
240
+ paths: ["/notes"],
241
+ health: "/notes/health",
242
+ },
243
+ h.manifestPath,
244
+ );
245
+ const availability = scriptedAvailability([
246
+ "surface", // pick a still-offered module
247
+ ]);
248
+ const code = await setup({
249
+ manifestPath: h.manifestPath,
250
+ configDir: h.configDir,
251
+ log: (l) => h.logs.push(l),
252
+ availability,
253
+ installFn: async (short, opts) => {
254
+ h.calls.push({ short, opts });
255
+ return 0;
256
+ },
257
+ });
258
+ expect(code).toBe(0);
259
+ const joined = h.logs.join("\n");
260
+ // Banner lists notes as already installed…
261
+ const installedBlock = joined.slice(
262
+ joined.indexOf("Already installed:"),
263
+ joined.indexOf("Available to install:"),
264
+ );
265
+ expect(installedBlock).toMatch(/\bnotes\b/);
266
+ // …but notes is NOT in the fresh-install offer.
267
+ const availableBlock = joined.slice(joined.indexOf("Available to install:"));
268
+ expect(availableBlock).not.toMatch(/\bnotes\b/);
269
+ expect(h.calls.map((c) => c.short)).not.toContain("notes");
270
+ } finally {
271
+ h.cleanup();
272
+ }
273
+ });
274
+
275
+ test("a LEGACY parachute-runner row doesn't break setup and is never offered (registry removal 2026-07-01)", async () => {
276
+ const h = makeHarness();
277
+ try {
278
+ // runner left the registries entirely — the survey no longer knows the
279
+ // short, so the row is simply invisible to setup (not in "Already
280
+ // installed", not in the offer). The load-bearing assertions: setup
281
+ // still runs to completion and never tries to install runner.
228
282
  upsertService(
229
283
  {
230
284
  name: "parachute-runner",
@@ -250,16 +304,10 @@ describe("setup", () => {
250
304
  });
251
305
  expect(code).toBe(0);
252
306
  const joined = h.logs.join("\n");
253
- // Banner lists runner as already installed…
254
- const installedBlock = joined.slice(
255
- joined.indexOf("Already installed:"),
256
- joined.indexOf("Available to install:"),
257
- );
258
- expect(installedBlock).toMatch(/\brunner\b/);
259
- // …but runner is NOT in the fresh-install offer.
260
307
  const availableBlock = joined.slice(joined.indexOf("Available to install:"));
261
308
  expect(availableBlock).not.toMatch(/\brunner\b/);
262
309
  expect(h.calls.map((c) => c.short)).not.toContain("runner");
310
+ expect(h.calls.map((c) => c.short)).toContain("surface");
263
311
  } finally {
264
312
  h.cleanup();
265
313
  }
@@ -55,7 +55,7 @@ function joined(calls: string[][]): string[] {
55
55
  describe("targetModuleShorts() — known module shorts, never hub/cloudflared", () => {
56
56
  test("includes the canonical module shorts and excludes hub", () => {
57
57
  const shorts = targetModuleShorts();
58
- // The canonical knownServices() set: vault / scribe / runner / surface / notes / channel.
58
+ // The canonical knownServices() set: vault / scribe / surface / notes / agent.
59
59
  expect(shorts).toContain("vault");
60
60
  expect(shorts).toContain("scribe");
61
61
  expect(shorts).toContain("surface");
@@ -1797,8 +1797,12 @@ function sessionUser(req: Request, deps: ConnectionsDeps): { userId: string } {
1797
1797
  * the rule found this engine minting ~90-day tokens with no registry row — an
1798
1798
  * unrevocable-by-construction credential (`api-revoke-token` 404s unknown
1799
1799
  * jtis; the revocation list only carries registered jtis).
1800
+ *
1801
+ * Exported so other unregistered-by-policy mint sites (git-notify.ts's
1802
+ * fire-and-forget notify/pull tokens) can statically assert their TTLs stay
1803
+ * under this policy line instead of restating `600` in a comment.
1800
1804
  */
1801
- const REGISTERED_MINT_TTL_THRESHOLD_SECONDS = 10 * 60;
1805
+ export const REGISTERED_MINT_TTL_THRESHOLD_SECONDS = 10 * 60;
1802
1806
 
1803
1807
  interface MintSpec {
1804
1808
  scopes: string[];
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Why this exists (2026-06-09 modular-UI architecture, P3): modules now own
6
6
  * their config/admin UIs and declare `configUiUrl` in `module.json` (scribe
7
- * `/scribe/admin`, runner `/runner/admin`, surface `/surface/admin/`, …). The
7
+ * `/scribe/admin`, surface `/surface/admin/`, …). The
8
8
  * hub frames/links those surfaces consistently (the Modules page "Configure"
9
9
  * action). Each module-owned config UI, served behind the hub proxy to a
10
10
  * logged-in portal operator, needs an admin-scoped hub Bearer to call its own
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
18
18
  * prefix). Modules validate the JWT's `aud` against their literal short name
19
- * (`scribe`, `runner`, `surface`, `agent`) — the same shape `inferAudience`
19
+ * (`scribe`, `surface`, `agent`) — the same shape `inferAudience`
20
20
  * stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
21
21
  * token are indistinguishable to the module. This mirrors the per-request
22
22
  * `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
@@ -354,13 +354,13 @@ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Respons
354
354
  * reach the same spec the API handlers use without duplicating the
355
355
  * curated-table lookup.
356
356
  *
357
- * Two source paths (post-FALLBACK-retirement: vault/scribe/runner in
357
+ * Two source paths (post-FALLBACK-retirement: vault/scribe in
358
358
  * hub#310, channel in boundary D3):
359
359
  *
360
360
  * - **FIRST_PARTY_FALLBACKS** (notes): vendored manifest is
361
361
  * authoritative pre-install — the embedded `manifest.startCmd` /
362
362
  * `manifest.paths` / etc. drive the install + spawn flow.
363
- * - **KNOWN_MODULES** (vault / scribe / runner / channel / surface): no
363
+ * - **KNOWN_MODULES** (vault / scribe / agent / surface): no
364
364
  * vendored manifest.
365
365
  * Pre-install we know only the npm package + manifestName + canonical
366
366
  * port + imperative `extras` (init, postInstallFooter, urlForEntry,
@@ -831,7 +831,7 @@ export async function runInstall(
831
831
  });
832
832
  }
833
833
 
834
- // KNOWN_MODULES shorts (vault / scribe / runner / channel / surface):
834
+ // KNOWN_MODULES shorts (vault / scribe / agent / surface):
835
835
  // module.json is the canonical source for startCmd. Re-resolve the spec
836
836
  // from `<installDir>/.parachute/module.json` when installDir is stamped so
837
837
  // the module is authoritative for its own spawn cmd. Falls back to the
@@ -18,7 +18,7 @@
18
18
  * `focus` ("core" | "experimental" | "deprecated") comes from each module's
19
19
  * `module.json` when declared, else `focusForShort`'s default map. The SPA
20
20
  * groups core first, de-emphasizes experimental, and de-emphasizes
21
- * `deprecated` (notes-daemon / runner) further — it NEVER hides an installed
21
+ * `deprecated` (notes-daemon) further — it NEVER hides an installed
22
22
  * module (so an existing operator can still manage / uninstall a deprecated
23
23
  * one). This is what makes a running, self-registered module (channel) visible
24
24
  * + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
@@ -67,7 +67,7 @@ import {
67
67
  } from "./service-spec.ts";
68
68
  // `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
69
69
  // `lookupModule` below — the former for notes (vendored manifest still
70
- // required) and the latter for vault/scribe/runner/agent (post-FALLBACK
70
+ // required) and the latter for vault/scribe/agent/surface (post-FALLBACK
71
71
  // retirement, hub#310). The local helper hides the split from the rest of
72
72
  // this file. `discoverableShorts` enumerates their UNION — the
73
73
  // self-registration-driven discovery surface that replaced the old
@@ -83,7 +83,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
83
83
  /**
84
84
  * Resolve a known module to the display + install bootstrap data the admin SPA
85
85
  * renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
86
- * KNOWN_MODULES (vault / scribe / runner / channel / surface) second.
86
+ * KNOWN_MODULES (vault / scribe / agent / surface) second.
87
87
  *
88
88
  * Returns `undefined` if the short is in neither table — a genuinely
89
89
  * third-party module discovered only via services.json / the supervisor. The
@@ -249,10 +249,10 @@ interface ModuleWireShape {
249
249
  * Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
250
250
  * 2026-06-25). `core` modules render in the headline group; `experimental`
251
251
  * modules render in a de-emphasized "Experimental" group; `deprecated`
252
- * modules (notes-daemon / runner) render in a further-de-emphasized
252
+ * modules (notes-daemon) render in a further-de-emphasized
253
253
  * "Deprecated" group — never hidden when installed. Resolved from the
254
254
  * module's `module.json` `focus` when declared, else `focusForShort`'s
255
- * default map (vault/scribe/hub/surface → core, notes/runner → deprecated,
255
+ * default map (vault/scribe/hub/surface → core, notes → deprecated,
256
256
  * others → experimental).
257
257
  */
258
258
  focus: ModuleFocus;
@@ -269,7 +269,7 @@ interface ModuleWireShape {
269
269
  * The fresh-install OFFER (2026-06-25): whether the hub presents this module
270
270
  * in the "available to install fresh" set. `available && focus !==
271
271
  * "deprecated"`. The SPA's "Install a module" catalog filters on this so
272
- * notes-daemon / runner aren't pushed on a fresh box; an already-installed
272
+ * notes-daemon isn't pushed on a fresh box; an already-installed
273
273
  * deprecated module still surfaces (in the Installed section) for management.
274
274
  */
275
275
  available_to_install: boolean;
@@ -315,8 +315,8 @@ interface ModuleWireShape {
315
315
  install_dir: string | null;
316
316
  /**
317
317
  * Hierarchical sub-units beneath this module (hub#313). Empty when the
318
- * module's services.json row doesn't declare `uis` (vault, scribe, notes,
319
- * runner today). Used by parachute-app to surface each hosted UI as its
318
+ * module's services.json row doesn't declare `uis` (vault, scribe, notes
319
+ * today). Used by parachute-app to surface each hosted UI as its
320
320
  * own discoverable sub-row under the App module. Per parachute-app
321
321
  * design doc §12.
322
322
  */
@@ -336,8 +336,8 @@ interface ModuleWireShape {
336
336
  * the operator UI (App today).
337
337
  * 3. Null when the module hasn't declared either field — the SPA
338
338
  * renders a disabled "Open" tooltip (pointing at Configure when
339
- * `config_ui_url` is set — runner's shape today or "module
340
- * hasn't shipped an admin UI yet" otherwise).
339
+ * `config_ui_url` is set — or "module hasn't shipped an admin UI
340
+ * yet" otherwise).
341
341
  *
342
342
  * Always an absolute path on the hub origin (leading `/`) — the SPA
343
343
  * navigates same-origin, no need to worry about cross-origin
@@ -660,7 +660,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
660
660
  // module's manifest-declared `focus` (when installed + declared) wins; else
661
661
  // the `focusForShort` default map. Sort: `core` group first (with the
662
662
  // CURATED_MODULES recommended-install order floated to the top of that
663
- // group), then `experimental`, then `deprecated` (notes / runner) last — the
663
+ // group), then `experimental`, then `deprecated` (notes) last — the
664
664
  // SPA renders the groups; `focus` never hides an installed module.
665
665
  const recommendedOrder = new Map<string, number>(
666
666
  (CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
@@ -684,7 +684,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
684
684
  // package) is not hub-installable → false.
685
685
  available: m !== undefined,
686
686
  // Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
687
- // deprecated short (notes / runner) stays `available` (re-installable
687
+ // deprecated short (notes) stays `available` (re-installable
688
688
  // for back-compat) but is dropped from the offer set so the SPA's
689
689
  // "Install a module" catalog doesn't push it on a fresh box.
690
690
  available_to_install: m !== undefined && focus !== "deprecated",
@@ -867,7 +867,7 @@ let warnedLegacyVaultAdminCandidate = false;
867
867
  * - `undefined` candidate → `undefined` (the module didn't declare it).
868
868
  * - Absolute http(s) URL → returned verbatim (off-origin escape hatch).
869
869
  * - Leading-`/` path → ORIGIN-ABSOLUTE, returned verbatim. Single-instance
870
- * modules (surface, scribe, runner, agent) declare their full hub-origin
870
+ * modules (surface, scribe, agent) declare their full hub-origin
871
871
  * path this way (`/surface/admin/`, `/scribe/admin`, `/agent/admin`);
872
872
  * vault's daemon-level surface is `/vault/admin/`.
873
873
  * - Relative path (no leading slash) → MOUNT-JOINED: the per-instance form
@@ -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",
@@ -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
  };
@@ -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:
@@ -123,7 +123,7 @@ function packageNameFor(entryName: string): string | undefined {
123
123
  if (short === undefined) return undefined;
124
124
  const fb = FIRST_PARTY_FALLBACKS[short];
125
125
  if (fb) return fb.package;
126
- // KNOWN_MODULES (vault / scribe / runner — post hub#310 FALLBACK
126
+ // KNOWN_MODULES (vault / scribe / agent / surface — post hub#310 FALLBACK
127
127
  // retirement) carries the package name without an embedded manifest.
128
128
  return KNOWN_MODULES[short]?.package;
129
129
  }