@openparachute/hub 0.6.3 → 0.6.4-rc.2

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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +880 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +217 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +381 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +121 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +345 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -49,6 +49,7 @@ import type { Database } from "bun:sqlite";
49
49
  import { hash as argonHash } from "@node-rs/argon2";
50
50
  import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
51
51
  import { renderAccountHome } from "./account-home-ui.ts";
52
+ import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
52
53
  import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
53
54
  import { renderAdminError } from "./admin-login-ui.ts";
54
55
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
@@ -419,6 +420,16 @@ export async function handleAccountChangePasswordPost(
419
420
  )
420
421
  .run(passwordHash, stamp, user.id);
421
422
  if (result.changes === 0) throw new UserNotFoundError(user.id);
423
+ // Revoke the user's still-active tokens on a self-service password change
424
+ // (item F / hub#469). The admin-reset path already revokes
425
+ // (`resetUserPassword`, users.ts); applying the same on self-change closes
426
+ // the "mint a token under the admin's temp password, then rotate but keep
427
+ // the token" gap — any token minted before the rotation dies with it. The
428
+ // user re-mints under their own (now-rotated) password if they need one.
429
+ // Same transaction as the hash write so the two are atomic.
430
+ deps.db
431
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
432
+ .run(stamp, user.id);
422
433
  })();
423
434
  } catch (err) {
424
435
  // The user row vanished between the session-resolve check above and
@@ -476,9 +487,24 @@ export async function handleAccountChangePasswordPost(
476
487
  export interface AccountHomeDeps extends ApiAccountDeps {
477
488
  /** Canonical hub origin for this request (e.g. `https://my-hub.example`). */
478
489
  hubOrigin: string;
490
+ /**
491
+ * Resolve a vault's loopback port from services.json, or `null` when no vault
492
+ * is mounted under that name. Threaded in by the route handler (which reads
493
+ * the manifest) so this module stays free of services.json plumbing. Absent
494
+ * in tests / older callers → usage surfacing is skipped (tiles render without
495
+ * the stat, same as a failed fetch).
496
+ */
497
+ resolveVaultPort?: (vaultName: string) => number | null;
498
+ /**
499
+ * Fetch one vault's usage stat, or `null` on any failure. Defaults to the
500
+ * real `fetchVaultUsage` (mints a read token + hits the vault's loopback
501
+ * usage endpoint). Injectable so tests assert the render without a live
502
+ * vault.
503
+ */
504
+ fetchUsage?: typeof fetchVaultUsage;
479
505
  }
480
506
 
481
- export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Response {
507
+ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Promise<Response> {
482
508
  const session = findActiveSession(deps.db, req);
483
509
  if (!session) {
484
510
  return redirect(`/login?next=${encodeURIComponent("/account/")}`);
@@ -501,6 +527,32 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
501
527
  const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
502
528
  if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
503
529
  }
530
+
531
+ // Per-vault usage stat ("X notes · Y MB"). Fetched concurrently across the
532
+ // user's assigned vaults via the read-scoped vault endpoint; each fetch is
533
+ // independently fault-tolerant (returns null → that tile renders without a
534
+ // stat). Skipped entirely when the route didn't wire a port resolver (tests /
535
+ // older callers). The READ scope means the user's own authority suffices.
536
+ const usageStats: Record<string, string> = {};
537
+ if (deps.resolveVaultPort && user.assignedVaults.length > 0) {
538
+ const fetchUsage = deps.fetchUsage ?? fetchVaultUsage;
539
+ const resolvePort = deps.resolveVaultPort;
540
+ await Promise.all(
541
+ user.assignedVaults.map(async (vaultName) => {
542
+ const port = resolvePort(vaultName);
543
+ if (port === null) return;
544
+ const stat = await fetchUsage(vaultName, {
545
+ db: deps.db,
546
+ hubOrigin: deps.hubOrigin,
547
+ vaultPort: port,
548
+ userId: user.id,
549
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
550
+ });
551
+ if (stat) usageStats[vaultName] = formatUsageStat(stat);
552
+ }),
553
+ );
554
+ }
555
+
504
556
  return htmlResponse(
505
557
  renderAccountHome({
506
558
  username: user.username,
@@ -511,6 +563,7 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
511
563
  csrfToken: csrf.token,
512
564
  twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
513
565
  mintableVerbs,
566
+ usageStats,
514
567
  }),
515
568
  200,
516
569
  extra,
@@ -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");