@openparachute/hub 0.7.6 → 0.7.7-rc.4

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.
@@ -0,0 +1,632 @@
1
+ /**
2
+ * `/account/*` — the Bearer-gated account-door REST facade (Phase 2, H2).
3
+ *
4
+ * This is the self-host door's slice of the normalized `/account/*` contract
5
+ * both doors mount (the hosted cloud door mounts the twin). It is deliberately
6
+ * a THIN FACADE over machinery the hub already ships — it does NOT reimplement
7
+ * vault provisioning, deletion, per-vault token minting, or caps. Each handler
8
+ * wraps the existing core:
9
+ *
10
+ * POST /account/vaults → `provisionVault` (admin-vaults.ts)
11
+ * GET /account/vaults → services.json vault enumeration + caps
12
+ * DELETE /account/vaults/<name> → `handleDeleteVault` (wired in hub-server)
13
+ * POST /account/vaults/<name>/token → `signAccessToken` (the same mint the
14
+ * friend-facing /account/vault-token
15
+ * surface uses, bearer-gated instead of
16
+ * cookie-gated)
17
+ * GET /account/vaults/<name>/caps → `getVaultCap` (vault-caps.ts)
18
+ * PUT /account/vaults/<name>/caps → `setVaultCap` (vault-caps.ts)
19
+ * GET /account → account bootstrap (id/email/door)
20
+ * GET /.well-known/parachute-account → the public capabilities descriptor
21
+ *
22
+ * Auth posture: `Authorization: Bearer` + scope, adopting the hub's admin shape
23
+ * (NOT the console session-cookie + CSRF + HTML-form shape). Mutations accept
24
+ * `account:self:admin` OR `parachute:host:admin`; reads additionally accept
25
+ * `account:self:read`. Per PLAN-DECISION SCOPE-b the hub's account token is a
26
+ * SUPERSET that carries both the `account:self:*` string AND the host scopes,
27
+ * so the wrapped cores (which still gate on `parachute:host:admin`) accept it
28
+ * unchanged and this facade works whether or not the H1 scope-registry PR has
29
+ * landed — a plain host-admin token is always sufficient.
30
+ *
31
+ * On self-host the account IS the box (operator ≡ account ≡ box): the account
32
+ * id is the sentinel `self`, and the operator owns every vault, so the
33
+ * ownership gate the cloud twin runs per-vault is trivially satisfied here.
34
+ */
35
+ import type { Database } from "bun:sqlite";
36
+ import { ACCOUNT_VAULT_TOKEN_TTL_SECONDS } from "./account-home-ui.ts";
37
+ import {
38
+ type AdminAuthContext,
39
+ AdminAuthError,
40
+ adminAuthErrorResponse,
41
+ extractBearerToken,
42
+ } from "./admin-auth.ts";
43
+ import { HOST_ADMIN_SCOPE, provisionVault } from "./admin-vaults.ts";
44
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
45
+ import { inferAudience } from "./jwt-audience.ts";
46
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
47
+ import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "./scope-explanations.ts";
48
+ import { readManifestLenient } from "./services-manifest.ts";
49
+ import { getUserById } from "./users.ts";
50
+ import { getVaultCap, setVaultCap } from "./vault-caps.ts";
51
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
52
+ import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
53
+
54
+ // The `account:self:{admin,read}` scope strings are defined once in
55
+ // scope-explanations.ts (H1, #746) — the same registry that makes them
56
+ // non-OAuth-requestable — and imported here so the two never drift.
57
+ /** client_id stamped on per-vault tokens this surface mints + their registry rows. */
58
+ const ACCOUNT_API_CLIENT_ID = "parachute-account";
59
+
60
+ /**
61
+ * Scopes that satisfy a `/account/*` MUTATION. The account superset token
62
+ * carries `account:self:admin`; a plain operator/host-admin token carries
63
+ * `parachute:host:admin`. Either is accepted so H2 works independent of H1's
64
+ * merge order (SCOPE-b).
65
+ */
66
+ const ADMIN_SCOPES: readonly string[] = [ACCOUNT_SELF_ADMIN_SCOPE, HOST_ADMIN_SCOPE];
67
+ /** Scopes that satisfy a `/account/*` READ. `admin ⊇ read`, spelled explicitly
68
+ * because the hub's `requireScope` does an exact-string membership check (no
69
+ * inheritance expansion at validate time). */
70
+ const READ_SCOPES: readonly string[] = [
71
+ ACCOUNT_SELF_READ_SCOPE,
72
+ ACCOUNT_SELF_ADMIN_SCOPE,
73
+ HOST_ADMIN_SCOPE,
74
+ ];
75
+
76
+ export interface AccountApiDeps {
77
+ db: Database;
78
+ /** Hub origin — JWT `iss` validation, response URLs, and minted-token `iss`. */
79
+ issuer: string;
80
+ /**
81
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
82
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's `iss`
83
+ * is validated against THIS set rather than the single `issuer` so a
84
+ * credential minted under a still-valid prior origin keeps working across an
85
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]`.
86
+ */
87
+ knownIssuers?: readonly string[];
88
+ /** Override services.json path. Defaults to `~/.parachute/services.json`. */
89
+ manifestPath?: string;
90
+ /** Test seam for the clock (mint + registry row). */
91
+ now?: () => Date;
92
+ /** Test seam threaded into `provisionVault` so create can be exercised
93
+ * without spawning the real `parachute-vault create` binary. */
94
+ runCommand?: (cmd: readonly string[]) => Promise<{
95
+ exitCode: number;
96
+ stdout: string;
97
+ stderr: string;
98
+ }>;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Shared helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function json(status: number, body: unknown): Response {
106
+ return new Response(JSON.stringify(body), {
107
+ status,
108
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
109
+ });
110
+ }
111
+
112
+ function methodNotAllowed(allow: string): Response {
113
+ return new Response(JSON.stringify({ error: "method_not_allowed", message: `use ${allow}` }), {
114
+ status: 405,
115
+ headers: { "content-type": "application/json", allow },
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Validate a presented bearer token and assert it carries ANY of `acceptable`.
121
+ * Mirrors `requireScope` (admin-auth.ts) exactly — same signature-first
122
+ * validation, same `iss`-∈-set relaxation, same claim surfacing — but matches
123
+ * a SET of scopes rather than a single required one, so `/account/*` can accept
124
+ * `account:self:*` OR `parachute:host:admin`. Throws `AdminAuthError` (401/403);
125
+ * callers translate via `adminAuthErrorResponse`.
126
+ */
127
+ export async function requireAnyScope(
128
+ db: Database,
129
+ req: Request,
130
+ acceptable: readonly string[],
131
+ expectedIssuer: string | readonly string[],
132
+ ): Promise<AdminAuthContext> {
133
+ const token = extractBearerToken(req);
134
+ let validated: Awaited<ReturnType<typeof validateAccessToken>>;
135
+ try {
136
+ validated = await validateAccessToken(db, token, expectedIssuer);
137
+ } catch (err) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ throw new AdminAuthError(401, `invalid token: ${msg}`);
140
+ }
141
+ const sub = typeof validated.payload.sub === "string" ? validated.payload.sub : null;
142
+ if (!sub) throw new AdminAuthError(401, "token missing required `sub` claim");
143
+ const scopeClaim = (validated.payload as { scope?: unknown }).scope;
144
+ const scopes =
145
+ typeof scopeClaim === "string" ? scopeClaim.split(/\s+/).filter((s) => s.length > 0) : [];
146
+ if (!acceptable.some((s) => scopes.includes(s))) {
147
+ throw new AdminAuthError(403, `token missing one of required scopes: ${acceptable.join(", ")}`);
148
+ }
149
+ const clientIdRaw = (validated.payload as { client_id?: unknown }).client_id;
150
+ const clientId = typeof clientIdRaw === "string" ? clientIdRaw : undefined;
151
+ const aud = typeof validated.payload.aud === "string" ? validated.payload.aud : undefined;
152
+ return { sub, scopes, clientId, audience: aud };
153
+ }
154
+
155
+ /** Scope set for a `/account/*` mutation (create / delete / mint / set-caps). */
156
+ export const ACCOUNT_MUTATION_SCOPES = ADMIN_SCOPES;
157
+ /** Scope set for a `/account/*` read (list / get-caps / bootstrap). */
158
+ export const ACCOUNT_READ_SCOPES = READ_SCOPES;
159
+
160
+ interface VaultMeta {
161
+ name: string;
162
+ url: string;
163
+ version: string;
164
+ }
165
+
166
+ /**
167
+ * Enumerate every servable vault from services.json with its canonical URL +
168
+ * version. Mirrors `findExistingVault`'s enumeration in admin-vaults.ts (same
169
+ * `isVaultEntry` filter, same empty-paths skip #478, same `vaultInstanceNameFor`
170
+ * name derivation, same `new URL(path, base)` URL build as `buildEntry`) so the
171
+ * account list agrees with the well-known vaults[] fan-out and the create path.
172
+ */
173
+ function listVaultsWithMeta(manifestPath: string, issuer: string): VaultMeta[] {
174
+ const base = issuer.replace(/\/$/, "");
175
+ const out: VaultMeta[] = [];
176
+ let manifest: ReturnType<typeof readManifestLenient>;
177
+ try {
178
+ manifest = readManifestLenient(manifestPath);
179
+ } catch {
180
+ return out;
181
+ }
182
+ for (const svc of manifest.services) {
183
+ if (!isVaultEntry(svc)) continue;
184
+ if (svc.paths.length === 0) continue; // #478: installed-but-no-instance
185
+ for (const path of svc.paths) {
186
+ const name = vaultInstanceNameFor(svc.name, path);
187
+ const url = new URL(path, `${base}/`).toString();
188
+ out.push({ name, url, version: svc.version });
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ function servicesBlock(meta: VaultMeta): Record<string, { url: string; version: string }> {
195
+ return { [`vault:${meta.name}`]: { url: meta.url, version: meta.version } };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // GET /.well-known/parachute-account — public capabilities descriptor
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * The self-host door descriptor. Public, no auth — it is what lets the app
204
+ * render honestly per door (D2): `billing:false` + `plans:[]` means the app
205
+ * shows no billing/upgrade UI on self-host; `caps_writable:true` means the
206
+ * operator can PUT caps freely (the cloud twin is plan-derived → false).
207
+ *
208
+ * `import`/`export` describe the self-host DOOR's portability capability
209
+ * (which it has). The `/account/vaults/<name>/export` + `/account/vaults/import`
210
+ * REST endpoints are a follow-on to this PR (H2 ships the vault-lifecycle +
211
+ * caps core); the flags stay true because the door supports portability.
212
+ */
213
+ export function handleAccountCapabilities(req: Request, deps: { issuer: string }): Response {
214
+ if (req.method !== "GET") return methodNotAllowed("GET");
215
+ const descriptor = {
216
+ door: "self-host",
217
+ issuer: deps.issuer.replace(/\/$/, ""),
218
+ account_token: { endpoint: "/account/token", method: "POST", scheme: "cookie" },
219
+ features: {
220
+ vault_create: true,
221
+ vault_delete: true,
222
+ import: true,
223
+ export: true,
224
+ billing: false,
225
+ plans: [] as string[],
226
+ modules: true,
227
+ expose: true,
228
+ },
229
+ caps_writable: true,
230
+ limits: { vaults_max: null as number | null },
231
+ };
232
+ return json(200, descriptor);
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // GET /account — account bootstrap
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /** `{ account_id, email, door }`. On self-host the account id is `self`. */
240
+ export async function handleAccountRoot(req: Request, deps: AccountApiDeps): Promise<Response> {
241
+ if (req.method !== "GET") return methodNotAllowed("GET");
242
+ let ctx: AdminAuthContext;
243
+ try {
244
+ ctx = await requireAnyScope(deps.db, req, READ_SCOPES, deps.knownIssuers ?? [deps.issuer]);
245
+ } catch (err) {
246
+ return adminAuthErrorResponse(err);
247
+ }
248
+ const user = getUserById(deps.db, ctx.sub);
249
+ return json(200, {
250
+ account_id: "self",
251
+ email: user?.email ?? null,
252
+ door: "self-host",
253
+ });
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // GET /account/vaults — list
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export async function handleAccountListVaults(
261
+ req: Request,
262
+ deps: AccountApiDeps,
263
+ ): Promise<Response> {
264
+ if (req.method !== "GET") return methodNotAllowed("GET");
265
+ try {
266
+ await requireAnyScope(deps.db, req, READ_SCOPES, deps.knownIssuers ?? [deps.issuer]);
267
+ } catch (err) {
268
+ return adminAuthErrorResponse(err);
269
+ }
270
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
271
+ const vaults = listVaultsWithMeta(manifestPath, deps.issuer).map((v) => {
272
+ const cap = getVaultCap(deps.db, v.name);
273
+ return {
274
+ name: v.name,
275
+ url: v.url,
276
+ version: v.version,
277
+ caps: { cap_bytes: cap?.capBytes ?? null },
278
+ };
279
+ });
280
+ return json(200, { vaults });
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // POST /account/vaults — create (returns a ready-to-use vault token)
285
+ // ---------------------------------------------------------------------------
286
+
287
+ interface NameBody {
288
+ ok: true;
289
+ name: string;
290
+ }
291
+ interface BodyErr {
292
+ ok: false;
293
+ status: number;
294
+ error: string;
295
+ message: string;
296
+ }
297
+
298
+ async function parseNameBody(req: Request): Promise<NameBody | BodyErr> {
299
+ const ctype = req.headers.get("content-type") ?? "";
300
+ if (!ctype.toLowerCase().includes("application/json")) {
301
+ return {
302
+ ok: false,
303
+ status: 400,
304
+ error: "invalid_request",
305
+ message: "Content-Type must be application/json",
306
+ };
307
+ }
308
+ let raw: unknown;
309
+ try {
310
+ raw = await req.json();
311
+ } catch (err) {
312
+ const msg = err instanceof Error ? err.message : String(err);
313
+ return {
314
+ ok: false,
315
+ status: 400,
316
+ error: "invalid_request",
317
+ message: `invalid JSON body: ${msg}`,
318
+ };
319
+ }
320
+ if (!raw || typeof raw !== "object") {
321
+ return {
322
+ ok: false,
323
+ status: 400,
324
+ error: "invalid_request",
325
+ message: "request body must be a JSON object",
326
+ };
327
+ }
328
+ const name = (raw as Record<string, unknown>).name;
329
+ if (typeof name !== "string" || name.length === 0) {
330
+ return {
331
+ ok: false,
332
+ status: 400,
333
+ error: "invalid_name",
334
+ message: '"name" must be a non-empty string',
335
+ };
336
+ }
337
+ return { ok: true, name };
338
+ }
339
+
340
+ /**
341
+ * Create a vault and return a ready-to-use vault token (the hinge, D2): the app
342
+ * lands the user IN the vault with zero extra round-trips. Wraps the auth-free
343
+ * `provisionVault` core (this facade already ran the scope gate). The hub's
344
+ * create already mints a `vault:<name>:admin` token; post-`pvt_*`-DROP that
345
+ * token can be `""` when no hub origin was reachable — the response forwards
346
+ * whatever the vault minted (+ `token_guidance`), and the app falls back to
347
+ * `POST /account/vaults/<name>/token` on an empty `vault_token` (risk #5).
348
+ */
349
+ export async function handleAccountCreateVault(
350
+ req: Request,
351
+ deps: AccountApiDeps,
352
+ ): Promise<Response> {
353
+ if (req.method !== "POST") return methodNotAllowed("POST");
354
+ try {
355
+ await requireAnyScope(deps.db, req, ADMIN_SCOPES, deps.knownIssuers ?? [deps.issuer]);
356
+ } catch (err) {
357
+ return adminAuthErrorResponse(err);
358
+ }
359
+ const parsed = await parseNameBody(req);
360
+ if (!parsed.ok) return json(parsed.status, { error: parsed.error, message: parsed.message });
361
+
362
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
363
+ const provisioned = await provisionVault(parsed.name, {
364
+ issuer: deps.issuer,
365
+ manifestPath,
366
+ ...(deps.runCommand ? { runCommand: deps.runCommand } : {}),
367
+ });
368
+ if (!provisioned.ok) {
369
+ const error = provisioned.status === 400 ? "invalid_name" : "server_error";
370
+ return json(provisioned.status, { error, message: provisioned.message });
371
+ }
372
+
373
+ const entry = provisioned.entry;
374
+ const meta: VaultMeta = { name: entry.name, url: entry.url, version: entry.version };
375
+ const body: {
376
+ name: string;
377
+ url: string;
378
+ vault_token: string;
379
+ token_guidance?: string;
380
+ services: Record<string, { url: string; version: string }>;
381
+ } = {
382
+ name: entry.name,
383
+ url: entry.url,
384
+ vault_token: provisioned.createJson?.token ?? "",
385
+ ...(provisioned.createJson?.token_guidance
386
+ ? { token_guidance: provisioned.createJson.token_guidance }
387
+ : {}),
388
+ services: servicesBlock(meta),
389
+ };
390
+ // 201 on a fresh create; 200 on an idempotent re-POST of an existing vault
391
+ // (no fresh token — `vault_token` is "" and the app mints via /token).
392
+ return json(provisioned.created ? 201 : 200, body);
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // POST /account/vaults/<name>/token — per-vault token mint
397
+ // ---------------------------------------------------------------------------
398
+
399
+ const MINTABLE_VERBS = new Set(["read", "write", "admin"]);
400
+
401
+ interface ScopesBody {
402
+ ok: true;
403
+ scopes: string[];
404
+ }
405
+
406
+ /**
407
+ * Parse + validate the requested `scopes`. Each must be `vault:<name>:<verb>`
408
+ * for THIS vault with a mintable verb. Omitted / empty → default read+write.
409
+ */
410
+ async function parseScopesBody(req: Request, vaultName: string): Promise<ScopesBody | BodyErr> {
411
+ const defaultScopes = [`vault:${vaultName}:read`, `vault:${vaultName}:write`];
412
+ const ctype = req.headers.get("content-type") ?? "";
413
+ // A body is optional; a token mint with no body defaults to read+write.
414
+ if (!ctype.toLowerCase().includes("application/json")) {
415
+ return { ok: true, scopes: defaultScopes };
416
+ }
417
+ let raw: unknown;
418
+ try {
419
+ raw = await req.json();
420
+ } catch {
421
+ return { ok: true, scopes: defaultScopes };
422
+ }
423
+ if (!raw || typeof raw !== "object") return { ok: true, scopes: defaultScopes };
424
+ const requested = (raw as Record<string, unknown>).scopes;
425
+ if (requested === undefined || requested === null) return { ok: true, scopes: defaultScopes };
426
+ if (!Array.isArray(requested) || requested.some((s) => typeof s !== "string")) {
427
+ return {
428
+ ok: false,
429
+ status: 400,
430
+ error: "invalid_request",
431
+ message: '"scopes" must be an array of strings',
432
+ };
433
+ }
434
+ const scopes = requested as string[];
435
+ if (scopes.length === 0) return { ok: true, scopes: defaultScopes };
436
+ for (const s of scopes) {
437
+ const parts = s.split(":");
438
+ if (
439
+ parts.length !== 3 ||
440
+ parts[0] !== "vault" ||
441
+ parts[1] !== vaultName ||
442
+ !MINTABLE_VERBS.has(parts[2] ?? "")
443
+ ) {
444
+ return {
445
+ ok: false,
446
+ status: 400,
447
+ error: "invalid_scope",
448
+ message: `scope "${s}" must be vault:${vaultName}:{read|write|admin}`,
449
+ };
450
+ }
451
+ }
452
+ return { ok: true, scopes };
453
+ }
454
+
455
+ export async function handleAccountMintVaultToken(
456
+ req: Request,
457
+ vaultName: string,
458
+ deps: AccountApiDeps,
459
+ ): Promise<Response> {
460
+ if (req.method !== "POST") return methodNotAllowed("POST");
461
+ let ctx: AdminAuthContext;
462
+ try {
463
+ ctx = await requireAnyScope(deps.db, req, ADMIN_SCOPES, deps.knownIssuers ?? [deps.issuer]);
464
+ } catch (err) {
465
+ return adminAuthErrorResponse(err);
466
+ }
467
+ if (!VAULT_NAME_CHARSET_RE.test(vaultName)) {
468
+ return json(400, {
469
+ error: "invalid_name",
470
+ message: `"${vaultName}" is not a valid vault name`,
471
+ });
472
+ }
473
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
474
+ const meta = listVaultsWithMeta(manifestPath, deps.issuer).find((v) => v.name === vaultName);
475
+ if (!meta) {
476
+ return json(404, { error: "vault_not_found", message: `no vault named "${vaultName}"` });
477
+ }
478
+ const parsed = await parseScopesBody(req, vaultName);
479
+ if (!parsed.ok) return json(parsed.status, { error: parsed.error, message: parsed.message });
480
+
481
+ const scopes = parsed.scopes;
482
+ const audience = inferAudience(scopes); // → vault.<name>
483
+ const minted = await signAccessToken(deps.db, {
484
+ sub: ctx.sub,
485
+ scopes,
486
+ audience,
487
+ clientId: ACCOUNT_API_CLIENT_ID,
488
+ issuer: deps.issuer,
489
+ ttlSeconds: ACCOUNT_VAULT_TOKEN_TTL_SECONDS,
490
+ vaultScope: [vaultName],
491
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
492
+ });
493
+ // Registry row so the operator token registry + revocation list attribute it.
494
+ // Anchor to the subject's user_id only when it names a real user row (an
495
+ // operator token's `sub` may be the "operator" sentinel, which is not a
496
+ // `users` row — pass it as `subject` but omit `user_id` to avoid a dangling FK).
497
+ const subjectIsUser = getUserById(deps.db, ctx.sub) !== null;
498
+ recordTokenMint(deps.db, {
499
+ jti: minted.jti,
500
+ createdVia: "cli_mint",
501
+ subject: ctx.sub,
502
+ ...(subjectIsUser ? { userId: ctx.sub } : {}),
503
+ clientId: ACCOUNT_API_CLIENT_ID,
504
+ scopes,
505
+ expiresAt: minted.expiresAt,
506
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
507
+ });
508
+
509
+ return json(200, {
510
+ vault_token: minted.token,
511
+ expires_at: minted.expiresAt,
512
+ services: servicesBlock(meta),
513
+ });
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // GET / PUT /account/vaults/<name>/caps
518
+ // ---------------------------------------------------------------------------
519
+
520
+ export async function handleAccountGetVaultCaps(
521
+ req: Request,
522
+ vaultName: string,
523
+ deps: AccountApiDeps,
524
+ ): Promise<Response> {
525
+ if (req.method !== "GET") return methodNotAllowed("GET");
526
+ try {
527
+ await requireAnyScope(deps.db, req, READ_SCOPES, deps.knownIssuers ?? [deps.issuer]);
528
+ } catch (err) {
529
+ return adminAuthErrorResponse(err);
530
+ }
531
+ if (!VAULT_NAME_CHARSET_RE.test(vaultName)) {
532
+ return json(400, {
533
+ error: "invalid_name",
534
+ message: `"${vaultName}" is not a valid vault name`,
535
+ });
536
+ }
537
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
538
+ const meta = listVaultsWithMeta(manifestPath, deps.issuer).find((v) => v.name === vaultName);
539
+ if (!meta) {
540
+ return json(404, { error: "vault_not_found", message: `no vault named "${vaultName}"` });
541
+ }
542
+ const cap = getVaultCap(deps.db, vaultName);
543
+ return json(200, {
544
+ name: vaultName,
545
+ caps: {
546
+ cap_bytes: cap?.capBytes ?? null,
547
+ created_at: cap?.createdAt ?? null,
548
+ updated_at: cap?.updatedAt ?? null,
549
+ },
550
+ caps_writable: true,
551
+ });
552
+ }
553
+
554
+ interface CapBody {
555
+ ok: true;
556
+ cap_bytes: number;
557
+ }
558
+
559
+ async function parseCapBody(req: Request): Promise<CapBody | BodyErr> {
560
+ const ctype = req.headers.get("content-type") ?? "";
561
+ if (!ctype.toLowerCase().includes("application/json")) {
562
+ return {
563
+ ok: false,
564
+ status: 400,
565
+ error: "invalid_request",
566
+ message: "Content-Type must be application/json",
567
+ };
568
+ }
569
+ let raw: unknown;
570
+ try {
571
+ raw = await req.json();
572
+ } catch (err) {
573
+ const msg = err instanceof Error ? err.message : String(err);
574
+ return {
575
+ ok: false,
576
+ status: 400,
577
+ error: "invalid_request",
578
+ message: `invalid JSON body: ${msg}`,
579
+ };
580
+ }
581
+ if (!raw || typeof raw !== "object") {
582
+ return {
583
+ ok: false,
584
+ status: 400,
585
+ error: "invalid_request",
586
+ message: "request body must be a JSON object",
587
+ };
588
+ }
589
+ const capBytes = (raw as Record<string, unknown>).cap_bytes;
590
+ if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
591
+ return {
592
+ ok: false,
593
+ status: 400,
594
+ error: "invalid_request",
595
+ message: '"cap_bytes" must be a positive integer number of bytes',
596
+ };
597
+ }
598
+ return { ok: true, cap_bytes: capBytes };
599
+ }
600
+
601
+ export async function handleAccountSetVaultCaps(
602
+ req: Request,
603
+ vaultName: string,
604
+ deps: AccountApiDeps,
605
+ ): Promise<Response> {
606
+ if (req.method !== "PUT") return methodNotAllowed("PUT");
607
+ try {
608
+ await requireAnyScope(deps.db, req, ADMIN_SCOPES, deps.knownIssuers ?? [deps.issuer]);
609
+ } catch (err) {
610
+ return adminAuthErrorResponse(err);
611
+ }
612
+ if (!VAULT_NAME_CHARSET_RE.test(vaultName)) {
613
+ return json(400, {
614
+ error: "invalid_name",
615
+ message: `"${vaultName}" is not a valid vault name`,
616
+ });
617
+ }
618
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
619
+ const meta = listVaultsWithMeta(manifestPath, deps.issuer).find((v) => v.name === vaultName);
620
+ if (!meta) {
621
+ return json(404, { error: "vault_not_found", message: `no vault named "${vaultName}"` });
622
+ }
623
+ const parsed = await parseCapBody(req);
624
+ if (!parsed.ok) return json(parsed.status, { error: parsed.error, message: parsed.message });
625
+
626
+ const cap = setVaultCap(deps.db, vaultName, parsed.cap_bytes);
627
+ return json(200, {
628
+ name: vaultName,
629
+ caps: { cap_bytes: cap.capBytes, created_at: cap.createdAt, updated_at: cap.updatedAt },
630
+ caps_writable: true,
631
+ });
632
+ }