@openparachute/hub 0.7.3-rc.2 → 0.7.3-rc.4

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Per-vault storage cap store (`src/vault-caps.ts`, migration v15, B4). The
3
+ * cap is PERSISTED here at provision time; a separate Phase-2 PR reads + enforces
4
+ * it. These tests cover the read/write/upsert/remove primitives + the "no row
5
+ * = uncapped" contract the Phase-2 reader relies on.
6
+ */
7
+ import { describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
12
+ import {
13
+ DEFAULT_VAULT_CAP_BYTES,
14
+ getVaultCap,
15
+ getVaultCapBytes,
16
+ removeVaultCap,
17
+ setVaultCap,
18
+ } from "../vault-caps.ts";
19
+
20
+ function makeDb() {
21
+ const dir = mkdtempSync(join(tmpdir(), "phub-vault-caps-"));
22
+ const db = openHubDb(hubDbPath(dir));
23
+ return {
24
+ db,
25
+ cleanup: () => {
26
+ db.close();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("vault-caps", () => {
33
+ test("default cap constant is ~1 GiB", () => {
34
+ expect(DEFAULT_VAULT_CAP_BYTES).toBe(1024 * 1024 * 1024);
35
+ });
36
+
37
+ test("no row = uncapped (null) — the Phase-2 reader's contract", () => {
38
+ const { db, cleanup } = makeDb();
39
+ try {
40
+ expect(getVaultCap(db, "nope")).toBeNull();
41
+ expect(getVaultCapBytes(db, "nope")).toBeNull();
42
+ } finally {
43
+ cleanup();
44
+ }
45
+ });
46
+
47
+ test("setVaultCap persists, getVaultCapBytes reads it back", () => {
48
+ const { db, cleanup } = makeDb();
49
+ try {
50
+ const now = new Date("2026-06-25T00:00:00Z");
51
+ const cap = setVaultCap(db, "alice", DEFAULT_VAULT_CAP_BYTES, now);
52
+ expect(cap.vaultName).toBe("alice");
53
+ expect(cap.capBytes).toBe(DEFAULT_VAULT_CAP_BYTES);
54
+ expect(cap.createdAt).toBe(now.toISOString());
55
+ expect(getVaultCapBytes(db, "alice")).toBe(DEFAULT_VAULT_CAP_BYTES);
56
+ } finally {
57
+ cleanup();
58
+ }
59
+ });
60
+
61
+ test("upsert: re-set overwrites cap_bytes, bumps updated_at, preserves created_at", () => {
62
+ const { db, cleanup } = makeDb();
63
+ try {
64
+ const t0 = new Date("2026-06-25T00:00:00Z");
65
+ const t1 = new Date("2026-06-26T00:00:00Z");
66
+ setVaultCap(db, "alice", 1000, t0);
67
+ const updated = setVaultCap(db, "alice", 2000, t1);
68
+ expect(updated.capBytes).toBe(2000);
69
+ expect(updated.createdAt).toBe(t0.toISOString()); // preserved
70
+ expect(updated.updatedAt).toBe(t1.toISOString()); // bumped
71
+ expect(getVaultCapBytes(db, "alice")).toBe(2000);
72
+ } finally {
73
+ cleanup();
74
+ }
75
+ });
76
+
77
+ test("removeVaultCap drops the row (cascade parity) — returns count", () => {
78
+ const { db, cleanup } = makeDb();
79
+ try {
80
+ setVaultCap(db, "alice", 1000);
81
+ expect(removeVaultCap(db, "alice")).toBe(1);
82
+ expect(getVaultCapBytes(db, "alice")).toBeNull();
83
+ // Idempotent — removing a missing row is 0.
84
+ expect(removeVaultCap(db, "alice")).toBe(0);
85
+ } finally {
86
+ cleanup();
87
+ }
88
+ });
89
+ });
@@ -6,10 +6,21 @@
6
6
  * account-claim flow (`setup-wizard.ts` `handleSetupAccountPost`). A brand-new
7
7
  * invitee opens the link with no session and no JS:
8
8
  *
9
- * GET → render the "pick username + password (+ vault name)" form.
9
+ * MULTI-USE (v15, DEMO-PREP-2026-06-25 Workstream B): this same surface is the
10
+ * PUBLIC SIGNUP PAGE (B4). A multi-use invite link (`max_uses > 1`) lets many
11
+ * strangers redeem the one link until its seats run out — each redeem creates
12
+ * a fresh account + vault. Such a link also collects the redeemer's EMAIL (B2,
13
+ * so the operator can reach signups) and STAMPS a per-vault storage cap (B4)
14
+ * onto each provisioned vault. A legacy single-use admin/friends invite
15
+ * (`max_uses = 1`, the default) behaves exactly as before — no email field, no
16
+ * cap, single redeem.
17
+ *
18
+ * GET → render the "pick username + password (+ email, + vault name)" form.
10
19
  * POST → redeem: look up the invite by sha256(token), validate it's still
11
- * redeemable, validate credentials, provision the vault, create the
12
- * user, stamp the invite used, mint a session, 302 /account/.
20
+ * redeemable (seats left, not expired/revoked), validate credentials
21
+ * (+ email for public links), provision the vault (stamping its cap),
22
+ * create the user, record the redemption (bump used_count, capture
23
+ * email), mint a session, 302 → /account/.
13
24
  *
14
25
  * Redeem ORDERING (the re-usability guarantee, mirroring the wizard):
15
26
  * 1. lookup + validate the invite (not-found/expired/used/revoked)
@@ -27,14 +38,17 @@
27
38
  * The vault must still exist in services.json (the vault-delete
28
39
  * cascade revokes pending invites, so this re-check is defense in
29
40
  * depth against manual manifest edits).
30
- * 4. createUser (the commit point)
31
- * 5. consumeInvite stamp used_at + redeemed_user_id ONLY AFTER (4) commits
32
- * 6. createSession + cookie + 302
41
+ * 4. createUser (the commit point) — and INSIDE its transaction (the
42
+ * `withinTx` hook): recordInviteRedemption (bump used_count + stamp
43
+ * used_at/email/redeemed_user_id) AND, for a capped provisioning link,
44
+ * setVaultCap. All three commit atomically with the user row.
45
+ * 5. createSession + cookie + 302
33
46
  *
34
- * Because the invite is consumed only after the user row commits, a
35
- * createUser exception (UNIQUE collision, disk full, anything) leaves the
36
- * invite re-usable — the invitee can simply retry. `consumeInvite`'s
37
- * `used_at IS NULL` guard makes the stamp itself single-use / race-free.
47
+ * Because the redemption is recorded only inside the user-row transaction, a
48
+ * createUser exception (UNIQUE collision, disk full, anything) rolls back the
49
+ * used_count bump + cap write together — the seat stays available and the
50
+ * invitee can simply retry. `recordInviteRedemption`'s `used_count < max_uses`
51
+ * guard makes the increment itself exhaustion-safe / race-free.
38
52
  *
39
53
  * What an invite pre-authorizes: EXACTLY one account + the one named/created/
40
54
  * shared vault at the baked-in role — NEVER host:admin, NEVER another vault.
@@ -53,14 +67,15 @@ import { type RunResult, provisionVault } from "./admin-vaults.ts";
53
67
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
54
68
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
55
69
  import {
70
+ InviteExhaustedError,
56
71
  InviteExpiredError,
57
72
  InviteNotFoundError,
58
73
  InviteRevokedError,
59
74
  InviteUsedError,
60
75
  assertInviteRedeemable,
61
- consumeInvite,
76
+ recordInviteRedemption,
62
77
  } from "./invites.ts";
63
- import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
78
+ import { clientIpFromRequest, signupRateLimiter } from "./rate-limit.ts";
64
79
  import { isHttpsRequest } from "./request-protocol.ts";
65
80
  import { SESSION_TTL_MS, buildSessionCookie, createSession } from "./sessions.ts";
66
81
  import {
@@ -68,9 +83,11 @@ import {
68
83
  UsernameTakenError,
69
84
  createUser,
70
85
  getUserByUsernameCI,
86
+ validateEmail,
71
87
  validatePassword,
72
88
  validateUsername,
73
89
  } from "./users.ts";
90
+ import { setVaultCap } from "./vault-caps.ts";
74
91
  import { validateVaultName } from "./vault-name.ts";
75
92
  import { listVaultNamesFromPath } from "./vault-names.ts";
76
93
 
@@ -127,6 +144,16 @@ function rejectInvite(err: unknown): Response {
127
144
  410,
128
145
  );
129
146
  }
147
+ if (err instanceof InviteExhaustedError) {
148
+ return htmlResponse(
149
+ renderAdminError({
150
+ title: "Signups closed",
151
+ message:
152
+ "This signup link has reached its maximum number of accounts. Ask your hub operator for a new link.",
153
+ }),
154
+ 410,
155
+ );
156
+ }
130
157
  if (err instanceof InviteRevokedError) {
131
158
  return htmlResponse(
132
159
  renderAdminError({
@@ -166,12 +193,25 @@ export function handleAccountSetupGet(
166
193
  pinnedUsername: invite.username,
167
194
  role: invite.role,
168
195
  provisionVault: invite.provisionVault,
196
+ collectEmail: collectsEmail(invite),
169
197
  }),
170
198
  200,
171
199
  setCookie,
172
200
  );
173
201
  }
174
202
 
203
+ /**
204
+ * Whether this redemption captures the redeemer's email (B2). Multi-use
205
+ * public-signup links collect it (the operator must be able to reach the
206
+ * stranger who signed up); legacy single-use admin invites don't bother
207
+ * (the admin already knows who they handed the link to). The signal: a
208
+ * multi-use link (`max_uses > 1`). Public-signup links always mint with
209
+ * `max_uses > 1`, so this cleanly distinguishes them from the friends flow.
210
+ */
211
+ function collectsEmail(invite: { maxUses: number }): boolean {
212
+ return invite.maxUses > 1;
213
+ }
214
+
175
215
  /** POST /account/setup/<token> — redeem the invite (see file docstring for ordering). */
176
216
  export async function handleAccountSetupPost(
177
217
  req: Request,
@@ -201,11 +241,15 @@ export async function handleAccountSetupPost(
201
241
  );
202
242
  }
203
243
 
204
- // Rate limit — reuse the /login IP bucket so a redeem flood and a login
205
- // flood share the same throttle. After CSRF (so a junk cross-site POST
206
- // doesn't burn the bucket), before any account/vault work.
244
+ // Rate limit — a DEDICATED signup bucket (per-IP, 60/15min), NOT the /login
245
+ // bucket. A public multi-use signup link is redeemed by a room of people
246
+ // sharing one NAT'd egress IP, so the /login-sized 5/15min cap would 429 the
247
+ // ~6th legitimate signer mid-demo. Generous-but-bounded: the invite's own
248
+ // max_uses + expiry are the primary bound; this is the abuse floor. After
249
+ // CSRF (so a junk cross-site POST doesn't burn the bucket), before any
250
+ // account/vault work.
207
251
  const clientIp = clientIpFromRequest(req);
208
- const gate = checkAndRecord(clientIp, now);
252
+ const gate = signupRateLimiter.checkAndRecord(clientIp, now);
209
253
  if (!gate.allowed) {
210
254
  return htmlResponse(
211
255
  renderAdminError({
@@ -223,6 +267,10 @@ export async function handleAccountSetupPost(
223
267
  invite.username !== null ? invite.username : String(form.get("username") ?? "").trim();
224
268
  const password = String(form.get("password") ?? "");
225
269
  const confirm = String(form.get("password_confirm") ?? "");
270
+ // Email (B2) — captured only for public-signup links (multi-use). The raw
271
+ // value is echoed back on a re-render so the redeemer doesn't retype it.
272
+ const collectEmail = collectsEmail(invite);
273
+ const emailRaw = String(form.get("email") ?? "").trim();
226
274
 
227
275
  const csrf = ensureCsrfToken(req);
228
276
  const setCookie: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
@@ -236,7 +284,9 @@ export async function handleAccountSetupPost(
236
284
  pinnedUsername: invite.username,
237
285
  role: invite.role,
238
286
  provisionVault: invite.provisionVault,
287
+ collectEmail,
239
288
  username,
289
+ ...(collectEmail ? { email: emailRaw } : {}),
240
290
  ...(vaultNameEcho !== undefined ? { vaultName: vaultNameEcho } : {}),
241
291
  errorMessage: message,
242
292
  }),
@@ -266,6 +316,21 @@ export async function handleAccountSetupPost(
266
316
  if (password !== confirm) {
267
317
  return rerender(400, "The two passwords don't match.");
268
318
  }
319
+ // (2b) Email (B2) — required + format-validated ONLY for public-signup
320
+ // (multi-use) links, where the operator must be able to reach the stranger
321
+ // who signed up. Legacy single-use admin invites don't collect it. The
322
+ // canonical form is lowercased/trimmed by validateEmail.
323
+ let email: string | null = null;
324
+ if (collectEmail) {
325
+ if (emailRaw.length === 0) {
326
+ return rerender(400, "Enter your email so the hub operator can reach you.");
327
+ }
328
+ const e = validateEmail(emailRaw);
329
+ if (!e.valid) {
330
+ return rerender(400, "Enter a valid email address (e.g. you@example.com).");
331
+ }
332
+ email = e.email;
333
+ }
269
334
  // Case-insensitive uniqueness — same gate as /api/users. For a pre-named
270
335
  // invite the name can't be changed by the invitee, so a collision (someone
271
336
  // took the name between mint and redeem) needs the operator to revoke +
@@ -393,22 +458,33 @@ export async function handleAccountSetupPost(
393
458
  : undefined,
394
459
  );
395
460
  }
461
+
462
+ // NOTE: the per-vault storage cap (B4) is PERSISTED below, inside
463
+ // createUser's transaction (the `withinTx` hook), so the cap write +
464
+ // redemption record + user insert all commit (or roll back) atomically —
465
+ // no stranded cap row for a vault whose account creation failed. The
466
+ // vault itself is provisioned here (the CLI shell-out can't join the
467
+ // sqlite tx), but the cap METADATA is hub-DB state, so it belongs in the
468
+ // atomic hook.
396
469
  }
397
470
 
398
- // (4) Create the user + consume the invite ATOMICALLY — the COMMIT POINT.
399
- // The invite is consumed INSIDE createUser's transaction (the `withinTx`
400
- // hook), so the two single-use guarantees compose:
471
+ // (4) Create the user + record the redemption ATOMICALLY — the COMMIT POINT.
472
+ // The redemption is recorded INSIDE createUser's transaction (the `withinTx`
473
+ // hook), so the seat-accounting guarantees compose:
401
474
  //
402
- // - Single-use under concurrency: two redeems of one invite both pass
403
- // `assertInviteRedeemable` above, but only one's `consumeInvite` UPDATE
404
- // (`used_at IS NULL AND revoked_at IS NULL`) changes a row. The loser's
405
- // hook throws `InviteUsedError`, which rolls back ITS user insert — no
406
- // orphan account. Exactly one account results.
475
+ // - Exhaustion under concurrency: two redeems of the LAST seat both pass
476
+ // `assertInviteRedeemable` above, but only one's `recordInviteRedemption`
477
+ // UPDATE (`used_count < max_uses AND revoked_at IS NULL`) changes a row.
478
+ // The loser's hook throws (InviteExhaustedError for a multi-use link,
479
+ // InviteUsedError for single-use), which rolls back ITS user insert — no
480
+ // orphan account. Exactly one account per remaining seat results.
407
481
  // - Re-usable on failure: if createUser throws (UNIQUE collision, etc.)
408
- // before the hook, the invite was never touched; if the hook itself
409
- // throws (lost race), the consume + the user insert roll back together.
410
- // Either way nothing commits, so the invite stays re-usable.
482
+ // before the hook, used_count was never bumped; if the hook itself
483
+ // throws (lost race), the increment + the user insert roll back together.
484
+ // Either way nothing commits, so the seat stays available.
411
485
  //
486
+ // The email (B2) is recorded with the redemption (latest redeemer) AND on
487
+ // the account row (`users.email`, the canonical per-account field) below.
412
488
  // passwordChanged: TRUE (the invitee chose their own password → no force-
413
489
  // change). assignedVaults pins them to exactly their one vault at the
414
490
  // invite's role. allowMulti because the first admin already exists.
@@ -419,17 +495,27 @@ export async function handleAccountSetupPost(
419
495
  passwordChanged: true,
420
496
  assignedVaults: vaultName !== null ? [vaultName] : [],
421
497
  role: invite.role,
498
+ ...(email !== null ? { email } : {}),
422
499
  withinTx: (newUserId) => {
423
- if (!consumeInvite(deps.db, invite.tokenHash, newUserId, now)) {
424
- // Lost the redeem race (or a concurrent revoke landed). Throw to
425
- // roll back this user insert; surfaced as the used/410 path below.
426
- throw new InviteUsedError();
500
+ if (!recordInviteRedemption(deps.db, invite.tokenHash, newUserId, email, now)) {
501
+ // Lost the redeem race (or a concurrent revoke landed). Throw the
502
+ // shape-appropriate error to roll back this user insert; surfaced
503
+ // as the 410 path below.
504
+ throw invite.maxUses > 1 ? new InviteExhaustedError() : new InviteUsedError();
505
+ }
506
+ // (3b) Persist the per-vault storage cap (B4) atomically with the
507
+ // account + redemption. Only for a freshly-provisioned vault carrying
508
+ // an invite cap; legacy/uncapped links leave the vault with no
509
+ // vault_caps row ("uncapped" for the Phase-2 reader). The vault was
510
+ // confirmed freshly created above; `setVaultCap` is a plain upsert.
511
+ if (invite.provisionVault && vaultName !== null && invite.vaultCapBytes !== null) {
512
+ setVaultCap(deps.db, vaultName, invite.vaultCapBytes, now);
427
513
  }
428
514
  },
429
515
  });
430
516
  userId = created.id;
431
517
  } catch (err) {
432
- if (err instanceof InviteUsedError) {
518
+ if (err instanceof InviteUsedError || err instanceof InviteExhaustedError) {
433
519
  return rejectInvite(err);
434
520
  }
435
521
  if (err instanceof UsernameTakenError) {
@@ -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.