@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -0,0 +1,345 @@
1
+ /**
2
+ * `/api/invites*` — admin endpoints for one-time invite links (design
3
+ * 2026-06-04-individual-users-and-vault-operations.md §7).
4
+ *
5
+ * POST /api/invites create an invite → { invite, url } (host:admin)
6
+ * The `url` carries the raw token and is the ONLY
7
+ * time it's retrievable — the hub stores only its
8
+ * sha256.
9
+ * GET /api/invites list invites with derived status (host:admin)
10
+ * DELETE /api/invites/:id revoke a pending invite by sha256 hash (host:admin)
11
+ *
12
+ * Auth: every endpoint requires a bearer carrying `parachute:host:admin` —
13
+ * the same gate as `/api/users` and `/vaults`. The SPA mints one via
14
+ * `/admin/host-admin-token` from the session cookie.
15
+ *
16
+ * Wire shape is snake_case (matches `/api/users`). An invite's raw token
17
+ * NEVER appears in a GET/list response — only in the POST-create `url`.
18
+ */
19
+ import type { Database } from "bun:sqlite";
20
+ import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
21
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
22
+ import {
23
+ DEFAULT_INVITE_TTL_SECONDS,
24
+ type Invite,
25
+ type InviteStatus,
26
+ findInviteByHash,
27
+ inviteStatus,
28
+ issueInvite,
29
+ listInvites,
30
+ revokeInvite,
31
+ } from "./invites.ts";
32
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
33
+
34
+ export interface ApiInvitesDeps {
35
+ db: Database;
36
+ /** Hub origin — JWT `iss` validation AND the base for the redemption URL. */
37
+ issuer: string;
38
+ manifestPath?: string;
39
+ now?: () => Date;
40
+ }
41
+
42
+ /** Roles an invite may grant. `'write'` = owner (full vault admin); `'read'` = read-only (shared). */
43
+ const ALLOWED_ROLES = new Set(["read", "write"]);
44
+ /** Cap an invite TTL so a typo can't mint a ~forever link. 90 days. */
45
+ const MAX_INVITE_TTL_SECONDS = 90 * 24 * 60 * 60;
46
+
47
+ interface InviteWireShape {
48
+ /** sha256 hash — the stable id for list/revoke. NOT the raw token. */
49
+ id: string;
50
+ status: InviteStatus;
51
+ vault_name: string | null;
52
+ role: string;
53
+ provision_vault: boolean;
54
+ default_mirror: string | null;
55
+ expires_at: string;
56
+ used_at: string | null;
57
+ redeemed_user_id: string | null;
58
+ revoked_at: string | null;
59
+ created_at: string;
60
+ }
61
+
62
+ function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
63
+ return {
64
+ id: invite.tokenHash,
65
+ status,
66
+ vault_name: invite.vaultName,
67
+ role: invite.role,
68
+ provision_vault: invite.provisionVault,
69
+ default_mirror: invite.defaultMirror,
70
+ expires_at: invite.expiresAt,
71
+ used_at: invite.usedAt,
72
+ redeemed_user_id: invite.redeemedUserId,
73
+ revoked_at: invite.revokedAt,
74
+ created_at: invite.createdAt,
75
+ };
76
+ }
77
+
78
+ function jsonError(status: number, error: string, description: string): Response {
79
+ return new Response(JSON.stringify({ error, error_description: description }), {
80
+ status,
81
+ headers: { "content-type": "application/json" },
82
+ });
83
+ }
84
+
85
+ function redeemUrl(issuer: string, rawToken: string): string {
86
+ const base = issuer.replace(/\/$/, "");
87
+ return `${base}/account/setup/${rawToken}`;
88
+ }
89
+
90
+ interface CreateInviteBody {
91
+ vaultName: string | null;
92
+ role: string;
93
+ provisionVault: boolean;
94
+ defaultMirror: string | null;
95
+ expiresInSeconds: number;
96
+ }
97
+
98
+ interface ParseErr {
99
+ ok: false;
100
+ status: number;
101
+ error: string;
102
+ description: string;
103
+ }
104
+
105
+ async function parseCreateBody(
106
+ req: Request,
107
+ ): Promise<{ ok: true; body: CreateInviteBody } | ParseErr> {
108
+ const ctype = req.headers.get("content-type") ?? "";
109
+ // Allow an empty body — every field has a default. Only enforce JSON when
110
+ // a body is actually present.
111
+ let raw: unknown = {};
112
+ if (ctype.toLowerCase().includes("application/json")) {
113
+ try {
114
+ const text = await req.text();
115
+ raw = text.trim() === "" ? {} : JSON.parse(text);
116
+ } catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ return {
119
+ ok: false,
120
+ status: 400,
121
+ error: "invalid_request",
122
+ description: `invalid JSON body: ${msg}`,
123
+ };
124
+ }
125
+ }
126
+ if (!raw || typeof raw !== "object") {
127
+ return {
128
+ ok: false,
129
+ status: 400,
130
+ error: "invalid_request",
131
+ description: "request body must be a JSON object",
132
+ };
133
+ }
134
+ const obj = raw as Record<string, unknown>;
135
+
136
+ // vault_name — optional. null/omitted = redeemer names their own vault.
137
+ let vaultName: string | null = null;
138
+ const rawVault =
139
+ Object.hasOwn(obj, "vault_name") && obj.vault_name !== undefined
140
+ ? obj.vault_name
141
+ : Object.hasOwn(obj, "vaultName") && obj.vaultName !== undefined
142
+ ? obj.vaultName
143
+ : undefined;
144
+ if (rawVault !== undefined && rawVault !== null) {
145
+ if (typeof rawVault !== "string" || rawVault.length === 0) {
146
+ return {
147
+ ok: false,
148
+ status: 400,
149
+ error: "invalid_request",
150
+ description: '"vault_name" must be a non-empty string or null',
151
+ };
152
+ }
153
+ if (!VAULT_NAME_CHARSET_RE.test(rawVault)) {
154
+ return {
155
+ ok: false,
156
+ status: 400,
157
+ error: "invalid_request",
158
+ description:
159
+ "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
160
+ };
161
+ }
162
+ vaultName = rawVault;
163
+ }
164
+
165
+ // role — default 'write' (owner).
166
+ let role = "write";
167
+ const rawRole = obj.role;
168
+ if (rawRole !== undefined && rawRole !== null) {
169
+ if (typeof rawRole !== "string" || !ALLOWED_ROLES.has(rawRole)) {
170
+ return {
171
+ ok: false,
172
+ status: 400,
173
+ error: "invalid_request",
174
+ description: '"role" must be "read" or "write"',
175
+ };
176
+ }
177
+ role = rawRole;
178
+ }
179
+
180
+ // provision_vault — default true (the primary flow).
181
+ let provisionVault = true;
182
+ const rawProvision =
183
+ Object.hasOwn(obj, "provision_vault") && obj.provision_vault !== undefined
184
+ ? obj.provision_vault
185
+ : Object.hasOwn(obj, "provisionVault") && obj.provisionVault !== undefined
186
+ ? obj.provisionVault
187
+ : undefined;
188
+ if (rawProvision !== undefined) {
189
+ if (typeof rawProvision !== "boolean") {
190
+ return {
191
+ ok: false,
192
+ status: 400,
193
+ error: "invalid_request",
194
+ description: '"provision_vault" must be a boolean',
195
+ };
196
+ }
197
+ provisionVault = rawProvision;
198
+ }
199
+
200
+ // default_mirror — optional 'internal' | 'off'.
201
+ let defaultMirror: string | null = null;
202
+ const rawMirror =
203
+ Object.hasOwn(obj, "default_mirror") && obj.default_mirror !== undefined
204
+ ? obj.default_mirror
205
+ : Object.hasOwn(obj, "defaultMirror") && obj.defaultMirror !== undefined
206
+ ? obj.defaultMirror
207
+ : undefined;
208
+ if (rawMirror !== undefined && rawMirror !== null) {
209
+ if (rawMirror !== "internal" && rawMirror !== "off") {
210
+ return {
211
+ ok: false,
212
+ status: 400,
213
+ error: "invalid_request",
214
+ description: '"default_mirror" must be "internal" or "off"',
215
+ };
216
+ }
217
+ defaultMirror = rawMirror;
218
+ }
219
+
220
+ // expires_in — seconds; default 7 days; capped at 90 days.
221
+ let expiresInSeconds = DEFAULT_INVITE_TTL_SECONDS;
222
+ const rawExpiry =
223
+ Object.hasOwn(obj, "expires_in") && obj.expires_in !== undefined
224
+ ? obj.expires_in
225
+ : Object.hasOwn(obj, "expiresIn") && obj.expiresIn !== undefined
226
+ ? obj.expiresIn
227
+ : undefined;
228
+ if (rawExpiry !== undefined && rawExpiry !== null) {
229
+ if (typeof rawExpiry !== "number" || !Number.isFinite(rawExpiry) || rawExpiry <= 0) {
230
+ return {
231
+ ok: false,
232
+ status: 400,
233
+ error: "invalid_request",
234
+ description: '"expires_in" must be a positive number of seconds',
235
+ };
236
+ }
237
+ if (rawExpiry > MAX_INVITE_TTL_SECONDS) {
238
+ return {
239
+ ok: false,
240
+ status: 400,
241
+ error: "invalid_request",
242
+ description: `"expires_in" must be ≤ ${MAX_INVITE_TTL_SECONDS} seconds (90 days)`,
243
+ };
244
+ }
245
+ expiresInSeconds = Math.floor(rawExpiry);
246
+ }
247
+
248
+ return { ok: true, body: { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } };
249
+ }
250
+
251
+ /** POST /api/invites — create an invite, return the single-emit URL + token. */
252
+ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Promise<Response> {
253
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
254
+ let authUserId: string;
255
+ try {
256
+ // `requireScope` returns the validated claims; the admin's `sub` is the
257
+ // `created_by` audit anchor (guaranteed present — it throws otherwise).
258
+ const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
259
+ authUserId = auth.sub;
260
+ } catch (err) {
261
+ return adminAuthErrorResponse(err as AdminAuthError);
262
+ }
263
+ const parsed = await parseCreateBody(req);
264
+ if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
265
+ const { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } = parsed.body;
266
+
267
+ // SECURITY: a pinned vault_name with provision_vault=false would assign the
268
+ // redeeming user to a PRE-EXISTING vault as owner-admin — a cross-tenant
269
+ // breach, since the owner-vs-shared role split isn't built. Shared-vault
270
+ // invites aren't supported yet, so reject this combination outright (defense
271
+ // in depth — the redeem path rejects it too). The supported shapes are:
272
+ // provision_vault=true (+ optional pinned name → provisions THAT name), or
273
+ // provision_vault=false with NO name (account-only, assignedVaults=[]).
274
+ if (vaultName !== null && !provisionVault) {
275
+ return jsonError(
276
+ 400,
277
+ "invalid_request",
278
+ "shared-vault invites (provision_vault=false with a vault_name) aren't supported yet — omit vault_name for an account-only invite, or set provision_vault=true to provision a new vault",
279
+ );
280
+ }
281
+
282
+ const issued = issueInvite(deps.db, {
283
+ createdBy: authUserId,
284
+ vaultName,
285
+ role,
286
+ provisionVault,
287
+ defaultMirror,
288
+ expiresInSeconds,
289
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
290
+ });
291
+ const status = inviteStatus(issued.invite, (deps.now ?? (() => new Date()))());
292
+ return new Response(
293
+ JSON.stringify({
294
+ invite: toWire(issued.invite, status),
295
+ // Single-emit: the raw token only ever appears here.
296
+ token: issued.rawToken,
297
+ url: redeemUrl(deps.issuer, issued.rawToken),
298
+ }),
299
+ { status: 201, headers: { "content-type": "application/json", "cache-control": "no-store" } },
300
+ );
301
+ }
302
+
303
+ /** GET /api/invites — list invites, newest first, status-annotated. */
304
+ export async function handleListInvites(req: Request, deps: ApiInvitesDeps): Promise<Response> {
305
+ if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
306
+ try {
307
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
308
+ } catch (err) {
309
+ return adminAuthErrorResponse(err as AdminAuthError);
310
+ }
311
+ const now = (deps.now ?? (() => new Date()))();
312
+ const invites = listInvites(deps.db, now).map((i) => toWire(i, i.status));
313
+ return new Response(JSON.stringify({ invites }), {
314
+ status: 200,
315
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
316
+ });
317
+ }
318
+
319
+ /** DELETE /api/invites/:id — revoke a pending invite by its sha256 hash. */
320
+ export async function handleRevokeInvite(
321
+ req: Request,
322
+ id: string,
323
+ deps: ApiInvitesDeps,
324
+ ): Promise<Response> {
325
+ if (req.method !== "DELETE") return jsonError(405, "method_not_allowed", "use DELETE");
326
+ try {
327
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
328
+ } catch (err) {
329
+ return adminAuthErrorResponse(err as AdminAuthError);
330
+ }
331
+ const existing = findInviteByHash(deps.db, id);
332
+ if (!existing) return jsonError(404, "not_found", `no invite with id "${id}"`);
333
+ const now = (deps.now ?? (() => new Date()))();
334
+ const revoked = revokeInvite(deps.db, id, now);
335
+ if (!revoked) {
336
+ // Already redeemed or revoked — nothing to do, but report the terminal
337
+ // state so the SPA can refresh rather than silently swallowing.
338
+ const status = inviteStatus(existing, now);
339
+ return jsonError(409, "invite_not_pending", `invite is already ${status}`);
340
+ }
341
+ return new Response(JSON.stringify({ ok: true }), {
342
+ status: 200,
343
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
344
+ });
345
+ }
@@ -52,6 +52,7 @@ import {
52
52
  MINT_HOST_AUTH_SCOPE,
53
53
  canGrant,
54
54
  hasMintingAuthority,
55
+ isOperatorBearer,
55
56
  } from "./scope-attenuation.ts";
56
57
  import {
57
58
  isVaultAdminScope,
@@ -86,6 +87,21 @@ export interface ApiMintTokenDeps {
86
87
  db: Database;
87
88
  /** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
88
89
  issuer: string;
90
+ /**
91
+ * Names of vault instances currently registered in services.json (item D /
92
+ * hub#450). When provided, a `vault:<name>:admin` mint whose `<name>` is not
93
+ * in this set is rejected with 400 — a typo'd name can no longer mint
94
+ * `vault:typo:admin` (an unusable token that authenticates against no real
95
+ * vault, only confusing automation that's debugging a typo). Mirrors the
96
+ * session-cookie path (`/admin/vault-admin-token/<name>`), which already
97
+ * 404s unknown vault names via `knownVaultNames.has`.
98
+ *
99
+ * Optional: when undefined the existence check is skipped (the documented
100
+ * "caller is responsible for a real vault name" fallback, and the shape used
101
+ * by unit tests that don't wire a manifest). Production wires it from
102
+ * services.json in hub-server.ts.
103
+ */
104
+ knownVaultNames?: ReadonlySet<string>;
89
105
  /** Test seam for time. */
90
106
  now?: () => Date;
91
107
  }
@@ -188,6 +204,56 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
188
204
  );
189
205
  }
190
206
 
207
+ // Item B / hub#451 — bare (unnamed) `vault:admin` is non-requestable on the
208
+ // HEADLESS mint path. The unnamed `vault:admin` form is a broad full-vault
209
+ // admin grant with no resource pin (`aud=vault`, `vault_scope=[]`); minting
210
+ // it via a host:auth bearer here would issue a surprising un-narrowed admin
211
+ // credential. Vault rejects broad `vault:admin` on hub-JWTs anyway (it forces
212
+ // resource-narrowing — `parachute-vault/src/auth.ts:428`), so the practical
213
+ // blast radius is low, but a headless caller should never be handed it.
214
+ //
215
+ // This is mint-side, NOT in the OAuth-shared `NON_REQUESTABLE_SCOPES`, on
216
+ // purpose: the public `/oauth/authorize` flow legitimately accepts an unnamed
217
+ // `vault:admin` and NARROWS it to `vault:<picked>:admin` via the vault picker
218
+ // (oauth-handlers.ts `narrowVaultScopes`) before any token is minted. Adding
219
+ // it to `NON_REQUESTABLE_SCOPES` would reject that narrowing flow (the
220
+ // requestability gate fires on the raw, pre-narrow scopes). Named
221
+ // `vault:<name>:admin` — the post-narrow form — stays mintable. `vault:read`
222
+ // / `vault:write` unnamed are unaffected (they carry no admin authority).
223
+ const bareVaultAdmin = scopes.filter((s) => s === "vault:admin");
224
+ if (bareVaultAdmin.length > 0) {
225
+ return jsonError(
226
+ 400,
227
+ "invalid_scope",
228
+ "bare vault:admin is not mintable headlessly; request a resource-narrowed vault:<name>:admin instead",
229
+ );
230
+ }
231
+
232
+ // Item D / hub#450 — vault-existence check for `vault:<name>:admin` mints.
233
+ // The session-cookie path (`/admin/vault-admin-token/<name>`) already 404s an
234
+ // unknown vault name; this mirrors it for the bearer path so a typo can't mint
235
+ // `vault:typo:admin` — an unusable token (it authenticates against no real
236
+ // vault) that only confuses automation debugging a typo. Gated on `<name>:admin`
237
+ // (the one form #450 calls out); read/write are left alone (even more harmless,
238
+ // and the broad-requestable path). Skipped entirely when `knownVaultNames` is
239
+ // absent (the documented "caller responsible" fallback + unit-test shape).
240
+ if (deps.knownVaultNames !== undefined) {
241
+ const unknownAdminVaults = scopes.filter((s) => {
242
+ if (!isVaultAdminScope(s)) return false;
243
+ const name = vaultScopeName(s);
244
+ return name !== null && !deps.knownVaultNames!.has(name);
245
+ });
246
+ if (unknownAdminVaults.length > 0) {
247
+ return jsonError(
248
+ 400,
249
+ "invalid_scope",
250
+ `no vault named ${unknownAdminVaults
251
+ .map((s) => `"${vaultScopeName(s)}"`)
252
+ .join(", ")} in this hub; create the vault before minting an admin token for it`,
253
+ );
254
+ }
255
+ }
256
+
191
257
  // Capability-attenuation guard: every requested scope must be a subset of
192
258
  // the bearer's own authority under `canGrant` (rules in the file docstring).
193
259
  // A `parachute:host:auth` bearer mints any requestable scope; a
@@ -235,6 +301,21 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
235
301
  if (body.subject === undefined) {
236
302
  subject = bearerSub;
237
303
  } else if (typeof body.subject === "string" && body.subject.length > 0) {
304
+ // Subject override is an OPERATOR-only capability (audit-attribution
305
+ // forgery otherwise). A host operator (`parachute:host:auth` /
306
+ // `parachute:host:admin`) may stamp a service-account `sub` other than its
307
+ // own — the documented service-account override. A merely vault-scoped
308
+ // bearer (`vault:<N>:admin` only, no host authority) has no business
309
+ // forging the minted token's subject: it would let a vault admin mint a
310
+ // token the registry + revocation list attribute to a foreign subject. So
311
+ // a non-operator bearer may only mint tokens carrying its OWN `sub`.
312
+ if (!isOperatorBearer(bearerScopes) && body.subject !== bearerSub) {
313
+ return jsonError(
314
+ 403,
315
+ "insufficient_scope",
316
+ "non-operator bearers may not override subject; omit `subject` to mint under your own identity",
317
+ );
318
+ }
238
319
  subject = body.subject;
239
320
  } else {
240
321
  return jsonError(400, "invalid_request", "subject must be a non-empty string when present");