@openparachute/hub 0.7.6 → 0.7.7-rc.12

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 (78) 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 +12 -0
  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-server.test.ts +37 -0
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +279 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/scope-explanations.test.ts +22 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +372 -132
  36. package/src/__tests__/sessions.test.ts +75 -28
  37. package/src/__tests__/setup-wizard.test.ts +7 -10
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/account-api.ts +677 -0
  42. package/src/account-session.ts +132 -0
  43. package/src/account-token.ts +200 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-invites.ts +19 -0
  50. package/src/api-mint-token.ts +2 -1
  51. package/src/api-modules-ops.ts +2 -1
  52. package/src/api-modules.ts +10 -8
  53. package/src/api-revoke-token.ts +2 -1
  54. package/src/api-settings-hub-origin.ts +2 -1
  55. package/src/api-settings-root-redirect.ts +2 -1
  56. package/src/api-tokens.ts +2 -1
  57. package/src/audience-gate.ts +2 -1
  58. package/src/chrome-strip.ts +16 -4
  59. package/src/commands/install.ts +86 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/help.ts +6 -6
  63. package/src/hub-server.ts +247 -52
  64. package/src/hub-settings.ts +25 -1
  65. package/src/hub.ts +64 -31
  66. package/src/invites.ts +42 -0
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/notes-serve.ts +73 -31
  69. package/src/oauth-handlers.ts +1 -11
  70. package/src/operator-token.ts +0 -1
  71. package/src/origin-check.ts +2 -2
  72. package/src/scope-explanations.ts +35 -5
  73. package/src/service-spec.ts +128 -74
  74. package/src/services-manifest.ts +112 -52
  75. package/src/sessions.ts +66 -30
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Tiny static-file server for the @openparachute/notes PWA bundle.
4
+ * Tiny static-file server for a Parachute PWA bundle — originally written
5
+ * for @openparachute/notes, generalized in hub-parity P5 (2026-07-11) to
6
+ * also serve @openparachute/parachute-app (the super-surface front door,
7
+ * port 1944, mount `/app`) via the same shim. Any package matching this
8
+ * shape (a prebuilt SPA `dist/` with no server of its own) can reuse it.
5
9
  *
6
- * Notes is a SPA — no backend of its own. `parachute start notes` invokes
7
- * this shim with the installed `dist/` path so the PWA is served at a
8
- * known port and can be reverse-proxied by `parachute expose` alongside
9
- * the other services.
10
+ * A served bundle is a SPA — no backend of its own. `parachute start
11
+ * <svc>` invokes this shim with the installed `dist/` path so the PWA is
12
+ * served at a known port and can be reverse-proxied by `parachute expose`
13
+ * alongside the other services.
10
14
  *
11
15
  * Invoked as:
12
- * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>]
16
+ * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>] [--package <npmName>]
13
17
  *
14
18
  * `--mount` (default `/notes`) is the path prefix the reverse proxy hands
15
19
  * us. We strip it before resolving against `dist/` so a request for
@@ -17,27 +21,43 @@
17
21
  * `{dist}/notes/sw.js`. Without the strip, the SW + .webmanifest both
18
22
  * SPA-fall-back to index.html with content-type text/html, and the PWA
19
23
  * install prompt never fires. Pass `--mount ""` (or `--mount /`) when the
20
- * bundle is served at the origin root.
24
+ * bundle is served at the origin root. THIS IS LOAD-BEARING for every
25
+ * package served by this shim, not just notes — keep it exactly as-is.
21
26
  *
22
- * If --dist is omitted, we resolve @openparachute/notes's dist directory
23
- * via Bun.resolveSync. If that fails (package not installed globally, or
27
+ * `--package` (default `@openparachute/notes`, back-compat) names the npm
28
+ * package whose `dist/` we resolve when `--dist` is omitted. Passed by
29
+ * FIRST_PARTY_FALLBACKS entries whose startCmd composes this shim for a
30
+ * package other than notes (e.g. `app`'s `--package @openparachute/parachute-app`).
31
+ *
32
+ * If --dist is omitted, we resolve the package's dist directory via
33
+ * Bun.resolveSync. If that fails (package not installed globally, or
24
34
  * package doesn't ship dist/), exit 1 with a clear error.
35
+ *
36
+ * `/health` (post-mount-strip) always answers 2xx — the doctor/status
37
+ * probe (`probeModuleHealth`) only cares about the status code, but we
38
+ * answer explicitly rather than relying on the SPA-shell catch-all so a
39
+ * missing/corrupt `dist/index.html` can't take the health check down with it.
25
40
  */
26
41
 
27
42
  import { existsSync } from "node:fs";
28
43
  import { homedir } from "node:os";
29
44
  import { dirname, join, resolve } from "node:path";
30
45
 
46
+ /** Back-compat default — the shim's original (and still primary) consumer. */
47
+ const DEFAULT_PACKAGE = "@openparachute/notes";
48
+
31
49
  interface Args {
32
50
  port: number;
33
51
  dist?: string;
34
52
  mount: string;
53
+ pkg: string;
35
54
  }
36
55
 
37
56
  function parseArgs(argv: string[]): Args {
38
57
  let port = 5173;
39
58
  let dist: string | undefined;
40
59
  let mount = "/notes";
60
+ let pkg = DEFAULT_PACKAGE;
41
61
  for (let i = 0; i < argv.length; i++) {
42
62
  const a = argv[i];
43
63
  if (a === "--port") {
@@ -56,11 +76,15 @@ function parseArgs(argv: string[]): Args {
56
76
  const v = argv[++i];
57
77
  if (v === undefined) throw new Error("--mount requires a value");
58
78
  mount = normalizeMount(v);
79
+ } else if (a === "--package") {
80
+ const v = argv[++i];
81
+ if (!v) throw new Error("--package requires a value");
82
+ pkg = v;
59
83
  } else {
60
84
  throw new Error(`unknown argument: ${a}`);
61
85
  }
62
86
  }
63
- return { port, dist, mount };
87
+ return { port, dist, mount, pkg };
64
88
  }
65
89
 
66
90
  export function normalizeMount(raw: string): string {
@@ -70,22 +94,22 @@ export function normalizeMount(raw: string): string {
70
94
 
71
95
  /**
72
96
  * Candidate base directories that `Bun.resolveSync` walks from when looking
73
- * for `@openparachute/notes/package.json`. Order matters:
97
+ * for `<package>/package.json`. Order matters:
74
98
  *
75
- * 1. `process.cwd()` — works when notes-serve is invoked from inside the
76
- * notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
77
- * any project that depends on `@openparachute/notes`.
99
+ * 1. `process.cwd()` — works when the shim is invoked from inside the
100
+ * package's own checkout (e.g. via `installDir` cwd in lifecycle.ts) or
101
+ * from any project that depends on the package.
78
102
  * 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
79
- * layout. This is where `bun add -g @openparachute/notes` lands the
80
- * package, and where `bun link @openparachute/notes` symlinks it.
103
+ * layout. This is where `bun add -g <package>` lands the package, and
104
+ * where `bun link <package>` symlinks it.
81
105
  * 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
82
106
  *
83
- * Hub itself does NOT depend on `@openparachute/notes`, so when
84
- * `parachute start notes` is run from the hub repo dir, the cwd-relative
85
- * resolve walks ancestral node_modules and finds nothing. Bun does not
86
- * auto-consult the global install dir, so bun-linked installs fail to
87
- * resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
88
- * `/notes/` because of this — fixed by trying the global install dirs.
107
+ * Hub itself does NOT depend on the served package, so when `parachute
108
+ * start <svc>` is run from the hub repo dir, the cwd-relative resolve walks
109
+ * ancestral node_modules and finds nothing. Bun does not auto-consult the
110
+ * global install dir, so bun-linked installs fail to resolve without
111
+ * (2)/(3). hub#194: Aaron hit silent 502 on tailnet `/notes/` because of
112
+ * this — fixed by trying the global install dirs.
89
113
  *
90
114
  * Exported (and parameterized via `cwd`/`home`) so tests can drive the
91
115
  * resolution order against a real fixture install without monkey-patching
@@ -98,6 +122,8 @@ export function notesDistCandidates(cwd: string, home: string): string[] {
98
122
  export interface ResolveNotesDistDeps {
99
123
  cwd?: string;
100
124
  home?: string;
125
+ /** npm package name to resolve. Defaults to `@openparachute/notes` (back-compat). */
126
+ pkg?: string;
101
127
  /** Override `Bun.resolveSync` for tests. */
102
128
  resolveSync?: (specifier: string, base: string) => string;
103
129
  existsSync?: (path: string) => boolean;
@@ -106,6 +132,7 @@ export interface ResolveNotesDistDeps {
106
132
  export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
107
133
  const cwd = deps.cwd ?? process.cwd();
108
134
  const home = deps.home ?? homedir();
135
+ const pkg = deps.pkg ?? DEFAULT_PACKAGE;
109
136
  const resolveSync = deps.resolveSync ?? Bun.resolveSync;
110
137
  const exists = deps.existsSync ?? existsSync;
111
138
  const candidates = notesDistCandidates(cwd, home);
@@ -113,7 +140,7 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
113
140
  for (const base of candidates) {
114
141
  let pkgPath: string;
115
142
  try {
116
- pkgPath = resolveSync("@openparachute/notes/package.json", base);
143
+ pkgPath = resolveSync(`${pkg}/package.json`, base);
117
144
  } catch (err) {
118
145
  resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
119
146
  continue;
@@ -126,18 +153,18 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
126
153
  // other candidates — they'd resolve to the same package and report
127
154
  // the same problem.
128
155
  throw new Error(
129
- `@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
156
+ `${pkg} resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the ${pkg} maintainer to add a prepublishOnly build step.`,
130
157
  );
131
158
  }
132
159
  return dist;
133
160
  }
134
161
  throw new Error(
135
- `Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
162
+ `Could not resolve ${pkg} from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g ${pkg}\` or the matching \`parachute install <short>\`.`,
136
163
  );
137
164
  }
138
165
 
139
- function resolveNotesDist(): string {
140
- return resolveNotesDistFrom();
166
+ function resolveNotesDist(pkg: string): string {
167
+ return resolveNotesDistFrom({ pkg });
141
168
  }
142
169
 
143
170
  function mimeFor(path: string): string | undefined {
@@ -160,6 +187,17 @@ export function notesFetch(dist: string, mount: string): (req: Request) => Respo
160
187
  if (mount && (pathname === mount || pathname.startsWith(`${mount}/`))) {
161
188
  pathname = pathname.slice(mount.length) || "/";
162
189
  }
190
+ if (pathname === "/health") {
191
+ // Explicit rather than falling through to the SPA shell: the doctor /
192
+ // status probe (`probeModuleHealth`) only checks for a 2xx status, but
193
+ // answering directly means a missing/corrupt `dist/index.html` can't
194
+ // take the health check down with it. Every FIRST_PARTY_FALLBACKS
195
+ // entry served by this shim declares `health` under its own mount
196
+ // (e.g. `/notes/health`, `/app/health`) — this answers all of them.
197
+ return new Response(JSON.stringify({ status: "ok" }), {
198
+ headers: { "content-type": "application/json" },
199
+ });
200
+ }
163
201
  if (pathname === "/" || pathname.endsWith("/")) {
164
202
  return spaShell();
165
203
  }
@@ -200,17 +238,21 @@ export function notesServeOptions(
200
238
  }
201
239
 
202
240
  if (import.meta.main) {
203
- const { port, dist: distArg, mount } = parseArgs(process.argv.slice(2));
241
+ const { port, dist: distArg, mount, pkg } = parseArgs(process.argv.slice(2));
204
242
 
205
243
  let dist: string;
206
244
  try {
207
- dist = distArg ?? resolveNotesDist();
245
+ dist = distArg ?? resolveNotesDist(pkg);
208
246
  } catch (err) {
209
- console.error(`parachute-notes-serve: ${err instanceof Error ? err.message : String(err)}`);
247
+ console.error(
248
+ `parachute-static-serve (${pkg}): ${err instanceof Error ? err.message : String(err)}`,
249
+ );
210
250
  process.exit(1);
211
251
  }
212
252
 
213
253
  Bun.serve(notesServeOptions(port, dist, mount));
214
254
 
215
- console.log(`notes static-serve listening on :${port} (dist=${dist}, mount=${mount || "/"})`);
255
+ console.log(
256
+ `static-serve listening on :${port} (pkg=${pkg}, dist=${dist}, mount=${mount || "/"})`,
257
+ );
216
258
  }
@@ -418,13 +418,9 @@ function oauthErrorRedirect(
418
418
  * Keyed on the SHORT name (resolved from each services.json row via
419
419
  * `shortNameForManifest`) rather than the raw manifest name so a row written
420
420
  * under a legacy manifest name still counts as installed — e.g. an un-upgraded
421
- * `parachute-channel` row aliases to short `agent` (channel→agent rename
422
- * 2026-06-17), so `agent:send` is still correctly advertised until the daemon
423
- * re-registers under `parachute-agent`.
424
421
  */
425
422
  const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, short: string]> = [
426
423
  ["scribe:", "scribe"],
427
- ["agent:", "agent"],
428
424
  ["surface:", "surface"],
429
425
  ];
430
426
 
@@ -1903,13 +1899,7 @@ export async function handleApproveClientPost(
1903
1899
  // Headers logged are non-sensitive (Origin/Referer/Host are public);
1904
1900
  // the bound set is hub's own configuration. Body content not logged.
1905
1901
  console.warn(
1906
- `[oauth] approve POST same-origin check failed. headers: ` +
1907
- `origin=${JSON.stringify(req.headers.get("origin"))} ` +
1908
- `referer=${JSON.stringify(req.headers.get("referer"))} ` +
1909
- `host=${JSON.stringify(req.headers.get("host"))} ` +
1910
- `xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} ` +
1911
- `xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. ` +
1912
- `bound origins: ${JSON.stringify(bound)}`,
1902
+ `[oauth] approve POST same-origin check failed. headers: origin=${JSON.stringify(req.headers.get("origin"))} referer=${JSON.stringify(req.headers.get("referer"))} host=${JSON.stringify(req.headers.get("host"))} xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. bound origins: ${JSON.stringify(bound)}`,
1913
1903
  );
1914
1904
  return htmlError(
1915
1905
  "Cross-origin request rejected",
@@ -104,7 +104,6 @@ export const OPERATOR_TOKEN_SCOPE_SETS: Readonly<Record<OperatorScopeSet, readon
104
104
  "parachute:host:vault",
105
105
  "vault:admin",
106
106
  "scribe:admin",
107
- "agent:send",
108
107
  ],
109
108
  };
110
109
 
@@ -198,8 +198,8 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
198
198
  *
199
199
  * NOT belted, and why:
200
200
  * - GET/HEAD/OPTIONS — read-shaped; the mint GETs
201
- * (`/admin/host-admin-token`, `/admin/agent-token`,
202
- * `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
201
+ * (`/admin/host-admin-token`, `/admin/module-token/<short>`,
202
+ * `/admin/vault-admin-token/<name>`)
203
203
  * enforce GET-only with a 405 and their response bodies are unreadable
204
204
  * cross-origin (no CORS on these routes).
205
205
  * - Bearer-authed requests — a cross-site page cannot attach an
@@ -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,58 @@ 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 opens the app too, unless you've already",
386
+ "set a custom root redirect (`parachute hub set-root-redirect` or the admin SPA).",
387
+ ],
388
+ },
389
+ };
390
+
320
391
  /**
321
392
  * Vendored manifests + extras for first-party modules that still need them.
322
393
  * Indexed by short name (the `parachute install <X>` token).
323
394
  *
324
- * Only notes remains — see the block comment above for the rationale
395
+ * notes + app remain — see the block comment above for the rationale
325
396
  * (vault/scribe/agent now self-register and ship their own
326
397
  * module.json). Other code paths consult both this table AND `KNOWN_MODULES`
327
398
  * (which carries the post-self-register-retirement entries) via the helpers
@@ -329,6 +400,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
329
400
  */
330
401
  export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
331
402
  notes: NOTES_FALLBACK,
403
+ app: APP_FALLBACK,
332
404
  };
333
405
 
334
406
  /**
@@ -438,8 +510,8 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
438
510
  },
439
511
  },
440
512
  // 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
513
+ // Aaron 2026-07-01 — the module set of record is vault / hub / scribe /
514
+ // surface). Runner is no longer offered, installable, or
443
515
  // lifecycle-addressable by short name from the hub's bootstrap registries.
444
516
  // Existing installs stay GRACEFUL: a legacy `parachute-runner` services.json
445
517
  // row is handled exactly like any unknown/third-party row — `parachute
@@ -448,29 +520,7 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
448
520
  // stamped and logs-and-skips otherwise. Deliberately NOT added to
449
521
  // RETIRED_MODULES: that registry GC-drops rows on load, which would break
450
522
  // 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
- },
523
+
474
524
  surface: {
475
525
  short: "surface",
476
526
  package: "@openparachute/surface",
@@ -520,39 +570,43 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
520
570
  * legacy operators. `notes` (the daemon) is the canonical
521
571
  * "deprecating-but-not-retired" case as of 2026-05-22: do not add
522
572
  * until its Phase 3 retirement lands.
523
- * - Entries stay forever. Removing an entry would let a stale row
524
- * reappear silently on legacy installs.
573
+ * - Entries stay forever UNLESS the retired name is later reclaimed by a
574
+ * genuinely new, unrelated module that needs to self-register under it
575
+ * for real (see the `app` / `parachute-app` un-retirement note below).
576
+ * That's the one case where removal is correct: keeping a retirement
577
+ * entry alongside a live FIRST_PARTY_FALLBACKS/KNOWN_MODULES entry of
578
+ * the SAME name would make `dropRetiredModuleRows` GC the live module's
579
+ * own services.json row on every read — an outright regression, not a
580
+ * conservative default. Ordinary "this module is genuinely gone"
581
+ * entries (no successor reusing the name) still stay forever.
525
582
  */
526
583
  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
- },
584
+ agent: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
585
+ channel: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
586
+ "parachute-agent": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
587
+ "parachute-channel": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
588
+ // Agent was removed from Hub on 2026-07-15. All historical service-row
589
+ // identities are retired so normal manifest reads stop routing or supervising
590
+ // the deprecated daemon. Generic AI-agent authorization grants/tokens are a
591
+ // separate Hub capability and remain supported.
592
+ // NOTE (2026-07-11, hub-parity P5 — `app` / `parachute-app` UN-RETIRED):
593
+ // this table carried `app` + `parachute-app` (retired 2026-05-27, the
594
+ // app surface rename, patterns#102) from hub#219 through hub-parity P4.
595
+ // They are REMOVED as of this note: a NEW, unrelated module — the real
596
+ // `@openparachute/parachute-app` super-surface front door (see
597
+ // FIRST_PARTY_FALLBACKS.app + PORT_RESERVATIONS' port-1944 comment) —
598
+ // claims the `parachute-app` manifestName (and the bare `app` short) for
599
+ // real. Keeping these entries would make `dropRetiredModuleRows`
600
+ // unconditionally GC the new module's services.json row on every read,
601
+ // breaking it outright — see the curation-rule exception above. An
602
+ // operator with a genuinely stale PRE-2026-05-27 `app`/`parachute-app` row
603
+ // (from the old, now-defunct @openparachute/app package) is handled the
604
+ // same as any other unrecognized row once un-retired: `parachute status`
605
+ // renders it, and `parachute serve` boots it via installDir's
606
+ // module.json if present and logs-and-skips otherwise — the same
607
+ // graceful fallback the `runner` registry removal (2026-07-01, see
608
+ // KNOWN_MODULES below) relies on. In practice the real parachute-app
609
+ // module's own first boot overwrites any stale row sharing its name.
556
610
  };
557
611
 
558
612
  /**
@@ -632,9 +686,9 @@ export function knownServices(): string[] {
632
686
  * manifest-declared `focus`; this map is the fallback when `module.json` omits
633
687
  * it (and the bootstrap value before a module is installed).
634
688
  *
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.
689
+ * - `core` — vault / scribe / hub / surface / app (the product surface;
690
+ * `app` joined 2026-07-11, hub-parity P5 the super-surface front door).
691
+ * - `experimental` — any unlisted third-party short.
638
692
  * - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
639
693
  * into parachute-surface). Still RESOLVABLE (discoverableShorts unchanged)
640
694
  * and SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but
@@ -650,7 +704,8 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
650
704
  scribe: "core",
651
705
  hub: "core",
652
706
  surface: "core",
653
- agent: "experimental",
707
+ app: "core",
708
+
654
709
  notes: "deprecated",
655
710
  };
656
711
 
@@ -738,7 +793,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
738
793
  /**
739
794
  * Resolve the runtime spec for a known short name.
740
795
  *
741
- * FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
796
+ * FIRST_PARTY_FALLBACKS shorts (notes / app) return a fully-composed
742
797
  * spec with embedded manifest + extras — the vendored manifest is the
743
798
  * source of truth pre-install and the install path preserves it through.
744
799
  *
@@ -847,11 +902,10 @@ export async function getSpecFromInstallDir(
847
902
  */
848
903
  const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
849
904
  "parachute-lens": "notes",
850
- "parachute-channel": "agent",
851
905
  };
852
906
 
853
907
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
854
- * Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
908
+ * Consults both FIRST_PARTY_FALLBACKS (notes / app) and KNOWN_MODULES
855
909
  * (vault / scribe / agent / surface — post-FALLBACK-retirement).
856
910
  * Returns undefined for unknown manifests. */
857
911
  export function shortNameForManifest(manifestName: string): string | undefined {