@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.
Files changed (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. 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
+ }
@@ -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 { ok: true, body: { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } };
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 } = parsed.body;
306
+ const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
307
+ parsed.body;
308
+ const now = (deps.now ?? (() => new Date()))();
266
309
 
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) {
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
- "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",
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, (deps.now ?? (() => new Date()))());
371
+ const status = inviteStatus(issued.invite, now);
292
372
  return new Response(
293
373
  JSON.stringify({
294
374
  invite: toWire(issued.invite, status),
@@ -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/channel/install` resolve — channel was discoverable +
290
- * running yet un-installable because it sat outside the whitelist.
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.
@@ -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/channel (vendored manifests
57
- // still required) and the latter for vault/scribe/runner (post-FALLBACK
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 (channel `/channel/admin`, scribe
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/channel-token`).
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. Channel declares `configUiUrl: "/channel/admin"` + `uiUrl`;
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` — channel ships
497
- // `configUiUrl: "/channel/admin"` (single-instance, origin-absolute)
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, channel) declare their full hub-origin
748
- * path this way (`/surface/admin/`, `/scribe/admin`, `/channel/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