@openparachute/hub 0.6.5-rc.8 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/invites.ts CHANGED
@@ -19,6 +19,24 @@
19
19
  * the createUser-then-stamp ordering so a createUser failure leaves the
20
20
  * invite re-usable.
21
21
  *
22
+ * Two invite shapes carry that authorization (plus the account-only shape):
23
+ * - provision_vault=1 — redemption provisions a NEW vault (optionally
24
+ * pre-named via `vault_name`) and assigns the redeemer at `role`
25
+ * (always 'write': the sole user of a fresh vault must hold write).
26
+ * - provision_vault=0 + vault_name — a SHARED-VAULT invite: redemption
27
+ * assigns the redeemer to the admin's EXISTING vault at `role`
28
+ * ('read' or 'write'). Issuing is host:admin-gated — the same
29
+ * authority that can already assign any user to any vault via
30
+ * `POST /api/users` / `PATCH /api/users/:id/vaults` — so the invite
31
+ * is a delivery mechanism for an admin-authorized assignment, not an
32
+ * escalation. The read-only role is enforced end-to-end: every mint
33
+ * path caps to `vaultVerbsForRole` (users.ts) and the vault's
34
+ * scope-guard refuses writes for a `vault:<name>:read` token.
35
+ *
36
+ * An invite may also pre-name the redeemer's USERNAME (`username` column,
37
+ * v13): the redemption form shows it read-only and the redeem handler
38
+ * enforces it. NULL = redeemer picks their own.
39
+ *
22
40
  * Single-use is enforced by stamping `used_at` on redemption — a replay
23
41
  * attempt sees the row with `used_at` set and `redeemInvite` throws
24
42
  * `InviteUsedError`. Revocation is a separate `revoked_at` stamp the admin
@@ -41,6 +59,11 @@ export interface Invite {
41
59
  createdBy: string | null;
42
60
  /** Pinned vault name, or null when the redeemer names their own vault. */
43
61
  vaultName: string | null;
62
+ /**
63
+ * Pre-named username the redeemer's account gets (ENFORCED at redeem),
64
+ * or null when the redeemer picks their own. v13.
65
+ */
66
+ username: string | null;
44
67
  /** `user_vaults.role` granted on redemption (`'write'` = owner). */
45
68
  role: string;
46
69
  /** Whether redemption provisions a NEW vault for the redeemer. */
@@ -86,6 +109,7 @@ interface Row {
86
109
  token: string;
87
110
  created_by: string | null;
88
111
  vault_name: string | null;
112
+ username: string | null;
89
113
  role: string;
90
114
  provision_vault: number;
91
115
  default_mirror: string | null;
@@ -101,6 +125,7 @@ function rowToInvite(r: Row): Invite {
101
125
  tokenHash: r.token,
102
126
  createdBy: r.created_by,
103
127
  vaultName: r.vault_name,
128
+ username: r.username,
104
129
  role: r.role,
105
130
  provisionVault: r.provision_vault === 1,
106
131
  defaultMirror: r.default_mirror,
@@ -130,6 +155,11 @@ export interface IssueInviteOpts {
130
155
  createdBy: string;
131
156
  /** Pinned vault name; omit/null to let the redeemer name their own. */
132
157
  vaultName?: string | null;
158
+ /**
159
+ * Pre-named username (ENFORCED at redeem); omit/null to let the redeemer
160
+ * pick their own. Caller validates the vocabulary + uniqueness.
161
+ */
162
+ username?: string | null;
133
163
  /** `user_vaults` role granted on redemption. Default `'write'` (owner). */
134
164
  role?: string;
135
165
  /** Provision a new vault on redemption. Default `true` (the primary flow). */
@@ -165,18 +195,20 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
165
195
  const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
166
196
  const role = opts.role ?? "write";
167
197
  const vaultName = opts.vaultName ?? null;
198
+ const username = opts.username ?? null;
168
199
  const provisionVault = opts.provisionVault ?? true;
169
200
  const defaultMirror = opts.defaultMirror ?? null;
170
201
 
171
202
  db.prepare(
172
203
  `INSERT INTO invites
173
- (token, created_by, vault_name, role, provision_vault, default_mirror,
204
+ (token, created_by, vault_name, username, role, provision_vault, default_mirror,
174
205
  expires_at, used_at, redeemed_user_id, revoked_at, created_at)
175
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
176
207
  ).run(
177
208
  tokenHash,
178
209
  opts.createdBy,
179
210
  vaultName,
211
+ username,
180
212
  role,
181
213
  provisionVault ? 1 : 0,
182
214
  defaultMirror,
@@ -190,6 +222,7 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
190
222
  tokenHash,
191
223
  createdBy: opts.createdBy,
192
224
  vaultName,
225
+ username,
193
226
  role,
194
227
  provisionVault,
195
228
  defaultMirror,
@@ -215,6 +248,40 @@ export function findInviteByHash(db: Database, tokenHash: string): Invite | null
215
248
  return row ? rowToInvite(row) : null;
216
249
  }
217
250
 
251
+ /**
252
+ * Is `username` already reserved by a PENDING pre-named invite (unredeemed,
253
+ * unrevoked, not yet expired)? Two pending invites pre-naming the same
254
+ * username would make the second one un-redeemable (the redeem path's
255
+ * uniqueness check fails permanently for an enforced name), so mint-time
256
+ * rejects the collision.
257
+ *
258
+ * Exact `=` comparison, deliberately NOT `COLLATE NOCASE` — an asymmetry
259
+ * with `getUserByUsernameCI` worth naming. The users-table CI lookup is
260
+ * defense in depth against legacy/hand-edited `users` rows that might carry
261
+ * mixed case from before the validator pinned lowercase. `invites.username`
262
+ * has no such legacy: the column is only ever written through the
263
+ * `validateUsername`-gated mint path (api-invites.ts), so every stored value
264
+ * is already lowercase, and the value compared against it went through the
265
+ * same validator. A hand-edited mixed-case invites row wouldn't reserve —
266
+ * but it also can't redeem: the redeem path re-runs `validateUsername` on
267
+ * the pre-named value and rejects it (the hand-edited-row backstop in
268
+ * account-setup.ts).
269
+ */
270
+ export function usernameReservedByPendingInvite(
271
+ db: Database,
272
+ username: string,
273
+ now: Date = new Date(),
274
+ ): boolean {
275
+ const row = db
276
+ .query<{ token: string }, [string, string]>(
277
+ `SELECT token FROM invites
278
+ WHERE username = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?
279
+ LIMIT 1`,
280
+ )
281
+ .get(username, now.toISOString());
282
+ return row !== null;
283
+ }
284
+
218
285
  /** List every invite, newest first, with derived status. */
219
286
  export function listInvites(
220
287
  db: Database,
@@ -289,3 +356,25 @@ export function revokeInvite(db: Database, tokenHash: string, now: Date = new Da
289
356
  .run(now.toISOString(), tokenHash);
290
357
  return res.changes > 0;
291
358
  }
359
+
360
+ /**
361
+ * Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): invalidate
362
+ * every UNREDEEMED invite pinned to the deleted vault. An un-revoked pending
363
+ * invite carrying `vault_name = <deleted>` would re-provision (resurrect)
364
+ * the name on redemption — the cascade must close that door. Used/already-
365
+ * revoked invites are untouched (terminal states). `vault_name` is an exact
366
+ * `=` comparison — no pattern matching. Returns the number of invites
367
+ * newly revoked.
368
+ */
369
+ export function revokeInvitesForVault(
370
+ db: Database,
371
+ vaultName: string,
372
+ now: Date = new Date(),
373
+ ): number {
374
+ const res = db
375
+ .prepare(
376
+ "UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_at IS NULL AND revoked_at IS NULL",
377
+ )
378
+ .run(now.toISOString(), vaultName);
379
+ return Number(res.changes);
380
+ }
package/src/jwt-sign.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  importSPKI,
28
28
  jwtVerify,
29
29
  } from "jose";
30
+ import { vaultScopeName } from "./scope-explanations.ts";
30
31
  import { getActiveSigningKey, getAllPublicKeys } from "./signing-keys.ts";
31
32
 
32
33
  export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
@@ -135,8 +136,23 @@ export interface SignRefreshTokenOpts {
135
136
  * one table for refresh tokens, one for CLI-minted access tokens, one
136
137
  * for operator tokens. Different mint paths = different rows; revocation
137
138
  * lookup + revocation list are uniform across all of them.
139
+ *
140
+ * `connection_provision` — long-lived tokens the Connections engine mints
141
+ * when provisioning a connection (the webhook bearer + the channel reply
142
+ * token). Registered so connection teardown can revoke them
143
+ * (hub-module-boundary charter, registered-mint rule).
144
+ *
145
+ * `connection_credential` — standing tag-scoped vault credentials minted by
146
+ * a `kind: "credential"` connection (H4, surface-runtime design). Registered
147
+ * for the same reason; renewal revokes the prior jti and registers the new
148
+ * one, so exactly one live row exists per credential connection.
138
149
  */
139
- export type TokenCreatedVia = "oauth_refresh" | "cli_mint" | "operator_mint";
150
+ export type TokenCreatedVia =
151
+ | "oauth_refresh"
152
+ | "cli_mint"
153
+ | "operator_mint"
154
+ | "connection_provision"
155
+ | "connection_credential";
140
156
 
141
157
  export interface SignedRefreshToken {
142
158
  /** Opaque token to return to the client. NOT recoverable from the DB. */
@@ -267,6 +283,36 @@ export function revokeTokenByJti(db: Database, jti: string, now: Date): boolean
267
283
  return Number(res.changes) > 0;
268
284
  }
269
285
 
286
+ /**
287
+ * Revoke every un-revoked tokens row whose recorded scopes NAME the given
288
+ * vault (`vault:<name>:<verb>`) — the B1 vault-delete registry sweep
289
+ * (2026-06-09 hub-module-boundary migration, lifecycle symmetry).
290
+ *
291
+ * Matching is EXACT scope-segment comparison via `vaultScopeName` — NEVER
292
+ * SQL `LIKE`: `_` in a vault name is a LIKE single-char wildcard, so a
293
+ * `LIKE '%vault:my_vault:%'` sweep for vault `my_vault` would also revoke
294
+ * `myxvault`-scoped tokens. We read candidate rows and match in JS instead.
295
+ * Unnamed scopes (`vault:read`) don't name an instance and are untouched.
296
+ *
297
+ * Returns the number of rows newly revoked. Idempotent (already-revoked
298
+ * rows are filtered by the WHERE and by `revokeTokenByJti`).
299
+ */
300
+ export function revokeTokensNamingVault(db: Database, vaultName: string, now: Date): number {
301
+ const rows = db
302
+ .query<{ jti: string; scopes: string }, []>(
303
+ "SELECT jti, scopes FROM tokens WHERE revoked_at IS NULL",
304
+ )
305
+ .all();
306
+ let revoked = 0;
307
+ for (const row of rows) {
308
+ const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
309
+ if (scopes.some((s) => vaultScopeName(s) === vaultName)) {
310
+ if (revokeTokenByJti(db, row.jti, now)) revoked++;
311
+ }
312
+ }
313
+ return revoked;
314
+ }
315
+
270
316
  /**
271
317
  * Snapshot of currently-revoked-and-not-yet-expired jtis. Powers the
272
318
  * `/.well-known/parachute-revocation.json` endpoint. Already-expired jtis