@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.
- package/package.json +1 -1
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/oauth-handlers.test.ts +291 -4
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/account-setup.ts +118 -32
- 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-users.ts +3 -0
- package/src/hub-db.ts +108 -1
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +58 -0
- package/src/oauth-handlers.ts +73 -12
- package/src/rate-limit.ts +38 -1
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
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-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
|
};
|
package/src/hub-db.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
|
|
5
5
|
* clients + auth-codes + grants + browser sessions (v3), TOTP 2FA
|
|
6
6
|
* enrollment on the users row (v11, hub#473), and one-time invite links
|
|
7
|
-
* (v12, the `invites` table; v13 adds the pre-named `username` column
|
|
7
|
+
* (v12, the `invites` table; v13 adds the pre-named `username` column;
|
|
8
|
+
* v14 adds refresh-token rotation grace; v15 generalizes invites into
|
|
9
|
+
* multi-use public-signup links + email-as-username + the `vault_caps`
|
|
10
|
+
* per-vault storage-cap table).
|
|
8
11
|
*
|
|
9
12
|
* Each open() runs `migrate()` to bring the schema up to date. A
|
|
10
13
|
* `schema_version` table records every applied migration so re-opens are
|
|
@@ -451,6 +454,110 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
451
454
|
ALTER TABLE invites ADD COLUMN username TEXT;
|
|
452
455
|
`,
|
|
453
456
|
},
|
|
457
|
+
{
|
|
458
|
+
version: 14,
|
|
459
|
+
sql: `
|
|
460
|
+
-- Refresh-token rotation grace window (hub#685). Records the successor
|
|
461
|
+
-- a refresh-token row rotated INTO, so the refresh handler can tell the
|
|
462
|
+
-- *immediately-previous* token (the single benign concurrent/retried
|
|
463
|
+
-- refresh a multi-tab / bfcache / network-retry client legitimately
|
|
464
|
+
-- presents) apart from a genuine replay of an older ancestor (theft).
|
|
465
|
+
--
|
|
466
|
+
-- \`rotated_to\` is the jti of the row minted when this row rotated. NULL
|
|
467
|
+
-- on every row that has not (yet) rotated: live tokens, revoked-by-
|
|
468
|
+
-- /oauth/revoke tokens, revoked-by-family-sweep tokens, and every
|
|
469
|
+
-- pre-v14 row (no backfill — pre-v14 rotations left no successor link,
|
|
470
|
+
-- so they fall through to the zero-tolerance theft path exactly as
|
|
471
|
+
-- before; the grace window only ever helps rows minted post-v14).
|
|
472
|
+
--
|
|
473
|
+
-- The successor link is the ONLY structural ordering signal we need:
|
|
474
|
+
-- created_at alone can't distinguish the direct predecessor from an
|
|
475
|
+
-- older ancestor under burst issuance (ties), and the grace decision is
|
|
476
|
+
-- security-load-bearing, so it must be exact, not heuristic.
|
|
477
|
+
ALTER TABLE tokens ADD COLUMN rotated_to TEXT;
|
|
478
|
+
`,
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
version: 15,
|
|
482
|
+
sql: `
|
|
483
|
+
-- Multi-use public signup links + email-as-username + per-vault caps
|
|
484
|
+
-- (DEMO-PREP-2026-06-25 Workstream B: B1 multi-use invite, B2 email
|
|
485
|
+
-- capture, B4 public signup page; the cap value is persisted now so
|
|
486
|
+
-- B3's vault-side enforcement — a separate Phase-2 PR — can read it).
|
|
487
|
+
--
|
|
488
|
+
-- (1) FOUR columns on \`invites\` generalize the single-use link into a
|
|
489
|
+
-- multi-use, capped one. Backwards compatible: every pre-v15 invite
|
|
490
|
+
-- redeems exactly as before because the defaults reproduce the old
|
|
491
|
+
-- single-use semantics.
|
|
492
|
+
--
|
|
493
|
+
-- * max_uses (INTEGER NOT NULL DEFAULT 1) — how many accounts ONE link
|
|
494
|
+
-- may create. DEFAULT 1 = the historical single-use invite, so every
|
|
495
|
+
-- pre-v15 row and every default-shaped new row stays single-use. A
|
|
496
|
+
-- public signup link mints with a higher value (e.g. 25 demo seats).
|
|
497
|
+
-- * used_count (INTEGER NOT NULL DEFAULT 0) — how many accounts the link
|
|
498
|
+
-- HAS created. Redeem refuses once used_count >= max_uses. Replaces
|
|
499
|
+
-- the boolean used_at as the exhaustion signal for multi-use links;
|
|
500
|
+
-- used_at is RETAINED (stamped on the FIRST redeem) so legacy
|
|
501
|
+
-- single-use lookups + the admin list's "redeemed" status keep
|
|
502
|
+
-- working unchanged.
|
|
503
|
+
-- * email (TEXT, nullable) — the redeemer's email captured at signup
|
|
504
|
+
-- as the contactable identity (B2). NULL on every pre-v15 invite and
|
|
505
|
+
-- on admin-issued links that don't collect email; set per-redemption
|
|
506
|
+
-- for public-signup links. Stored so the operator can reach signups.
|
|
507
|
+
-- (One link → many signups means one invite row can't hold every
|
|
508
|
+
-- redeemer's email; this column holds the MOST-RECENT redeemer's
|
|
509
|
+
-- email for a single-use link, and the per-account email lives on the
|
|
510
|
+
-- users row — see (3). For multi-use the canonical per-user email is
|
|
511
|
+
-- users.email.)
|
|
512
|
+
-- * vault_cap_bytes (INTEGER, nullable) — the per-vault storage cap to
|
|
513
|
+
-- STAMP onto each vault this link provisions (B4). NULL on every
|
|
514
|
+
-- pre-v15 invite + admin-issued links that don't set a cap (those
|
|
515
|
+
-- provision uncapped, the historical behavior); set to ~1 GB on a
|
|
516
|
+
-- public-signup link so each provisioned vault gets a vault_caps row
|
|
517
|
+
-- for the Phase-2 enforcement PR to read. The cap travels on the
|
|
518
|
+
-- invite (not a server-wide default) so different links can carry
|
|
519
|
+
-- different caps.
|
|
520
|
+
--
|
|
521
|
+
-- (2) ONE column on \`users\`: email-as-contactable-identity (B2). The
|
|
522
|
+
-- username stays the login + URL identity ([a-z0-9_-]); email is the
|
|
523
|
+
-- separate contact field the operator sees + uses to reach a signup.
|
|
524
|
+
-- Nullable: every pre-v15 account (wizard admin, env-seeded admin,
|
|
525
|
+
-- pre-named friend invites) predates email capture and keeps NULL.
|
|
526
|
+
-- NOT UNIQUE — two people behind one shared mailbox, or an operator
|
|
527
|
+
-- re-using their address across test accounts, shouldn't be blocked
|
|
528
|
+
-- at the schema. Validation (format) happens at the API/redeem edge.
|
|
529
|
+
--
|
|
530
|
+
-- (3) NEW \`vault_caps\` table — the per-vault storage cap, persisted at
|
|
531
|
+
-- provision time (B4: "persist a per-vault cap value at provision
|
|
532
|
+
-- time EVEN THOUGH vault-side enforcement lands in a separate
|
|
533
|
+
-- Phase-2 PR — at minimum store the cap so the later PR can read +
|
|
534
|
+
-- enforce it"). Keyed by vault_name (the same instance-name space
|
|
535
|
+
-- used across services.json / user_vaults / invites.vault_name; no
|
|
536
|
+
-- FK — vault names resolve through services.json, not a DB row, the
|
|
537
|
+
-- established pattern). cap_bytes is the byte ceiling (default
|
|
538
|
+
-- ~1 GB stamped by the public-signup flow), guarded by a
|
|
539
|
+
-- CHECK (cap_bytes > 0) so a direct DB write can't seed a 0/negative
|
|
540
|
+
-- cap the Phase-2 reader would misinterpret. No backfill — existing
|
|
541
|
+
-- vaults have no cap row, which the Phase-2 enforcement reads as
|
|
542
|
+
-- "uncapped" (only vaults provisioned through a capped signup get a
|
|
543
|
+
-- row).
|
|
544
|
+
ALTER TABLE invites ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1;
|
|
545
|
+
ALTER TABLE invites ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
|
|
546
|
+
ALTER TABLE invites ADD COLUMN email TEXT;
|
|
547
|
+
ALTER TABLE invites ADD COLUMN vault_cap_bytes INTEGER;
|
|
548
|
+
ALTER TABLE users ADD COLUMN email TEXT;
|
|
549
|
+
CREATE TABLE vault_caps (
|
|
550
|
+
vault_name TEXT PRIMARY KEY,
|
|
551
|
+
-- CHECK (cap_bytes > 0): guard against a 0/negative cap from a direct
|
|
552
|
+
-- DB write making the Phase-2 enforcement reader treat the vault as
|
|
553
|
+
-- "0-byte ceiling" (everything over cap) or nonsense. The mint + redeem
|
|
554
|
+
-- paths already validate positivity; this is the at-rest backstop.
|
|
555
|
+
cap_bytes INTEGER NOT NULL CHECK (cap_bytes > 0),
|
|
556
|
+
created_at TEXT NOT NULL,
|
|
557
|
+
updated_at TEXT NOT NULL
|
|
558
|
+
);
|
|
559
|
+
`,
|
|
560
|
+
},
|
|
454
561
|
];
|
|
455
562
|
|
|
456
563
|
export function openHubDb(path: string = hubDbPath()): Database {
|