@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.
- package/package.json +6 -3
- package/src/__tests__/account-api.test.ts +511 -0
- package/src/__tests__/account-token.test.ts +292 -0
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/scope-explanations.test.ts +22 -0
- package/src/account-api.ts +632 -0
- package/src/account-token.ts +200 -0
- package/src/hub-server.ts +176 -0
- package/src/scope-explanations.ts +33 -0
|
@@ -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
|
+
}
|