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