@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -55,12 +55,15 @@ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
55
55
  import { changePasswordRateLimiter } from "./rate-limit.ts";
56
56
  import { isHttpsRequest } from "./request-protocol.ts";
57
57
  import { findActiveSession } from "./sessions.ts";
58
+ import { isTotpEnrolled } from "./two-factor-store.ts";
58
59
  import {
59
60
  PASSWORD_MAX_LEN,
60
61
  UserNotFoundError,
62
+ type VaultVerb,
61
63
  getUserById,
62
64
  isFirstAdmin,
63
65
  validatePassword,
66
+ vaultVerbsForUserVault,
64
67
  verifyPassword,
65
68
  } from "./users.ts";
66
69
 
@@ -489,6 +492,15 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
489
492
  const adminFlag = isFirstAdmin(deps.db, user.id);
490
493
  const csrf = ensureCsrfToken(req);
491
494
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
495
+ // Per-vault mintable verbs for the "mint an access token" affordance on each
496
+ // tile. Reads the assignment role (today always write → ["read", "write"])
497
+ // so the UI only ever offers a verb the POST handler would accept. Empty for
498
+ // the admin / no-vault branches (no assigned vaults to iterate).
499
+ const mintableVerbs: Record<string, VaultVerb[]> = {};
500
+ for (const v of user.assignedVaults) {
501
+ const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
502
+ if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
503
+ }
492
504
  return htmlResponse(
493
505
  renderAccountHome({
494
506
  username: user.username,
@@ -497,6 +509,8 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
497
509
  hubOrigin: deps.hubOrigin,
498
510
  isFirstAdmin: adminFlag,
499
511
  csrfToken: csrf.token,
512
+ twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
513
+ mintableVerbs,
500
514
  }),
501
515
  200,
502
516
  extra,
@@ -8,10 +8,31 @@
8
8
  * - the future admin SPA when the operator wants to mint a one-shot
9
9
  * scope-narrow token without dropping to a terminal.
10
10
  *
11
- * Auth: `Authorization: Bearer <token>` where `token`'s `scope` claim
12
- * contains `parachute:host:auth`. The operator's local operator.token
13
- * (admin scope-set) covers this; a narrow `--scope-set=auth` operator
14
- * token also covers this.
11
+ * Auth — capability attenuation: any bearer may mint a token whose authority
12
+ * is a SUBSET of its own. A requested scope `s` is grantable (`canGrant`) iff:
13
+ *
14
+ * 1. `s` is requestable AND the bearer holds `parachute:host:auth`
15
+ * — host:auth mints any requestable scope (vault/scribe verbs, etc.).
16
+ * 2. `s` is `vault:<N>:admin` AND the bearer holds `parachute:host:admin`
17
+ * — box-wide admin attenuates to one named vault's admin.
18
+ * 3. `s` is `vault:<N>:<verb>` (verb ∈ read/write/admin) AND the bearer
19
+ * holds `vault:<N>:admin` for the SAME `<N>` — a vault-admin attenuates
20
+ * to any same-vault subset, including an equal-level admin.
21
+ *
22
+ * Otherwise `s` is refused (400 `invalid_scope`). This single rule subsumes
23
+ * the former two-part guard: the old hard `parachute:host:auth` gate is now
24
+ * rule 1, and PR-A's `host:admin → vault:<name>:admin` carve-out (hub#449) is
25
+ * now rule 2. Rule 3 is new — it lets a `vault:<name>:admin` bearer mint
26
+ * same-vault sub-tokens (the canonical headless path to per-vault admin,
27
+ * replacing deprecated `pvt_*` — vault#282 — and the path the SPA tokens
28
+ * page uses via session → /admin/host-admin-token → here). Cross-vault and
29
+ * host-authority escalation are always blocked: a `vault:work:admin` bearer
30
+ * can never mint `vault:other:*` or any `parachute:host:*`.
31
+ *
32
+ * Entry gate: the bearer must hold at least one minting authority —
33
+ * `parachute:host:auth`, `parachute:host:admin`, or some `vault:<*>:admin`.
34
+ * A bearer with none (e.g. a read-only token) gets 403 `insufficient_scope`
35
+ * before any per-scope check; it cannot mint anything.
15
36
  *
16
37
  * Why a separate endpoint instead of extending /admin/host-admin-token:
17
38
  * that endpoint is session-cookie-gated for the SPA's needs and only
@@ -26,14 +47,38 @@
26
47
  import type { Database } from "bun:sqlite";
27
48
  import { inferAudience } from "./jwt-audience.ts";
28
49
  import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
29
- import { isNonRequestableScope } from "./scope-explanations.ts";
50
+ import {
51
+ MINT_HOST_ADMIN_SCOPE,
52
+ MINT_HOST_AUTH_SCOPE,
53
+ canGrant,
54
+ hasMintingAuthority,
55
+ } from "./scope-attenuation.ts";
56
+ import {
57
+ isVaultAdminScope,
58
+ isWellFormedOrNonVaultScope,
59
+ vaultScopeName,
60
+ } from "./scope-explanations.ts";
61
+
62
+ // Re-export `canGrant` so existing importers (and the symmetric revoke path)
63
+ // have a single name to reach for; the implementation lives in the shared
64
+ // `scope-attenuation.ts` module alongside `hasMintingAuthority`.
65
+ export { canGrant } from "./scope-attenuation.ts";
30
66
 
31
67
  /** Default lifetime when --expires-in / `expires_in` is omitted. Matches the CLI. */
32
68
  export const API_MINT_TOKEN_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
33
69
  /** Hard cap. Matches the CLI's --expires-in upper bound. */
34
70
  export const API_MINT_TOKEN_MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
35
- /** Scope required on the bearer token to call this endpoint. */
36
- export const API_MINT_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
71
+ /**
72
+ * Bearer scope that authorises minting any *requestable* scope (rule 1 of the
73
+ * attenuation model). Re-exported alias of the shared `MINT_HOST_AUTH_SCOPE`
74
+ * for back-compat with existing importers.
75
+ */
76
+ export const API_MINT_TOKEN_HOST_AUTH_SCOPE = MINT_HOST_AUTH_SCOPE;
77
+ /**
78
+ * Bearer scope that authorises minting `vault:<name>:admin` (rule 2).
79
+ * Re-exported alias of the shared `MINT_HOST_ADMIN_SCOPE`.
80
+ */
81
+ export const API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE = MINT_HOST_ADMIN_SCOPE;
37
82
  /** client_id stamped on minted tokens. Matches the CLI flow's value. */
38
83
  export const API_MINT_TOKEN_CLIENT_ID = "parachute-hub";
39
84
 
@@ -87,12 +132,17 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
87
132
  return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
88
133
  }
89
134
 
90
- // 3. Scope gate.
91
- if (!bearerScopes.includes(API_MINT_TOKEN_REQUIRED_SCOPE)) {
135
+ // 3. Entry gate — the bearer must hold at least one minting authority
136
+ // (`parachute:host:auth`, `parachute:host:admin`, or some
137
+ // `vault:<*>:admin`). A bearer with none can mint nothing under the
138
+ // attenuation model, so we 403 before per-scope checks. Per-scope
139
+ // grantability (which authority covers which scope) is enforced below
140
+ // via `canGrant`.
141
+ if (!hasMintingAuthority(bearerScopes)) {
92
142
  return jsonError(
93
143
  403,
94
144
  "insufficient_scope",
95
- `bearer token lacks ${API_MINT_TOKEN_REQUIRED_SCOPE}`,
145
+ `bearer token holds no minting authority (need ${API_MINT_TOKEN_HOST_AUTH_SCOPE}, ${API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE}, or vault:<name>:admin)`,
96
146
  );
97
147
  }
98
148
 
@@ -117,19 +167,40 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
117
167
  return jsonError(400, "invalid_request", "scope must contain at least one scope");
118
168
  }
119
169
 
120
- // Privilege-diffusion guard: mint paths cannot themselves mint tokens
121
- // carrying non-requestable scopes (parachute:host:admin, the host:*
122
- // narrow scopes, vault:<name>:admin). Holder of `parachute:host:auth`
123
- // can mint vault/scribe/agent verb scopes for downstream services, but
124
- // cannot mint another `:auth` (or any other non-requestable) without
125
- // forced re-auth via the operator.token rotation path. Same set the
126
- // public OAuth flow already rejects.
127
- const blocked = scopes.filter((s) => isNonRequestableScope(s));
170
+ // Shape guard (defensive hygiene adversarial audit 2026-05-28): reject any
171
+ // scope that is shaped like a *named* per-vault scope but malformed
172
+ // `vault:work:ADMIN` (uppercase verb), `vault::admin` (empty name),
173
+ // `vault:work:read:admin` (extra segment), `VAULT:work:admin` (uppercase
174
+ // resource). These slip past `isNonRequestableScope`'s strict regexes, so
175
+ // `canGrant` rule 1 would admit them as "requestable" and mint a junk
176
+ // registry row. They grant zero access today (the vault consumer's
177
+ // `decomposeVaultScope` rejects all four), so this is NOT exploitable now —
178
+ // the check is a backstop against a future consumer-normalization regression
179
+ // plus registry hygiene. It's an input-shape check, orthogonal to authority,
180
+ // so it runs for ALL callers before any `canGrant` attenuation. Non-vault
181
+ // scopes and the unnamed `vault:<verb>` forms are unaffected.
182
+ const malformed = scopes.filter((s) => !isWellFormedOrNonVaultScope(s));
183
+ if (malformed.length > 0) {
184
+ return jsonError(
185
+ 400,
186
+ "invalid_scope",
187
+ `malformed vault scope ${malformed.join(", ")}; expected vault:<name>:<read|write|admin>`,
188
+ );
189
+ }
190
+
191
+ // Capability-attenuation guard: every requested scope must be a subset of
192
+ // the bearer's own authority under `canGrant` (rules in the file docstring).
193
+ // A `parachute:host:auth` bearer mints any requestable scope; a
194
+ // `parachute:host:admin` bearer additionally mints `vault:<name>:admin`; a
195
+ // `vault:<name>:admin` bearer mints same-vault subsets only. Anything else
196
+ // — host:* escalation, cross-vault, a non-requestable with no covering
197
+ // authority — is blocked. One blocked scope rejects the whole request.
198
+ const blocked = scopes.filter((s) => !canGrant(bearerScopes, s));
128
199
  if (blocked.length > 0) {
129
200
  return jsonError(
130
201
  400,
131
202
  "invalid_scope",
132
- `scope ${blocked.join(", ")} is not requestable via mint-token; use OAuth flow or operator rotation`,
203
+ `scope ${blocked.join(", ")} is not grantable by this bearer; use OAuth flow or operator rotation`,
133
204
  );
134
205
  }
135
206
 
@@ -183,6 +254,40 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
183
254
  permissionsCanonical = JSON.stringify(permissionsClaim);
184
255
  }
185
256
 
257
+ // Derive the `vault_scope` pin. Collect the set of vault names `<N>` from
258
+ // every requested `vault:<N>:<verb>` scope that was authorized via a
259
+ // vault-scoped authority — rule 2 (host:admin → vault:<N>:admin) or rule 3
260
+ // (vault:<N>:admin → same-vault subset). These are the vault-scoped mints,
261
+ // so we pin the token to those vault(s): it can ONLY ever be used against
262
+ // them (defense-in-depth + least privilege), matching the canonical
263
+ // session-path mint in `admin-vault-admin-token.ts`.
264
+ //
265
+ // Pure `parachute:host:auth` requestable mints (a `vault:<N>:read/write`
266
+ // granted by rule 1 with no covering vault-admin authority) stay UNpinned
267
+ // (`[]`) — the "no per-user restriction" sentinel; the scope string +
268
+ // audience are the authorization-bearing gate there, as before. We
269
+ // distinguish by checking the bearer's own vault-scoped authority: a vault
270
+ // name is pinned only when the bearer held `vault:<N>:admin` (rule 3) or
271
+ // host:admin and the scope is admin (rule 2).
272
+ //
273
+ // Note: `audience` is single-valued and `inferAudience` is first-wins, so a
274
+ // multi-vault request gets `aud=vault.<first>` and only authenticates
275
+ // against that vault. Mint one token per vault for the multi-vault case.
276
+ // The canonical consumers (mcp-install, SPA tokens page) request a single
277
+ // vault.
278
+ const bearerHasHostAdmin = bearerScopes.includes(API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE);
279
+ const vaultScopePinSet = new Set<string>();
280
+ for (const s of scopes) {
281
+ const name = vaultScopeName(s);
282
+ if (name === null) continue;
283
+ const grantedByVaultAdminBearer = bearerScopes.includes(`vault:${name}:admin`); // rule 3
284
+ const grantedByHostAdminForAdmin = isVaultAdminScope(s) && bearerHasHostAdmin; // rule 2
285
+ if (grantedByVaultAdminBearer || grantedByHostAdminForAdmin) {
286
+ vaultScopePinSet.add(name);
287
+ }
288
+ }
289
+ const vaultScopePin = [...vaultScopePinSet];
290
+
186
291
  // 6. Mint + register.
187
292
  const minted = await signAccessToken(deps.db, {
188
293
  sub: subject,
@@ -192,11 +297,14 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
192
297
  issuer: deps.issuer,
193
298
  ttlSeconds,
194
299
  // Operator-driven CLI/API mint — the bearer already cleared the
195
- // `parachute:host:auth` privilege gate, so there's no per-user vault
196
- // pin to enforce. Empty `vault_scope` is the "no restriction"
197
- // sentinel; the `scopes` themselves remain authorization-bearing as
198
- // before.
199
- vaultScope: [],
300
+ // attenuation guard. `vault_scope` is `[]` (no restriction) for any
301
+ // verb scope granted by rule 1, or the named vault(s) for vault-scoped
302
+ // mints authorized via rule 2 / rule 3 (see above). The pin tracks the
303
+ // grant rule, not the bearer: a host:admin bearer minting
304
+ // `vault:work:write` goes through rule 1 (write is requestable), so it
305
+ // ALSO gets `vault_scope:[]` — only its `vault:work:admin` mints (rule 2)
306
+ // are pinned.
307
+ vaultScope: vaultScopePin,
200
308
  ...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
201
309
  ...(deps.now !== undefined ? { now: deps.now } : {}),
202
310
  });
@@ -35,6 +35,7 @@
35
35
  import type { Database } from "bun:sqlite";
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { dirname } from "node:path";
38
+ import { MissingDependencyError, type MissingDependencyWire } from "@openparachute/depcheck";
38
39
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
39
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
40
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
@@ -49,11 +50,7 @@ import {
49
50
  getSpec,
50
51
  synthesizeManifestForKnownModule,
51
52
  } from "./service-spec.ts";
52
- import {
53
- findService,
54
- readManifestLenient,
55
- removeService,
56
- } from "./services-manifest.ts";
53
+ import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
57
54
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
58
55
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
59
56
 
@@ -81,6 +78,15 @@ export interface Operation {
81
78
  log: string[];
82
79
  /** Error message when status is `failed`. Mirrored from the underlying throw. */
83
80
  error?: string;
81
+ /**
82
+ * Structured error detail when the failure is a known typed error — today
83
+ * only `MissingDependencyError.toWire()` (a missing external binary like
84
+ * `bun` / `git` during install). The operations-polling SPA switches on
85
+ * `error_detail.error_type === "missing_dependency"` to render a dedicated
86
+ * install card; the plain `error` string is the fallback for everything
87
+ * else. Wire shape matches `@openparachute/depcheck`'s `MissingDependencyWire`.
88
+ */
89
+ error_detail?: MissingDependencyWire;
84
90
  startedAt: string;
85
91
  finishedAt?: string;
86
92
  }
@@ -89,7 +95,11 @@ export interface OperationsRegistry {
89
95
  create(kind: OperationKind, short: string): Operation;
90
96
  get(id: string): Operation | undefined;
91
97
  /** Append a log line + (optionally) advance status. */
92
- update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void;
98
+ update(
99
+ id: string,
100
+ patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
101
+ logLine?: string,
102
+ ): void;
93
103
  }
94
104
 
95
105
  /**
@@ -122,11 +132,16 @@ class InMemoryOperationsRegistry implements OperationsRegistry {
122
132
  return this.ops.get(id);
123
133
  }
124
134
 
125
- update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void {
135
+ update(
136
+ id: string,
137
+ patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
138
+ logLine?: string,
139
+ ): void {
126
140
  const op = this.ops.get(id);
127
141
  if (!op) return;
128
142
  if (patch.status) op.status = patch.status;
129
143
  if (patch.error !== undefined) op.error = patch.error;
144
+ if (patch.error_detail !== undefined) op.error_detail = patch.error_detail;
130
145
  if (logLine) op.log.push(logLine);
131
146
  if (patch.status === "succeeded" || patch.status === "failed") {
132
147
  op.finishedAt = this.clock().toISOString();
@@ -520,13 +535,37 @@ export async function handleInstall(
520
535
  // immediately + the work runs in the background. Errors get logged
521
536
  // to the operation; nothing throws back to the request handler.
522
537
  void runInstall(op.id, short, spec, deps, bodyChannel).catch((err) => {
523
- const msg = err instanceof Error ? err.message : String(err);
524
- registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
538
+ failOperation(registry, op.id, "install", err);
525
539
  });
526
540
 
527
541
  return acceptedOp(op.id);
528
542
  }
529
543
 
544
+ /**
545
+ * Mark an async op failed, attaching the structured `error_detail` wire when
546
+ * the underlying throw is a `MissingDependencyError` (a missing external
547
+ * binary like `bun` / `git` during install). The operations-polling SPA reads
548
+ * `error_detail` to render the dedicated install card; the plain `error`
549
+ * string is the fallback for every other failure.
550
+ */
551
+ function failOperation(
552
+ registry: OperationsRegistry,
553
+ opId: string,
554
+ verb: string,
555
+ err: unknown,
556
+ ): void {
557
+ const msg = err instanceof Error ? err.message : String(err);
558
+ if (err instanceof MissingDependencyError) {
559
+ registry.update(
560
+ opId,
561
+ { status: "failed", error: msg, error_detail: err.toWire() },
562
+ `${verb} failed: ${err.binary} not installed`,
563
+ );
564
+ return;
565
+ }
566
+ registry.update(opId, { status: "failed", error: msg }, `${verb} failed: ${msg}`);
567
+ }
568
+
530
569
  /**
531
570
  * Internal install runner. Exported so non-API callers (the first-boot
532
571
  * wizard at `/admin/setup`, hub#259) can drive the same install →
@@ -722,8 +761,7 @@ export async function handleUpgrade(
722
761
  const spec = specFor(short);
723
762
 
724
763
  void runUpgrade(op.id, short, spec, deps).catch((err) => {
725
- const msg = err instanceof Error ? err.message : String(err);
726
- registry.update(op.id, { status: "failed", error: msg }, `upgrade failed: ${msg}`);
764
+ failOperation(registry, op.id, "upgrade", err);
727
765
  });
728
766
  return acceptedOp(op.id);
729
767
  }
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * Combines three sources into a single per-module row:
5
5
  *
6
- * - **Curated availability** — vault, notes, scribe, runner (the v0.6
7
- * release bar). The Phase-2 marketplace will broaden this; for now
8
- * it's hardcoded so the admin UI has a stable "what can I install?"
9
- * list even on a fresh container where services.json is empty.
6
+ * - **Curated availability** — vault, scribe (the launch focus per
7
+ * Aaron 2026-05-27). The list was previously broader; trimmed for
8
+ * the launch arc. The Phase-2 marketplace will broaden this; for
9
+ * now it's hardcoded so the admin UI has a stable "what can I
10
+ * install?" list even on a fresh container where services.json is
11
+ * empty.
10
12
  * - **Installed state** — services.json reads (version, installDir).
11
13
  * - **Supervisor state** — per-module run status (`running` / `stopped`
12
14
  * / `crashed` / `starting` / `restarting`) + pid. Absent when the
@@ -80,15 +82,30 @@ function lookupModule(
80
82
  export const API_MODULES_REQUIRED_SCOPE = "parachute:host:auth";
81
83
 
82
84
  /**
83
- * Curated module short-names for v0.6 Render self-host. Marketplace is
84
- * Phase 2 until then, the admin UI offers exactly these. Order is the
85
- * recommended install order (vault → app → notes → scribe → runner;
86
- * app auto-bootstraps notes-ui on first boot — `notes` here is the
87
- * notes-daemon back-compat install path retained for operators still on
88
- * the pre-app architecture; scribe + runner come last because they
89
- * depend on a working vault + app to be useful).
85
+ * Curated module short-names. The admin UI offers exactly these for install
86
+ * + management. Order is the recommended install order (vault first, scribe
87
+ * second).
88
+ *
89
+ * Trimmed 2026-05-27 (Aaron-directed launch focus) from the prior set of
90
+ * `["vault", "surface", "notes", "scribe", "runner"]`. The dropped modules
91
+ * are still published on npm and still work they're just not the focus:
92
+ *
93
+ * - `notes` (notes-daemon): retired. Notes-UI now lives at
94
+ * `notes.parachute.computer` as a hosted SPA — operators don't install
95
+ * a notes daemon anymore. The npm package `@openparachute/notes-ui`
96
+ * is a library imported by `parachute-surface` and by custom-surface
97
+ * builders.
98
+ * - `surface` (host module): de-emphasized. `@openparachute/surface-client`
99
+ * remains the canonical library for folks building their own UIs
100
+ * against a Parachute hub; running the surface-host module on your
101
+ * own box is no longer the headline path (use notes.parachute.computer
102
+ * or build your own).
103
+ * - `runner`: experimental, not in the focus set for launch.
104
+ *
105
+ * Re-adding any of these is one line — keep the list small until use
106
+ * cases demand otherwise.
90
107
  */
91
- export const CURATED_MODULES = ["vault", "surface", "notes", "scribe", "runner"] as const;
108
+ export const CURATED_MODULES = ["vault", "scribe"] as const;
92
109
  export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
93
110
 
94
111
  export interface ApiModulesDeps {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `GET /api/ready` — hub-side boot-readiness probe (hub#443).
3
+ *
4
+ * Public (no bearer required) — used by:
5
+ *
6
+ * 1. The transient-state HTML page rendered by the upstream-error
7
+ * flow (see `proxy-error-ui.ts`). Its inline poll script hits this
8
+ * endpoint every 2s up to 5 times so a wizard mid-boot can refresh
9
+ * itself without an HTML reload.
10
+ * 2. Any third-party tool (smoke test, dashboard) that wants to know
11
+ * whether the hub's modules are all up.
12
+ *
13
+ * Shape:
14
+ *
15
+ * {
16
+ * "ready": boolean,
17
+ * "ready_modules": string[], // shorts that are up
18
+ * "transient_modules": string[], // shorts currently booting
19
+ * "persistent_modules": string[] // shorts crashed / stopped
20
+ * }
21
+ *
22
+ * `ready: true` iff every supervised module is in the "running" state
23
+ * past its boot window AND no module is in transient/persistent
24
+ * failure. The hub itself is implicit — if you reached this endpoint,
25
+ * hub is up.
26
+ *
27
+ * Why public: the page that polls this is itself served pre-auth (a
28
+ * 503 from a proxied request before the operator has even reached
29
+ * /login). Bearer-gating would make the poll fail and the page sit
30
+ * forever on "still loading."
31
+ */
32
+
33
+ import { DEFAULT_BOOT_WINDOW_MS } from "./proxy-state.ts";
34
+ import type { Supervisor } from "./supervisor.ts";
35
+
36
+ export interface ApiReadyDeps {
37
+ /** Container-mode supervisor handle. When absent the hub is in CLI
38
+ * mode and we report ready=true (we have no visibility into other
39
+ * processes' boot state). */
40
+ supervisor?: Supervisor;
41
+ /** Test seam over Date.now. */
42
+ now?: () => number;
43
+ /** Test seam over the boot window. */
44
+ bootWindowMs?: number;
45
+ }
46
+
47
+ export function handleApiReady(req: Request, deps: ApiReadyDeps = {}): Response {
48
+ if (req.method !== "GET" && req.method !== "HEAD") {
49
+ return new Response("method not allowed", { status: 405 });
50
+ }
51
+ const now = (deps.now ?? Date.now)();
52
+ const bootWindow = deps.bootWindowMs ?? DEFAULT_BOOT_WINDOW_MS;
53
+
54
+ const ready: string[] = [];
55
+ const transient: string[] = [];
56
+ const persistent: string[] = [];
57
+
58
+ if (deps.supervisor) {
59
+ for (const m of deps.supervisor.list()) {
60
+ switch (m.status) {
61
+ case "starting":
62
+ case "restarting":
63
+ transient.push(m.short);
64
+ break;
65
+ case "crashed":
66
+ case "stopped":
67
+ persistent.push(m.short);
68
+ break;
69
+ case "running": {
70
+ // Inside the boot window we report transient even though the
71
+ // process is "running" — the listener may not have bound yet.
72
+ // After the window we report ready (process is up + presumed
73
+ // listening; if it's not, the proxy classifier still catches
74
+ // it via the same window check and surfaces persistent state).
75
+ let startedMs = 0;
76
+ if (m.startedAt) {
77
+ const parsed = Date.parse(m.startedAt);
78
+ if (Number.isFinite(parsed)) startedMs = parsed;
79
+ }
80
+ if (startedMs > 0 && now - startedMs < bootWindow) {
81
+ transient.push(m.short);
82
+ } else {
83
+ ready.push(m.short);
84
+ }
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ const isReady = transient.length === 0 && persistent.length === 0;
92
+ const body = JSON.stringify({
93
+ ready: isReady,
94
+ ready_modules: ready,
95
+ transient_modules: transient,
96
+ persistent_modules: persistent,
97
+ });
98
+ return new Response(body, {
99
+ status: 200,
100
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
101
+ });
102
+ }