@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/api-account.ts
CHANGED
|
@@ -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,
|
package/src/api-mint-token.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
36
|
-
|
|
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.
|
|
91
|
-
|
|
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
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
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
|
|
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
|
-
// `
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
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
|
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/api-modules.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Combines three sources into a single per-module row:
|
|
5
5
|
*
|
|
6
|
-
* - **Curated availability** — vault,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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", "
|
|
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 {
|
package/src/api-ready.ts
ADDED
|
@@ -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
|
+
}
|