@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- 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 =
|
|
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
|