@openparachute/hub 0.7.2 → 0.7.3-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -175,7 +175,16 @@ export interface InviteSetupProps {
175
175
  role: string;
176
176
  /** Whether redemption provisions a vault at all (shows the vault row iff true). */
177
177
  provisionVault: boolean;
178
+ /**
179
+ * Whether to collect the redeemer's email (B2). True for public-signup
180
+ * (multi-use) links — the operator must be able to reach the stranger who
181
+ * signed up; false for legacy single-use admin/friends invites. Default
182
+ * false (omitted) preserves the friends flow's no-email form.
183
+ */
184
+ collectEmail?: boolean;
178
185
  username?: string;
186
+ /** Echoed email value on a re-render so the redeemer doesn't retype it. */
187
+ email?: string;
179
188
  vaultName?: string;
180
189
  errorMessage?: string;
181
190
  }
@@ -194,12 +203,28 @@ export function renderInviteSetup(props: InviteSetupProps): string {
194
203
  pinnedUsername,
195
204
  role,
196
205
  provisionVault,
206
+ collectEmail,
197
207
  username,
208
+ email,
198
209
  vaultName,
199
210
  errorMessage,
200
211
  } = props;
201
212
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
202
213
  const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
214
+ const emailAttr = email ? ` value="${escapeAttr(email)}"` : "";
215
+
216
+ // Email row (B2): shown ONLY for public-signup (multi-use) links so the
217
+ // operator can reach the stranger who signed up. `type=email` gets the
218
+ // browser's built-in client-side format check (the server re-validates).
219
+ const emailRow = collectEmail
220
+ ? `
221
+ <label class="field">
222
+ <span class="field-label">Email</span>
223
+ <input type="email" name="email" autocomplete="email" required
224
+ spellcheck="false" autocapitalize="off"${emailAttr} />
225
+ <span class="field-hint">So your hub operator can reach you.</span>
226
+ </label>`
227
+ : "";
203
228
 
204
229
  // Username row: pre-named → read-only display (the server ENFORCES the
205
230
  // invite's username; the disabled input never submits and the handler
@@ -304,6 +329,7 @@ export function renderInviteSetup(props: InviteSetupProps): string {
304
329
  <form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
305
330
  ${renderCsrfHiddenInput(csrfToken)}
306
331
  ${usernameRow}
332
+ ${emailRow}
307
333
  <label class="field">
308
334
  <span class="field-label">Password</span>
309
335
  <input type="password" name="password" autocomplete="new-password"
@@ -405,7 +431,7 @@ const STYLES = `
405
431
  letter-spacing: 0.01em;
406
432
  font-family: ${FONT_MONO};
407
433
  }
408
- input[type=text], input[type=password] {
434
+ input[type=text], input[type=password], input[type=email] {
409
435
  font: inherit;
410
436
  width: 100%;
411
437
  padding: 0.6rem 0.75rem;
@@ -415,7 +441,7 @@ const STYLES = `
415
441
  color: ${PALETTE.fg};
416
442
  transition: border-color 0.15s ease, background 0.15s ease;
417
443
  }
418
- input[type=text]:focus, input[type=password]:focus {
444
+ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus {
419
445
  outline: none;
420
446
  border-color: ${PALETTE.accent};
421
447
  background: ${PALETTE.cardBg};
@@ -461,10 +487,10 @@ const STYLES = `
461
487
  .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
462
488
  h1 { color: #f0ece4; }
463
489
  .subtitle, .field-label { color: #a8a29a; }
464
- input[type=text], input[type=password] {
490
+ input[type=text], input[type=password], input[type=email] {
465
491
  background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
466
492
  }
467
- input[type=text]:focus, input[type=password]:focus {
493
+ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus {
468
494
  background: #25221d;
469
495
  }
470
496
  .brand-tag { border-color: #3a362f; color: #a8a29a; }
@@ -68,6 +68,7 @@ import { revokeTokensNamingVault, signAccessToken } from "./jwt-sign.ts";
68
68
  import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
69
69
  import { enrichedPath } from "./spawn-path.ts";
70
70
  import { removeVaultAssignments } from "./users.ts";
71
+ import { removeVaultCap } from "./vault-caps.ts";
71
72
  import { RESERVED_VAULT_NAMES, VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
72
73
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
73
74
 
@@ -577,6 +578,7 @@ interface CascadeSummary {
577
578
  grants_dropped: number;
578
579
  user_vaults_removed: number;
579
580
  invites_invalidated: number;
581
+ vault_cap_removed: boolean;
580
582
  connections_torn_down: number;
581
583
  /**
582
584
  * Vault-backed channel-daemon entries still referencing the deleted vault
@@ -596,6 +598,7 @@ function emptyCascadeSummary(): CascadeSummary {
596
598
  grants_dropped: 0,
597
599
  user_vaults_removed: 0,
598
600
  invites_invalidated: 0,
601
+ vault_cap_removed: false,
599
602
  connections_torn_down: 0,
600
603
  orphaned_channels: [],
601
604
  vault_removed: false,
@@ -648,7 +651,8 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
648
651
  * it empties — a (user,client) grant can span multiple vaults);
649
652
  * 3. `user_vaults` rows;
650
653
  * 4. unredeemed invites pinned to the vault (redemption would resurrect
651
- * the name);
654
+ * the name); the per-vault storage-cap row (v15) so a re-created
655
+ * same-name vault doesn't inherit a stale cap;
652
656
  * 5. connections whose source/provisioned vault is the deleted vault
653
657
  * (via `teardownConnection`, which post-B0 also revokes the registered
654
658
  * long-lived mints) + a report-only scan of the agent's `/api/channels`
@@ -753,6 +757,10 @@ export async function handleDeleteVault(
753
757
  // --- 4. Unredeemed invites pinned to the vault. ----------------------------
754
758
  summary.invites_invalidated = revokeInvitesForVault(deps.db, name, now);
755
759
 
760
+ // --- 4b. Per-vault storage cap row (v15). ----------------------------------
761
+ // A re-created same-name vault must not inherit a stale cap; drop it.
762
+ summary.vault_cap_removed = removeVaultCap(deps.db, name) > 0;
763
+
756
764
  // --- 5. Connections teardown (+ legacy channel scan, report-only). --------
757
765
  // Runs BEFORE the CLI remove so the vault daemon is still alive to accept
758
766
  // the trigger-deregister calls.
@@ -15,6 +15,18 @@
15
15
  *
16
16
  * Wire shape is snake_case (matches `/api/users`). An invite's raw token
17
17
  * NEVER appears in a GET/list response — only in the POST-create `url`.
18
+ *
19
+ * v15 (DEMO-PREP Workstream B) extends POST /api/invites with `max_uses`
20
+ * (multi-use public-signup links, default 1 = single-use) and
21
+ * `vault_cap_bytes` (per-vault storage cap stamped at provision, B4). A
22
+ * multi-use PROVISIONING link with no explicit `vault_cap_bytes` auto-defaults
23
+ * to ~1 GB (DEFAULT_VAULT_CAP_BYTES) so a public link never provisions
24
+ * uncapped by omission; single-use friends links stay uncapped unless opted
25
+ * in. The list/create wire shape gains `max_uses`, `used_count`, `email`
26
+ * (latest redeemer, B2), and `vault_cap_bytes`. `expires_in` already bounded
27
+ * the link lifetime (B1's expiry). A multi-use link can't pre-name a username
28
+ * or pin a single new vault name (every seat past the first would collide —
29
+ * rejected at mint).
18
30
  */
19
31
  import type { Database } from "bun:sqlite";
20
32
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
@@ -32,6 +44,7 @@ import {
32
44
  usernameReservedByPendingInvite,
33
45
  } from "./invites.ts";
34
46
  import { getUserByUsernameCI, validateUsername } from "./users.ts";
47
+ import { DEFAULT_VAULT_CAP_BYTES } from "./vault-caps.ts";
35
48
  import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
36
49
  import { listVaultNamesFromPath } from "./vault-names.ts";
37
50
 
@@ -47,6 +60,18 @@ export interface ApiInvitesDeps {
47
60
  const ALLOWED_ROLES = new Set(["read", "write"]);
48
61
  /** Cap an invite TTL so a typo can't mint a ~forever link. 90 days. */
49
62
  const MAX_INVITE_TTL_SECONDS = 90 * 24 * 60 * 60;
63
+ /**
64
+ * Cap `max_uses` (v15) so a typo can't mint a runaway public-signup link on a
65
+ * shared box. 1000 is comfortably above any demo cohort while still bounding
66
+ * the abuse surface. The DEMO-PREP shared box uses small values (tens of seats).
67
+ */
68
+ const MAX_INVITE_USES = 1000;
69
+ /**
70
+ * Ceiling on a per-vault cap an invite may carry (v15, B4). 100 GiB — far
71
+ * above the ~1 GB default, but bounds a fat-finger that would make the cap
72
+ * meaningless on a shared box. The minimum is 1 byte (a positive integer).
73
+ */
74
+ const MAX_VAULT_CAP_BYTES = 100 * 1024 * 1024 * 1024;
50
75
 
51
76
  interface InviteWireShape {
52
77
  /** sha256 hash — the stable id for list/revoke. NOT the raw token. */
@@ -57,6 +82,14 @@ interface InviteWireShape {
57
82
  role: string;
58
83
  provision_vault: boolean;
59
84
  default_mirror: string | null;
85
+ /** v15 — how many accounts this link may create (1 = single-use). */
86
+ max_uses: number;
87
+ /** v15 — how many it has created so far. */
88
+ used_count: number;
89
+ /** v15 — most-recent redeemer's email (B2), or null. */
90
+ email: string | null;
91
+ /** v15 — per-vault storage cap to stamp at provision (B4), or null (uncapped). */
92
+ vault_cap_bytes: number | null;
60
93
  expires_at: string;
61
94
  used_at: string | null;
62
95
  redeemed_user_id: string | null;
@@ -73,6 +106,10 @@ function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
73
106
  role: invite.role,
74
107
  provision_vault: invite.provisionVault,
75
108
  default_mirror: invite.defaultMirror,
109
+ max_uses: invite.maxUses,
110
+ used_count: invite.usedCount,
111
+ email: invite.email,
112
+ vault_cap_bytes: invite.vaultCapBytes,
76
113
  expires_at: invite.expiresAt,
77
114
  used_at: invite.usedAt,
78
115
  redeemed_user_id: invite.redeemedUserId,
@@ -100,6 +137,10 @@ interface CreateInviteBody {
100
137
  provisionVault: boolean;
101
138
  defaultMirror: string | null;
102
139
  expiresInSeconds: number;
140
+ /** v15 — how many accounts the link may create (default 1 = single-use). */
141
+ maxUses: number;
142
+ /** v15 — per-vault storage cap (bytes) to stamp at provision, or null (uncapped). */
143
+ vaultCapBytes: number | null;
103
144
  }
104
145
 
105
146
  interface ParseErr {
@@ -283,9 +324,71 @@ async function parseCreateBody(
283
324
  expiresInSeconds = Math.floor(rawExpiry);
284
325
  }
285
326
 
327
+ // max_uses — v15. Default 1 (single-use, the legacy shape). A positive
328
+ // integer ≤ MAX_INVITE_USES. >1 = a multi-use public-signup link.
329
+ let maxUses = 1;
330
+ const rawMaxUses =
331
+ Object.hasOwn(obj, "max_uses") && obj.max_uses !== undefined
332
+ ? obj.max_uses
333
+ : Object.hasOwn(obj, "maxUses") && obj.maxUses !== undefined
334
+ ? obj.maxUses
335
+ : undefined;
336
+ if (rawMaxUses !== undefined && rawMaxUses !== null) {
337
+ if (
338
+ typeof rawMaxUses !== "number" ||
339
+ !Number.isInteger(rawMaxUses) ||
340
+ rawMaxUses < 1 ||
341
+ rawMaxUses > MAX_INVITE_USES
342
+ ) {
343
+ return {
344
+ ok: false,
345
+ status: 400,
346
+ error: "invalid_request",
347
+ description: `"max_uses" must be an integer between 1 and ${MAX_INVITE_USES}`,
348
+ };
349
+ }
350
+ maxUses = rawMaxUses;
351
+ }
352
+
353
+ // vault_cap_bytes — v15, B4. Optional per-vault storage cap stamped at
354
+ // provision. null/omitted = uncapped (legacy). A positive integer
355
+ // ≤ MAX_VAULT_CAP_BYTES.
356
+ let vaultCapBytes: number | null = null;
357
+ const rawCap =
358
+ Object.hasOwn(obj, "vault_cap_bytes") && obj.vault_cap_bytes !== undefined
359
+ ? obj.vault_cap_bytes
360
+ : Object.hasOwn(obj, "vaultCapBytes") && obj.vaultCapBytes !== undefined
361
+ ? obj.vaultCapBytes
362
+ : undefined;
363
+ if (rawCap !== undefined && rawCap !== null) {
364
+ if (
365
+ typeof rawCap !== "number" ||
366
+ !Number.isInteger(rawCap) ||
367
+ rawCap < 1 ||
368
+ rawCap > MAX_VAULT_CAP_BYTES
369
+ ) {
370
+ return {
371
+ ok: false,
372
+ status: 400,
373
+ error: "invalid_request",
374
+ description: `"vault_cap_bytes" must be a positive integer ≤ ${MAX_VAULT_CAP_BYTES} (100 GiB)`,
375
+ };
376
+ }
377
+ vaultCapBytes = rawCap;
378
+ }
379
+
286
380
  return {
287
381
  ok: true,
288
- body: { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds },
382
+ body: {
383
+ vaultName,
384
+ username,
385
+ role,
386
+ provisionVault,
387
+ defaultMirror,
388
+ expiresInSeconds,
389
+ maxUses,
390
+ vaultCapBytes,
391
+ },
289
392
  };
290
393
  }
291
394
 
@@ -303,8 +406,16 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
303
406
  }
304
407
  const parsed = await parseCreateBody(req);
305
408
  if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
306
- const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
307
- parsed.body;
409
+ const {
410
+ vaultName,
411
+ username,
412
+ role,
413
+ provisionVault,
414
+ defaultMirror,
415
+ expiresInSeconds,
416
+ maxUses,
417
+ vaultCapBytes,
418
+ } = parsed.body;
308
419
  const now = (deps.now ?? (() => new Date()))();
309
420
 
310
421
  // Shape gates over the three supported invite shapes:
@@ -330,6 +441,42 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
330
441
  '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',
331
442
  );
332
443
  }
444
+
445
+ // Multi-use coherence gates (v15). A link that creates many accounts can't:
446
+ // - pre-name a username (one name can't be reused across redeemers), or
447
+ // - pin a single NEW vault name (every redeemer would collide on it —
448
+ // the redeem path's freshly-created invariant rejects the 2nd onward).
449
+ // Either combination would make every seat past the first dead-on-arrival,
450
+ // so reject at mint. (A multi-use SHARED-vault invite — provision_vault=false
451
+ // + an existing vault_name — IS coherent: many users joining one vault; it's
452
+ // not blocked here.)
453
+ if (maxUses > 1) {
454
+ if (username !== null) {
455
+ return jsonError(
456
+ 400,
457
+ "invalid_request",
458
+ "a multi-use link (max_uses > 1) can't pre-name a username — one username can't be shared across signups. Omit \"username\".",
459
+ );
460
+ }
461
+ if (provisionVault && vaultName !== null) {
462
+ return jsonError(
463
+ 400,
464
+ "invalid_request",
465
+ 'a multi-use provisioning link (max_uses > 1) can\'t pin a single vault name — every signup would collide on it. Omit "vault_name" so each signup names their own vault.',
466
+ );
467
+ }
468
+ }
469
+
470
+ // A per-vault cap only applies to a vault THIS link provisions. Pinning a
471
+ // cap on a non-provisioning invite (account-only or shared-existing-vault)
472
+ // has nothing to stamp — reject the contradiction at mint.
473
+ if (vaultCapBytes !== null && !provisionVault) {
474
+ return jsonError(
475
+ 400,
476
+ "invalid_request",
477
+ '"vault_cap_bytes" only applies to a provisioning invite (provision_vault=true) — a non-provisioning link has no vault to cap',
478
+ );
479
+ }
333
480
  if (vaultName !== null && !provisionVault) {
334
481
  const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
335
482
  const known = new Set(listVaultNamesFromPath(manifestPath));
@@ -358,6 +505,17 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
358
505
  }
359
506
  }
360
507
 
508
+ // Auto-apply the ~1 GB default cap to a PUBLIC-SIGNUP (multi-use,
509
+ // provisioning) link when the admin didn't set one explicitly — the
510
+ // DEMO-PREP decision is "1 GB per vault, configurable," so a multi-use
511
+ // signup link on a shared box should never provision UNcapped by omission.
512
+ // An explicit cap (any value, validated above) is honored as-is; a
513
+ // single-use friends link stays uncapped unless the admin opts in.
514
+ const effectiveVaultCapBytes =
515
+ vaultCapBytes === null && maxUses > 1 && provisionVault
516
+ ? DEFAULT_VAULT_CAP_BYTES
517
+ : vaultCapBytes;
518
+
361
519
  const issued = issueInvite(deps.db, {
362
520
  createdBy: authUserId,
363
521
  vaultName,
@@ -366,6 +524,8 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
366
524
  provisionVault,
367
525
  defaultMirror,
368
526
  expiresInSeconds,
527
+ maxUses,
528
+ vaultCapBytes: effectiveVaultCapBytes,
369
529
  ...(deps.now !== undefined ? { now: deps.now } : {}),
370
530
  });
371
531
  const status = inviteStatus(issued.invite, now);
@@ -55,6 +55,7 @@ import {
55
55
  import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
56
56
  import { enrichedPath } from "./spawn-path.ts";
57
57
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
58
+ import { buildHubOriginsEnvValue } from "./vault-hub-origin-env.ts";
58
59
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
59
60
 
60
61
  /**
@@ -609,10 +610,21 @@ async function spawnSupervised(
609
610
  // `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
610
611
  // re-enriching here is a harmless no-op — `enrichedPath` is idempotent
611
612
  // (dedupe + append-only), so double-enrichment can't duplicate or reorder.
613
+ // PARACHUTE_HUB_ORIGINS (multi-origin iss-set, onboarding-streamline
614
+ // 2026-06-25): alongside the single canonical PARACHUTE_HUB_ORIGIN, inject
615
+ // the SET of origins the hub legitimately answers on (issuer ∪ loopback ∪
616
+ // expose-state ∪ platform). A resource server on scope-guard ≥0.5.0 widens
617
+ // its accepted-`iss` check to this set so a token minted under one URL of a
618
+ // multi-URL box validates via another URL of the SAME box. Mirrors
619
+ // buildModuleSpawnRequest (serve-boot.ts) — keep the two in sync. SECURITY:
620
+ // the set is hub-controlled config/disk state ONLY, never a request Host
621
+ // (see buildHubOriginsEnvValue). `deps.spawnEnv` still wins.
622
+ const hubOrigins = deps.issuer ? buildHubOriginsEnvValue(deps.configDir, deps.issuer) : undefined;
612
623
  const childEnv: Record<string, string> = {
613
624
  PATH: enrichedPath(),
614
625
  PORT: String(entry.port),
615
626
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
627
+ ...(hubOrigins ? { PARACHUTE_HUB_ORIGINS: hubOrigins } : {}),
616
628
  ...(deps.spawnEnv ?? {}),
617
629
  };
618
630
  const req: SpawnRequest = {
@@ -15,11 +15,21 @@
15
15
  * / `crashed` / `starting` / `restarting`) + pid. Absent when the
16
16
  * hub is in CLI mode (no supervisor injected through HubFetchDeps).
17
17
  *
18
- * `focus` ("core" | "experimental") comes from each module's `module.json`
19
- * when declared, else `focusForShort`'s default map. The SPA groups core first
20
- * + de-emphasizes experimental it NEVER hides a module. This is what makes a
21
- * running, self-registered module (channel) visible + installable; the old
22
- * `CURATED_MODULES = ["vault","scribe"]` whitelist made it invisible.
18
+ * `focus` ("core" | "experimental" | "deprecated") comes from each module's
19
+ * `module.json` when declared, else `focusForShort`'s default map. The SPA
20
+ * groups core first, de-emphasizes experimental, and de-emphasizes
21
+ * `deprecated` (notes-daemon / runner) further it NEVER hides an installed
22
+ * module (so an existing operator can still manage / uninstall a deprecated
23
+ * one). This is what makes a running, self-registered module (channel) visible
24
+ * + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
25
+ * it invisible.
26
+ *
27
+ * `available_to_install` is the fresh-install OFFER (2026-06-25): a module the
28
+ * hub will push as a new install. It's `available && focus !== "deprecated"`
29
+ * — so a `deprecated` module that is ALREADY installed still surfaces (via the
30
+ * `modules` union + `installed: true`) and is manageable, but a deprecated
31
+ * module is never offered as a fresh install. `agent` (`experimental`) stays
32
+ * offered.
23
33
  *
24
34
  * Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
25
35
  * and `/api/grants` — the admin SPA mints this scope via
@@ -199,14 +209,33 @@ interface ModuleWireShape {
199
209
  display_name: string;
200
210
  tagline: string;
201
211
  /**
202
- * Discovery tier (2026-06-09 modular-UI architecture). `core` modules render
203
- * in the headline group; `experimental` modules render in a de-emphasized
204
- * "Experimental" group below never hidden. Resolved from the module's
205
- * `module.json` `focus` when declared, else `focusForShort`'s default map
206
- * (vault/scribe/hub/surface core, channel/runner/others experimental).
212
+ * Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
213
+ * 2026-06-25). `core` modules render in the headline group; `experimental`
214
+ * modules render in a de-emphasized "Experimental" group; `deprecated`
215
+ * modules (notes-daemon / runner) render in a further-de-emphasized
216
+ * "Deprecated" group never hidden when installed. Resolved from the
217
+ * module's `module.json` `focus` when declared, else `focusForShort`'s
218
+ * default map (vault/scribe/hub/surface → core, notes/runner → deprecated,
219
+ * others → experimental).
207
220
  */
208
221
  focus: ModuleFocus;
222
+ /**
223
+ * True iff the hub knows how to install this module (`package`/`manifest`
224
+ * resolvable). Historically "in the curated install catalog". Still set for
225
+ * every known module; a purely third-party services.json row is false.
226
+ * NOTE: this is NOT the fresh-install OFFER — a `deprecated` module is still
227
+ * `available: true` (it's installable for back-compat / re-install) but is
228
+ * excluded from `available_to_install`.
229
+ */
209
230
  available: boolean;
231
+ /**
232
+ * The fresh-install OFFER (2026-06-25): whether the hub presents this module
233
+ * in the "available to install fresh" set. `available && focus !==
234
+ * "deprecated"`. The SPA's "Install a module" catalog filters on this so
235
+ * notes-daemon / runner aren't pushed on a fresh box; an already-installed
236
+ * deprecated module still surfaces (in the Installed section) for management.
237
+ */
238
+ available_to_install: boolean;
210
239
  installed: boolean;
211
240
  installed_version: string | null;
212
241
  latest_version: string | null;
@@ -562,8 +591,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
562
591
  // module's manifest-declared `focus` (when installed + declared) wins; else
563
592
  // the `focusForShort` default map. Sort: `core` group first (with the
564
593
  // CURATED_MODULES recommended-install order floated to the top of that
565
- // group), then `experimental` the SPA renders the two groups; `focus`
566
- // never hides a module.
594
+ // group), then `experimental`, then `deprecated` (notes / runner) last the
595
+ // SPA renders the groups; `focus` never hides an installed module.
567
596
  const recommendedOrder = new Map<string, number>(
568
597
  (CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
569
598
  );
@@ -585,6 +614,11 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
585
614
  // known modules; a purely third-party services.json row (no install
586
615
  // package) is not hub-installable → false.
587
616
  available: m !== undefined,
617
+ // Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
618
+ // deprecated short (notes / runner) stays `available` (re-installable
619
+ // for back-compat) but is dropped from the offer set so the SPA's
620
+ // "Install a module" catalog doesn't push it on a fresh box.
621
+ available_to_install: m !== undefined && focus !== "deprecated",
588
622
  installed: installed !== undefined,
589
623
  installed_version: installed?.version ?? null,
590
624
  latest_version: latestByShort.get(short) ?? null,
@@ -598,7 +632,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
598
632
  };
599
633
  return row;
600
634
  });
601
- const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : 1);
635
+ const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : f === "experimental" ? 1 : 2);
602
636
  rows.sort((a, b) => {
603
637
  if (a.focus !== b.focus) return focusRank(a.focus) - focusRank(b.focus);
604
638
  const ai = recommendedOrder.get(a.short) ?? Number.POSITIVE_INFINITY;
package/src/api-users.ts CHANGED
@@ -88,6 +88,8 @@ export interface UserWireShape {
88
88
  id: string;
89
89
  username: string;
90
90
  password_changed: boolean;
91
+ /** Contactable email captured at signup (v15, B2), or null. Visible to admin. */
92
+ email: string | null;
91
93
  assigned_vaults: string[];
92
94
  created_at: string;
93
95
  }
@@ -97,6 +99,7 @@ function toWire(u: User): UserWireShape {
97
99
  id: u.id,
98
100
  username: u.username,
99
101
  password_changed: u.passwordChanged,
102
+ email: u.email,
100
103
  assigned_vaults: [...u.assignedVaults],
101
104
  created_at: u.createdAt,
102
105
  };