@openparachute/hub 0.7.0 → 0.7.2-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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin screen-lock management API (hub admin-lock feature).
|
|
3
|
+
*
|
|
4
|
+
* All endpoints are session-cookie-gated to the first admin — the same gate
|
|
5
|
+
* the admin-token mints use (`isFirstAdmin`). These manage the lock itself, so
|
|
6
|
+
* they are NOT behind the lock gate (you must be able to unlock + set the PIN
|
|
7
|
+
* even when the surface is locked).
|
|
8
|
+
*
|
|
9
|
+
* GET /api/admin-lock → status { configured, locked, idle_seconds, unlock_seconds_remaining }
|
|
10
|
+
* POST /api/admin-lock/set → set the FIRST PIN (no PIN configured yet)
|
|
11
|
+
* POST /api/admin-lock/change → rotate PIN (requires current PIN or unlocked session)
|
|
12
|
+
* POST /api/admin-lock/remove → turn the feature OFF (requires current PIN or unlocked session)
|
|
13
|
+
* POST /api/admin-lock/unlock → verify PIN, open an unlock window
|
|
14
|
+
* POST /api/admin-lock/lock → "Lock now" — drop the session's unlock window
|
|
15
|
+
* POST /api/admin-lock/heartbeat → slide the idle window forward on activity
|
|
16
|
+
*
|
|
17
|
+
* The chicken-and-egg: setting the FIRST PIN is an authenticated admin action
|
|
18
|
+
* (logged-in session, no PIN yet → allowed). Once a PIN exists, change/remove
|
|
19
|
+
* require proving knowledge of the current PIN OR an already-unlocked session
|
|
20
|
+
* (the operator just unlocked, so they hold the PIN — re-typing would be
|
|
21
|
+
* friction). Setting over an existing PIN is rejected (use change).
|
|
22
|
+
*
|
|
23
|
+
* State-changing POSTs additionally require a CSRF token (`__csrf`) — these
|
|
24
|
+
* are JSON endpoints on the same origin as the cookie, and SameSite=Lax alone
|
|
25
|
+
* doesn't stop same-site CSRF (see src/csrf.ts). The SPA sources the token
|
|
26
|
+
* from `/api/me`.
|
|
27
|
+
*/
|
|
28
|
+
import type { Database } from "bun:sqlite";
|
|
29
|
+
import {
|
|
30
|
+
clampIdleSeconds,
|
|
31
|
+
clearPin,
|
|
32
|
+
getIdleSeconds,
|
|
33
|
+
isLockConfigured,
|
|
34
|
+
isSessionUnlocked,
|
|
35
|
+
lockSession,
|
|
36
|
+
recordUnlock,
|
|
37
|
+
refreshActivity,
|
|
38
|
+
setIdleSeconds,
|
|
39
|
+
setPin,
|
|
40
|
+
unlockLimiter,
|
|
41
|
+
unlockSecondsRemaining,
|
|
42
|
+
validatePin,
|
|
43
|
+
verifyPin,
|
|
44
|
+
} from "./admin-lock.ts";
|
|
45
|
+
import { verifyCsrfToken } from "./csrf.ts";
|
|
46
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
47
|
+
import { isFirstAdmin } from "./users.ts";
|
|
48
|
+
|
|
49
|
+
export interface AdminLockDeps {
|
|
50
|
+
db: Database;
|
|
51
|
+
/** Injectable clock for tests. */
|
|
52
|
+
now?: () => Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function json(status: number, body: unknown): Response {
|
|
56
|
+
return new Response(JSON.stringify(body), {
|
|
57
|
+
status,
|
|
58
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function err(status: number, error: string, description: string): Response {
|
|
63
|
+
return json(status, { error, error_description: description });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the first-admin session for this request, or an error Response.
|
|
68
|
+
* Mirrors the gate the token-mint endpoints apply, so the lock-management
|
|
69
|
+
* surface has the same audience.
|
|
70
|
+
*/
|
|
71
|
+
function requireAdminSession(
|
|
72
|
+
db: Database,
|
|
73
|
+
req: Request,
|
|
74
|
+
): { ok: true; sessionId: string; userId: string } | { ok: false; res: Response } {
|
|
75
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
76
|
+
const session = sid ? findSession(db, sid) : null;
|
|
77
|
+
if (!session || !sid) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
res: err(401, "unauthenticated", "no admin session — sign in at /login first"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!isFirstAdmin(db, session.userId)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
res: err(
|
|
87
|
+
403,
|
|
88
|
+
"not_admin",
|
|
89
|
+
"admin lock management is restricted to the hub admin — your account home is at /account/",
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, sessionId: sid, userId: session.userId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
|
|
97
|
+
try {
|
|
98
|
+
const body = (await req.json()) as unknown;
|
|
99
|
+
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkCsrf(req: Request, body: Record<string, unknown>): boolean {
|
|
106
|
+
const token = typeof body.__csrf === "string" ? body.__csrf : null;
|
|
107
|
+
return verifyCsrfToken(req, token);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The lock-management router. `subpath` is the path AFTER `/api/admin-lock`
|
|
112
|
+
* (e.g. "" for status, "/unlock", "/set"). The hub-server dispatcher slices it.
|
|
113
|
+
*/
|
|
114
|
+
export async function handleAdminLock(
|
|
115
|
+
req: Request,
|
|
116
|
+
subpath: string,
|
|
117
|
+
deps: AdminLockDeps,
|
|
118
|
+
): Promise<Response> {
|
|
119
|
+
const { db } = deps;
|
|
120
|
+
const now = deps.now ?? (() => new Date());
|
|
121
|
+
|
|
122
|
+
// GET status (no body, no CSRF — read-only).
|
|
123
|
+
if (subpath === "" || subpath === "/") {
|
|
124
|
+
if (req.method !== "GET") return err(405, "method_not_allowed", "use GET");
|
|
125
|
+
const gate = requireAdminSession(db, req);
|
|
126
|
+
if (!gate.ok) return gate.res;
|
|
127
|
+
const configured = isLockConfigured(db);
|
|
128
|
+
const nowMs = now().getTime();
|
|
129
|
+
const locked = configured && !isSessionUnlocked(gate.sessionId, nowMs);
|
|
130
|
+
return json(200, {
|
|
131
|
+
configured,
|
|
132
|
+
locked,
|
|
133
|
+
idle_seconds: getIdleSeconds(db),
|
|
134
|
+
unlock_seconds_remaining: configured ? unlockSecondsRemaining(gate.sessionId, nowMs) : 0,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Everything below is a POST.
|
|
139
|
+
if (req.method !== "POST") return err(405, "method_not_allowed", "use POST");
|
|
140
|
+
const gate = requireAdminSession(db, req);
|
|
141
|
+
if (!gate.ok) return gate.res;
|
|
142
|
+
const body = await readJsonBody(req);
|
|
143
|
+
if (!checkCsrf(req, body)) {
|
|
144
|
+
return err(403, "csrf_failed", "missing or invalid CSRF token");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
switch (subpath) {
|
|
148
|
+
case "/set":
|
|
149
|
+
return handleSet(db, gate.sessionId, body);
|
|
150
|
+
case "/change":
|
|
151
|
+
return handleChange(db, gate.sessionId, body, now);
|
|
152
|
+
case "/remove":
|
|
153
|
+
return handleRemove(db, gate.sessionId, body, now);
|
|
154
|
+
case "/unlock":
|
|
155
|
+
return handleUnlock(db, gate.sessionId, gate.userId, body, now);
|
|
156
|
+
case "/lock":
|
|
157
|
+
lockSession(gate.sessionId);
|
|
158
|
+
return json(200, { locked: true });
|
|
159
|
+
case "/heartbeat":
|
|
160
|
+
// Slide the idle window forward if (and only if) currently unlocked.
|
|
161
|
+
refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
|
|
162
|
+
return json(200, {
|
|
163
|
+
locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
|
|
164
|
+
unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
|
|
165
|
+
});
|
|
166
|
+
default:
|
|
167
|
+
return err(404, "not_found", `no admin-lock route at /api/admin-lock${subpath}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Optional idle-seconds override from a request body field. Clamped; undefined when absent/invalid. */
|
|
172
|
+
function readIdleSeconds(body: Record<string, unknown>): number | undefined {
|
|
173
|
+
const raw = body.idle_seconds;
|
|
174
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
|
|
175
|
+
return clampIdleSeconds(raw);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function handleSet(
|
|
179
|
+
db: Database,
|
|
180
|
+
sessionId: string,
|
|
181
|
+
body: Record<string, unknown>,
|
|
182
|
+
): Promise<Response> {
|
|
183
|
+
// Setting the FIRST PIN — refuse if one already exists (use /change).
|
|
184
|
+
if (isLockConfigured(db)) {
|
|
185
|
+
return err(409, "already_configured", "a PIN is already set — use /api/admin-lock/change");
|
|
186
|
+
}
|
|
187
|
+
const pin = typeof body.pin === "string" ? body.pin : "";
|
|
188
|
+
if (!validatePin(pin).valid) {
|
|
189
|
+
return err(400, "invalid_pin", "PIN must be 4–12 digits");
|
|
190
|
+
}
|
|
191
|
+
const idle = readIdleSeconds(body);
|
|
192
|
+
if (idle !== undefined) setIdleSeconds(db, idle);
|
|
193
|
+
await setPin(db, pin);
|
|
194
|
+
// Setting the PIN immediately opens an unlock window for this session — the
|
|
195
|
+
// operator who just typed it isn't locked out of their own current session.
|
|
196
|
+
recordUnlock(sessionId, getIdleSeconds(db));
|
|
197
|
+
return json(201, { configured: true, locked: false, idle_seconds: getIdleSeconds(db) });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function handleChange(
|
|
201
|
+
db: Database,
|
|
202
|
+
sessionId: string,
|
|
203
|
+
body: Record<string, unknown>,
|
|
204
|
+
now: () => Date,
|
|
205
|
+
): Promise<Response> {
|
|
206
|
+
if (!isLockConfigured(db)) {
|
|
207
|
+
return err(409, "not_configured", "no PIN is set — use /api/admin-lock/set");
|
|
208
|
+
}
|
|
209
|
+
const newPin = typeof body.new_pin === "string" ? body.new_pin : "";
|
|
210
|
+
if (!validatePin(newPin).valid) {
|
|
211
|
+
return err(400, "invalid_pin", "new PIN must be 4–12 digits");
|
|
212
|
+
}
|
|
213
|
+
// Authorize the change: an already-unlocked session OR a correct current PIN.
|
|
214
|
+
const authorized = await authorizeMutation(db, sessionId, body, now);
|
|
215
|
+
if (!authorized.ok) return authorized.res;
|
|
216
|
+
const idle = readIdleSeconds(body);
|
|
217
|
+
if (idle !== undefined) setIdleSeconds(db, idle);
|
|
218
|
+
await setPin(db, newPin);
|
|
219
|
+
// Re-open the unlock window for this session under the new PIN.
|
|
220
|
+
recordUnlock(sessionId, getIdleSeconds(db), now().getTime());
|
|
221
|
+
return json(200, { configured: true, locked: false, idle_seconds: getIdleSeconds(db) });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleRemove(
|
|
225
|
+
db: Database,
|
|
226
|
+
sessionId: string,
|
|
227
|
+
body: Record<string, unknown>,
|
|
228
|
+
now: () => Date,
|
|
229
|
+
): Promise<Response> {
|
|
230
|
+
if (!isLockConfigured(db)) {
|
|
231
|
+
// Idempotent — already off.
|
|
232
|
+
return json(200, { configured: false, locked: false });
|
|
233
|
+
}
|
|
234
|
+
const authorized = await authorizeMutation(db, sessionId, body, now);
|
|
235
|
+
if (!authorized.ok) return authorized.res;
|
|
236
|
+
clearPin(db);
|
|
237
|
+
// Feature is off now — drop any unlock window (it no longer means anything).
|
|
238
|
+
lockSession(sessionId);
|
|
239
|
+
return json(200, { configured: false, locked: false });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Shared authorization for change/remove: succeed when the session is already
|
|
244
|
+
* unlocked, OR a correct `current_pin` is supplied. The current-PIN path runs
|
|
245
|
+
* through the same brute-force limiter as /unlock.
|
|
246
|
+
*/
|
|
247
|
+
async function authorizeMutation(
|
|
248
|
+
db: Database,
|
|
249
|
+
sessionId: string,
|
|
250
|
+
body: Record<string, unknown>,
|
|
251
|
+
now: () => Date,
|
|
252
|
+
): Promise<{ ok: true } | { ok: false; res: Response }> {
|
|
253
|
+
if (isSessionUnlocked(sessionId, now().getTime())) return { ok: true };
|
|
254
|
+
const currentPin = typeof body.current_pin === "string" ? body.current_pin : "";
|
|
255
|
+
if (!currentPin) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
res: err(
|
|
259
|
+
401,
|
|
260
|
+
"pin_required",
|
|
261
|
+
"this session is locked — supply current_pin (or unlock first)",
|
|
262
|
+
),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// Rate-limit BEFORE the argon2id verify (a stolen cookie shouldn't grind).
|
|
266
|
+
const rl = unlockLimiter.checkAndRecord(sessionId, now());
|
|
267
|
+
if (!rl.allowed) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
res: new Response(
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
error: "too_many_attempts",
|
|
273
|
+
error_description: `too many PIN attempts — retry in ${rl.retryAfterSeconds}s`,
|
|
274
|
+
}),
|
|
275
|
+
{
|
|
276
|
+
status: 429,
|
|
277
|
+
headers: {
|
|
278
|
+
"content-type": "application/json",
|
|
279
|
+
"cache-control": "no-store",
|
|
280
|
+
"retry-after": String(rl.retryAfterSeconds ?? 60),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const ok = await verifyPin(db, currentPin);
|
|
287
|
+
if (!ok) return { ok: false, res: err(401, "wrong_pin", "incorrect PIN") };
|
|
288
|
+
return { ok: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function handleUnlock(
|
|
292
|
+
db: Database,
|
|
293
|
+
sessionId: string,
|
|
294
|
+
_userId: string,
|
|
295
|
+
body: Record<string, unknown>,
|
|
296
|
+
now: () => Date,
|
|
297
|
+
): Promise<Response> {
|
|
298
|
+
if (!isLockConfigured(db)) {
|
|
299
|
+
// No PIN → nothing to unlock; treat as already-open so the SPA proceeds.
|
|
300
|
+
return json(200, { unlocked: true, configured: false, idle_seconds: getIdleSeconds(db) });
|
|
301
|
+
}
|
|
302
|
+
const pin = typeof body.pin === "string" ? body.pin : "";
|
|
303
|
+
// Rate-limit BEFORE the argon2id verify — keyed by session (the session
|
|
304
|
+
// already identifies the actor; a stolen cookie shouldn't get fresh buckets
|
|
305
|
+
// across IPs). The denied attempt still counts toward the bucket so a wrong
|
|
306
|
+
// PIN can't be retried infinitely.
|
|
307
|
+
const rl = unlockLimiter.checkAndRecord(sessionId, now());
|
|
308
|
+
if (!rl.allowed) {
|
|
309
|
+
return new Response(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
error: "too_many_attempts",
|
|
312
|
+
error_description: `too many PIN attempts — retry in ${rl.retryAfterSeconds}s`,
|
|
313
|
+
}),
|
|
314
|
+
{
|
|
315
|
+
status: 429,
|
|
316
|
+
headers: {
|
|
317
|
+
"content-type": "application/json",
|
|
318
|
+
"cache-control": "no-store",
|
|
319
|
+
"retry-after": String(rl.retryAfterSeconds ?? 60),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const ok = await verifyPin(db, pin);
|
|
325
|
+
if (!ok) {
|
|
326
|
+
return err(401, "wrong_pin", "incorrect PIN");
|
|
327
|
+
}
|
|
328
|
+
recordUnlock(sessionId, getIdleSeconds(db), now().getTime());
|
|
329
|
+
return json(200, {
|
|
330
|
+
unlocked: true,
|
|
331
|
+
configured: true,
|
|
332
|
+
idle_seconds: getIdleSeconds(db),
|
|
333
|
+
unlock_seconds_remaining: unlockSecondsRemaining(sessionId, now().getTime()),
|
|
334
|
+
});
|
|
335
|
+
}
|
package/src/api-invites.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { Database } from "bun:sqlite";
|
|
20
20
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
21
21
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
22
|
+
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
22
23
|
import {
|
|
23
24
|
DEFAULT_INVITE_TTL_SECONDS,
|
|
24
25
|
type Invite,
|
|
@@ -28,8 +29,11 @@ import {
|
|
|
28
29
|
issueInvite,
|
|
29
30
|
listInvites,
|
|
30
31
|
revokeInvite,
|
|
32
|
+
usernameReservedByPendingInvite,
|
|
31
33
|
} from "./invites.ts";
|
|
34
|
+
import { getUserByUsernameCI, validateUsername } from "./users.ts";
|
|
32
35
|
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
36
|
+
import { listVaultNamesFromPath } from "./vault-names.ts";
|
|
33
37
|
|
|
34
38
|
export interface ApiInvitesDeps {
|
|
35
39
|
db: Database;
|
|
@@ -49,6 +53,7 @@ interface InviteWireShape {
|
|
|
49
53
|
id: string;
|
|
50
54
|
status: InviteStatus;
|
|
51
55
|
vault_name: string | null;
|
|
56
|
+
username: string | null;
|
|
52
57
|
role: string;
|
|
53
58
|
provision_vault: boolean;
|
|
54
59
|
default_mirror: string | null;
|
|
@@ -64,6 +69,7 @@ function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
|
|
|
64
69
|
id: invite.tokenHash,
|
|
65
70
|
status,
|
|
66
71
|
vault_name: invite.vaultName,
|
|
72
|
+
username: invite.username,
|
|
67
73
|
role: invite.role,
|
|
68
74
|
provision_vault: invite.provisionVault,
|
|
69
75
|
default_mirror: invite.defaultMirror,
|
|
@@ -89,6 +95,7 @@ function redeemUrl(issuer: string, rawToken: string): string {
|
|
|
89
95
|
|
|
90
96
|
interface CreateInviteBody {
|
|
91
97
|
vaultName: string | null;
|
|
98
|
+
username: string | null;
|
|
92
99
|
role: string;
|
|
93
100
|
provisionVault: boolean;
|
|
94
101
|
defaultMirror: string | null;
|
|
@@ -162,6 +169,37 @@ async function parseCreateBody(
|
|
|
162
169
|
vaultName = rawVault;
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
// username — optional. null/omitted = redeemer picks their own. Validated
|
|
173
|
+
// with the SAME vocabulary as /api/users (charset + length + reserved).
|
|
174
|
+
let username: string | null = null;
|
|
175
|
+
const rawUsername =
|
|
176
|
+
Object.hasOwn(obj, "username") && obj.username !== undefined ? obj.username : undefined;
|
|
177
|
+
if (rawUsername !== undefined && rawUsername !== null) {
|
|
178
|
+
if (typeof rawUsername !== "string" || rawUsername.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
status: 400,
|
|
182
|
+
error: "invalid_request",
|
|
183
|
+
description: '"username" must be a non-empty string or null',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const u = validateUsername(rawUsername);
|
|
187
|
+
if (!u.valid) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
status: 400,
|
|
191
|
+
error: "invalid_username",
|
|
192
|
+
description:
|
|
193
|
+
u.reason === "length"
|
|
194
|
+
? "username must be 2-32 characters long"
|
|
195
|
+
: u.reason === "reserved"
|
|
196
|
+
? "username is reserved (admin, root, system, setup, parachute, hub)"
|
|
197
|
+
: "username must contain only lowercase letters, digits, hyphens, and underscores ([a-z0-9_-])",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
username = u.name;
|
|
201
|
+
}
|
|
202
|
+
|
|
165
203
|
// role — default 'write' (owner).
|
|
166
204
|
let role = "write";
|
|
167
205
|
const rawRole = obj.role;
|
|
@@ -245,7 +283,10 @@ async function parseCreateBody(
|
|
|
245
283
|
expiresInSeconds = Math.floor(rawExpiry);
|
|
246
284
|
}
|
|
247
285
|
|
|
248
|
-
return {
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
body: { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds },
|
|
289
|
+
};
|
|
249
290
|
}
|
|
250
291
|
|
|
251
292
|
/** POST /api/invites — create an invite, return the single-emit URL + token. */
|
|
@@ -262,33 +303,72 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
|
|
|
262
303
|
}
|
|
263
304
|
const parsed = await parseCreateBody(req);
|
|
264
305
|
if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
|
|
265
|
-
const { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } =
|
|
306
|
+
const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
|
|
307
|
+
parsed.body;
|
|
308
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
266
309
|
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// provision_vault=false
|
|
274
|
-
|
|
310
|
+
// Shape gates over the three supported invite shapes:
|
|
311
|
+
//
|
|
312
|
+
// 1. provision_vault=true (+ optional pinned NEW name) — redemption
|
|
313
|
+
// provisions a fresh vault for the redeemer. Role must be 'write':
|
|
314
|
+
// the redeemer is the vault's ONLY user, so a read-only sole user
|
|
315
|
+
// would leave the new vault permanently un-writable.
|
|
316
|
+
// 2. provision_vault=false + vault_name — SHARED-VAULT invite: redemption
|
|
317
|
+
// assigns the redeemer to the admin's EXISTING vault at `role` ('read'
|
|
318
|
+
// or 'write'). The vault must exist NOW (services.json) — pinning a
|
|
319
|
+
// nonexistent name is a typo, not a future reservation. Issuing is
|
|
320
|
+
// host:admin-gated, the same authority that can already assign any
|
|
321
|
+
// user to any vault via POST /api/users — the invite only packages
|
|
322
|
+
// that assignment as a deliverable link. The vault-delete cascade
|
|
323
|
+
// (`revokeInvitesForVault`) revokes pending shared invites when the
|
|
324
|
+
// pinned vault is deleted, and the redeem path re-checks existence.
|
|
325
|
+
// 3. provision_vault=false with NO name — account-only (assignedVaults=[]).
|
|
326
|
+
if (provisionVault && role !== "write") {
|
|
275
327
|
return jsonError(
|
|
276
328
|
400,
|
|
277
329
|
"invalid_request",
|
|
278
|
-
|
|
330
|
+
'a provisioned vault\'s sole user must hold write — use role "write", or share an existing vault (provision_vault=false + vault_name) for read-only access',
|
|
279
331
|
);
|
|
280
332
|
}
|
|
333
|
+
if (vaultName !== null && !provisionVault) {
|
|
334
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
335
|
+
const known = new Set(listVaultNamesFromPath(manifestPath));
|
|
336
|
+
if (!known.has(vaultName)) {
|
|
337
|
+
return jsonError(
|
|
338
|
+
400,
|
|
339
|
+
"vault_not_found",
|
|
340
|
+
`vault "${vaultName}" is not registered on this hub — shared-vault invites must name an existing vault`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Pre-named username: catch collisions at MINT time (the redeem-time check
|
|
346
|
+
// stays authoritative, but an enforced name that's already taken makes the
|
|
347
|
+
// link dead-on-arrival — fail fast for the admin instead).
|
|
348
|
+
if (username !== null) {
|
|
349
|
+
if (getUserByUsernameCI(deps.db, username) !== null) {
|
|
350
|
+
return jsonError(409, "username_taken", `username "${username}" is already in use`);
|
|
351
|
+
}
|
|
352
|
+
if (usernameReservedByPendingInvite(deps.db, username, now)) {
|
|
353
|
+
return jsonError(
|
|
354
|
+
409,
|
|
355
|
+
"username_reserved",
|
|
356
|
+
`username "${username}" is already reserved by another pending invite — revoke that invite first or pick a different name`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
281
360
|
|
|
282
361
|
const issued = issueInvite(deps.db, {
|
|
283
362
|
createdBy: authUserId,
|
|
284
363
|
vaultName,
|
|
364
|
+
username,
|
|
285
365
|
role,
|
|
286
366
|
provisionVault,
|
|
287
367
|
defaultMirror,
|
|
288
368
|
expiresInSeconds,
|
|
289
369
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
290
370
|
});
|
|
291
|
-
const status = inviteStatus(issued.invite,
|
|
371
|
+
const status = inviteStatus(issued.invite, now);
|
|
292
372
|
return new Response(
|
|
293
373
|
JSON.stringify({
|
|
294
374
|
invite: toWire(issued.invite, status),
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -286,8 +286,9 @@ interface PathMatch {
|
|
|
286
286
|
* for — but the gate is now "is this a KNOWN module" (`isKnownModuleShort`:
|
|
287
287
|
* KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old `CURATED_MODULES`
|
|
288
288
|
* whitelist (2026-06-09 modular-UI architecture, P2). This is what makes
|
|
289
|
-
* `POST /api/modules/
|
|
290
|
-
* running yet un-installable because it sat
|
|
289
|
+
* `POST /api/modules/agent/install` resolve — the agent module (then named
|
|
290
|
+
* channel) was discoverable + running yet un-installable because it sat
|
|
291
|
+
* outside the whitelist.
|
|
291
292
|
*
|
|
292
293
|
* The hub (`hub`) and genuinely third-party services.json rows still fall
|
|
293
294
|
* through to undefined: there's no install package to act on.
|
package/src/api-modules.ts
CHANGED
|
@@ -53,8 +53,8 @@ import {
|
|
|
53
53
|
shortNameForManifest,
|
|
54
54
|
} from "./service-spec.ts";
|
|
55
55
|
// `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
|
|
56
|
-
// `lookupModule` below — the former for notes
|
|
57
|
-
//
|
|
56
|
+
// `lookupModule` below — the former for notes (vendored manifest still
|
|
57
|
+
// required) and the latter for vault/scribe/runner/agent (post-FALLBACK
|
|
58
58
|
// retirement, hub#310). The local helper hides the split from the rest of
|
|
59
59
|
// this file. `discoverableShorts` enumerates their UNION — the
|
|
60
60
|
// self-registration-driven discovery surface that replaced the old
|
|
@@ -267,14 +267,14 @@ interface ModuleWireShape {
|
|
|
267
267
|
* `.parachute/module.json` `configUiUrl`, joined against its mount path the
|
|
268
268
|
* same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
|
|
269
269
|
* Modules page's consistent **Configure** action — clicking lands the
|
|
270
|
-
* operator on the module's own config UI (
|
|
270
|
+
* operator on the module's own config UI (agent `/agent/admin`, scribe
|
|
271
271
|
* `/scribe/admin`, …), which mints its admin Bearer from the hub's
|
|
272
|
-
* cookie-gated `/admin/module-token/<short>` (or `/admin/
|
|
272
|
+
* cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
|
|
273
273
|
*
|
|
274
274
|
* Null when the module hasn't declared `configUiUrl` — the SPA omits the
|
|
275
275
|
* Configure action for that module rather than rendering a dead button.
|
|
276
276
|
* Distinct from `management_url`: a module may declare one, both, or
|
|
277
|
-
* neither.
|
|
277
|
+
* neither. Agent declares `configUiUrl: "/agent/admin"` + `uiUrl`;
|
|
278
278
|
* vault declares `managementUrl` (its admin SPA is the config surface).
|
|
279
279
|
*/
|
|
280
280
|
config_ui_url: string | null;
|
|
@@ -493,8 +493,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
493
493
|
);
|
|
494
494
|
if (resolvedManagement !== undefined) managementUrlByShort.set(short, resolvedManagement);
|
|
495
495
|
// The config surface resolves with the SAME rule. A module may declare
|
|
496
|
-
// `configUiUrl` independently of `managementUrl` —
|
|
497
|
-
// `configUiUrl: "/
|
|
496
|
+
// `configUiUrl` independently of `managementUrl` — agent ships
|
|
497
|
+
// `configUiUrl: "/agent/admin"` (single-instance, origin-absolute)
|
|
498
498
|
// alongside a separate `uiUrl`.
|
|
499
499
|
const resolvedConfig = resolveModuleUrl(m.configUiUrl, value.mountPath, short);
|
|
500
500
|
if (resolvedConfig !== undefined) configUiUrlByShort.set(short, resolvedConfig);
|
|
@@ -744,8 +744,8 @@ let warnedLegacyVaultAdminCandidate = false;
|
|
|
744
744
|
* - `undefined` candidate → `undefined` (the module didn't declare it).
|
|
745
745
|
* - Absolute http(s) URL → returned verbatim (off-origin escape hatch).
|
|
746
746
|
* - Leading-`/` path → ORIGIN-ABSOLUTE, returned verbatim. Single-instance
|
|
747
|
-
* modules (surface, scribe, runner,
|
|
748
|
-
* path this way (`/surface/admin/`, `/scribe/admin`, `/
|
|
747
|
+
* modules (surface, scribe, runner, agent) declare their full hub-origin
|
|
748
|
+
* path this way (`/surface/admin/`, `/scribe/admin`, `/agent/admin`);
|
|
749
749
|
* vault's daemon-level surface is `/vault/admin/`.
|
|
750
750
|
* - Relative path (no leading slash) → MOUNT-JOINED: the per-instance form
|
|
751
751
|
* (`admin/` on a `/vault/default` mount → `/vault/default/admin/`). With
|