@openparachute/hub 0.5.14-rc.8 → 0.6.0

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 (87) 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-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. 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
  }
@@ -1,38 +1,73 @@
1
1
  /**
2
2
  * `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
3
- * revoke-token <jti>` (hub#221) and the missing piece behind the future
4
- * admin UI's revoke action.
3
+ * revoke-token <jti>` (hub#221) and the backing endpoint for the admin
4
+ * UI's revoke action. Closes hub#220.
5
5
  *
6
- * Same auth shape as `POST /api/auth/mint-token`: bearer-gated on
7
- * `parachute:host:auth` (admin scope-set tokens carry it as a superset;
8
- * narrow `--scope-set auth` operator tokens carry it directly). Closes
9
- * hub#220.
6
+ * Auth capability attenuation, SYMMETRIC to mint-token (hub#452): you may
7
+ * revoke exactly what you could have minted. After validating the bearer
8
+ * (signature / issuer / expiry same as today):
9
+ *
10
+ * 1. If the bearer holds `parachute:host:auth` → it may revoke ANY jti
11
+ * (the original, broadest behavior — preserved unchanged).
12
+ * 2. Otherwise the bearer must clear the entry gate — it must hold at least
13
+ * one minting authority (`parachute:host:auth`, `parachute:host:admin`,
14
+ * or some `vault:<*>:admin`, via `hasMintingAuthority`). A bearer with
15
+ * none (e.g. a read-only token) gets 403 up front — it can revoke
16
+ * nothing, just as it can mint nothing.
17
+ * 3. The per-jti authority check then governs what such a bearer may
18
+ * actually revoke: the target jti is revocable iff EVERY one of its
19
+ * recorded scopes satisfies `canGrant(bearerScopes, scope)` — i.e. the
20
+ * bearer could have minted that exact token. A `vault:work:admin` bearer
21
+ * can revoke a `vault:work:write` or `vault:work:admin` jti, but NOT a
22
+ * `vault:other:*` jti and NOT a `parachute:host:*` jti — the same
23
+ * cross-vault / host-escalation walls mint enforces.
24
+ *
25
+ * Idempotency / no-info-leak: an UNKNOWN jti (no `tokens` row — never minted
26
+ * or already purged) returns the SAME 404 `not_found` the endpoint has always
27
+ * returned, for every caller including host:auth. The per-jti authority check
28
+ * only runs when the row is FOUND. So an attenuated bearer probing a jti it
29
+ * doesn't own cannot distinguish "exists but not yours" from "doesn't exist"
30
+ * by the unknown-jti path — it gets the identical 404 a host:auth bearer
31
+ * would. A jti that EXISTS but is out of the bearer's authority returns 403
32
+ * (and is NOT revoked): the caller already knows the jti string, so "exists
33
+ * but not yours" leaks nothing beyond what it already holds — and returning
34
+ * idempotent-ok there would be a lie (it revoked nothing).
10
35
  *
11
36
  * Body: `{ jti: string }`.
12
37
  *
13
- * Responses (matching the OAuth 2.0 error-shape vocabulary used by
14
- * mint-token and the rest of the hub's bearer-protected admin API):
38
+ * Responses (OAuth 2.0 error-shape vocabulary, matching mint-token):
15
39
  *
16
40
  * - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
17
- * already-revoked jti returns the existing `revoked_at` and 200,
18
- * same as the CLI's exit-0-with-existing-timestamp behavior.
41
+ * already-revoked jti returns the existing `revoked_at` and 200.
19
42
  * - 400 `invalid_request` — missing/malformed body, missing jti.
20
43
  * - 401 `unauthenticated` — missing or invalid bearer.
21
- * - 403 `insufficient_scope` — bearer lacks `parachute:host:auth`.
44
+ * - 403 `insufficient_scope` — bearer holds no minting authority (entry
45
+ * gate), or the target jti carries a scope the bearer couldn't have
46
+ * minted (per-jti authority check).
22
47
  * - 404 `not_found` — no `tokens` row matches the jti.
23
48
  * - 405 `method_not_allowed` — non-POST.
24
49
  *
25
50
  * Identity field in audit-friendly success: not echoed in the response
26
51
  * body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
27
52
  * is all a UI consumer needs); operator-side audit lives in hub logs.
28
- * Mirrors the CLI's design where `identity=` was added for stdout but
29
- * the wire response stays narrow.
30
53
  */
31
54
  import type { Database } from "bun:sqlite";
32
55
  import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
56
+ import { MINT_HOST_AUTH_SCOPE, canGrant, hasMintingAuthority } from "./scope-attenuation.ts";
33
57
 
34
- /** Scope required on the bearer token to call this endpoint. */
35
- export const API_REVOKE_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
58
+ /**
59
+ * Scope that authorises revoking ANY jti unconditionally (rule 1). A bearer
60
+ * without it may still revoke via attenuation (rule 3) if it clears the
61
+ * `hasMintingAuthority` entry gate.
62
+ */
63
+ export const API_REVOKE_TOKEN_REQUIRED_SCOPE = MINT_HOST_AUTH_SCOPE;
64
+
65
+ /**
66
+ * Maximum accepted length of a caller-supplied `jti`. A real jti is a UUID or
67
+ * short opaque token; anything materially longer is malformed input. Capping
68
+ * it keeps the verbatim-echoed value out of structured logs from bloating.
69
+ */
70
+ export const MAX_JTI_LENGTH = 256;
36
71
 
37
72
  export interface ApiRevokeTokenDeps {
38
73
  db: Database;
@@ -80,12 +115,19 @@ export async function handleApiRevokeToken(
80
115
  return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
81
116
  }
82
117
 
83
- // 3. Scope gate.
84
- if (!bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE)) {
118
+ // 3. Entry gate. A `parachute:host:auth` bearer may revoke anything
119
+ // (rule 1) and skips the per-jti authority check below. Any other
120
+ // bearer must hold SOME minting authority (host:admin or a
121
+ // `vault:<*>:admin`) to attempt a revoke at all — a bearer with none
122
+ // can revoke nothing under attenuation, so we 403 it here rather than
123
+ // looking up the jti. Whether such a bearer may revoke a SPECIFIC jti
124
+ // is decided per-jti in step 5 via `canGrant`.
125
+ const bearerHasHostAuth = bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE);
126
+ if (!bearerHasHostAuth && !hasMintingAuthority(bearerScopes)) {
85
127
  return jsonError(
86
128
  403,
87
129
  "insufficient_scope",
88
- `bearer token lacks ${API_REVOKE_TOKEN_REQUIRED_SCOPE}`,
130
+ `bearer token holds no revoke authority (need ${API_REVOKE_TOKEN_REQUIRED_SCOPE}, parachute:host:admin, or vault:<name>:admin)`,
89
131
  );
90
132
  }
91
133
 
@@ -103,15 +145,59 @@ export async function handleApiRevokeToken(
103
145
  if (typeof body.jti !== "string" || body.jti.length === 0) {
104
146
  return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
105
147
  }
148
+ // Cap the jti length. It's echoed verbatim into `error_description` and
149
+ // structured log lines; a real jti is a UUID/short token (well under 256
150
+ // chars), so a longer value is malformed input — reject it before it can
151
+ // bloat log lines. JSON-encoded responses already neutralize injection;
152
+ // this is a size guard, not an escaping one.
153
+ if (body.jti.length > MAX_JTI_LENGTH) {
154
+ return jsonError(400, "invalid_request", `jti exceeds ${MAX_JTI_LENGTH}-character maximum`);
155
+ }
106
156
  const jti = body.jti;
107
157
 
108
- // 5. Lookup + revoke. Order: row-existence first (404 if missing), then
109
- // attempt revoke. Idempotent: if already revoked, surface the existing
110
- // revoked_at same CLI semantics from hub#221.
158
+ // 5. Lookup + per-jti authority + revoke. Order: row-existence first
159
+ // (404 if missing same response for every caller, no leak), then the
160
+ // attenuation authority check (for non-host:auth bearers), then attempt
161
+ // revoke. Idempotent: if already revoked, surface the existing revoked_at
162
+ // — same CLI semantics from hub#221.
111
163
  const existing = findTokenRowByJti(deps.db, jti);
112
164
  if (!existing) {
113
165
  return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
114
166
  }
167
+
168
+ // Per-jti authority (rule 3 / symmetric to mint attenuation). A host:auth
169
+ // bearer skips this — it may revoke anything. Any other bearer may revoke
170
+ // this jti only if EVERY one of its recorded scopes is one the bearer could
171
+ // have minted (`canGrant`). One out-of-authority scope (cross-vault, a
172
+ // host:* scope, etc.) blocks the whole revoke with 403 — and the token is
173
+ // left intact. The caller already knows the jti, so "exists but not yours"
174
+ // leaks nothing beyond what it holds; idempotent-ok would falsely imply a
175
+ // revoke happened.
176
+ if (!bearerHasHostAuth) {
177
+ // A scopeless target (recorded `scopes: []`) would otherwise pass the
178
+ // `canGrant` filter vacuously — `[].filter(...)` is empty, so
179
+ // `ungrantable.length === 0`. That's silently permissive: any bearer
180
+ // clearing the entry gate could revoke a zero-scope token. Such tokens
181
+ // shouldn't exist (the CLI/SPA never mint them), but if one does, only a
182
+ // host:auth bearer may revoke it — a non-host:auth bearer has no
183
+ // attenuation authority that "covers" the empty scope set.
184
+ if (existing.scopes.length === 0) {
185
+ return jsonError(
186
+ 403,
187
+ "insufficient_scope",
188
+ `bearer token cannot revoke jti ${jti}: target has no recorded scopes (only ${API_REVOKE_TOKEN_REQUIRED_SCOPE} may revoke a scopeless token)`,
189
+ );
190
+ }
191
+ const ungrantable = existing.scopes.filter((s) => !canGrant(bearerScopes, s));
192
+ if (ungrantable.length > 0) {
193
+ return jsonError(
194
+ 403,
195
+ "insufficient_scope",
196
+ `bearer token cannot revoke jti ${jti}: its scope(s) ${ungrantable.join(", ")} are outside the bearer's authority`,
197
+ );
198
+ }
199
+ }
200
+
115
201
  if (existing.revokedAt) {
116
202
  return ok({ jti, revoked_at: existing.revokedAt });
117
203
  }
package/src/api-users.ts CHANGED
@@ -336,7 +336,16 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
336
336
  }
337
337
  }
338
338
 
339
- /** DELETE /api/users/:id — hard-delete + token revocation + session/grant cleanup. */
339
+ /**
340
+ * DELETE /api/users/:id — hard-delete + token revocation + session/grant
341
+ * cleanup.
342
+ *
343
+ * Success returns `200 { ok: true, revocation_lag_seconds: 60 }` (was a bare
344
+ * 204 pre-consistency-fix) so the SPA can warn that the deleted user's
345
+ * tokens linger ~60s on resource-server revocation caches — same surface
346
+ * the reset-password path carries. The race-tolerant "row already gone"
347
+ * path stays a bodyless 204 (nothing was revoked here, no lag to report).
348
+ */
340
349
  export async function handleDeleteUser(
341
350
  req: Request,
342
351
  userId: string,
@@ -390,11 +399,28 @@ export async function handleDeleteUser(
390
399
  if (!removed) {
391
400
  // Race: row deleted by a concurrent request. Operator's intent
392
401
  // (no such user) is already satisfied — same shape as the grant-
393
- // revoke race in `admin-grants.ts`.
402
+ // revoke race in `admin-grants.ts`. No tokens were revoked by THIS
403
+ // call, so there's no revocation lag to warn about; keep the bodyless
404
+ // 204 for the race path.
394
405
  return new Response(null, { status: 204 });
395
406
  }
396
407
  console.log(`user deleted: id=${userId} username=${target.username}`);
397
- return new Response(null, { status: 204 });
408
+ // `revocation_lag_seconds`: same consistency fix the reset-password path
409
+ // got (smoke 2026-05-27 finding 3). Deleting a user revokes their tokens
410
+ // in hub's DB immediately, but resource servers (vault, scribe, …) cache
411
+ // the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
412
+ // — a deleted user's tokens linger for up to ~60s on those caches. Surface
413
+ // that so the admin isn't surprised when a just-deleted user's client can
414
+ // still read for a minute (relevant in the stolen-device / compromise
415
+ // threat model). 200 + body instead of the old bare 204 so the SPA can
416
+ // render the warning banner.
417
+ return new Response(
418
+ JSON.stringify({ ok: true, revocation_lag_seconds: REVOCATION_LAG_SECONDS }),
419
+ {
420
+ status: 200,
421
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
422
+ },
423
+ );
398
424
  }
399
425
 
400
426
  /**