@openparachute/hub 0.6.3 → 0.6.4-rc.1
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 -1
- package/src/__tests__/account-setup.test.ts +609 -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 +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- 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__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -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__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -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 +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- 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/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -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 +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- 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
package/src/api-account.ts
CHANGED
|
@@ -49,6 +49,7 @@ import type { Database } from "bun:sqlite";
|
|
|
49
49
|
import { hash as argonHash } from "@node-rs/argon2";
|
|
50
50
|
import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
|
|
51
51
|
import { renderAccountHome } from "./account-home-ui.ts";
|
|
52
|
+
import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
|
|
52
53
|
import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
|
|
53
54
|
import { renderAdminError } from "./admin-login-ui.ts";
|
|
54
55
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
@@ -419,6 +420,16 @@ export async function handleAccountChangePasswordPost(
|
|
|
419
420
|
)
|
|
420
421
|
.run(passwordHash, stamp, user.id);
|
|
421
422
|
if (result.changes === 0) throw new UserNotFoundError(user.id);
|
|
423
|
+
// Revoke the user's still-active tokens on a self-service password change
|
|
424
|
+
// (item F / hub#469). The admin-reset path already revokes
|
|
425
|
+
// (`resetUserPassword`, users.ts); applying the same on self-change closes
|
|
426
|
+
// the "mint a token under the admin's temp password, then rotate but keep
|
|
427
|
+
// the token" gap — any token minted before the rotation dies with it. The
|
|
428
|
+
// user re-mints under their own (now-rotated) password if they need one.
|
|
429
|
+
// Same transaction as the hash write so the two are atomic.
|
|
430
|
+
deps.db
|
|
431
|
+
.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
|
|
432
|
+
.run(stamp, user.id);
|
|
422
433
|
})();
|
|
423
434
|
} catch (err) {
|
|
424
435
|
// The user row vanished between the session-resolve check above and
|
|
@@ -476,9 +487,24 @@ export async function handleAccountChangePasswordPost(
|
|
|
476
487
|
export interface AccountHomeDeps extends ApiAccountDeps {
|
|
477
488
|
/** Canonical hub origin for this request (e.g. `https://my-hub.example`). */
|
|
478
489
|
hubOrigin: string;
|
|
490
|
+
/**
|
|
491
|
+
* Resolve a vault's loopback port from services.json, or `null` when no vault
|
|
492
|
+
* is mounted under that name. Threaded in by the route handler (which reads
|
|
493
|
+
* the manifest) so this module stays free of services.json plumbing. Absent
|
|
494
|
+
* in tests / older callers → usage surfacing is skipped (tiles render without
|
|
495
|
+
* the stat, same as a failed fetch).
|
|
496
|
+
*/
|
|
497
|
+
resolveVaultPort?: (vaultName: string) => number | null;
|
|
498
|
+
/**
|
|
499
|
+
* Fetch one vault's usage stat, or `null` on any failure. Defaults to the
|
|
500
|
+
* real `fetchVaultUsage` (mints a read token + hits the vault's loopback
|
|
501
|
+
* usage endpoint). Injectable so tests assert the render without a live
|
|
502
|
+
* vault.
|
|
503
|
+
*/
|
|
504
|
+
fetchUsage?: typeof fetchVaultUsage;
|
|
479
505
|
}
|
|
480
506
|
|
|
481
|
-
export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Response {
|
|
507
|
+
export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Promise<Response> {
|
|
482
508
|
const session = findActiveSession(deps.db, req);
|
|
483
509
|
if (!session) {
|
|
484
510
|
return redirect(`/login?next=${encodeURIComponent("/account/")}`);
|
|
@@ -501,6 +527,32 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
501
527
|
const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
|
|
502
528
|
if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
|
|
503
529
|
}
|
|
530
|
+
|
|
531
|
+
// Per-vault usage stat ("X notes · Y MB"). Fetched concurrently across the
|
|
532
|
+
// user's assigned vaults via the read-scoped vault endpoint; each fetch is
|
|
533
|
+
// independently fault-tolerant (returns null → that tile renders without a
|
|
534
|
+
// stat). Skipped entirely when the route didn't wire a port resolver (tests /
|
|
535
|
+
// older callers). The READ scope means the user's own authority suffices.
|
|
536
|
+
const usageStats: Record<string, string> = {};
|
|
537
|
+
if (deps.resolveVaultPort && user.assignedVaults.length > 0) {
|
|
538
|
+
const fetchUsage = deps.fetchUsage ?? fetchVaultUsage;
|
|
539
|
+
const resolvePort = deps.resolveVaultPort;
|
|
540
|
+
await Promise.all(
|
|
541
|
+
user.assignedVaults.map(async (vaultName) => {
|
|
542
|
+
const port = resolvePort(vaultName);
|
|
543
|
+
if (port === null) return;
|
|
544
|
+
const stat = await fetchUsage(vaultName, {
|
|
545
|
+
db: deps.db,
|
|
546
|
+
hubOrigin: deps.hubOrigin,
|
|
547
|
+
vaultPort: port,
|
|
548
|
+
userId: user.id,
|
|
549
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
550
|
+
});
|
|
551
|
+
if (stat) usageStats[vaultName] = formatUsageStat(stat);
|
|
552
|
+
}),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
504
556
|
return htmlResponse(
|
|
505
557
|
renderAccountHome({
|
|
506
558
|
username: user.username,
|
|
@@ -511,6 +563,7 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
511
563
|
csrfToken: csrf.token,
|
|
512
564
|
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
513
565
|
mintableVerbs,
|
|
566
|
+
usageStats,
|
|
514
567
|
}),
|
|
515
568
|
200,
|
|
516
569
|
extra,
|
|
@@ -0,0 +1,347 @@
|
|
|
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 { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_INVITE_TTL_SECONDS,
|
|
25
|
+
type Invite,
|
|
26
|
+
type InviteStatus,
|
|
27
|
+
findInviteByHash,
|
|
28
|
+
inviteStatus,
|
|
29
|
+
issueInvite,
|
|
30
|
+
listInvites,
|
|
31
|
+
revokeInvite,
|
|
32
|
+
} from "./invites.ts";
|
|
33
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
34
|
+
import { listVaultNamesFromPath } from "./vault-names.ts";
|
|
35
|
+
|
|
36
|
+
export interface ApiInvitesDeps {
|
|
37
|
+
db: Database;
|
|
38
|
+
/** Hub origin — JWT `iss` validation AND the base for the redemption URL. */
|
|
39
|
+
issuer: string;
|
|
40
|
+
manifestPath?: string;
|
|
41
|
+
now?: () => Date;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Roles an invite may grant. `'write'` = owner (full vault admin); `'read'` = read-only (shared). */
|
|
45
|
+
const ALLOWED_ROLES = new Set(["read", "write"]);
|
|
46
|
+
/** Cap an invite TTL so a typo can't mint a ~forever link. 90 days. */
|
|
47
|
+
const MAX_INVITE_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
48
|
+
|
|
49
|
+
interface InviteWireShape {
|
|
50
|
+
/** sha256 hash — the stable id for list/revoke. NOT the raw token. */
|
|
51
|
+
id: string;
|
|
52
|
+
status: InviteStatus;
|
|
53
|
+
vault_name: string | null;
|
|
54
|
+
role: string;
|
|
55
|
+
provision_vault: boolean;
|
|
56
|
+
default_mirror: string | null;
|
|
57
|
+
expires_at: string;
|
|
58
|
+
used_at: string | null;
|
|
59
|
+
redeemed_user_id: string | null;
|
|
60
|
+
revoked_at: string | null;
|
|
61
|
+
created_at: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
|
|
65
|
+
return {
|
|
66
|
+
id: invite.tokenHash,
|
|
67
|
+
status,
|
|
68
|
+
vault_name: invite.vaultName,
|
|
69
|
+
role: invite.role,
|
|
70
|
+
provision_vault: invite.provisionVault,
|
|
71
|
+
default_mirror: invite.defaultMirror,
|
|
72
|
+
expires_at: invite.expiresAt,
|
|
73
|
+
used_at: invite.usedAt,
|
|
74
|
+
redeemed_user_id: invite.redeemedUserId,
|
|
75
|
+
revoked_at: invite.revokedAt,
|
|
76
|
+
created_at: invite.createdAt,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
81
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
82
|
+
status,
|
|
83
|
+
headers: { "content-type": "application/json" },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function redeemUrl(issuer: string, rawToken: string): string {
|
|
88
|
+
const base = issuer.replace(/\/$/, "");
|
|
89
|
+
return `${base}/account/setup/${rawToken}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface CreateInviteBody {
|
|
93
|
+
vaultName: string | null;
|
|
94
|
+
role: string;
|
|
95
|
+
provisionVault: boolean;
|
|
96
|
+
defaultMirror: string | null;
|
|
97
|
+
expiresInSeconds: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ParseErr {
|
|
101
|
+
ok: false;
|
|
102
|
+
status: number;
|
|
103
|
+
error: string;
|
|
104
|
+
description: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function parseCreateBody(
|
|
108
|
+
req: Request,
|
|
109
|
+
): Promise<{ ok: true; body: CreateInviteBody } | ParseErr> {
|
|
110
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
111
|
+
// Allow an empty body — every field has a default. Only enforce JSON when
|
|
112
|
+
// a body is actually present.
|
|
113
|
+
let raw: unknown = {};
|
|
114
|
+
if (ctype.toLowerCase().includes("application/json")) {
|
|
115
|
+
try {
|
|
116
|
+
const text = await req.text();
|
|
117
|
+
raw = text.trim() === "" ? {} : JSON.parse(text);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
status: 400,
|
|
123
|
+
error: "invalid_request",
|
|
124
|
+
description: `invalid JSON body: ${msg}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!raw || typeof raw !== "object") {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
status: 400,
|
|
132
|
+
error: "invalid_request",
|
|
133
|
+
description: "request body must be a JSON object",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const obj = raw as Record<string, unknown>;
|
|
137
|
+
|
|
138
|
+
// vault_name — optional. null/omitted = redeemer names their own vault.
|
|
139
|
+
let vaultName: string | null = null;
|
|
140
|
+
const rawVault =
|
|
141
|
+
Object.hasOwn(obj, "vault_name") && obj.vault_name !== undefined
|
|
142
|
+
? obj.vault_name
|
|
143
|
+
: Object.hasOwn(obj, "vaultName") && obj.vaultName !== undefined
|
|
144
|
+
? obj.vaultName
|
|
145
|
+
: undefined;
|
|
146
|
+
if (rawVault !== undefined && rawVault !== null) {
|
|
147
|
+
if (typeof rawVault !== "string" || rawVault.length === 0) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
status: 400,
|
|
151
|
+
error: "invalid_request",
|
|
152
|
+
description: '"vault_name" must be a non-empty string or null',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!VAULT_NAME_CHARSET_RE.test(rawVault)) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 400,
|
|
159
|
+
error: "invalid_request",
|
|
160
|
+
description:
|
|
161
|
+
"vault name must contain only lowercase letters, numbers, hyphens, and underscores",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
vaultName = rawVault;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// role — default 'write' (owner).
|
|
168
|
+
let role = "write";
|
|
169
|
+
const rawRole = obj.role;
|
|
170
|
+
if (rawRole !== undefined && rawRole !== null) {
|
|
171
|
+
if (typeof rawRole !== "string" || !ALLOWED_ROLES.has(rawRole)) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
status: 400,
|
|
175
|
+
error: "invalid_request",
|
|
176
|
+
description: '"role" must be "read" or "write"',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
role = rawRole;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// provision_vault — default true (the primary flow).
|
|
183
|
+
let provisionVault = true;
|
|
184
|
+
const rawProvision =
|
|
185
|
+
Object.hasOwn(obj, "provision_vault") && obj.provision_vault !== undefined
|
|
186
|
+
? obj.provision_vault
|
|
187
|
+
: Object.hasOwn(obj, "provisionVault") && obj.provisionVault !== undefined
|
|
188
|
+
? obj.provisionVault
|
|
189
|
+
: undefined;
|
|
190
|
+
if (rawProvision !== undefined) {
|
|
191
|
+
if (typeof rawProvision !== "boolean") {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
status: 400,
|
|
195
|
+
error: "invalid_request",
|
|
196
|
+
description: '"provision_vault" must be a boolean',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
provisionVault = rawProvision;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// default_mirror — optional 'internal' | 'off'.
|
|
203
|
+
let defaultMirror: string | null = null;
|
|
204
|
+
const rawMirror =
|
|
205
|
+
Object.hasOwn(obj, "default_mirror") && obj.default_mirror !== undefined
|
|
206
|
+
? obj.default_mirror
|
|
207
|
+
: Object.hasOwn(obj, "defaultMirror") && obj.defaultMirror !== undefined
|
|
208
|
+
? obj.defaultMirror
|
|
209
|
+
: undefined;
|
|
210
|
+
if (rawMirror !== undefined && rawMirror !== null) {
|
|
211
|
+
if (rawMirror !== "internal" && rawMirror !== "off") {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
status: 400,
|
|
215
|
+
error: "invalid_request",
|
|
216
|
+
description: '"default_mirror" must be "internal" or "off"',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
defaultMirror = rawMirror;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// expires_in — seconds; default 7 days; capped at 90 days.
|
|
223
|
+
let expiresInSeconds = DEFAULT_INVITE_TTL_SECONDS;
|
|
224
|
+
const rawExpiry =
|
|
225
|
+
Object.hasOwn(obj, "expires_in") && obj.expires_in !== undefined
|
|
226
|
+
? obj.expires_in
|
|
227
|
+
: Object.hasOwn(obj, "expiresIn") && obj.expiresIn !== undefined
|
|
228
|
+
? obj.expiresIn
|
|
229
|
+
: undefined;
|
|
230
|
+
if (rawExpiry !== undefined && rawExpiry !== null) {
|
|
231
|
+
if (typeof rawExpiry !== "number" || !Number.isFinite(rawExpiry) || rawExpiry <= 0) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
status: 400,
|
|
235
|
+
error: "invalid_request",
|
|
236
|
+
description: '"expires_in" must be a positive number of seconds',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (rawExpiry > MAX_INVITE_TTL_SECONDS) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
status: 400,
|
|
243
|
+
error: "invalid_request",
|
|
244
|
+
description: `"expires_in" must be ≤ ${MAX_INVITE_TTL_SECONDS} seconds (90 days)`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
expiresInSeconds = Math.floor(rawExpiry);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { ok: true, body: { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** POST /api/invites — create an invite, return the single-emit URL + token. */
|
|
254
|
+
export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Promise<Response> {
|
|
255
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
256
|
+
let authUserId: string;
|
|
257
|
+
try {
|
|
258
|
+
// `requireScope` returns the validated claims; the admin's `sub` is the
|
|
259
|
+
// `created_by` audit anchor (guaranteed present — it throws otherwise).
|
|
260
|
+
const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
261
|
+
authUserId = auth.sub;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
264
|
+
}
|
|
265
|
+
const parsed = await parseCreateBody(req);
|
|
266
|
+
if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
|
|
267
|
+
const { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } = parsed.body;
|
|
268
|
+
|
|
269
|
+
// A pinned vault_name with provision_vault=false means "assign an existing
|
|
270
|
+
// vault" — validate it exists. (provision_vault=true with a pinned name
|
|
271
|
+
// provisions THAT name; provision_vault=false without a name is account-only.)
|
|
272
|
+
if (vaultName !== null && !provisionVault) {
|
|
273
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
274
|
+
const known = new Set(listVaultNamesFromPath(manifestPath));
|
|
275
|
+
if (!known.has(vaultName)) {
|
|
276
|
+
return jsonError(
|
|
277
|
+
400,
|
|
278
|
+
"vault_not_found",
|
|
279
|
+
`vault "${vaultName}" is not registered in services.json`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const issued = issueInvite(deps.db, {
|
|
285
|
+
createdBy: authUserId,
|
|
286
|
+
vaultName,
|
|
287
|
+
role,
|
|
288
|
+
provisionVault,
|
|
289
|
+
defaultMirror,
|
|
290
|
+
expiresInSeconds,
|
|
291
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
292
|
+
});
|
|
293
|
+
const status = inviteStatus(issued.invite, (deps.now ?? (() => new Date()))());
|
|
294
|
+
return new Response(
|
|
295
|
+
JSON.stringify({
|
|
296
|
+
invite: toWire(issued.invite, status),
|
|
297
|
+
// Single-emit: the raw token only ever appears here.
|
|
298
|
+
token: issued.rawToken,
|
|
299
|
+
url: redeemUrl(deps.issuer, issued.rawToken),
|
|
300
|
+
}),
|
|
301
|
+
{ status: 201, headers: { "content-type": "application/json", "cache-control": "no-store" } },
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** GET /api/invites — list invites, newest first, status-annotated. */
|
|
306
|
+
export async function handleListInvites(req: Request, deps: ApiInvitesDeps): Promise<Response> {
|
|
307
|
+
if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
|
|
308
|
+
try {
|
|
309
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
312
|
+
}
|
|
313
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
314
|
+
const invites = listInvites(deps.db, now).map((i) => toWire(i, i.status));
|
|
315
|
+
return new Response(JSON.stringify({ invites }), {
|
|
316
|
+
status: 200,
|
|
317
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** DELETE /api/invites/:id — revoke a pending invite by its sha256 hash. */
|
|
322
|
+
export async function handleRevokeInvite(
|
|
323
|
+
req: Request,
|
|
324
|
+
id: string,
|
|
325
|
+
deps: ApiInvitesDeps,
|
|
326
|
+
): Promise<Response> {
|
|
327
|
+
if (req.method !== "DELETE") return jsonError(405, "method_not_allowed", "use DELETE");
|
|
328
|
+
try {
|
|
329
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
332
|
+
}
|
|
333
|
+
const existing = findInviteByHash(deps.db, id);
|
|
334
|
+
if (!existing) return jsonError(404, "not_found", `no invite with id "${id}"`);
|
|
335
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
336
|
+
const revoked = revokeInvite(deps.db, id, now);
|
|
337
|
+
if (!revoked) {
|
|
338
|
+
// Already redeemed or revoked — nothing to do, but report the terminal
|
|
339
|
+
// state so the SPA can refresh rather than silently swallowing.
|
|
340
|
+
const status = inviteStatus(existing, now);
|
|
341
|
+
return jsonError(409, "invite_not_pending", `invite is already ${status}`);
|
|
342
|
+
}
|
|
343
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
344
|
+
status: 200,
|
|
345
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
346
|
+
});
|
|
347
|
+
}
|
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");
|