@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/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 {
|
package/src/hub-origin.ts
CHANGED
|
@@ -14,6 +14,70 @@
|
|
|
14
14
|
|
|
15
15
|
export const HUB_ORIGIN_ENV = "PARACHUTE_HUB_ORIGIN";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The env var carrying the SET of origins the hub legitimately answers on,
|
|
19
|
+
* comma-separated (multi-origin iss-set, onboarding-streamline 2026-06-25).
|
|
20
|
+
*
|
|
21
|
+
* Supervised resource servers (vault, scribe) read this in addition to the
|
|
22
|
+
* single canonical `PARACHUTE_HUB_ORIGIN` and widen their accepted-`iss` check
|
|
23
|
+
* from one string to this set — so a token minted under one URL of a
|
|
24
|
+
* multi-URL box (loopback ∪ `<ip>.sslip.io` ∪ a custom domain behind Caddy)
|
|
25
|
+
* validates when the resource is reached via another URL of the SAME box. The
|
|
26
|
+
* signing key is stable + origin-independent, so only the `iss` string varies.
|
|
27
|
+
*
|
|
28
|
+
* SECURITY INVARIANT: the value MUST be the hub's `buildHubBoundOrigins`
|
|
29
|
+
* output — configured issuer ∪ loopback aliases ∪ expose-state public origin ∪
|
|
30
|
+
* platform origin — published BY THE HUB. It must NEVER contain an unvalidated
|
|
31
|
+
* request `Host` / `X-Forwarded-Host`. Accepting `iss ∈ this-set` is safe
|
|
32
|
+
* ONLY because the resource server's JWKS signature verify runs first and
|
|
33
|
+
* proves the hub minted the token.
|
|
34
|
+
*
|
|
35
|
+
* `PARACHUTE_HUB_ORIGIN` (the single canonical origin) is ALWAYS written
|
|
36
|
+
* alongside this var for back-compat: a resource server on an older
|
|
37
|
+
* scope-guard that doesn't read `PARACHUTE_HUB_ORIGINS` keeps its existing
|
|
38
|
+
* single-origin behavior.
|
|
39
|
+
*/
|
|
40
|
+
export const HUB_ORIGINS_ENV = "PARACHUTE_HUB_ORIGINS";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Serialize an origin SET into the comma-separated `PARACHUTE_HUB_ORIGINS`
|
|
44
|
+
* wire form. Dedupes, drops empties, preserves first-seen order. Returns
|
|
45
|
+
* `undefined` when nothing survives (caller skips setting the env var so a
|
|
46
|
+
* resource server falls back to single-origin behavior). Pair with
|
|
47
|
+
* `buildHubBoundOrigins` to produce `origins`.
|
|
48
|
+
*/
|
|
49
|
+
export function serializeHubOrigins(origins: readonly string[]): string | undefined {
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
for (const raw of origins) {
|
|
52
|
+
if (typeof raw !== "string") continue;
|
|
53
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
54
|
+
if (trimmed.length > 0) seen.add(trimmed);
|
|
55
|
+
}
|
|
56
|
+
if (seen.size === 0) return undefined;
|
|
57
|
+
return Array.from(seen).join(",");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse the comma-separated `PARACHUTE_HUB_ORIGINS` wire form back into an
|
|
62
|
+
* origin array. Tolerant of surrounding whitespace + trailing slashes + empty
|
|
63
|
+
* segments. The resource-server inverse of `serializeHubOrigins`; lives here
|
|
64
|
+
* (rather than only in the consumer adapters) so the hub's own injection tests
|
|
65
|
+
* can round-trip against the canonical parser. Returns `[]` for an
|
|
66
|
+
* absent/empty/garbage value.
|
|
67
|
+
*/
|
|
68
|
+
export function parseHubOrigins(raw: string | undefined): string[] {
|
|
69
|
+
if (!raw) return [];
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const seg of raw.split(",")) {
|
|
73
|
+
const trimmed = seg.trim().replace(/\/+$/, "");
|
|
74
|
+
if (trimmed.length === 0 || seen.has(trimmed)) continue;
|
|
75
|
+
seen.add(trimmed);
|
|
76
|
+
out.push(trimmed);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
export interface DeriveHubOriginOpts {
|
|
18
82
|
/** Explicit user override (e.g., `--hub-origin`). Wins over everything else. */
|
|
19
83
|
override?: string;
|
package/src/hub-server.ts
CHANGED
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
* /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
|
|
97
97
|
* /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
|
|
98
98
|
* /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
|
|
99
|
+
* /api/vault-caps (GET) → list vaults + persisted storage caps (host:admin)
|
|
100
|
+
* /api/vault-caps/<name> (PUT) → set/update a vault's storage cap (host:admin)
|
|
99
101
|
* /login (GET + POST) → operator password login
|
|
100
102
|
* /login/2fa (POST) → second-factor (TOTP/backup) step
|
|
101
103
|
* (hub#473; reached after a correct
|
|
@@ -221,6 +223,7 @@ import {
|
|
|
221
223
|
handleResetUserPassword,
|
|
222
224
|
handleUpdateUserVaults,
|
|
223
225
|
} from "./api-users.ts";
|
|
226
|
+
import { handleListVaultCaps, handleSetVaultCap } from "./api-vault-caps.ts";
|
|
224
227
|
import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
|
|
225
228
|
import {
|
|
226
229
|
CHROME_OPT_OUT_PREFIXES,
|
|
@@ -3351,6 +3354,30 @@ export function hubFetch(
|
|
|
3351
3354
|
});
|
|
3352
3355
|
}
|
|
3353
3356
|
|
|
3357
|
+
// Per-vault storage caps (B5 admin visibility / D-slice). GET lists every
|
|
3358
|
+
// vault from services.json joined with its persisted cap; PUT /:name
|
|
3359
|
+
// sets/updates a cap. host:admin-gated, same gate flavor as /api/users.
|
|
3360
|
+
if (pathname === "/api/vault-caps") {
|
|
3361
|
+
if (!getDb) return dbNotConfigured();
|
|
3362
|
+
return handleListVaultCaps(req, {
|
|
3363
|
+
db: getDb(),
|
|
3364
|
+
issuer: oauthDeps(req).issuer,
|
|
3365
|
+
manifestPath,
|
|
3366
|
+
});
|
|
3367
|
+
}
|
|
3368
|
+
if (pathname.startsWith("/api/vault-caps/")) {
|
|
3369
|
+
if (!getDb) return dbNotConfigured();
|
|
3370
|
+
const name = decodeURIComponent(pathname.slice("/api/vault-caps/".length));
|
|
3371
|
+
if (!name || name.includes("/")) {
|
|
3372
|
+
return new Response("not found", { status: 404 });
|
|
3373
|
+
}
|
|
3374
|
+
return handleSetVaultCap(req, name, {
|
|
3375
|
+
db: getDb(),
|
|
3376
|
+
issuer: oauthDeps(req).issuer,
|
|
3377
|
+
manifestPath,
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3354
3381
|
// Canonical login/logout. The handlers themselves are unchanged from
|
|
3355
3382
|
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
3356
3383
|
// via #231-followup so the URL reflects the surface's actual scope
|
package/src/invites.ts
CHANGED
|
@@ -37,10 +37,23 @@
|
|
|
37
37
|
* v13): the redemption form shows it read-only and the redeem handler
|
|
38
38
|
* enforces it. NULL = redeemer picks their own.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* MULTI-USE (migration v15, DEMO-PREP-2026-06-25 Workstream B): an invite
|
|
41
|
+
* carries `max_uses` (how many accounts ONE link may create) + `used_count`
|
|
42
|
+
* (how many it has). A redeem is refused once `used_count >= max_uses`. A
|
|
43
|
+
* LEGACY single-use invite is exactly `max_uses = 1` (the column default), so
|
|
44
|
+
* pre-v15 invites and default-shaped new ones behave identically to before.
|
|
45
|
+
* `used_at` is RETAINED — stamped on the FIRST redeem — so the admin list's
|
|
46
|
+
* "redeemed" status and any single-use lookups keep reading the same signal.
|
|
47
|
+
* Exhaustion is enforced atomically in `recordInviteRedemption` (the
|
|
48
|
+
* `used_count < max_uses` guard in the UPDATE), so two concurrent redeems of
|
|
49
|
+
* the last remaining seat can't both succeed.
|
|
50
|
+
*
|
|
51
|
+
* Revocation is a separate `revoked_at` stamp the admin sets before
|
|
52
|
+
* redemption (terminal regardless of remaining uses). Expiry is enforced at
|
|
53
|
+
* redeem-time. A public-signup link also captures the redeemer's `email`
|
|
54
|
+
* (B2 — the contactable identity, also stored per-account on `users.email`)
|
|
55
|
+
* and may carry a `vault_cap_bytes` to stamp onto each provisioned vault (B4
|
|
56
|
+
* — persisted to `vault_caps` for the Phase-2 enforcement PR to read).
|
|
44
57
|
*/
|
|
45
58
|
import type { Database } from "bun:sqlite";
|
|
46
59
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -70,6 +83,24 @@ export interface Invite {
|
|
|
70
83
|
provisionVault: boolean;
|
|
71
84
|
/** `'internal' | 'off'` mirror knob for the provisioned vault, or null. */
|
|
72
85
|
defaultMirror: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* How many accounts this link may create (v15). 1 = legacy single-use
|
|
88
|
+
* (the column default); >1 = a multi-use public-signup link.
|
|
89
|
+
*/
|
|
90
|
+
maxUses: number;
|
|
91
|
+
/** How many accounts this link HAS created (v15). Redeem refused once == maxUses. */
|
|
92
|
+
usedCount: number;
|
|
93
|
+
/**
|
|
94
|
+
* Most-recent redeemer's email (v15, B2), or null. For a multi-use link
|
|
95
|
+
* the canonical per-account email lives on `users.email`; this column
|
|
96
|
+
* holds the latest redeemer's for the admin's at-a-glance audit.
|
|
97
|
+
*/
|
|
98
|
+
email: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* Per-vault storage cap (bytes) to stamp onto each provisioned vault
|
|
101
|
+
* (v15, B4), or null to provision uncapped (legacy behavior).
|
|
102
|
+
*/
|
|
103
|
+
vaultCapBytes: number | null;
|
|
73
104
|
expiresAt: string;
|
|
74
105
|
usedAt: string | null;
|
|
75
106
|
redeemedUserId: string | null;
|
|
@@ -98,6 +129,20 @@ export class InviteUsedError extends Error {
|
|
|
98
129
|
}
|
|
99
130
|
}
|
|
100
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A multi-use link whose seats are all taken (`used_count >= max_uses`). For
|
|
134
|
+
* a single-use (max_uses=1) link this is the same condition the legacy
|
|
135
|
+
* `InviteUsedError` named, but the redeem path throws `InviteUsedError` for a
|
|
136
|
+
* single-use link (familiar "already used" copy) and `InviteExhaustedError`
|
|
137
|
+
* for a multi-use one ("all signups taken") so the message can differ.
|
|
138
|
+
*/
|
|
139
|
+
export class InviteExhaustedError extends Error {
|
|
140
|
+
constructor() {
|
|
141
|
+
super("invite has reached its maximum number of signups");
|
|
142
|
+
this.name = "InviteExhaustedError";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
101
146
|
export class InviteRevokedError extends Error {
|
|
102
147
|
constructor() {
|
|
103
148
|
super("invite has been revoked");
|
|
@@ -113,6 +158,10 @@ interface Row {
|
|
|
113
158
|
role: string;
|
|
114
159
|
provision_vault: number;
|
|
115
160
|
default_mirror: string | null;
|
|
161
|
+
max_uses: number;
|
|
162
|
+
used_count: number;
|
|
163
|
+
email: string | null;
|
|
164
|
+
vault_cap_bytes: number | null;
|
|
116
165
|
expires_at: string;
|
|
117
166
|
used_at: string | null;
|
|
118
167
|
redeemed_user_id: string | null;
|
|
@@ -129,6 +178,10 @@ function rowToInvite(r: Row): Invite {
|
|
|
129
178
|
role: r.role,
|
|
130
179
|
provisionVault: r.provision_vault === 1,
|
|
131
180
|
defaultMirror: r.default_mirror,
|
|
181
|
+
maxUses: r.max_uses,
|
|
182
|
+
usedCount: r.used_count,
|
|
183
|
+
email: r.email,
|
|
184
|
+
vaultCapBytes: r.vault_cap_bytes,
|
|
132
185
|
expiresAt: r.expires_at,
|
|
133
186
|
usedAt: r.used_at,
|
|
134
187
|
redeemedUserId: r.redeemed_user_id,
|
|
@@ -142,10 +195,19 @@ export function hashInviteToken(rawToken: string): string {
|
|
|
142
195
|
return createHash("sha256").update(rawToken).digest("hex");
|
|
143
196
|
}
|
|
144
197
|
|
|
145
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Derive an invite's status from its stamps + the current time.
|
|
200
|
+
*
|
|
201
|
+
* Multi-use (v15): an invite is "redeemed" only once every seat is taken
|
|
202
|
+
* (`usedCount >= maxUses`). A partially-used multi-use link (some seats left,
|
|
203
|
+
* not expired/revoked) is still "pending" — it can take more signups. A
|
|
204
|
+
* legacy single-use link (maxUses=1) flips to "redeemed" on its first
|
|
205
|
+
* redeem, exactly as before, because 1 >= 1. Expired wins over a not-yet-
|
|
206
|
+
* exhausted link; revoked + exhausted are checked first (terminal).
|
|
207
|
+
*/
|
|
146
208
|
export function inviteStatus(invite: Invite, now: Date = new Date()): InviteStatus {
|
|
147
209
|
if (invite.revokedAt) return "revoked";
|
|
148
|
-
if (invite.
|
|
210
|
+
if (invite.usedCount >= invite.maxUses) return "redeemed";
|
|
149
211
|
if (now.getTime() > new Date(invite.expiresAt).getTime()) return "expired";
|
|
150
212
|
return "pending";
|
|
151
213
|
}
|
|
@@ -166,6 +228,16 @@ export interface IssueInviteOpts {
|
|
|
166
228
|
provisionVault?: boolean;
|
|
167
229
|
/** `'internal' | 'off'` mirror knob for the provisioned vault. */
|
|
168
230
|
defaultMirror?: string | null;
|
|
231
|
+
/**
|
|
232
|
+
* How many accounts this link may create (v15). Default 1 (single-use,
|
|
233
|
+
* the legacy shape). Caller validates the upper bound.
|
|
234
|
+
*/
|
|
235
|
+
maxUses?: number;
|
|
236
|
+
/**
|
|
237
|
+
* Per-vault storage cap (bytes) to stamp onto each provisioned vault
|
|
238
|
+
* (v15, B4). Omit/null = provision uncapped (legacy behavior).
|
|
239
|
+
*/
|
|
240
|
+
vaultCapBytes?: number | null;
|
|
169
241
|
/** Lifetime in seconds. Default {@link DEFAULT_INVITE_TTL_SECONDS} (7 days). */
|
|
170
242
|
expiresInSeconds?: number;
|
|
171
243
|
now?: () => Date;
|
|
@@ -198,12 +270,15 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
198
270
|
const username = opts.username ?? null;
|
|
199
271
|
const provisionVault = opts.provisionVault ?? true;
|
|
200
272
|
const defaultMirror = opts.defaultMirror ?? null;
|
|
273
|
+
const maxUses = opts.maxUses ?? 1;
|
|
274
|
+
const vaultCapBytes = opts.vaultCapBytes ?? null;
|
|
201
275
|
|
|
202
276
|
db.prepare(
|
|
203
277
|
`INSERT INTO invites
|
|
204
278
|
(token, created_by, vault_name, username, role, provision_vault, default_mirror,
|
|
279
|
+
max_uses, used_count, email, vault_cap_bytes,
|
|
205
280
|
expires_at, used_at, redeemed_user_id, revoked_at, created_at)
|
|
206
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
|
|
281
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, NULL, NULL, NULL, ?)`,
|
|
207
282
|
).run(
|
|
208
283
|
tokenHash,
|
|
209
284
|
opts.createdBy,
|
|
@@ -212,6 +287,8 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
212
287
|
role,
|
|
213
288
|
provisionVault ? 1 : 0,
|
|
214
289
|
defaultMirror,
|
|
290
|
+
maxUses,
|
|
291
|
+
vaultCapBytes,
|
|
215
292
|
expiresAt,
|
|
216
293
|
createdAt,
|
|
217
294
|
);
|
|
@@ -226,6 +303,10 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
226
303
|
role,
|
|
227
304
|
provisionVault,
|
|
228
305
|
defaultMirror,
|
|
306
|
+
maxUses,
|
|
307
|
+
usedCount: 0,
|
|
308
|
+
email: null,
|
|
309
|
+
vaultCapBytes,
|
|
229
310
|
expiresAt,
|
|
230
311
|
usedAt: null,
|
|
231
312
|
redeemedUserId: null,
|
|
@@ -275,7 +356,7 @@ export function usernameReservedByPendingInvite(
|
|
|
275
356
|
const row = db
|
|
276
357
|
.query<{ token: string }, [string, string]>(
|
|
277
358
|
`SELECT token FROM invites
|
|
278
|
-
WHERE username = ? AND
|
|
359
|
+
WHERE username = ? AND used_count < max_uses AND revoked_at IS NULL AND expires_at > ?
|
|
279
360
|
LIMIT 1`,
|
|
280
361
|
)
|
|
281
362
|
.get(username, now.toISOString());
|
|
@@ -295,11 +376,13 @@ export function listInvites(
|
|
|
295
376
|
}
|
|
296
377
|
|
|
297
378
|
/**
|
|
298
|
-
* Validate an invite for redemption WITHOUT
|
|
299
|
-
* not-redeemable branch (not-found / expired /
|
|
300
|
-
* invite when it's redeemable. The redemption handler calls this FIRST (so
|
|
301
|
-
* bad token is rejected before any account/vault work), then does
|
|
302
|
-
*
|
|
379
|
+
* Validate an invite for redemption WITHOUT recording one. Throws on every
|
|
380
|
+
* not-redeemable branch (not-found / expired / exhausted / revoked). Returns
|
|
381
|
+
* the invite when it's redeemable. The redemption handler calls this FIRST (so
|
|
382
|
+
* a bad token is rejected before any account/vault work), then does createUser,
|
|
383
|
+
* then `recordInviteRedemption` INSIDE the user-row transaction (the atomic
|
|
384
|
+
* seat-lock — the `< max_uses` UPDATE guard is what actually prevents an
|
|
385
|
+
* over-seat under concurrency; this early check is the fast-path rejection).
|
|
303
386
|
*/
|
|
304
387
|
export function assertInviteRedeemable(
|
|
305
388
|
db: Database,
|
|
@@ -308,10 +391,15 @@ export function assertInviteRedeemable(
|
|
|
308
391
|
): Invite {
|
|
309
392
|
const invite = findInviteByRawToken(db, rawToken);
|
|
310
393
|
if (!invite) throw new InviteNotFoundError();
|
|
311
|
-
// Revoked +
|
|
312
|
-
// so a revoked-then-expired invite reports the more specific reason.
|
|
394
|
+
// Revoked + exhausted are terminal regardless of clock; check them before
|
|
395
|
+
// expiry so a revoked-then-expired invite reports the more specific reason.
|
|
313
396
|
if (invite.revokedAt) throw new InviteRevokedError();
|
|
314
|
-
|
|
397
|
+
// Exhaustion (v15): seats all taken. A single-use link (maxUses=1) throws
|
|
398
|
+
// the familiar InviteUsedError ("already used"); a multi-use one throws
|
|
399
|
+
// InviteExhaustedError ("all signups taken") so the copy can differ.
|
|
400
|
+
if (invite.usedCount >= invite.maxUses) {
|
|
401
|
+
throw invite.maxUses > 1 ? new InviteExhaustedError() : new InviteUsedError();
|
|
402
|
+
}
|
|
315
403
|
if (now.getTime() > new Date(invite.expiresAt).getTime()) {
|
|
316
404
|
throw new InviteExpiredError();
|
|
317
405
|
}
|
|
@@ -319,39 +407,75 @@ export function assertInviteRedeemable(
|
|
|
319
407
|
}
|
|
320
408
|
|
|
321
409
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* `used_at
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
410
|
+
* Record ONE redemption against an invite — the multi-use-aware consume
|
|
411
|
+
* (v15). Atomically:
|
|
412
|
+
* - increments `used_count` by 1,
|
|
413
|
+
* - stamps `used_at` on the FIRST redeem only (COALESCE keeps the original),
|
|
414
|
+
* - records the redeemer's `email` (B2) + `redeemed_user_id` as the
|
|
415
|
+
* MOST-RECENT redeemer (the canonical per-account email lives on
|
|
416
|
+
* `users.email`; this is the admin's at-a-glance latest).
|
|
417
|
+
*
|
|
418
|
+
* Exhaustion + revocation are enforced by the `used_count < max_uses AND
|
|
419
|
+
* revoked_at IS NULL` guard in the UPDATE: a racing redeem that would take a
|
|
420
|
+
* seat past the cap — or one racing a revoke — updates zero rows, and the
|
|
421
|
+
* caller treats that as exhausted/revoked. Race-safe because sqlite
|
|
422
|
+
* serializes writes (a legacy single-use link is just the max_uses=1 case:
|
|
423
|
+
* the second concurrent redeem sees `used_count`=1, fails the `< max_uses`
|
|
424
|
+
* guard, and changes zero rows — exactly the old single-use behavior).
|
|
329
425
|
*
|
|
330
|
-
* Returns `true` if THIS call
|
|
426
|
+
* Returns `true` if THIS call recorded a redemption, `false` otherwise.
|
|
331
427
|
*/
|
|
332
|
-
export function
|
|
428
|
+
export function recordInviteRedemption(
|
|
333
429
|
db: Database,
|
|
334
430
|
tokenHash: string,
|
|
335
431
|
redeemedUserId: string,
|
|
432
|
+
email: string | null = null,
|
|
336
433
|
now: Date = new Date(),
|
|
337
434
|
): boolean {
|
|
435
|
+
const stamp = now.toISOString();
|
|
338
436
|
const res = db
|
|
339
437
|
.prepare(
|
|
340
|
-
|
|
438
|
+
`UPDATE invites
|
|
439
|
+
SET used_count = used_count + 1,
|
|
440
|
+
used_at = COALESCE(used_at, ?),
|
|
441
|
+
redeemed_user_id = ?,
|
|
442
|
+
email = ?
|
|
443
|
+
WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL`,
|
|
341
444
|
)
|
|
342
|
-
.run(
|
|
445
|
+
.run(stamp, redeemedUserId, email, tokenHash);
|
|
343
446
|
return res.changes > 0;
|
|
344
447
|
}
|
|
345
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Legacy single-use consume — thin wrapper over {@link recordInviteRedemption}
|
|
451
|
+
* for callers that don't capture email. Retained for backwards compatibility;
|
|
452
|
+
* the redeem path uses `recordInviteRedemption` directly so it can record the
|
|
453
|
+
* email. Race-safe + exhaustion-aware via the underlying function.
|
|
454
|
+
*
|
|
455
|
+
* Returns `true` if THIS call recorded a redemption, `false` otherwise.
|
|
456
|
+
*/
|
|
457
|
+
export function consumeInvite(
|
|
458
|
+
db: Database,
|
|
459
|
+
tokenHash: string,
|
|
460
|
+
redeemedUserId: string,
|
|
461
|
+
now: Date = new Date(),
|
|
462
|
+
): boolean {
|
|
463
|
+
return recordInviteRedemption(db, tokenHash, redeemedUserId, null, now);
|
|
464
|
+
}
|
|
465
|
+
|
|
346
466
|
/**
|
|
347
467
|
* Revoke a pending invite (admin DELETE). Stamps `revoked_at` only when the
|
|
348
|
-
* invite
|
|
349
|
-
*
|
|
468
|
+
* invite still has seats left (`used_count < max_uses`) and isn't already
|
|
469
|
+
* revoked. A multi-use link can be revoked mid-life to cut off its remaining
|
|
470
|
+
* seats; a fully-exhausted link is terminal and revoke is a no-op. (For a
|
|
471
|
+
* legacy single-use link `used_count < max_uses` is equivalent to the old
|
|
472
|
+
* `used_at IS NULL` guard.) Returns `true` if this call revoked it, `false`
|
|
473
|
+
* if it was already exhausted/revoked or not found.
|
|
350
474
|
*/
|
|
351
475
|
export function revokeInvite(db: Database, tokenHash: string, now: Date = new Date()): boolean {
|
|
352
476
|
const res = db
|
|
353
477
|
.prepare(
|
|
354
|
-
"UPDATE invites SET revoked_at = ? WHERE token = ? AND
|
|
478
|
+
"UPDATE invites SET revoked_at = ? WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL",
|
|
355
479
|
)
|
|
356
480
|
.run(now.toISOString(), tokenHash);
|
|
357
481
|
return res.changes > 0;
|
|
@@ -373,7 +497,7 @@ export function revokeInvitesForVault(
|
|
|
373
497
|
): number {
|
|
374
498
|
const res = db
|
|
375
499
|
.prepare(
|
|
376
|
-
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND
|
|
500
|
+
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_count < max_uses AND revoked_at IS NULL",
|
|
377
501
|
)
|
|
378
502
|
.run(now.toISOString(), vaultName);
|
|
379
503
|
return Number(res.changes);
|
package/src/jwt-sign.ts
CHANGED
|
@@ -32,6 +32,18 @@ import { getActiveSigningKey, getAllPublicKeys } from "./signing-keys.ts";
|
|
|
32
32
|
|
|
33
33
|
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
|
|
34
34
|
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
35
|
+
/**
|
|
36
|
+
* One-generation rotation grace window (hub#685). When a presented refresh
|
|
37
|
+
* token has already been rotated (revoked), a refresh of the *immediately-
|
|
38
|
+
* previous* token within this window is treated as a benign concurrent /
|
|
39
|
+
* retried refresh (multi-tab SPA, bfcache/stale-tab resume, a network retry)
|
|
40
|
+
* rather than theft — it converges the client on the live tip instead of
|
|
41
|
+
* revoking the whole family. The window is deliberately short: it tolerates
|
|
42
|
+
* the brief overlap of a real client racing itself, NOT a delayed replay.
|
|
43
|
+
* Genuine theft (an OLDER ancestor, or ANY replay past the window) still
|
|
44
|
+
* revokes the family per RFC 6819 §5.2.2.3. See `handleTokenRefresh`.
|
|
45
|
+
*/
|
|
46
|
+
export const REFRESH_GRACE_MS = 30_000;
|
|
35
47
|
export const SIGNING_ALGORITHM = "RS256";
|
|
36
48
|
|
|
37
49
|
export interface SignAccessTokenOpts {
|
|
@@ -43,9 +55,20 @@ export interface SignAccessTokenOpts {
|
|
|
43
55
|
clientId: string;
|
|
44
56
|
/**
|
|
45
57
|
* Hub origin — sets the `iss` claim. Required: every consumer (vault,
|
|
46
|
-
* scribe,
|
|
47
|
-
*
|
|
48
|
-
*
|
|
58
|
+
* scribe, agent) validates `iss`, and a missing claim is rejected. Callers
|
|
59
|
+
* derive this via `deriveHubOrigin()` or thread it from `OAuthDeps.issuer`
|
|
60
|
+
* (the per-request origin from `resolveIssuer`).
|
|
61
|
+
*
|
|
62
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25): consumers no
|
|
63
|
+
* longer pin `iss` to a SINGLE `PARACHUTE_HUB_ORIGIN`. The hub publishes the
|
|
64
|
+
* SET of origins it legitimately answers on via `PARACHUTE_HUB_ORIGINS`
|
|
65
|
+
* (`buildHubBoundOrigins` — issuer ∪ loopback ∪ expose-state ∪ platform),
|
|
66
|
+
* and a scope-guard ≥0.5.0 consumer accepts `iss` ∈ that set. So a token
|
|
67
|
+
* minted here with one URL of a multi-URL box validates when the resource is
|
|
68
|
+
* reached via another URL of the SAME box. Mint stays per-request as-is: the
|
|
69
|
+
* canonical configured origin (the intended Caddy/zero-SSH deploy) is minted
|
|
70
|
+
* on every request AND is a member of the published set, so mint and validate
|
|
71
|
+
* already align — no clamp needed here.
|
|
49
72
|
*/
|
|
50
73
|
issuer: string;
|
|
51
74
|
/**
|
|
@@ -529,6 +552,14 @@ export interface RefreshTokenRow {
|
|
|
529
552
|
createdVia: TokenCreatedVia;
|
|
530
553
|
/** JSON-encoded fine-grained constraints (auth-architecture-shape.md §11.3). */
|
|
531
554
|
permissions: string | null;
|
|
555
|
+
/**
|
|
556
|
+
* jti of the refresh-token row this row rotated INTO (set by
|
|
557
|
+
* `linkRotation` inside the rotation transaction). NULL until this row
|
|
558
|
+
* rotates, and NULL on every pre-v14 row. Powers the one-generation
|
|
559
|
+
* rotation grace window (hub#685): only the row whose `rotatedTo` is the
|
|
560
|
+
* family's single live tip is the benign immediate-predecessor.
|
|
561
|
+
*/
|
|
562
|
+
rotatedTo: string | null;
|
|
532
563
|
}
|
|
533
564
|
|
|
534
565
|
/**
|
|
@@ -553,6 +584,7 @@ interface TokenRowDb {
|
|
|
553
584
|
permissions: string | null;
|
|
554
585
|
created_via: string;
|
|
555
586
|
subject: string | null;
|
|
587
|
+
rotated_to: string | null;
|
|
556
588
|
}
|
|
557
589
|
|
|
558
590
|
function rowToRefreshToken(row: TokenRowDb): RefreshTokenRow {
|
|
@@ -568,6 +600,7 @@ function rowToRefreshToken(row: TokenRowDb): RefreshTokenRow {
|
|
|
568
600
|
createdAt: row.created_at,
|
|
569
601
|
createdVia: (row.created_via as TokenCreatedVia) ?? "oauth_refresh",
|
|
570
602
|
permissions: row.permissions,
|
|
603
|
+
rotatedTo: row.rotated_to ?? null,
|
|
571
604
|
};
|
|
572
605
|
}
|
|
573
606
|
|
|
@@ -599,3 +632,39 @@ export function revokeFamily(db: Database, familyId: string, now: Date): number
|
|
|
599
632
|
.run(now.toISOString(), familyId);
|
|
600
633
|
return Number(res.changes);
|
|
601
634
|
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Record that `predecessorJti` rotated INTO `successorJti`. Called INSIDE the
|
|
638
|
+
* rotation transaction (alongside the revoke-old + insert-new) so the
|
|
639
|
+
* successor link, the predecessor's `revoked_at`, and the new row commit or
|
|
640
|
+
* roll back as a unit. Powers the one-generation rotation grace window
|
|
641
|
+
* (hub#685): a benign concurrent/retried refresh of the immediate
|
|
642
|
+
* predecessor can be told apart from a genuine replay of an older ancestor.
|
|
643
|
+
*/
|
|
644
|
+
export function linkRotation(db: Database, predecessorJti: string, successorJti: string): void {
|
|
645
|
+
db.prepare("UPDATE tokens SET rotated_to = ? WHERE jti = ?").run(successorJti, predecessorJti);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The live (un-revoked AND un-expired) refresh-token rows in a family as of
|
|
650
|
+
* `now`. The grace window asks "is there exactly one live tip, and is the
|
|
651
|
+
* presented (revoked) row its direct predecessor?" — so callers inspect this
|
|
652
|
+
* set. Two filters matter:
|
|
653
|
+
* - `refresh_token_hash IS NOT NULL` — the family can also contain
|
|
654
|
+
* access-token rows sharing the jti/family, irrelevant to rotation lineage.
|
|
655
|
+
* - `expires_at > now` — an expired-but-not-revoked tip is not a valid
|
|
656
|
+
* rotation target, so the grace path must never rotate into it. Keeping
|
|
657
|
+
* "live" exhaustive here means the single caller doesn't re-check expiry.
|
|
658
|
+
*/
|
|
659
|
+
export function liveFamilyRefreshRows(
|
|
660
|
+
db: Database,
|
|
661
|
+
familyId: string,
|
|
662
|
+
now: Date,
|
|
663
|
+
): RefreshTokenRow[] {
|
|
664
|
+
const rows = db
|
|
665
|
+
.query<TokenRowDb, [string, string]>(
|
|
666
|
+
"SELECT * FROM tokens WHERE family_id = ? AND revoked_at IS NULL AND refresh_token_hash IS NOT NULL AND expires_at > ?",
|
|
667
|
+
)
|
|
668
|
+
.all(familyId, now.toISOString());
|
|
669
|
+
return rows.map(rowToRefreshToken);
|
|
670
|
+
}
|