@openparachute/hub 0.7.0 → 0.7.2-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.
Files changed (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -0,0 +1,272 @@
1
+ /**
2
+ * `agent-grants.json` — the persisted registry of approval-gated **agent
3
+ * connector grants** (Phase 4b-1, agent-connectors design 2026-06-17).
4
+ *
5
+ * NOT to be confused with `grants.ts` (the OAuth consent skip-list, one row per
6
+ * `(user_id, client_id)` in the SQLite `grants` table). THIS store is the
7
+ * agent-connector subsystem: an agent (a `#agent/definition` note in the agent
8
+ * module) declares connections it WANTS beyond its own def-vault; the agent
9
+ * module registers each as a PENDING grant here; the operator approves
10
+ * per-connection in hub admin; the hub mints/stores the secret; the agent
11
+ * module fetches the material at spawn and injects it.
12
+ *
13
+ * The one invariant (from the design): **a vault note can only REQUEST; it can
14
+ * never GRANT.** A grant created by the module sits `pending` and grants nothing
15
+ * until the operator approves in the hub. The minted/pasted secret (`material`)
16
+ * is the only sensitive field — it lives here on disk (0600), is NEVER logged,
17
+ * and is NEVER returned by the list endpoint (only by the approved-only
18
+ * `/material` endpoint).
19
+ *
20
+ * One file, a flat array. Cardinality is "a handful of grants per agent", not a
21
+ * hot path — small + synchronous Bun file I/O, mirroring `connections-store.ts`.
22
+ * Unlike `connections.json`, this file IS written 0600 because it holds the
23
+ * granted secrets.
24
+ */
25
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { dirname } from "node:path";
27
+
28
+ /** Verb for a vault grant. Service/mcp grants carry no access verb. */
29
+ export type GrantAccess = "read" | "write";
30
+
31
+ /** Where the agent side injects a resolved grant. */
32
+ export type GrantInject = "env" | "mcp";
33
+
34
+ /**
35
+ * A **connection spec** — what the agent WANTS. The wire shape the agent module
36
+ * sends to `PUT /admin/grants` and the shape echoed back by the list endpoint.
37
+ *
38
+ * - `vault` — a LOCAL vault other than the def-vault. `target` is the vault
39
+ * name; `access` the verb; optional `tags` narrow the grant to a tag set
40
+ * (rides the vault's `scoped_tags` tokens). Grant = a hub-minted
41
+ * `vault:<target>:<access>` token.
42
+ * - `service` — an external service credential (`github`, `cloudflare`).
43
+ * `target` is the service key; `inject` hints how the agent side wires it
44
+ * (`env` → an env var, `mcp` → the service's MCP server, or both). Grant =
45
+ * an operator-pasted API token.
46
+ * - `mcp` — a remote MCP / remote vault. `target` is the MCP URL. Grant =
47
+ * an OAuth token — NOT implemented in 4b-1 (slice 2). Modeled here; the
48
+ * grant stays `pending` with a clear reason.
49
+ */
50
+ export interface ConnectionSpec {
51
+ readonly kind: "vault" | "service" | "mcp";
52
+ /** Vault name / service key / MCP URL, per `kind`. */
53
+ readonly target: string;
54
+ /** Vault grants only — `read` (default) or `write`. */
55
+ readonly access?: GrantAccess;
56
+ /** Vault grants only — tag-scope (`scoped_tags`); empty/absent = vault-wide. */
57
+ readonly tags?: readonly string[];
58
+ /** Service grants only — injection hints carried for the agent side. */
59
+ readonly inject?: readonly GrantInject[];
60
+ }
61
+
62
+ /**
63
+ * Grant lifecycle. `needs_consent` (added 4b-2) is distinct from `revoked`
64
+ * (operator-intended teardown) — it means "an mcp grant was working, its refresh
65
+ * died, re-consent to revive." The status resolver treats it as not-approved
66
+ * (so the agent def shows it in `pending:[…]`).
67
+ */
68
+ export type GrantStatus = "pending" | "approved" | "revoked" | "needs_consent";
69
+
70
+ /**
71
+ * The granted secret material, kept on disk ONLY. Discriminated by `kind` to
72
+ * mirror the spec. NEVER returned by the list endpoint; only the approved-only
73
+ * `/material` endpoint reads it.
74
+ */
75
+ export type GrantMaterial =
76
+ | {
77
+ readonly kind: "vault";
78
+ /** The minted `vault:<target>:<access>` JWT. */
79
+ readonly token: string;
80
+ /** jti of the minted token — registered so revoke can drop it. */
81
+ readonly jti: string;
82
+ /** ISO expiry of the minted token. */
83
+ readonly expiresAt: string;
84
+ }
85
+ | {
86
+ readonly kind: "service";
87
+ /** The operator-pasted API token. */
88
+ readonly token: string;
89
+ }
90
+ | {
91
+ readonly kind: "mcp";
92
+ /**
93
+ * The remote MCP access token — OAuth-issued (auto-refreshed) OR an
94
+ * operator-pasted static bearer (no refresh). The `/material` WIRE shape
95
+ * projects this as `token` (matching the vault/service material the agent
96
+ * already consumes); the internal store field is `access_token`.
97
+ */
98
+ readonly access_token: string;
99
+ /** OAuth refresh token. Absent for a static-bearer grant. */
100
+ readonly refresh_token?: string;
101
+ /** ISO expiry of the access token. Absent for a static bearer (never refreshed). */
102
+ readonly expiresAt?: string;
103
+ /** Issuer (for refresh + revoke). Absent for a static bearer. */
104
+ readonly issuer?: string;
105
+ /** DCR client_id (for refresh). Absent for a static bearer. */
106
+ readonly clientId?: string;
107
+ /** Cached token endpoint (for refresh). Absent for a static bearer. */
108
+ readonly tokenEndpoint?: string;
109
+ /** Cached revocation endpoint (for revoke). Absent for a static bearer / non-advertising issuer. */
110
+ readonly revocationEndpoint?: string;
111
+ /** The remote MCP URL the agent connects to. */
112
+ readonly mcpUrl: string;
113
+ };
114
+
115
+ export interface GrantRecord {
116
+ readonly id: string;
117
+ /** Agent name (the `#agent/definition` note's agent identity). */
118
+ readonly agent: string;
119
+ /** The declared connection spec. */
120
+ readonly connection: ConnectionSpec;
121
+ readonly status: GrantStatus;
122
+ /** Why a grant is pending/blocked (e.g. mcp "oauth not yet supported"). */
123
+ readonly reason?: string;
124
+ /** The secret — present only when `status === "approved"`. Never logged/listed. */
125
+ readonly material?: GrantMaterial;
126
+ readonly createdAt: string;
127
+ readonly approvedAt?: string;
128
+ }
129
+
130
+ interface GrantsFile {
131
+ grants: GrantRecord[];
132
+ }
133
+
134
+ /**
135
+ * Stable connection key — the idempotency key for `(agent, connection)` upsert.
136
+ * A spec's identity is its `kind` + `target` + (for vault) the access verb +
137
+ * the sorted tag set. `inject` is NOT part of the key — it's an agent-side
138
+ * hint, so re-declaring the same service with different inject modes updates
139
+ * the existing grant rather than forking a second one. The grant `id` derives
140
+ * from `agent` + this key.
141
+ */
142
+ export function connectionKey(spec: ConnectionSpec): string {
143
+ const target = spec.target.trim().toLowerCase();
144
+ if (spec.kind === "vault") {
145
+ const access = spec.access ?? "read";
146
+ const tags = [...(spec.tags ?? [])].map((t) => t.trim()).sort();
147
+ return tags.length > 0
148
+ ? `vault:${target}:${access}#${tags.join(",")}`
149
+ : `vault:${target}:${access}`;
150
+ }
151
+ if (spec.kind === "service") {
152
+ return `service:${target}`;
153
+ }
154
+ // mcp — keyed on the URL only (its target).
155
+ return `mcp:${target}`;
156
+ }
157
+
158
+ const GRANT_ID_CHARSET = /[^a-z0-9]+/g;
159
+
160
+ /**
161
+ * Derive the grant id from `(agent, connectionKey)`. A conservative slug — it
162
+ * lands in a URL path segment (`/admin/grants/<id>/...`). Deterministic so the
163
+ * same `(agent, connection)` always upserts the same row.
164
+ *
165
+ * The slug collapses non-`[a-z0-9]` runs to a single `-`. A collision (two distinct
166
+ * `(agent, connection)` pairs slugging identically) is infeasible given the upstream
167
+ * validators — `kind` is an enum, vault `target` passes `validateVaultName`, service
168
+ * `target` matches `SERVICE_KEY_RE`, `access` is `read|write`, tags are charset-bounded
169
+ * — so the separators that survive the slug are non-ambiguous in practice.
170
+ */
171
+ export function grantId(agent: string, spec: ConnectionSpec): string {
172
+ const raw = `${agent.trim().toLowerCase()}--${connectionKey(spec)}`;
173
+ return raw.replace(GRANT_ID_CHARSET, "-").replace(/^-+|-+$/g, "");
174
+ }
175
+
176
+ function emptyFile(): GrantsFile {
177
+ return { grants: [] };
178
+ }
179
+
180
+ function isConnectionSpec(v: unknown): v is ConnectionSpec {
181
+ if (!v || typeof v !== "object") return false;
182
+ const s = v as Record<string, unknown>;
183
+ return (
184
+ (s.kind === "vault" || s.kind === "service" || s.kind === "mcp") && typeof s.target === "string"
185
+ );
186
+ }
187
+
188
+ /** Read the store. A missing/garbage file reads as empty (fresh hub). */
189
+ export function readGrants(storePath: string): GrantRecord[] {
190
+ let buf: string;
191
+ try {
192
+ buf = readFileSync(storePath, "utf8");
193
+ } catch {
194
+ return [];
195
+ }
196
+ let parsed: unknown;
197
+ try {
198
+ parsed = JSON.parse(buf);
199
+ } catch {
200
+ return [];
201
+ }
202
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
203
+ const arr = (parsed as { grants?: unknown }).grants;
204
+ if (!Array.isArray(arr)) return [];
205
+ // Lenient: drop a malformed row rather than failing the whole read (mirrors
206
+ // the connections-store / services.json lenient-read posture).
207
+ return arr.filter((r): r is GrantRecord => {
208
+ if (!r || typeof r !== "object") return false;
209
+ const rec = r as Record<string, unknown>;
210
+ return (
211
+ typeof rec.id === "string" &&
212
+ typeof rec.agent === "string" &&
213
+ isConnectionSpec(rec.connection) &&
214
+ // NOTE (4b-2): `needs_consent` MUST be accepted here — else a row that
215
+ // flipped to needs_consent on a failed mcp refresh is silently dropped on
216
+ // re-read and `/material` 404s instead of 409ing. Regression-tested.
217
+ (rec.status === "pending" ||
218
+ rec.status === "approved" ||
219
+ rec.status === "revoked" ||
220
+ rec.status === "needs_consent")
221
+ );
222
+ });
223
+ }
224
+
225
+ function writeAll(storePath: string, records: GrantRecord[]): void {
226
+ mkdirSync(dirname(storePath), { recursive: true });
227
+ const file: GrantsFile = { grants: records };
228
+ // 0600 — UNLIKE connections.json, this file holds the granted secrets
229
+ // (minted vault tokens + pasted service creds in `material`). `writeFileSync`'s
230
+ // `mode` applies at CREATE time (passed to open(O_CREAT)), so a fresh file is
231
+ // never world-readable even for an instant; the chmodSync below is belt-and-
232
+ // suspenders for the re-write case (an existing file left at looser perms).
233
+ writeFileSync(storePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
234
+ try {
235
+ chmodSync(storePath, 0o600);
236
+ } catch {
237
+ // Best-effort on platforms without POSIX perms (the secret is still only
238
+ // reachable through the approved-only, auth-gated /material endpoint).
239
+ }
240
+ }
241
+
242
+ /** Upsert by id (replace an existing record with the same id, else append). */
243
+ export function putGrant(storePath: string, record: GrantRecord): void {
244
+ const records = readGrants(storePath);
245
+ const idx = records.findIndex((r) => r.id === record.id);
246
+ if (idx >= 0) records[idx] = record;
247
+ else records.push(record);
248
+ writeAll(storePath, records);
249
+ }
250
+
251
+ export function getGrant(storePath: string, id: string): GrantRecord | null {
252
+ return readGrants(storePath).find((r) => r.id === id) ?? null;
253
+ }
254
+
255
+ /** All grants for an agent, in stored order. */
256
+ export function listGrantsForAgent(storePath: string, agent: string): GrantRecord[] {
257
+ const want = agent.trim().toLowerCase();
258
+ return readGrants(storePath).filter((r) => r.agent.trim().toLowerCase() === want);
259
+ }
260
+
261
+ /** Remove a grant by id. Returns the removed record, or null if absent. */
262
+ export function removeGrant(storePath: string, id: string): GrantRecord | null {
263
+ const records = readGrants(storePath);
264
+ const idx = records.findIndex((r) => r.id === id);
265
+ if (idx < 0) return null;
266
+ const [removed] = records.splice(idx, 1);
267
+ writeAll(storePath, records);
268
+ return removed ?? null;
269
+ }
270
+
271
+ /** Re-export for the unused-import linter when only the type is consumed. */
272
+ export const _emptyGrantsFile = emptyFile;
package/src/help.ts CHANGED
@@ -31,6 +31,9 @@ Usage:
31
31
  parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
32
32
  etc.) — forwards to parachute-vault.
33
33
  For lifecycle, use \`parachute start|stop|restart|logs vault\`.
34
+ \`vault remove <name>\` is routed through the hub's
35
+ identity cascade (revokes the vault's tokens/grants),
36
+ not the raw mechanics-only delete.
34
37
 
35
38
  Flags:
36
39
  --help, -h show this help (also per-subcommand: \`parachute <cmd> --help\`)
@@ -469,7 +472,7 @@ Module start commands (run by the supervisor under \`serve\`):
469
472
  vault parachute-vault serve
470
473
  scribe parachute-scribe serve
471
474
  app parachute-app serve
472
- channel parachute-channel daemon
475
+ agent parachute-agent
473
476
  notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
474
477
  `;
475
478
  }
@@ -583,12 +586,19 @@ export function logsHelp(): string {
583
586
  Usage:
584
587
  parachute logs <service> print the last 200 lines
585
588
  parachute logs <service> -f tail the log (like \`tail -f\`)
586
- parachute logs hub logs for the internal hub
587
-
588
- Log file:
589
- ~/.parachute/<service>/logs/<service>.log
590
-
591
- If no log file exists yet, prints a hint to \`parachute start <service>\`.
589
+ parachute logs hub the full hub log (every module interleaved)
590
+
591
+ Where logs live:
592
+ Supervised modules (the normal shape — hub-as-supervisor) write through
593
+ the hub: the supervisor multiplexes each child's output into
594
+ ~/.parachute/hub/logs/hub.log with a \`[<service>]\` line prefix.
595
+ \`parachute logs <service>\` reads that stream filtered to the service's
596
+ lines, prefix stripped (-n caps the MATCHING lines, not raw hub-log
597
+ lines; \`logs hub\` is unfiltered). A legacy per-service file
598
+ (~/.parachute/<service>/logs/<service>.log) is read instead when it is
599
+ fresher than the hub log — the pre-supervised install shape.
600
+
601
+ If no log lines exist yet, prints a hint to \`parachute start <service>\`.
592
602
  `;
593
603
  }
594
604
 
@@ -40,11 +40,15 @@
40
40
  * OAuth / access-token validation (vault / MCP tokens, `aud: "vault.<name>"`)
41
41
  * stays STRICT per-request-issuer and lives on entirely separate code paths
42
42
  * (the resource servers' own validators, hub's `/api/auth/*`, etc.). This
43
- * helper is invoked ONLY from the two loopback host-admin module surfaces
43
+ * helper is invoked from the two loopback host-admin module surfaces
44
44
  * (`/api/modules` GET — the `status` read; `/api/modules/:short/*` POST — the
45
45
  * lifecycle ops), both of which already gate on the non-requestable
46
46
  * `parachute:host:admin` / `parachute:host:auth` scopes that no OAuth token
47
- * can carry. The relaxation cannot reach an OAuth token's validation.
47
+ * can carry, and from the per-UI audience gate's Bearer branch
48
+ * (`src/audience-gate.ts`, H3) — same self-issued-token shape, same
49
+ * iss-∈-bound-origins need (a PWA's token carries the public origin while
50
+ * the proxied request resolves the loopback issuer), with the surface's
51
+ * declared `scopes_required` enforced by the gate on top.
48
52
  */
49
53
  import type { Database } from "bun:sqlite";
50
54
  import { type ValidatedAccessToken, validateAccessToken } from "./jwt-sign.ts";
package/src/hub-db.ts CHANGED
@@ -4,7 +4,7 @@
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).
7
+ * (v12, the `invites` table; v13 adds the pre-named `username` column).
8
8
  *
9
9
  * Each open() runs `migrate()` to bring the schema up to date. A
10
10
  * `schema_version` table records every applied migration so re-opens are
@@ -426,6 +426,31 @@ const MIGRATIONS: readonly Migration[] = [
426
426
  CREATE INDEX invites_created_at ON invites (created_at);
427
427
  `,
428
428
  },
429
+ {
430
+ version: 13,
431
+ sql: `
432
+ -- Pre-named invites (Adam/Jonathan scenario). Adds an optional
433
+ -- \`username\` column to \`invites\`: when set, the invite pre-names
434
+ -- the account the redeemer gets — the redemption form shows the name
435
+ -- read-only and the redeem handler ENFORCES it (the form's username
436
+ -- field is ignored). NULL = the redeemer picks their own username
437
+ -- (every pre-v13 invite, and the default for new ones).
438
+ --
439
+ -- Enforced (not just pre-filled) because a pre-named invite is a
440
+ -- *named deliverable*: the admin mints "Jonathan's link" and hands it
441
+ -- to Jonathan; if the redeemer could pick a different name, the
442
+ -- link's identity binding would be decorative — the admin's audit
443
+ -- expectation ("this link = jonathan") and any vault assignment
444
+ -- story told against that name would silently break.
445
+ --
446
+ -- Stored as plain TEXT (already-validated lowercase [a-z0-9_-], the
447
+ -- users.username vocabulary). Mint-time validation rejects names
448
+ -- taken by an existing user or reserved by another pending invite;
449
+ -- the redeem path re-checks authoritatively. No backfill — every
450
+ -- existing invite predates pre-naming and keeps NULL.
451
+ ALTER TABLE invites ADD COLUMN username TEXT;
452
+ `,
453
+ },
429
454
  ];
430
455
 
431
456
  export function openHubDb(path: string = hubDbPath()): Database {