@openparachute/hub 0.7.6 → 0.7.7-rc.13

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 (83) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-command.test.ts +70 -2
  25. package/src/__tests__/hub-server.test.ts +322 -1
  26. package/src/__tests__/hub-settings.test.ts +110 -6
  27. package/src/__tests__/hub.test.ts +29 -0
  28. package/src/__tests__/install.test.ts +359 -5
  29. package/src/__tests__/migrate.test.ts +3 -1
  30. package/src/__tests__/notes-serve.test.ts +216 -0
  31. package/src/__tests__/oauth-handlers.test.ts +19 -8
  32. package/src/__tests__/operator-token.test.ts +1 -2
  33. package/src/__tests__/port-assign.test.ts +37 -17
  34. package/src/__tests__/root-serve.test.ts +139 -0
  35. package/src/__tests__/scope-explanations.test.ts +22 -2
  36. package/src/__tests__/serve-boot.test.ts +25 -36
  37. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  38. package/src/__tests__/services-manifest.test.ts +372 -132
  39. package/src/__tests__/sessions.test.ts +75 -28
  40. package/src/__tests__/setup-wizard.test.ts +7 -10
  41. package/src/__tests__/setup.test.ts +13 -14
  42. package/src/__tests__/status.test.ts +0 -5
  43. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  44. package/src/account-api.ts +677 -0
  45. package/src/account-session.ts +132 -0
  46. package/src/account-token.ts +200 -0
  47. package/src/admin-connections.ts +4 -2
  48. package/src/admin-lock.ts +1 -2
  49. package/src/admin-module-token.ts +13 -8
  50. package/src/admin-surfaces.ts +2 -1
  51. package/src/api-hub-upgrade.ts +2 -1
  52. package/src/api-invites.ts +19 -0
  53. package/src/api-mint-token.ts +2 -1
  54. package/src/api-modules-ops.ts +2 -1
  55. package/src/api-modules.ts +10 -8
  56. package/src/api-revoke-token.ts +2 -1
  57. package/src/api-settings-hub-origin.ts +2 -1
  58. package/src/api-settings-root-redirect.ts +97 -20
  59. package/src/api-tokens.ts +2 -1
  60. package/src/audience-gate.ts +2 -1
  61. package/src/chrome-strip.ts +16 -4
  62. package/src/commands/hub.ts +104 -0
  63. package/src/commands/install.ts +97 -8
  64. package/src/commands/migrate.ts +5 -1
  65. package/src/commands/setup.ts +9 -6
  66. package/src/help.ts +6 -6
  67. package/src/hub-server.ts +318 -55
  68. package/src/hub-settings.ts +162 -1
  69. package/src/hub.ts +64 -31
  70. package/src/invites.ts +42 -0
  71. package/src/module-ops-client.ts +2 -1
  72. package/src/notes-serve.ts +73 -31
  73. package/src/oauth-handlers.ts +1 -11
  74. package/src/operator-token.ts +0 -1
  75. package/src/origin-check.ts +2 -2
  76. package/src/root-serve.ts +156 -0
  77. package/src/scope-explanations.ts +35 -5
  78. package/src/service-spec.ts +129 -74
  79. package/src/services-manifest.ts +112 -52
  80. package/src/sessions.ts +66 -30
  81. package/src/surface-notes-alias.ts +126 -0
  82. package/src/__tests__/admin-agent-token.test.ts +0 -173
  83. package/src/admin-agent-token.ts +0 -147
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Serving the Parachute app AT the hub's origin root (`root_mode = serve-app`).
5
+ *
6
+ * When an operator flips the root mode to `serve-app` (or a fresh hub installs
7
+ * the app during setup), the hub answers its own origin root with the installed
8
+ * `@openparachute/parachute-app` bundle instead of 302-ing to `/admin`. This is
9
+ * the self-hosted mirror of the hosted door: hit your box's URL, land in the
10
+ * app — no redirect hop.
11
+ *
12
+ * Why this is asset-correct with no rebuild: the app's Vite build is ROOT-based
13
+ * (absolute `/assets/*`, PWA scope `/`, OAuth redirect URIs at the origin root).
14
+ * The `/app` service mount serves the SAME dist through the `notes-serve.ts`
15
+ * shim with a `/app` prefix-strip; here we serve that identical dist verbatim at
16
+ * `/`, which is exactly the origin the build assumes. So `serveAppAtRoot` and
17
+ * the `/app` mount hand back byte-identical bundle files — the only difference
18
+ * is the URL prefix.
19
+ *
20
+ * Precedence is the caller's job (hub-server.ts): the fresh-hub setup wizard
21
+ * funnel and the pre-admin 503 lockout run BEFORE the root `/` handler, and
22
+ * every hub-owned route (/admin, /oauth, /.well-known, /vault, /git, /api,
23
+ * service mounts) dispatches BEFORE the SPA-fallback tail. This module only
24
+ * decides "given an already-unclaimed GET, does the app dist answer it?".
25
+ *
26
+ * NO chrome injection ever rides a root-served response — the app owns its whole
27
+ * page (same posture as the public/surface chrome opt-out). We build the
28
+ * responses here directly rather than routing through `decorateWithChrome`.
29
+ */
30
+
31
+ import { existsSync, statSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { resolveNotesDistFrom } from "./notes-serve.ts";
34
+
35
+ /** The npm package whose built `dist/` is the app front door. */
36
+ export const APP_PACKAGE = "@openparachute/parachute-app";
37
+
38
+ /**
39
+ * Namespaces that keep the hub's branded 404 even in serve-app mode. These are
40
+ * hub / protocol surfaces, never app client-side routes, so an SPA shell there
41
+ * would be wrong — and would mask a genuine 404 for an API/OAuth/well-known
42
+ * typo. (Real dist files never live under these prefixes anyway; this makes the
43
+ * "don't shell it" decision explicit rather than incidental.)
44
+ */
45
+ export const ROOT_SERVE_RESERVED_PREFIXES: readonly string[] = [
46
+ "/api/",
47
+ "/oauth/",
48
+ "/.well-known/",
49
+ ];
50
+
51
+ /**
52
+ * Build a resolver for the installed app's `dist/` directory.
53
+ *
54
+ * Resolution goes through the SAME `resolveNotesDistFrom` machinery the `/app`
55
+ * mount's static-serve shim uses (so root-serve and `/app` resolve to one dist).
56
+ * A SUCCESSFUL resolution is memoized for the process lifetime — a resolved dist
57
+ * path doesn't move while the hub runs, and re-walking `Bun.resolveSync` on every
58
+ * asset request would be needless work. A FAILURE is NOT cached: an operator who
59
+ * set `serve-app` before installing the app, then runs `parachute install app`,
60
+ * is picked up on the next request without a hub restart (dynamic recovery).
61
+ *
62
+ * `resolve` is the test seam (defaults to the real package resolution). Return
63
+ * value is the dist dir, or `null` when the app can't be resolved (not installed
64
+ * / ships no `dist/`).
65
+ */
66
+ export function makeAppDistResolver(
67
+ resolve: () => string = () => resolveNotesDistFrom({ pkg: APP_PACKAGE }),
68
+ ): () => string | null {
69
+ let cached: string | null = null;
70
+ return () => {
71
+ if (cached !== null) return cached;
72
+ try {
73
+ cached = resolve();
74
+ return cached;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ }
80
+
81
+ /** MIME overrides Bun.file doesn't infer (mirrors notes-serve.ts). */
82
+ function mimeFor(path: string): string | undefined {
83
+ // Without this the PWA install prompt sees text/html for the manifest + bails.
84
+ if (path.endsWith(".webmanifest")) return "application/manifest+json";
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Answer an already-unclaimed request from the app's root-based `dist/`, or
90
+ * return `null` to tell the caller to fall through to its normal handling
91
+ * (the `/` 302 fallback, or the branded 404 tail).
92
+ *
93
+ * Contract:
94
+ * - non-GET → null (fall through)
95
+ * - a reserved hub/protocol prefix → null (keep the branded 404)
96
+ * - GET + an existing file under dist → that file (asset-correct MIME)
97
+ * - GET `/` or a trailing-slash "dir" request → dist/index.html (SPA shell)
98
+ * - GET + no file + Accept: text/html → dist/index.html (SPA deep link)
99
+ * - GET + no file + non-HTML (API probe, etc.) → null (branded 404)
100
+ *
101
+ * `dist` is a resolved directory (from `makeAppDistResolver`); if its
102
+ * `index.html` has since vanished (app uninstalled after a successful resolve)
103
+ * we return `null` rather than serving a broken shell.
104
+ */
105
+ export function serveAppAtRoot(dist: string, req: Request, pathname: string): Response | null {
106
+ // Only GET is served (HEAD/POST/etc. keep the caller's default — a non-GET to
107
+ // an unclaimed path stays a 404, and a non-GET `/` keeps its 302).
108
+ if (req.method !== "GET") return null;
109
+
110
+ // Hub/protocol namespaces are never the app's — leave their 404 branded.
111
+ for (const prefix of ROOT_SERVE_RESERVED_PREFIXES) {
112
+ if (pathname.startsWith(prefix)) return null;
113
+ }
114
+
115
+ const indexHtml = join(dist, "index.html");
116
+ // The resolved bundle lost its shell (uninstalled mid-flight) → fall through.
117
+ if (!existsSync(indexHtml)) return null;
118
+
119
+ const spaShell = () =>
120
+ new Response(Bun.file(indexHtml), {
121
+ headers: { "content-type": "text/html; charset=utf-8" },
122
+ });
123
+
124
+ // Bare root or a trailing-slash "directory" request → the SPA shell.
125
+ if (pathname === "/" || pathname.endsWith("/")) return spaShell();
126
+
127
+ // An existing dist file: /assets/*, /icon.svg, /manifest.webmanifest, /sw.js …
128
+ // Malformed percent-encoding (e.g. `/foo%`, `/assets/%ZZ`) makes
129
+ // `decodeURIComponent` throw a URIError. Catch it and fall through to the
130
+ // branded 404 (redirect-mode parity) — WITHOUT the guard the throw escapes to
131
+ // the dispatch outer catch, which classifies a non-DB error as "other" and
132
+ // re-throws → a bare 500. This path is reachable pre-auth on a public expose,
133
+ // so a garbage-encoded asset URL must 404, never 500.
134
+ let decodedPath: string;
135
+ try {
136
+ decodedPath = decodeURIComponent(pathname);
137
+ } catch {
138
+ return null;
139
+ }
140
+ // Path-traversal guard: the joined path must stay strictly under dist/.
141
+ const filePath = join(dist, decodedPath);
142
+ if (filePath.startsWith(`${dist}/`) && existsSync(filePath) && statSync(filePath).isFile()) {
143
+ const mime = mimeFor(filePath);
144
+ return new Response(
145
+ Bun.file(filePath),
146
+ mime ? { headers: { "content-type": mime } } : undefined,
147
+ );
148
+ }
149
+
150
+ // No matching file. Serve the SPA shell ONLY for HTML navigations (a deep link
151
+ // like /some-note the app routes client-side). A browser navigation sends
152
+ // `Accept: text/html`; a non-HTML unclaimed request (API probe, a missing
153
+ // asset fetched with `Accept: */*`) falls through to the branded 404.
154
+ const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
155
+ return wantsHtml ? spaShell() : null;
156
+ }
@@ -46,7 +46,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
46
46
  "Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
47
47
  level: "admin",
48
48
  },
49
- // Optional-module scopes (scribe / agent / surface). These are in
49
+ // Optional-module scopes (scribe / surface). These are in
50
50
  // FIRST_PARTY_SCOPES (= Object.keys(this map)) but the modules may not be
51
51
  // installed — so they're GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts)
52
52
  // and only advertised in `scopes_supported` when the service is in
@@ -61,10 +61,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
61
61
  label: "Manage Scribe configuration (provider keys, models, quotas).",
62
62
  level: "admin",
63
63
  },
64
- "agent:send": {
65
- label: "Post messages to your Agent.",
66
- level: "send",
67
- },
64
+
68
65
  // Surface Git Transport scopes (surface-host). `surface:read` = clone/fetch a
69
66
  // surface's hub-hosted git repo; `surface:write` = push to it. Named forms
70
67
  // (`surface:<name>:<verb>`) collapse to these via the 3→2-segment rule in
@@ -112,8 +109,34 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
112
109
  label: "Administer vaults on this host (create, configure, delete).",
113
110
  level: "admin",
114
111
  },
112
+ // Account scopes (Parachute App campaign, Phase 2 — the `/account/*` door
113
+ // contract). `account:<id>:admin` authorizes every account mutation
114
+ // (create/delete/import vaults, mint per-vault tokens, set caps); `:read`
115
+ // authorizes the read side (list vaults, read caps/usage). On a self-host
116
+ // hub `<id>` is the sentinel `self` (account ≡ box). These are minted ONLY
117
+ // by the cookie→bearer `POST /account/token` exchange, NEVER via the public
118
+ // OAuth flow — they're in NON_REQUESTABLE_SCOPES below, like the host scopes.
119
+ // Listed here (a) so `NON_REQUESTABLE_SCOPES ⊆ FIRST_PARTY_SCOPES` holds and
120
+ // (b) so scope-guard's `admin ⊇ read` inheritance recognizes the grammar
121
+ // (`account:self:admin` satisfies `account:self:read`).
122
+ "account:self:admin": {
123
+ label:
124
+ "Manage your Parachute account — create, delete, and configure your vaults, and mint access tokens for them.",
125
+ level: "admin",
126
+ },
127
+ "account:self:read": {
128
+ label: "View your Parachute account — list your vaults and read their settings and usage.",
129
+ level: "read",
130
+ },
115
131
  };
116
132
 
133
+ /** Account scope strings (Parachute App campaign, Phase 2). Exported so the
134
+ * `POST /account/token` mint and the `/account/*` route gates reference the
135
+ * canonical constants rather than re-literaling the strings. `self` is the
136
+ * self-host account sentinel (account ≡ box). */
137
+ export const ACCOUNT_SELF_ADMIN_SCOPE = "account:self:admin";
138
+ export const ACCOUNT_SELF_READ_SCOPE = "account:self:read";
139
+
117
140
  /**
118
141
  * Sorted list of every first-party scope the hub recognizes. Used as the
119
142
  * baseline for `scopes_supported` in the OAuth-AS metadata; module-declared
@@ -170,6 +193,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
170
193
  // Service-admin scopes: operator-only, never requestable via /oauth/authorize.
171
194
  "hub:admin",
172
195
  "scribe:admin",
196
+ // Account scopes (Parachute App campaign, Phase 2): cookie-minted only via
197
+ // `POST /account/token`, NEVER OAuth-requestable. A third-party app pointed
198
+ // at the hub AS must not be able to consent its way to account authority
199
+ // (create/delete vaults, mint per-vault tokens) — the account credential is
200
+ // exclusively the same-origin app's cookie→bearer exchange.
201
+ ACCOUNT_SELF_ADMIN_SCOPE,
202
+ ACCOUNT_SELF_READ_SCOPE,
173
203
  ]);
174
204
 
175
205
  /**
@@ -8,10 +8,16 @@ import type { ServiceEntry } from "./services-manifest.ts";
8
8
  *
9
9
  * 1939 parachute-hub internal static + proxy, CLI-managed
10
10
  * 1940 parachute-vault committed core
11
- * 1941 parachute-agent exploration (renamed from parachute-channel)
11
+ * 1941 unassigned
12
12
  * 1942 parachute-notes committed core (PWA bundle)
13
13
  * 1943 parachute-scribe committed core
14
- * 1944–1949 unassigned
14
+ * 1944 parachute-app the super-surface front door (PWA bundle,
15
+ * hub-parity P5) — see the PORT_RESERVATIONS
16
+ * comment below for why this is a DIFFERENT
17
+ * module from the pre-2026-05-27 `app`
18
+ * 1945 unassigned
19
+ * 1946 parachute-surface UI host (renamed from `app` 2026-05-27)
20
+ * 1947–1949 unassigned
15
21
  *
16
22
  * Hub pins 1939: `parachute expose` composes hub targets as
17
23
  * `http://127.0.0.1:1939/` and that URL has to be stable across machines for
@@ -64,15 +70,27 @@ export interface PortReservation {
64
70
  export const PORT_RESERVATIONS: readonly PortReservation[] = [
65
71
  { port: 1939, name: "parachute-hub", status: "assigned" },
66
72
  { port: 1940, name: "parachute-vault", status: "assigned" },
67
- { port: 1941, name: "parachute-agent", status: "assigned" },
73
+ { port: 1941, name: "unassigned", status: "reserved" },
68
74
  { port: 1942, name: "parachute-notes", status: "assigned" },
69
75
  { port: 1943, name: "parachute-scribe", status: "assigned" },
70
- { port: 1944, name: "unassigned", status: "reserved" },
76
+ // hub-parity P5 (2026-07-11): parachute-app's canonical slot — the NEW
77
+ // super-surface front door (@openparachute/parachute-app), landing as a
78
+ // FIRST_PARTY_FALLBACKS entry (short "app") with its own hub-side static-
79
+ // serve shim (notes-serve.ts --package). This is a DIFFERENT, unrelated
80
+ // module from the pre-2026-05-27 `app` package described in the 1946
81
+ // comment below — the two just happen to share the name across a rename.
82
+ // Status `assigned` keeps the fallback-port walker (`assignPort` in
83
+ // port-assign.ts) from handing this port to a colliding third-party
84
+ // module.
85
+ { port: 1944, name: "parachute-app", status: "assigned" },
71
86
  { port: 1945, name: "unassigned", status: "reserved" },
72
- // hub#323: parachute-app's canonical slot. Status `assigned` keeps the
73
- // fallback-port walker (`assignPort` in port-assign.ts) from handing this
74
- // port out to a colliding third-party module. The matching KNOWN_MODULES
75
- // row carries the canonicalPort + paths for status/expose surfaces.
87
+ // hub#323 (historical): this WAS `parachute-app`'s canonical slot back
88
+ // when "app" meant the UI-host module. That module renamed to
89
+ // `parachute-surface` 2026-05-27 (patterns#102); port 1944 above is the
90
+ // different, new parachute-app (super-surface) from hub-parity P5. Status
91
+ // `assigned` keeps the fallback-port walker from handing 1946 to a
92
+ // colliding third-party module. The matching KNOWN_MODULES row carries
93
+ // the canonicalPort + paths for status/expose surfaces.
76
94
  { port: 1946, name: "parachute-surface", status: "assigned" },
77
95
  { port: 1947, name: "unassigned", status: "reserved" },
78
96
  { port: 1948, name: "unassigned", status: "reserved" },
@@ -278,9 +296,15 @@ export function composeServiceSpec(opts: {
278
296
  // — its startCmd is composed from the services.json entry's port + mount,
279
297
  // which is hub-side logic, not something notes itself runs. (The archive
280
298
  // isn't done — notes-daemon Phase 3 retirement hasn't landed.)
299
+ // - app: the NEW super-surface front door (@openparachute/parachute-app,
300
+ // hub-parity P5, 2026-07-11) — same shape as notes: a frontend bundle
301
+ // with no server of its own, served by the SAME hub-side shim
302
+ // (`notes-serve.ts --package @openparachute/parachute-app`). Unrelated
303
+ // to the pre-2026-05-27 `app` package that renamed to `surface` (see
304
+ // the KNOWN_MODULES.surface tagline + the RETIRED_MODULES note below).
281
305
  //
282
- // The remaining entry keeps its "FALLBACK: Delete when …" marker so the
283
- // next cleanup pass is a one-grep operation.
306
+ // Each entry keeps its "FALLBACK: Delete when …" marker so the next cleanup
307
+ // pass is a one-grep operation.
284
308
  // ---------------------------------------------------------------------------
285
309
 
286
310
  // FALLBACK: Delete when @openparachute/notes ships .parachute/module.json AND
@@ -317,11 +341,59 @@ const NOTES_FALLBACK: FirstPartyFallback = {
317
341
  },
318
342
  };
319
343
 
344
+ // FALLBACK: Delete when @openparachute/parachute-app ships
345
+ // .parachute/module.json AND self-registers its services.json row at boot.
346
+ // As of hub-parity P5 the app repo doesn't carry the real module.json yet —
347
+ // only a stale `dist/.parachute/info` artifact (name: "parachute-notes")
348
+ // left over from seeding the app off notes-ui, which is a DIFFERENT file
349
+ // (module-manifest.ts's runtime `/.parachute/info`, not the static
350
+ // `.parachute/module.json` contract this fallback stands in for) and
351
+ // doesn't block this fallback path. parachute-app is a frontend bundle (the
352
+ // super-surface front door) with no server of its own, so — same as notes —
353
+ // its startCmd is hub-side logic composed from the services.json entry
354
+ // (port + mount), running through the SAME `notes-serve.ts` shim
355
+ // generalized in hub-parity P5 via `--package`.
356
+ const APP_FALLBACK: FirstPartyFallback = {
357
+ package: "@openparachute/parachute-app",
358
+ manifest: {
359
+ name: "app",
360
+ manifestName: "parachute-app",
361
+ displayName: "Parachute",
362
+ tagline: "The Parachute app — your parachute's front door.",
363
+ port: 1944,
364
+ paths: ["/app"],
365
+ health: "/app/health",
366
+ },
367
+ extras: {
368
+ startCmd: (entry) => {
369
+ const first = entry.paths[0] ?? "/app";
370
+ const mount = first === "/" ? "" : first.replace(/\/+$/, "");
371
+ return [
372
+ "bun",
373
+ NOTES_SERVE_PATH,
374
+ "--port",
375
+ String(entry.port),
376
+ "--mount",
377
+ mount,
378
+ "--package",
379
+ "@openparachute/parachute-app",
380
+ ];
381
+ },
382
+ postInstallFooter: () => [
383
+ "",
384
+ "Open your Parachute at <origin>/app — it signs in with your hub account.",
385
+ "The hub's front page (`/`) now serves the app directly too, unless you've",
386
+ "already customized the root (`parachute hub set-root-mode` / `set-root-redirect`",
387
+ "or the admin SPA).",
388
+ ],
389
+ },
390
+ };
391
+
320
392
  /**
321
393
  * Vendored manifests + extras for first-party modules that still need them.
322
394
  * Indexed by short name (the `parachute install <X>` token).
323
395
  *
324
- * Only notes remains — see the block comment above for the rationale
396
+ * notes + app remain — see the block comment above for the rationale
325
397
  * (vault/scribe/agent now self-register and ship their own
326
398
  * module.json). Other code paths consult both this table AND `KNOWN_MODULES`
327
399
  * (which carries the post-self-register-retirement entries) via the helpers
@@ -329,6 +401,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
329
401
  */
330
402
  export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
331
403
  notes: NOTES_FALLBACK,
404
+ app: APP_FALLBACK,
332
405
  };
333
406
 
334
407
  /**
@@ -438,8 +511,8 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
438
511
  },
439
512
  },
440
513
  // NOTE (2026-07-01): `runner` was REMOVED from this registry (decision:
441
- // Aaron 2026-07-01 — the module set of record is vault / hub / agent /
442
- // scribe / surface). Runner is no longer offered, installable, or
514
+ // Aaron 2026-07-01 — the module set of record is vault / hub / scribe /
515
+ // surface). Runner is no longer offered, installable, or
443
516
  // lifecycle-addressable by short name from the hub's bootstrap registries.
444
517
  // Existing installs stay GRACEFUL: a legacy `parachute-runner` services.json
445
518
  // row is handled exactly like any unknown/third-party row — `parachute
@@ -448,29 +521,7 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
448
521
  // stamped and logs-and-skips otherwise. Deliberately NOT added to
449
522
  // RETIRED_MODULES: that registry GC-drops rows on load, which would break
450
523
  // routing for operators still running the runner daemon.
451
- agent: {
452
- short: "agent",
453
- package: "@openparachute/agent",
454
- manifestName: "parachute-agent",
455
- canonicalPort: 1941,
456
- displayName: "Agent",
457
- // Mirrors agent's own module.json (the canonical fields below do too —
458
- // keep in sync if agent's declaration changes). Renamed from `channel`
459
- // 2026-06-17 (parachute-channel → parachute-agent); the module is the
460
- // same webhook-fan-out + MCP bridge daemon, on the same port 1941.
461
- tagline: "Chat with your Claude Code sessions — a channel per session.",
462
- canonicalPaths: ["/agent"],
463
- canonicalHealth: "/health",
464
- canonicalStripPrefix: true,
465
- extras: {
466
- // Backward-compat startCmd for rows without installDir — same rationale
467
- // as scribe / vault. The bare binary IS the daemon (agent's
468
- // package.json bin maps `parachute-agent` → src/daemon.ts).
469
- startCmd: () => ["parachute-agent"],
470
- // Agent gates its endpoints behind hub-issued JWTs (agent:* scopes).
471
- hasAuth: true,
472
- },
473
- },
524
+
474
525
  surface: {
475
526
  short: "surface",
476
527
  package: "@openparachute/surface",
@@ -520,39 +571,43 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
520
571
  * legacy operators. `notes` (the daemon) is the canonical
521
572
  * "deprecating-but-not-retired" case as of 2026-05-22: do not add
522
573
  * until its Phase 3 retirement lands.
523
- * - Entries stay forever. Removing an entry would let a stale row
524
- * reappear silently on legacy installs.
574
+ * - Entries stay forever UNLESS the retired name is later reclaimed by a
575
+ * genuinely new, unrelated module that needs to self-register under it
576
+ * for real (see the `app` / `parachute-app` un-retirement note below).
577
+ * That's the one case where removal is correct: keeping a retirement
578
+ * entry alongside a live FIRST_PARTY_FALLBACKS/KNOWN_MODULES entry of
579
+ * the SAME name would make `dropRetiredModuleRows` GC the live module's
580
+ * own services.json row on every read — an outright regression, not a
581
+ * conservative default. Ordinary "this module is genuinely gone"
582
+ * entries (no successor reusing the name) still stay forever.
525
583
  */
526
584
  export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
527
- // NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
528
- // names were retired here on 2026-05-20 (the Claude-in-containers module).
529
- // They are NOW RE-ASSIGNED to the renamed channel module (parachute-channel
530
- // → parachute-agent). Keeping these as RETIRED entries would make
531
- // `dropRetiredModuleRows` drop the NEW agent module's services.json row on
532
- // load breaking the live module. The names are no longer retired; they
533
- // belong to the active agent module. The retired containers code now lives
534
- // under `parachute-agent-legacy` (GitHub) and is not installable, so there
535
- // is no live `agent` daemon to GC. (The historical `claw → agent`
536
- // migration in services-manifest.ts still rewrites stale paraclaw rows to
537
- // `name: "agent"` they now resolve to the LIVE agent module's mount,
538
- // which is harmless / arguably correct since paraclaw was the original
539
- // "agent".)
540
- //
541
- // The `parachute-app` row name retires 2026-05-27 along with the
542
- // app surface rename (patterns#102). Operators upgrading from
543
- // 0.5.13-stable will have a `parachute-app` row in services.json
544
- // pointing at the now-removed @openparachute/app package; this entry
545
- // drops it on load + steers them at `parachute install surface`.
546
- // The short-name `app` form is included for legacy rows that used
547
- // the short name as the `name` field.
548
- app: {
549
- retiredAt: "2026-05-27",
550
- replacement: "parachute-surface (renamed from parachute-app `parachute install surface`)",
551
- },
552
- "parachute-app": {
553
- retiredAt: "2026-05-27",
554
- replacement: "parachute-surface (renamed from parachute-app — `parachute install surface`)",
555
- },
585
+ agent: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
586
+ channel: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
587
+ "parachute-agent": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
588
+ "parachute-channel": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
589
+ // Agent was removed from Hub on 2026-07-15. All historical service-row
590
+ // identities are retired so normal manifest reads stop routing or supervising
591
+ // the deprecated daemon. Generic AI-agent authorization grants/tokens are a
592
+ // separate Hub capability and remain supported.
593
+ // NOTE (2026-07-11, hub-parity P5 — `app` / `parachute-app` UN-RETIRED):
594
+ // this table carried `app` + `parachute-app` (retired 2026-05-27, the
595
+ // app surface rename, patterns#102) from hub#219 through hub-parity P4.
596
+ // They are REMOVED as of this note: a NEW, unrelated module — the real
597
+ // `@openparachute/parachute-app` super-surface front door (see
598
+ // FIRST_PARTY_FALLBACKS.app + PORT_RESERVATIONS' port-1944 comment) —
599
+ // claims the `parachute-app` manifestName (and the bare `app` short) for
600
+ // real. Keeping these entries would make `dropRetiredModuleRows`
601
+ // unconditionally GC the new module's services.json row on every read,
602
+ // breaking it outright — see the curation-rule exception above. An
603
+ // operator with a genuinely stale PRE-2026-05-27 `app`/`parachute-app` row
604
+ // (from the old, now-defunct @openparachute/app package) is handled the
605
+ // same as any other unrecognized row once un-retired: `parachute status`
606
+ // renders it, and `parachute serve` boots it via installDir's
607
+ // module.json if present and logs-and-skips otherwise — the same
608
+ // graceful fallback the `runner` registry removal (2026-07-01, see
609
+ // KNOWN_MODULES below) relies on. In practice the real parachute-app
610
+ // module's own first boot overwrites any stale row sharing its name.
556
611
  };
557
612
 
558
613
  /**
@@ -632,9 +687,9 @@ export function knownServices(): string[] {
632
687
  * manifest-declared `focus`; this map is the fallback when `module.json` omits
633
688
  * it (and the bootstrap value before a module is installed).
634
689
  *
635
- * - `core` — vault / scribe / hub / surface (the product surface).
636
- * - `experimental` agent (legit preview; still OFFERED on a fresh install)
637
- * + any unlisted third-party short.
690
+ * - `core` — vault / scribe / hub / surface / app (the product surface;
691
+ * `app` joined 2026-07-11, hub-parity P5 the super-surface front door).
692
+ * - `experimental` — any unlisted third-party short.
638
693
  * - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
639
694
  * into parachute-surface). Still RESOLVABLE (discoverableShorts unchanged)
640
695
  * and SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but
@@ -650,7 +705,8 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
650
705
  scribe: "core",
651
706
  hub: "core",
652
707
  surface: "core",
653
- agent: "experimental",
708
+ app: "core",
709
+
654
710
  notes: "deprecated",
655
711
  };
656
712
 
@@ -738,7 +794,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
738
794
  /**
739
795
  * Resolve the runtime spec for a known short name.
740
796
  *
741
- * FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
797
+ * FIRST_PARTY_FALLBACKS shorts (notes / app) return a fully-composed
742
798
  * spec with embedded manifest + extras — the vendored manifest is the
743
799
  * source of truth pre-install and the install path preserves it through.
744
800
  *
@@ -847,11 +903,10 @@ export async function getSpecFromInstallDir(
847
903
  */
848
904
  const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
849
905
  "parachute-lens": "notes",
850
- "parachute-channel": "agent",
851
906
  };
852
907
 
853
908
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
854
- * Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
909
+ * Consults both FIRST_PARTY_FALLBACKS (notes / app) and KNOWN_MODULES
855
910
  * (vault / scribe / agent / surface — post-FALLBACK-retirement).
856
911
  * Returns undefined for unknown manifests. */
857
912
  export function shortNameForManifest(manifestName: string): string | undefined {