@openparachute/hub 0.7.6 → 0.7.7-rc.12
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/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +12 -0
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-server.test.ts +37 -0
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +279 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +2 -1
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/install.ts +86 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +247 -52
- package/src/hub-settings.ts +25 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +128 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -0,0 +1,677 @@
|
|
|
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 {
|
|
37
|
+
type AccountBootstrap,
|
|
38
|
+
type ParachuteAccountDescriptor,
|
|
39
|
+
validateVaultScopes,
|
|
40
|
+
} from "@openparachute/door-contract";
|
|
41
|
+
import { ACCOUNT_VAULT_TOKEN_TTL_SECONDS } from "./account-home-ui.ts";
|
|
42
|
+
import {
|
|
43
|
+
type AdminAuthContext,
|
|
44
|
+
AdminAuthError,
|
|
45
|
+
adminAuthErrorResponse,
|
|
46
|
+
extractBearerToken,
|
|
47
|
+
} from "./admin-auth.ts";
|
|
48
|
+
import { HOST_ADMIN_SCOPE, provisionVault } from "./admin-vaults.ts";
|
|
49
|
+
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
50
|
+
import { activePublicSignupPath } from "./invites.ts";
|
|
51
|
+
import { inferAudience } from "./jwt-audience.ts";
|
|
52
|
+
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
53
|
+
import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "./scope-explanations.ts";
|
|
54
|
+
import { readManifestLenient } from "./services-manifest.ts";
|
|
55
|
+
import { getUserById } from "./users.ts";
|
|
56
|
+
import { getVaultCap, setVaultCap } from "./vault-caps.ts";
|
|
57
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
58
|
+
import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
59
|
+
|
|
60
|
+
// The `account:self:{admin,read}` scope strings are defined once in
|
|
61
|
+
// scope-explanations.ts (H1, #746) — the same registry that makes them
|
|
62
|
+
// non-OAuth-requestable — and imported here so the two never drift.
|
|
63
|
+
/** client_id stamped on per-vault tokens this surface mints + their registry rows. */
|
|
64
|
+
const ACCOUNT_API_CLIENT_ID = "parachute-account";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scopes that satisfy a `/account/*` MUTATION. The account superset token
|
|
68
|
+
* carries `account:self:admin`; a plain operator/host-admin token carries
|
|
69
|
+
* `parachute:host:admin`. Either is accepted so H2 works independent of H1's
|
|
70
|
+
* merge order (SCOPE-b).
|
|
71
|
+
*/
|
|
72
|
+
const ADMIN_SCOPES: readonly string[] = [ACCOUNT_SELF_ADMIN_SCOPE, HOST_ADMIN_SCOPE];
|
|
73
|
+
/** Scopes that satisfy a `/account/*` READ. `admin ⊇ read`, spelled explicitly
|
|
74
|
+
* because the hub's `requireScope` does an exact-string membership check (no
|
|
75
|
+
* inheritance expansion at validate time). */
|
|
76
|
+
const READ_SCOPES: readonly string[] = [
|
|
77
|
+
ACCOUNT_SELF_READ_SCOPE,
|
|
78
|
+
ACCOUNT_SELF_ADMIN_SCOPE,
|
|
79
|
+
HOST_ADMIN_SCOPE,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export interface AccountApiDeps {
|
|
83
|
+
db: Database;
|
|
84
|
+
/** Hub origin — JWT `iss` validation, response URLs, and minted-token `iss`. */
|
|
85
|
+
issuer: string;
|
|
86
|
+
/**
|
|
87
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
88
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's `iss`
|
|
89
|
+
* is validated against THIS set rather than the single `issuer` so a
|
|
90
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
91
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]`.
|
|
92
|
+
*/
|
|
93
|
+
knownIssuers?: readonly string[];
|
|
94
|
+
/** Override services.json path. Defaults to `~/.parachute/services.json`. */
|
|
95
|
+
manifestPath?: string;
|
|
96
|
+
/** Test seam for the clock (mint + registry row). */
|
|
97
|
+
now?: () => Date;
|
|
98
|
+
/** Test seam threaded into `provisionVault` so create can be exercised
|
|
99
|
+
* without spawning the real `parachute-vault create` binary. */
|
|
100
|
+
runCommand?: (cmd: readonly string[]) => Promise<{
|
|
101
|
+
exitCode: number;
|
|
102
|
+
stdout: string;
|
|
103
|
+
stderr: string;
|
|
104
|
+
}>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Shared helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function json(status: number, body: unknown, extraHeaders?: Record<string, string>): Response {
|
|
112
|
+
return new Response(JSON.stringify(body), {
|
|
113
|
+
status,
|
|
114
|
+
headers: {
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
"cache-control": "no-store",
|
|
117
|
+
...extraHeaders,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function methodNotAllowed(allow: string): Response {
|
|
123
|
+
return new Response(JSON.stringify({ error: "method_not_allowed", message: `use ${allow}` }), {
|
|
124
|
+
status: 405,
|
|
125
|
+
headers: { "content-type": "application/json", allow },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate a presented bearer token and assert it carries ANY of `acceptable`.
|
|
131
|
+
* Mirrors `requireScope` (admin-auth.ts) exactly — same signature-first
|
|
132
|
+
* validation, same `iss`-∈-set relaxation, same claim surfacing — but matches
|
|
133
|
+
* a SET of scopes rather than a single required one, so `/account/*` can accept
|
|
134
|
+
* `account:self:*` OR `parachute:host:admin`. Throws `AdminAuthError` (401/403);
|
|
135
|
+
* callers translate via `adminAuthErrorResponse`.
|
|
136
|
+
*/
|
|
137
|
+
export async function requireAnyScope(
|
|
138
|
+
db: Database,
|
|
139
|
+
req: Request,
|
|
140
|
+
acceptable: readonly string[],
|
|
141
|
+
expectedIssuer: string | readonly string[],
|
|
142
|
+
): Promise<AdminAuthContext> {
|
|
143
|
+
const token = extractBearerToken(req);
|
|
144
|
+
let validated: Awaited<ReturnType<typeof validateAccessToken>>;
|
|
145
|
+
try {
|
|
146
|
+
validated = await validateAccessToken(db, token, expectedIssuer);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
149
|
+
throw new AdminAuthError(401, `invalid token: ${msg}`);
|
|
150
|
+
}
|
|
151
|
+
const sub = typeof validated.payload.sub === "string" ? validated.payload.sub : null;
|
|
152
|
+
if (!sub) throw new AdminAuthError(401, "token missing required `sub` claim");
|
|
153
|
+
const scopeClaim = (validated.payload as { scope?: unknown }).scope;
|
|
154
|
+
const scopes =
|
|
155
|
+
typeof scopeClaim === "string" ? scopeClaim.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
156
|
+
if (!acceptable.some((s) => scopes.includes(s))) {
|
|
157
|
+
throw new AdminAuthError(403, `token missing one of required scopes: ${acceptable.join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
const clientIdRaw = (validated.payload as { client_id?: unknown }).client_id;
|
|
160
|
+
const clientId = typeof clientIdRaw === "string" ? clientIdRaw : undefined;
|
|
161
|
+
const aud = typeof validated.payload.aud === "string" ? validated.payload.aud : undefined;
|
|
162
|
+
return { sub, scopes, clientId, audience: aud };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Scope set for a `/account/*` mutation (create / delete / mint / set-caps). */
|
|
166
|
+
export const ACCOUNT_MUTATION_SCOPES = ADMIN_SCOPES;
|
|
167
|
+
/** Scope set for a `/account/*` read (list / get-caps / bootstrap). */
|
|
168
|
+
export const ACCOUNT_READ_SCOPES = READ_SCOPES;
|
|
169
|
+
|
|
170
|
+
interface VaultMeta {
|
|
171
|
+
name: string;
|
|
172
|
+
url: string;
|
|
173
|
+
version: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Enumerate every servable vault from services.json with its canonical URL +
|
|
178
|
+
* version. Mirrors `findExistingVault`'s enumeration in admin-vaults.ts (same
|
|
179
|
+
* `isVaultEntry` filter, same empty-paths skip #478, same `vaultInstanceNameFor`
|
|
180
|
+
* name derivation, same `new URL(path, base)` URL build as `buildEntry`) so the
|
|
181
|
+
* account list agrees with the well-known vaults[] fan-out and the create path.
|
|
182
|
+
*/
|
|
183
|
+
function listVaultsWithMeta(manifestPath: string, issuer: string): VaultMeta[] {
|
|
184
|
+
const base = issuer.replace(/\/$/, "");
|
|
185
|
+
const out: VaultMeta[] = [];
|
|
186
|
+
let manifest: ReturnType<typeof readManifestLenient>;
|
|
187
|
+
try {
|
|
188
|
+
manifest = readManifestLenient(manifestPath);
|
|
189
|
+
} catch {
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
for (const svc of manifest.services) {
|
|
193
|
+
if (!isVaultEntry(svc)) continue;
|
|
194
|
+
if (svc.paths.length === 0) continue; // #478: installed-but-no-instance
|
|
195
|
+
for (const path of svc.paths) {
|
|
196
|
+
const name = vaultInstanceNameFor(svc.name, path);
|
|
197
|
+
const url = new URL(path, `${base}/`).toString();
|
|
198
|
+
out.push({ name, url, version: svc.version });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function servicesBlock(meta: VaultMeta): Record<string, { url: string; version: string }> {
|
|
205
|
+
return { [`vault:${meta.name}`]: { url: meta.url, version: meta.version } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// GET /.well-known/parachute-account — public capabilities descriptor
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The self-host door descriptor — the canonical `ParachuteAccountDescriptor`
|
|
214
|
+
* (door-contract 0.4.0) both doors serve, so a client (the app) branches its
|
|
215
|
+
* front door without hardcoding per-door shapes. Public, no auth, wildcard
|
|
216
|
+
* CORS (the app pulls it cross-origin).
|
|
217
|
+
*
|
|
218
|
+
* `features`/`caps_writable` are hub EXTRAS beyond the shared contract — the
|
|
219
|
+
* shared conformance checker (`checkAccountDescriptor`) walks expected keys
|
|
220
|
+
* only, so these ride along without breaking cross-door conformance.
|
|
221
|
+
* `billing:false` + `plans:[]` (Q7, parked) mean the app shows no
|
|
222
|
+
* billing/upgrade UI on self-host; `caps_writable:true` means the operator
|
|
223
|
+
* can PUT caps freely (the cloud twin is plan-derived → false).
|
|
224
|
+
*
|
|
225
|
+
* `signup_path` is conditional (Q2): present only while an active multi-use
|
|
226
|
+
* public invite exists (`activePublicSignupPath`, invites.ts) — an operator-
|
|
227
|
+
* shared link is otherwise the only way in, so the app must not render a
|
|
228
|
+
* "create account" affordance when there is nowhere for it to go.
|
|
229
|
+
*/
|
|
230
|
+
export function handleAccountCapabilities(
|
|
231
|
+
req: Request,
|
|
232
|
+
deps: { db: Database; issuer: string; now?: () => Date },
|
|
233
|
+
): Response {
|
|
234
|
+
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
235
|
+
const issuer = deps.issuer.replace(/\/$/, "");
|
|
236
|
+
const now = deps.now ? deps.now() : new Date();
|
|
237
|
+
const signupPath = activePublicSignupPath(deps.db, now);
|
|
238
|
+
const descriptor: ParachuteAccountDescriptor & {
|
|
239
|
+
features: {
|
|
240
|
+
modules: boolean;
|
|
241
|
+
expose: boolean;
|
|
242
|
+
import: boolean;
|
|
243
|
+
export: boolean;
|
|
244
|
+
billing: boolean;
|
|
245
|
+
};
|
|
246
|
+
caps_writable: boolean;
|
|
247
|
+
} = {
|
|
248
|
+
issuer,
|
|
249
|
+
door: "hub",
|
|
250
|
+
account_endpoint: `${issuer}/account`,
|
|
251
|
+
auth: { methods: ["password"], signin_path: "/login" },
|
|
252
|
+
...(signupPath ? { signup_path: signupPath } : {}),
|
|
253
|
+
vault_url_template: `${issuer}/vault/{name}`,
|
|
254
|
+
capabilities: { vault_create: true, vault_rename: false, vault_delete: true },
|
|
255
|
+
plans: [],
|
|
256
|
+
// Hub EXTRAS (kept — see the doc comment above).
|
|
257
|
+
features: { modules: true, expose: true, import: true, export: true, billing: false },
|
|
258
|
+
caps_writable: true,
|
|
259
|
+
};
|
|
260
|
+
return json(200, descriptor, {
|
|
261
|
+
"access-control-allow-origin": "*",
|
|
262
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// GET /account — account bootstrap
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The contract's `AccountBootstrap` — `{ id, email?, door }`. On self-host the
|
|
272
|
+
* account id is the sentinel `self`; `email` is present only when the
|
|
273
|
+
* operator row has one (`users.email` is nullable-by-history, migration
|
|
274
|
+
* v15 — the door-contract type models it as optional, not nullable).
|
|
275
|
+
*/
|
|
276
|
+
export async function handleAccountRoot(req: Request, deps: AccountApiDeps): Promise<Response> {
|
|
277
|
+
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
278
|
+
let ctx: AdminAuthContext;
|
|
279
|
+
try {
|
|
280
|
+
ctx = await requireAnyScope(deps.db, req, READ_SCOPES, deps.knownIssuers ?? [deps.issuer]);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
return adminAuthErrorResponse(err);
|
|
283
|
+
}
|
|
284
|
+
const user = getUserById(deps.db, ctx.sub);
|
|
285
|
+
const body: AccountBootstrap = {
|
|
286
|
+
id: "self",
|
|
287
|
+
door: "hub",
|
|
288
|
+
...(user?.email ? { email: user.email } : {}),
|
|
289
|
+
};
|
|
290
|
+
return json(200, body);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// GET /account/vaults — list
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
export async function handleAccountListVaults(
|
|
298
|
+
req: Request,
|
|
299
|
+
deps: AccountApiDeps,
|
|
300
|
+
): Promise<Response> {
|
|
301
|
+
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
302
|
+
try {
|
|
303
|
+
await requireAnyScope(deps.db, req, READ_SCOPES, deps.knownIssuers ?? [deps.issuer]);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
return adminAuthErrorResponse(err);
|
|
306
|
+
}
|
|
307
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
308
|
+
const vaults = listVaultsWithMeta(manifestPath, deps.issuer).map((v) => {
|
|
309
|
+
const cap = getVaultCap(deps.db, v.name);
|
|
310
|
+
return {
|
|
311
|
+
name: v.name,
|
|
312
|
+
url: v.url,
|
|
313
|
+
version: v.version,
|
|
314
|
+
caps: { cap_bytes: cap?.capBytes ?? null },
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
return json(200, { vaults });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// POST /account/vaults — create (returns a ready-to-use vault token)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
interface NameBody {
|
|
325
|
+
ok: true;
|
|
326
|
+
name: string;
|
|
327
|
+
}
|
|
328
|
+
interface BodyErr {
|
|
329
|
+
ok: false;
|
|
330
|
+
status: number;
|
|
331
|
+
error: string;
|
|
332
|
+
message: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function parseNameBody(req: Request): Promise<NameBody | BodyErr> {
|
|
336
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
337
|
+
if (!ctype.toLowerCase().includes("application/json")) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
status: 400,
|
|
341
|
+
error: "invalid_request",
|
|
342
|
+
message: "Content-Type must be application/json",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
let raw: unknown;
|
|
346
|
+
try {
|
|
347
|
+
raw = await req.json();
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: 400,
|
|
353
|
+
error: "invalid_request",
|
|
354
|
+
message: `invalid JSON body: ${msg}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (!raw || typeof raw !== "object") {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
status: 400,
|
|
361
|
+
error: "invalid_request",
|
|
362
|
+
message: "request body must be a JSON object",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const name = (raw as Record<string, unknown>).name;
|
|
366
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
status: 400,
|
|
370
|
+
error: "invalid_name",
|
|
371
|
+
message: '"name" must be a non-empty string',
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, name };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Create a vault and return a ready-to-use vault token (the hinge, D2): the app
|
|
379
|
+
* lands the user IN the vault with zero extra round-trips. Wraps the auth-free
|
|
380
|
+
* `provisionVault` core (this facade already ran the scope gate). The hub's
|
|
381
|
+
* create already mints a `vault:<name>:admin` token; post-`pvt_*`-DROP that
|
|
382
|
+
* token can be `""` when no hub origin was reachable — the response forwards
|
|
383
|
+
* whatever the vault minted (+ `token_guidance`), and the app falls back to
|
|
384
|
+
* `POST /account/vaults/<name>/token` on an empty `vault_token` (risk #5).
|
|
385
|
+
*/
|
|
386
|
+
export async function handleAccountCreateVault(
|
|
387
|
+
req: Request,
|
|
388
|
+
deps: AccountApiDeps,
|
|
389
|
+
): Promise<Response> {
|
|
390
|
+
if (req.method !== "POST") return methodNotAllowed("POST");
|
|
391
|
+
try {
|
|
392
|
+
await requireAnyScope(deps.db, req, ADMIN_SCOPES, deps.knownIssuers ?? [deps.issuer]);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
return adminAuthErrorResponse(err);
|
|
395
|
+
}
|
|
396
|
+
const parsed = await parseNameBody(req);
|
|
397
|
+
if (!parsed.ok) return json(parsed.status, { error: parsed.error, message: parsed.message });
|
|
398
|
+
|
|
399
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
400
|
+
const provisioned = await provisionVault(parsed.name, {
|
|
401
|
+
issuer: deps.issuer,
|
|
402
|
+
manifestPath,
|
|
403
|
+
...(deps.runCommand ? { runCommand: deps.runCommand } : {}),
|
|
404
|
+
});
|
|
405
|
+
if (!provisioned.ok) {
|
|
406
|
+
const error = provisioned.status === 400 ? "invalid_name" : "server_error";
|
|
407
|
+
return json(provisioned.status, { error, message: provisioned.message });
|
|
408
|
+
}
|
|
409
|
+
// Q6 (hub-parity P2): this facade no longer answers 200-idempotent on an
|
|
410
|
+
// existing name — it converges on cloud's exact 409 `vault_taken` shape.
|
|
411
|
+
// `provisionVault` itself is UNCHANGED (still idempotent for its other
|
|
412
|
+
// caller, the invite-redeem flow, which doesn't route through this
|
|
413
|
+
// facade) — only this facade's wire answer changes. A scripted consumer
|
|
414
|
+
// that relied on 200-on-existing must follow up with
|
|
415
|
+
// `POST /account/vaults/<name>/token` to get a usable token.
|
|
416
|
+
if (!provisioned.created) {
|
|
417
|
+
return json(409, {
|
|
418
|
+
error: "vault_taken",
|
|
419
|
+
message: "That vault name is already taken.",
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const entry = provisioned.entry;
|
|
424
|
+
const meta: VaultMeta = { name: entry.name, url: entry.url, version: entry.version };
|
|
425
|
+
const body: {
|
|
426
|
+
name: string;
|
|
427
|
+
url: string;
|
|
428
|
+
vault_token: string;
|
|
429
|
+
token_guidance?: string;
|
|
430
|
+
services: Record<string, { url: string; version: string }>;
|
|
431
|
+
} = {
|
|
432
|
+
name: entry.name,
|
|
433
|
+
url: entry.url,
|
|
434
|
+
vault_token: provisioned.createJson?.token ?? "",
|
|
435
|
+
...(provisioned.createJson?.token_guidance
|
|
436
|
+
? { token_guidance: provisioned.createJson.token_guidance }
|
|
437
|
+
: {}),
|
|
438
|
+
services: servicesBlock(meta),
|
|
439
|
+
};
|
|
440
|
+
return json(201, body);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// POST /account/vaults/<name>/token — per-vault token mint
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
interface ScopesBody {
|
|
448
|
+
ok: true;
|
|
449
|
+
scopes: string[];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Parse + validate the requested `scopes`. The JSON-parse tolerance (optional
|
|
454
|
+
* body, optional content-type, swallow a malformed body) stays LOCAL — it's
|
|
455
|
+
* HTTP plumbing the shared validator knows nothing about (it's pure, no
|
|
456
|
+
* `Request`). The scope-SHAPE logic (array check, per-entry
|
|
457
|
+
* `vault:<name>:<verb>` grammar, empty/absent → default read+write) is the
|
|
458
|
+
* shared `validateVaultScopes` (door-contract 0.4.0) — the ONE implementation
|
|
459
|
+
* cloud's twin also imports, replacing the two hand-synced copies. Its reason
|
|
460
|
+
* taxonomy (`invalid_request` | `invalid_scope`) was built byte-exact with
|
|
461
|
+
* this function's prior behavior (see vault-scopes.ts's doc comment), so this
|
|
462
|
+
* swap is a behavioral no-op for the hub — verified by rerunning this file's
|
|
463
|
+
* existing test cases unchanged (account-api.test.ts).
|
|
464
|
+
*/
|
|
465
|
+
async function parseScopesBody(req: Request, vaultName: string): Promise<ScopesBody | BodyErr> {
|
|
466
|
+
const defaultScopes = [`vault:${vaultName}:read`, `vault:${vaultName}:write`];
|
|
467
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
468
|
+
// A body is optional; a token mint with no body defaults to read+write.
|
|
469
|
+
if (!ctype.toLowerCase().includes("application/json")) {
|
|
470
|
+
return { ok: true, scopes: defaultScopes };
|
|
471
|
+
}
|
|
472
|
+
let raw: unknown;
|
|
473
|
+
try {
|
|
474
|
+
raw = await req.json();
|
|
475
|
+
} catch {
|
|
476
|
+
return { ok: true, scopes: defaultScopes };
|
|
477
|
+
}
|
|
478
|
+
if (!raw || typeof raw !== "object") return { ok: true, scopes: defaultScopes };
|
|
479
|
+
const requested = (raw as Record<string, unknown>).scopes;
|
|
480
|
+
|
|
481
|
+
const result = validateVaultScopes(requested, vaultName);
|
|
482
|
+
if (!result.ok) {
|
|
483
|
+
return result.reason === "invalid_request"
|
|
484
|
+
? {
|
|
485
|
+
ok: false,
|
|
486
|
+
status: 400,
|
|
487
|
+
error: "invalid_request",
|
|
488
|
+
message: '"scopes" must be an array of strings',
|
|
489
|
+
}
|
|
490
|
+
: {
|
|
491
|
+
ok: false,
|
|
492
|
+
status: 400,
|
|
493
|
+
error: "invalid_scope",
|
|
494
|
+
message: `every scope must be vault:${vaultName}:{read|write|admin}`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return { ok: true, scopes: result.scopes };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export async function handleAccountMintVaultToken(
|
|
501
|
+
req: Request,
|
|
502
|
+
vaultName: string,
|
|
503
|
+
deps: AccountApiDeps,
|
|
504
|
+
): Promise<Response> {
|
|
505
|
+
if (req.method !== "POST") return methodNotAllowed("POST");
|
|
506
|
+
let ctx: AdminAuthContext;
|
|
507
|
+
try {
|
|
508
|
+
ctx = await requireAnyScope(deps.db, req, ADMIN_SCOPES, deps.knownIssuers ?? [deps.issuer]);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
return adminAuthErrorResponse(err);
|
|
511
|
+
}
|
|
512
|
+
if (!VAULT_NAME_CHARSET_RE.test(vaultName)) {
|
|
513
|
+
return json(400, {
|
|
514
|
+
error: "invalid_name",
|
|
515
|
+
message: `"${vaultName}" is not a valid vault name`,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
519
|
+
const meta = listVaultsWithMeta(manifestPath, deps.issuer).find((v) => v.name === vaultName);
|
|
520
|
+
if (!meta) {
|
|
521
|
+
return json(404, { error: "vault_not_found", message: `no vault named "${vaultName}"` });
|
|
522
|
+
}
|
|
523
|
+
const parsed = await parseScopesBody(req, vaultName);
|
|
524
|
+
if (!parsed.ok) return json(parsed.status, { error: parsed.error, message: parsed.message });
|
|
525
|
+
|
|
526
|
+
const scopes = parsed.scopes;
|
|
527
|
+
const audience = inferAudience(scopes); // → vault.<name>
|
|
528
|
+
const minted = await signAccessToken(deps.db, {
|
|
529
|
+
sub: ctx.sub,
|
|
530
|
+
scopes,
|
|
531
|
+
audience,
|
|
532
|
+
clientId: ACCOUNT_API_CLIENT_ID,
|
|
533
|
+
issuer: deps.issuer,
|
|
534
|
+
ttlSeconds: ACCOUNT_VAULT_TOKEN_TTL_SECONDS,
|
|
535
|
+
vaultScope: [vaultName],
|
|
536
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
537
|
+
});
|
|
538
|
+
// Registry row so the operator token registry + revocation list attribute it.
|
|
539
|
+
// Anchor to the subject's user_id only when it names a real user row (an
|
|
540
|
+
// operator token's `sub` may be the "operator" sentinel, which is not a
|
|
541
|
+
// `users` row — pass it as `subject` but omit `user_id` to avoid a dangling FK).
|
|
542
|
+
const subjectIsUser = getUserById(deps.db, ctx.sub) !== null;
|
|
543
|
+
recordTokenMint(deps.db, {
|
|
544
|
+
jti: minted.jti,
|
|
545
|
+
createdVia: "cli_mint",
|
|
546
|
+
subject: ctx.sub,
|
|
547
|
+
...(subjectIsUser ? { userId: ctx.sub } : {}),
|
|
548
|
+
clientId: ACCOUNT_API_CLIENT_ID,
|
|
549
|
+
scopes,
|
|
550
|
+
expiresAt: minted.expiresAt,
|
|
551
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return json(200, {
|
|
555
|
+
vault_token: minted.token,
|
|
556
|
+
expires_at: minted.expiresAt,
|
|
557
|
+
services: servicesBlock(meta),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// GET / PUT /account/vaults/<name>/caps
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
export async function handleAccountGetVaultCaps(
|
|
566
|
+
req: Request,
|
|
567
|
+
vaultName: string,
|
|
568
|
+
deps: AccountApiDeps,
|
|
569
|
+
): Promise<Response> {
|
|
570
|
+
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
571
|
+
try {
|
|
572
|
+
await requireAnyScope(deps.db, req, READ_SCOPES, deps.knownIssuers ?? [deps.issuer]);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
return adminAuthErrorResponse(err);
|
|
575
|
+
}
|
|
576
|
+
if (!VAULT_NAME_CHARSET_RE.test(vaultName)) {
|
|
577
|
+
return json(400, {
|
|
578
|
+
error: "invalid_name",
|
|
579
|
+
message: `"${vaultName}" is not a valid vault name`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
583
|
+
const meta = listVaultsWithMeta(manifestPath, deps.issuer).find((v) => v.name === vaultName);
|
|
584
|
+
if (!meta) {
|
|
585
|
+
return json(404, { error: "vault_not_found", message: `no vault named "${vaultName}"` });
|
|
586
|
+
}
|
|
587
|
+
const cap = getVaultCap(deps.db, vaultName);
|
|
588
|
+
return json(200, {
|
|
589
|
+
name: vaultName,
|
|
590
|
+
caps: {
|
|
591
|
+
cap_bytes: cap?.capBytes ?? null,
|
|
592
|
+
created_at: cap?.createdAt ?? null,
|
|
593
|
+
updated_at: cap?.updatedAt ?? null,
|
|
594
|
+
},
|
|
595
|
+
caps_writable: true,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
interface CapBody {
|
|
600
|
+
ok: true;
|
|
601
|
+
cap_bytes: number;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function parseCapBody(req: Request): Promise<CapBody | BodyErr> {
|
|
605
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
606
|
+
if (!ctype.toLowerCase().includes("application/json")) {
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
status: 400,
|
|
610
|
+
error: "invalid_request",
|
|
611
|
+
message: "Content-Type must be application/json",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
let raw: unknown;
|
|
615
|
+
try {
|
|
616
|
+
raw = await req.json();
|
|
617
|
+
} catch (err) {
|
|
618
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
619
|
+
return {
|
|
620
|
+
ok: false,
|
|
621
|
+
status: 400,
|
|
622
|
+
error: "invalid_request",
|
|
623
|
+
message: `invalid JSON body: ${msg}`,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (!raw || typeof raw !== "object") {
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
status: 400,
|
|
630
|
+
error: "invalid_request",
|
|
631
|
+
message: "request body must be a JSON object",
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const capBytes = (raw as Record<string, unknown>).cap_bytes;
|
|
635
|
+
if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
status: 400,
|
|
639
|
+
error: "invalid_request",
|
|
640
|
+
message: '"cap_bytes" must be a positive integer number of bytes',
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
return { ok: true, cap_bytes: capBytes };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function handleAccountSetVaultCaps(
|
|
647
|
+
req: Request,
|
|
648
|
+
vaultName: string,
|
|
649
|
+
deps: AccountApiDeps,
|
|
650
|
+
): Promise<Response> {
|
|
651
|
+
if (req.method !== "PUT") return methodNotAllowed("PUT");
|
|
652
|
+
try {
|
|
653
|
+
await requireAnyScope(deps.db, req, ADMIN_SCOPES, deps.knownIssuers ?? [deps.issuer]);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
return adminAuthErrorResponse(err);
|
|
656
|
+
}
|
|
657
|
+
if (!VAULT_NAME_CHARSET_RE.test(vaultName)) {
|
|
658
|
+
return json(400, {
|
|
659
|
+
error: "invalid_name",
|
|
660
|
+
message: `"${vaultName}" is not a valid vault name`,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
664
|
+
const meta = listVaultsWithMeta(manifestPath, deps.issuer).find((v) => v.name === vaultName);
|
|
665
|
+
if (!meta) {
|
|
666
|
+
return json(404, { error: "vault_not_found", message: `no vault named "${vaultName}"` });
|
|
667
|
+
}
|
|
668
|
+
const parsed = await parseCapBody(req);
|
|
669
|
+
if (!parsed.ok) return json(parsed.status, { error: parsed.error, message: parsed.message });
|
|
670
|
+
|
|
671
|
+
const cap = setVaultCap(deps.db, vaultName, parsed.cap_bytes);
|
|
672
|
+
return json(200, {
|
|
673
|
+
name: vaultName,
|
|
674
|
+
caps: { cap_bytes: cap.capBytes, created_at: cap.createdAt, updated_at: cap.updatedAt },
|
|
675
|
+
caps_writable: true,
|
|
676
|
+
});
|
|
677
|
+
}
|