@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.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-B5AUE359.js +0 -61
package/src/admin-login-ui.ts
CHANGED
|
@@ -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; }
|
package/src/admin-vaults.ts
CHANGED
|
@@ -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.
|
package/src/api-invites.ts
CHANGED
|
@@ -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: {
|
|
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 {
|
|
307
|
-
|
|
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);
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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 = {
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
|
19
|
-
* when declared, else `focusForShort`'s default map. The SPA
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
203
|
-
* in the headline group; `experimental`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
566
|
-
// never hides
|
|
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
|
};
|