@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.
- package/package.json +1 -1
- package/src/__tests__/admin-module-token.test.ts +40 -3
- package/src/__tests__/api-modules-ops.test.ts +8 -3
- package/src/__tests__/api-modules.test.ts +26 -18
- package/src/__tests__/connections-store.test.ts +84 -0
- package/src/__tests__/git-notify.test.ts +29 -1
- package/src/__tests__/grants-store.test.ts +33 -1
- package/src/__tests__/install.test.ts +28 -0
- package/src/__tests__/serve-boot.test.ts +60 -0
- package/src/__tests__/service-spec-discovery.test.ts +32 -9
- package/src/__tests__/setup.test.ts +64 -16
- package/src/__tests__/stale-module-units.test.ts +1 -1
- package/src/admin-connections.ts +5 -1
- package/src/admin-module-token.ts +2 -2
- package/src/api-modules-ops.ts +3 -3
- package/src/api-modules.ts +13 -13
- package/src/commands/install.ts +29 -3
- package/src/commands/migrate.ts +5 -0
- package/src/commands/setup.ts +10 -9
- package/src/connections-store.ts +15 -2
- package/src/git-notify.ts +34 -5
- package/src/grants-store.ts +15 -2
- package/src/help.ts +3 -3
- package/src/install-source.ts +1 -1
- package/src/service-spec.ts +36 -44
- package/src/services-manifest.ts +1 -1
- package/src/stale-module-units.ts +2 -2
- package/src/well-known.ts +3 -3
|
@@ -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
|
|
122
|
+
test("does NOT offer a deprecated module (notes) on a fresh install", () => {
|
|
123
123
|
expect(isOfferable({ short: "notes", installed: false })).toBe(false);
|
|
124
|
-
|
|
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
|
|
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
|
|
183
|
-
//
|
|
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
|
|
225
|
-
// "Already installed" banner (so they know it's there + can manage
|
|
226
|
-
// `parachute <verb>
|
|
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 /
|
|
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");
|
package/src/admin-connections.ts
CHANGED
|
@@ -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`,
|
|
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`, `
|
|
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
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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
|
|
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 /
|
|
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 /
|
|
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
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
|
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/
|
|
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 /
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 —
|
|
340
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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
|
package/src/commands/install.ts
CHANGED
|
@@ -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 /
|
|
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 /
|
|
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 /
|
|
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
|
package/src/commands/migrate.ts
CHANGED
|
@@ -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",
|
package/src/commands/setup.ts
CHANGED
|
@@ -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 /
|
|
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
|
-
*
|
|
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
|
|
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 /
|
|
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
|
|
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
|
|
179
|
-
//
|
|
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
|
};
|
package/src/connections-store.ts
CHANGED
|
@@ -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 (
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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;
|
package/src/grants-store.ts
CHANGED
|
@@ -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 /
|
|
107
|
-
scribe
|
|
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 /
|
|
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:
|
package/src/install-source.ts
CHANGED
|
@@ -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 /
|
|
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
|
}
|