@openparachute/hub 0.6.3 → 0.6.4-rc.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 +609 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/commands/serve.ts
CHANGED
|
@@ -26,17 +26,21 @@
|
|
|
26
26
|
|
|
27
27
|
import { existsSync, mkdirSync } from "node:fs";
|
|
28
28
|
import { join } from "node:path";
|
|
29
|
+
import { selfHealScribeAuth } from "../auto-wire.ts";
|
|
29
30
|
import { generateBootstrapToken } from "../bootstrap-token.ts";
|
|
30
31
|
// NOTE: CONFIG_DIR/WELL_KNOWN_DIR/SERVICES_MANIFEST_PATH are evaluated at
|
|
31
32
|
// import time from process.env.PARACHUTE_HOME. The `env` parameter on
|
|
32
33
|
// `serve()` cannot reroute them — set PARACHUTE_HOME before importing for
|
|
33
34
|
// path isolation.
|
|
34
35
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
36
|
+
import { readExposeState } from "../expose-state.ts";
|
|
35
37
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
36
38
|
import { hubFetch } from "../hub-server.ts";
|
|
37
39
|
import { writeHubFile } from "../hub.ts";
|
|
40
|
+
import { enrichedPath } from "../spawn-path.ts";
|
|
38
41
|
import { Supervisor } from "../supervisor.ts";
|
|
39
42
|
import { createUser, userCount } from "../users.ts";
|
|
43
|
+
import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
|
|
40
44
|
import { WELL_KNOWN_DIR } from "../well-known.ts";
|
|
41
45
|
import { bootSupervisedModules } from "./serve-boot.ts";
|
|
42
46
|
|
|
@@ -101,6 +105,22 @@ export function formatListeningBanner(args: {
|
|
|
101
105
|
return `parachute serve: listening on http://${displayHost}:${port}${boundNote} (PARACHUTE_HOME=${configDir}, db=${dbPath}, issuer=${issuer ?? "<request-origin>"}, admin=${adminBootstrap})`;
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Map a `Bun.serve` bind failure to a clear "another supervisor is running"
|
|
110
|
+
* message when it's a port-in-use error, or `null` for any other error (so the
|
|
111
|
+
* caller re-throws the original). Keeps a duplicate-supervisor start from
|
|
112
|
+
* surfacing as a raw `EADDRINUSE` stack — the operator's actionable next step
|
|
113
|
+
* is "stop the other instance," not a backtrace. See hub#536. Exported for
|
|
114
|
+
* testing (the bind itself isn't seam-injectable).
|
|
115
|
+
*/
|
|
116
|
+
export function hubPortConflictMessage(err: unknown, port: number): string | null {
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
if (/EADDRINUSE|address already in use|in use/i.test(msg)) {
|
|
119
|
+
return `parachute serve: hub port ${port} is already in use — another hub/supervisor is running. Refusing to start a duplicate supervisor (it would fight the live one over module ports). Stop the other instance first, then retry.`;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
104
124
|
function parsePort(raw: string | undefined): number | undefined {
|
|
105
125
|
if (raw === undefined || raw === "") return undefined;
|
|
106
126
|
const n = Number.parseInt(raw, 10);
|
|
@@ -124,7 +144,21 @@ function parsePort(raw: string | undefined): number | undefined {
|
|
|
124
144
|
* operator can't know the URL at deploy time. Without this, supervised
|
|
125
145
|
* modules' iss-validation breaks on hub-minted tokens (iss-mismatch
|
|
126
146
|
* every time).
|
|
127
|
-
* 4.
|
|
147
|
+
* 4. `expose-state.json`'s `hubOrigin` — the canonical public origin a
|
|
148
|
+
* live tailscale/cloudflare exposure recorded (e.g.
|
|
149
|
+
* `https://parachute.taildf9ce2.ts.net`). This is the load-bearing
|
|
150
|
+
* tier for the **owner-operated reboot-persistent path**: the launchd
|
|
151
|
+
* plist / systemd unit that keeps `parachute serve` alive carries no
|
|
152
|
+
* `PARACHUTE_HUB_ORIGIN` env, so on every reboot the hub would
|
|
153
|
+
* otherwise boot issuer-less (tier 5), stamp `iss` from the per-request
|
|
154
|
+
* origin, and inject nothing into children — vault then defaults to
|
|
155
|
+
* loopback and rejects hub-minted tokens with `unexpected "iss" claim
|
|
156
|
+
* value` until it restarts. Reading the exposed origin off disk makes
|
|
157
|
+
* `iss` deterministic across reboots with zero operator action. Guarded
|
|
158
|
+
* to a non-loopback `http(s)` origin (a loopback value here would
|
|
159
|
+
* re-pin the degraded mode; expose-state should never carry one, but we
|
|
160
|
+
* defend anyway).
|
|
161
|
+
* 5. None (returns undefined). Hub falls back to per-request derivation
|
|
128
162
|
* via `resolveIssuer` in hub-server.ts — works for `/.well-known`
|
|
129
163
|
* discovery but supervised modules with cached iss expectations
|
|
130
164
|
* won't have a static value to validate against, so OAuth flows
|
|
@@ -136,18 +170,60 @@ function parsePort(raw: string | undefined): number | undefined {
|
|
|
136
170
|
*
|
|
137
171
|
* Trailing slashes are stripped for canonical-form comparison; empty
|
|
138
172
|
* strings collapse to undefined.
|
|
173
|
+
*
|
|
174
|
+
* `readExpose` is injectable so tests exercise the expose-state tier
|
|
175
|
+
* without touching the real `~/.parachute`. The default reads
|
|
176
|
+
* `expose-state.json` and swallows a malformed-file throw (a corrupt state
|
|
177
|
+
* file must never crash startup — fall through to the request-origin mode);
|
|
178
|
+
* the `readExpose()` call is additionally try/catch-wrapped here so even an
|
|
179
|
+
* injected non-swallowing reader can't crash startup.
|
|
180
|
+
*
|
|
181
|
+
* KNOWN ASTERISK (tracked in #532): this resolves the issuer at boot, so a
|
|
182
|
+
* child module spawned during a *pre-expose* boot — hub started before the
|
|
183
|
+
* first-ever `parachute expose` — gets no `PARACHUTE_HUB_ORIGIN` injected
|
|
184
|
+
* until it's restarted after the exposure exists. Once an exposure is
|
|
185
|
+
* recorded, every subsequent reboot picks it up here automatically. The
|
|
186
|
+
* remaining gap (rebuild the live spawn-env on `supervisor.restart` so the
|
|
187
|
+
* first exposure propagates to already-running children without a manual
|
|
188
|
+
* restart) is the deferred #532 follow-up; not implemented in this PR.
|
|
139
189
|
*/
|
|
140
190
|
export function resolveStartupIssuer(
|
|
141
191
|
opts: { issuer?: string },
|
|
142
192
|
env: NodeJS.ProcessEnv,
|
|
193
|
+
readExpose: () => string | undefined = defaultReadExposeHubOrigin,
|
|
143
194
|
): string | undefined {
|
|
144
195
|
const flyOrigin = flyDefaultOriginFromEnv(env);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
);
|
|
196
|
+
const explicit = (
|
|
197
|
+
opts.issuer ??
|
|
198
|
+
env.PARACHUTE_HUB_ORIGIN ??
|
|
199
|
+
env.RENDER_EXTERNAL_URL ??
|
|
200
|
+
flyOrigin
|
|
201
|
+
)?.replace(/\/+$/, "");
|
|
202
|
+
if (explicit) return explicit;
|
|
203
|
+
// No flag / env / platform origin set — fall back to the exposed origin
|
|
204
|
+
// recorded on disk. `sanitizePublicOrigin` applies the same non-loopback
|
|
205
|
+
// http(s) guard as the hub-server chokepoint (#531) so a stray loopback
|
|
206
|
+
// value never pins the degraded request-origin mode.
|
|
207
|
+
let raw: string | undefined;
|
|
208
|
+
try {
|
|
209
|
+
raw = readExpose();
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return sanitizePublicOrigin(raw);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read `expose-state.json`'s `hubOrigin` for the startup-issuer fallback,
|
|
218
|
+
* swallowing a malformed-file throw. Kept separate so it can be passed as
|
|
219
|
+
* the default `readExpose` arg and stubbed in tests.
|
|
220
|
+
*/
|
|
221
|
+
function defaultReadExposeHubOrigin(): string | undefined {
|
|
222
|
+
try {
|
|
223
|
+
return readExposeState()?.hubOrigin;
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
151
227
|
}
|
|
152
228
|
|
|
153
229
|
/**
|
|
@@ -242,6 +318,16 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
242
318
|
const env = opts.env ?? process.env;
|
|
243
319
|
const log = opts.log ?? ((line) => console.log(line));
|
|
244
320
|
|
|
321
|
+
// PATH enrichment (hub launchd-PATH regression): the launchd/systemd hub unit
|
|
322
|
+
// bakes a minimal PATH. Enrich the hub's OWN process PATH so its `Bun.which`
|
|
323
|
+
// probes (cloudflared / tailscale detection, etc.) see operator-tool dirs
|
|
324
|
+
// (`$HOME/.local/bin`, brew bin) too — and so any child that inherits raw
|
|
325
|
+
// `process.env` (not the explicit per-child env) starts from the enriched
|
|
326
|
+
// PATH. The per-child spawn env is enriched independently in
|
|
327
|
+
// `buildModuleSpawnRequest` / `spawnSupervised`. See `spawn-path.ts`. Only
|
|
328
|
+
// mutate the live process env, never a test-injected `opts.env`.
|
|
329
|
+
if (!opts.env) process.env.PATH = enrichedPath(process.env);
|
|
330
|
+
|
|
245
331
|
const envPort = parsePort(env.PORT);
|
|
246
332
|
const port = opts.port ?? envPort ?? DEFAULT_PORT;
|
|
247
333
|
const issuer = resolveStartupIssuer(opts, env);
|
|
@@ -276,8 +362,71 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
276
362
|
|
|
277
363
|
const supervisor = opts.supervisor ?? new Supervisor();
|
|
278
364
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
365
|
+
// Claim the hub port FIRST — before booting a single supervised module. If
|
|
366
|
+
// another hub/supervisor already owns it, `Bun.serve` throws here and we
|
|
367
|
+
// exit immediately. The prior order (boot modules, *then* bind) let a
|
|
368
|
+
// duplicate `serve` spawn + port-race the live hub's children over their
|
|
369
|
+
// module ports before it ever hit the hub-port conflict — the
|
|
370
|
+
// dual-supervisor crash loop in hub#536. Binding first makes a duplicate
|
|
371
|
+
// fail fast and cleanly, leaving the live hub's children untouched.
|
|
372
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
373
|
+
try {
|
|
374
|
+
server = Bun.serve({
|
|
375
|
+
port,
|
|
376
|
+
hostname,
|
|
377
|
+
// Hold idle keep-alive connections for Bun's maximum 255s so reverse-
|
|
378
|
+
// proxy edges (Render, Cloudflare, fly.io) don't race us when reusing
|
|
379
|
+
// pooled connections. See `src/hub-server.ts` for the full rationale —
|
|
380
|
+
// this is the active code path for `bun src/cli.ts serve` (the Docker
|
|
381
|
+
// CMD), so the fix has to land here too. Closes hub#399.
|
|
382
|
+
idleTimeout: 255,
|
|
383
|
+
fetch: hubFetch(WELL_KNOWN_DIR, {
|
|
384
|
+
getDb: () => db,
|
|
385
|
+
issuer,
|
|
386
|
+
loopbackPort: port,
|
|
387
|
+
supervisor,
|
|
388
|
+
}),
|
|
389
|
+
});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const conflict = hubPortConflictMessage(err, port);
|
|
392
|
+
if (conflict) throw new Error(conflict);
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
log(
|
|
397
|
+
formatListeningBanner({
|
|
398
|
+
hostname,
|
|
399
|
+
port,
|
|
400
|
+
configDir: CONFIG_DIR,
|
|
401
|
+
dbPath,
|
|
402
|
+
issuer,
|
|
403
|
+
adminBootstrap,
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Self-heal scribe's auth token from vault's .env (item H) BEFORE booting
|
|
408
|
+
// modules, so scribe's first boot below reads the synced config. Closes the
|
|
409
|
+
// "scribe installed pre-auto-wire boots auth-OPEN over loopback" gap: every
|
|
410
|
+
// `serve` start re-syncs scribe's `auth.required_token` to vault's
|
|
411
|
+
// SCRIBE_AUTH_TOKEN. Fully idempotent — no-op when there's nothing to sync or
|
|
412
|
+
// the two already match; logs only when it heals. Mirrors the issuer
|
|
413
|
+
// self-heal pattern in vault-hub-origin-env.ts. Skipped in tests via
|
|
414
|
+
// `opts.skipModuleBoot` (which also gates the boot it feeds).
|
|
415
|
+
if (!opts.skipModuleBoot) {
|
|
416
|
+
try {
|
|
417
|
+
selfHealScribeAuth({ configDir: CONFIG_DIR, log });
|
|
418
|
+
} catch (err) {
|
|
419
|
+
// A self-heal failure must never block the hub from starting — scribe
|
|
420
|
+
// just keeps whatever auth state it had. Log and move on.
|
|
421
|
+
log(
|
|
422
|
+
`parachute serve: scribe auth self-heal failed (${err instanceof Error ? err.message : String(err)}); continuing.`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Boot already-installed modules from services.json — now that we own the
|
|
428
|
+
// hub port (above), we're guaranteed to be the sole supervisor. In a
|
|
429
|
+
// container, this is the path that re-spawns vault / notes / scribe after a
|
|
281
430
|
// restart — the persistent disk preserved both the install (in
|
|
282
431
|
// `$BUN_INSTALL/install/global/node_modules`) and the row that says
|
|
283
432
|
// "this module is registered + active." Idempotent: the supervisor
|
|
@@ -304,34 +453,6 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
304
453
|
}
|
|
305
454
|
}
|
|
306
455
|
|
|
307
|
-
const server = Bun.serve({
|
|
308
|
-
port,
|
|
309
|
-
hostname,
|
|
310
|
-
// Hold idle keep-alive connections for Bun's maximum 255s so reverse-
|
|
311
|
-
// proxy edges (Render, Cloudflare, fly.io) don't race us when reusing
|
|
312
|
-
// pooled connections. See `src/hub-server.ts` for the full rationale —
|
|
313
|
-
// this is the active code path for `bun src/cli.ts serve` (the Docker
|
|
314
|
-
// CMD), so the fix has to land here too. Closes hub#399.
|
|
315
|
-
idleTimeout: 255,
|
|
316
|
-
fetch: hubFetch(WELL_KNOWN_DIR, {
|
|
317
|
-
getDb: () => db,
|
|
318
|
-
issuer,
|
|
319
|
-
loopbackPort: port,
|
|
320
|
-
supervisor,
|
|
321
|
-
}),
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
log(
|
|
325
|
-
formatListeningBanner({
|
|
326
|
-
hostname,
|
|
327
|
-
port,
|
|
328
|
-
configDir: CONFIG_DIR,
|
|
329
|
-
dbPath,
|
|
330
|
-
issuer,
|
|
331
|
-
adminBootstrap,
|
|
332
|
-
}),
|
|
333
|
-
);
|
|
334
|
-
|
|
335
456
|
return {
|
|
336
457
|
result: {
|
|
337
458
|
port,
|
package/src/commands/status.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
type DriveModuleOpDeps,
|
|
22
22
|
type ModuleStatesResult,
|
|
23
|
+
type ModuleStateSnapshot,
|
|
23
24
|
NoOperatorTokenError,
|
|
24
25
|
OperatorTokenExpiredError,
|
|
25
26
|
fetchModuleStates as fetchModuleStatesImpl,
|
|
@@ -487,10 +488,17 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
487
488
|
}
|
|
488
489
|
}
|
|
489
490
|
|
|
490
|
-
const stateByShort = new Map<string,
|
|
491
|
+
const stateByShort = new Map<string, ModuleStateSnapshot>();
|
|
491
492
|
for (const m of states?.modules ?? []) {
|
|
492
493
|
if (m.short) stateByShort.set(m.short, m);
|
|
493
494
|
}
|
|
495
|
+
// Fall back to the full `supervised` snapshot for modules the curated
|
|
496
|
+
// `modules` catalog omits — e.g. the `surface` UI host, which the supervisor
|
|
497
|
+
// runs but isn't a curated installable. Without this it'd map to `inactive`
|
|
498
|
+
// despite running (hub#539). Curated entries already in the map win (richer).
|
|
499
|
+
for (const m of states?.supervised ?? []) {
|
|
500
|
+
if (m.short && !stateByShort.has(m.short)) stateByShort.set(m.short, m);
|
|
501
|
+
}
|
|
494
502
|
|
|
495
503
|
const rows: StatusRow[] = manifest.services.map((entry) => {
|
|
496
504
|
const base = manifestRowBase(entry, installSourceDeps);
|
package/src/hub-db.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Hub-local SQLite database. Opens `~/.parachute/hub.db` (overridable via
|
|
3
3
|
* `$PARACHUTE_HOME`). Holds everything the hub owns as the ecosystem's OAuth
|
|
4
4
|
* issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
|
|
5
|
-
* clients + auth-codes + grants + browser sessions (v3),
|
|
6
|
-
* enrollment on the users row (v11, hub#473)
|
|
5
|
+
* clients + auth-codes + grants + browser sessions (v3), TOTP 2FA
|
|
6
|
+
* enrollment on the users row (v11, hub#473), and one-time invite links
|
|
7
|
+
* (v12, the `invites` table).
|
|
7
8
|
*
|
|
8
9
|
* Each open() runs `migrate()` to bring the schema up to date. A
|
|
9
10
|
* `schema_version` table records every applied migration so re-opens are
|
|
@@ -358,6 +359,73 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
358
359
|
ALTER TABLE users ADD COLUMN totp_enrolled_at TEXT;
|
|
359
360
|
`,
|
|
360
361
|
},
|
|
362
|
+
{
|
|
363
|
+
version: 12,
|
|
364
|
+
sql: `
|
|
365
|
+
-- One-time, expiring invite links (design
|
|
366
|
+
-- 2026-06-04-individual-users-and-vault-operations.md §7). An admin
|
|
367
|
+
-- generates a link; the recipient opens it, picks a username +
|
|
368
|
+
-- password, and gets their OWN freshly-provisioned vault as owner.
|
|
369
|
+
--
|
|
370
|
+
-- The row stores sha256(token), NOT the raw token. Invites are
|
|
371
|
+
-- longer-lived than the 60s OAuth auth-codes (default 7-day expiry),
|
|
372
|
+
-- so a DB read must not be enough to replay the link — the raw token
|
|
373
|
+
-- is emitted exactly once at creation and never persisted, exactly
|
|
374
|
+
-- like the bootstrap token. Lookup hashes the URL token and selects
|
|
375
|
+
-- by the hash; the hash is the primary key.
|
|
376
|
+
--
|
|
377
|
+
-- Columns:
|
|
378
|
+
-- * token (TEXT PK) — sha256(raw token), hex. Never the raw value.
|
|
379
|
+
-- * created_by (TEXT) — admin user id that issued the invite
|
|
380
|
+
-- (FK users.id; ON DELETE SET NULL so
|
|
381
|
+
-- deleting the issuer doesn't orphan-block
|
|
382
|
+
-- the audit row).
|
|
383
|
+
-- * vault_name (TEXT) — nullable. When set, the invite pins the
|
|
384
|
+
-- vault name (redeemer can't squat names).
|
|
385
|
+
-- When NULL + provision_vault=1 the redeemer
|
|
386
|
+
-- names their own vault at redeem time.
|
|
387
|
+
-- * role (TEXT DEFAULT 'write') — the user_vaults role the redeemed
|
|
388
|
+
-- user gets on their vault. 'write' = owner
|
|
389
|
+
-- (full vault admin per vaultVerbsForRole).
|
|
390
|
+
-- Carried so the shared-into-existing-vault
|
|
391
|
+
-- case is a later policy change, not a
|
|
392
|
+
-- migration.
|
|
393
|
+
-- * provision_vault (INTEGER) — 1 = provision a NEW vault for the
|
|
394
|
+
-- redeemer (the primary flow); 0 = account
|
|
395
|
+
-- only / assign an existing vault.
|
|
396
|
+
-- * default_mirror (TEXT) — nullable; wires the §3 default-mirror knob
|
|
397
|
+
-- ('internal' | 'off') through to the
|
|
398
|
+
-- provisioned vault. NULL = vault's own
|
|
399
|
+
-- default.
|
|
400
|
+
-- * expires_at (TEXT) — ISO-8601; redeem rejects past this.
|
|
401
|
+
-- * used_at (TEXT) — ISO-8601 stamp set at redeem. Single-use:
|
|
402
|
+
-- a second redeem sees this set and is
|
|
403
|
+
-- rejected. Stamped only AFTER the user row
|
|
404
|
+
-- commits, so a createUser failure leaves
|
|
405
|
+
-- the invite re-usable.
|
|
406
|
+
-- * redeemed_user_id (TEXT) — the user id the invite created (FK
|
|
407
|
+
-- users.id; ON DELETE SET NULL).
|
|
408
|
+
-- * revoked_at (TEXT) — ISO-8601 stamp when the admin revokes the
|
|
409
|
+
-- invite before redemption.
|
|
410
|
+
-- * created_at (TEXT) — ISO-8601.
|
|
411
|
+
--
|
|
412
|
+
-- No backfill — no invites pre-date this migration.
|
|
413
|
+
CREATE TABLE invites (
|
|
414
|
+
token TEXT PRIMARY KEY,
|
|
415
|
+
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
416
|
+
vault_name TEXT,
|
|
417
|
+
role TEXT NOT NULL DEFAULT 'write',
|
|
418
|
+
provision_vault INTEGER NOT NULL DEFAULT 1,
|
|
419
|
+
default_mirror TEXT,
|
|
420
|
+
expires_at TEXT NOT NULL,
|
|
421
|
+
used_at TEXT,
|
|
422
|
+
redeemed_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
423
|
+
revoked_at TEXT,
|
|
424
|
+
created_at TEXT NOT NULL
|
|
425
|
+
);
|
|
426
|
+
CREATE INDEX invites_created_at ON invites (created_at);
|
|
427
|
+
`,
|
|
428
|
+
},
|
|
361
429
|
];
|
|
362
430
|
|
|
363
431
|
export function openHubDb(path: string = hubDbPath()): Database {
|