@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
@@ -22,11 +22,12 @@ import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
22
22
  import { ModuleManifestError } from "../module-manifest.ts";
23
23
  import {
24
24
  type ServiceSpec,
25
+ canonicalPortForManifest,
25
26
  getSpec,
26
27
  getSpecFromInstallDir,
27
28
  shortNameForManifest,
28
29
  } from "../service-spec.ts";
29
- import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
30
+ import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
30
31
  import { enrichedPath } from "../spawn-path.ts";
31
32
  import type { Supervisor } from "../supervisor.ts";
32
33
 
@@ -69,6 +70,77 @@ export interface BuildSpawnRequestOpts {
69
70
  readonly extraEnv?: Record<string, string>;
70
71
  }
71
72
 
73
+ /**
74
+ * Snap a fixed-port first-party module's services.json `port` back to its
75
+ * compiled-in canonical value when it has DRIFTED, and persist the correction.
76
+ * Returns the entry to spawn with — reconciled when a drift was repaired, the
77
+ * original otherwise.
78
+ *
79
+ * Why (channel#41): the hub is the port authority, and a first-party module
80
+ * with a compiled-in canonical port (`canonicalPortForManifest` ≠ undefined —
81
+ * vault / scribe / surface / channel / notes / runner) has exactly ONE correct
82
+ * port: its canonical one. The injected `PORT`, the supervisor's readiness
83
+ * probe, and the reverse-proxy target are ALL derived from the services.json
84
+ * row's `port`. So once a transiently-wrong port lands in that row (the channel
85
+ * row was observed live carrying `19415` instead of `1941`), it self-perpetuates:
86
+ * the supervisor probes/proxies the wrong port forever and `/channel/*` routes
87
+ * to a dead port. The canonical port is authoritative, so we reconcile the row
88
+ * before spawn rather than honoring the drift.
89
+ *
90
+ * Safety for the other fixed-port modules (vault / scribe / surface): the
91
+ * canonical value is exactly what each ALREADY self-registers, so on the
92
+ * common path `entry.port === canonical` and this is a no-op. There is no
93
+ * legitimate "first-party module intentionally on a non-canonical port" case —
94
+ * install-time `assignPort` only walks off canonical on a genuine collision,
95
+ * and that's a same-range fallback for a DIFFERENT module, never a steady state
96
+ * for the canonical owner. If another row genuinely already owns the canonical
97
+ * port we DON'T rewrite (it would trip the write-side duplicate-port guard and
98
+ * isn't ours to take) — we leave the drift in place and let the supervisor's
99
+ * port-squatter detection surface it. Third-party modules (no canonical) are
100
+ * never touched.
101
+ */
102
+ export function reconcilePortToCanonical(
103
+ entry: ServiceEntry,
104
+ manifestPath: string,
105
+ log: (line: string) => void = () => {},
106
+ ): ServiceEntry {
107
+ const canonical = canonicalPortForManifest(entry.name);
108
+ if (canonical === undefined || entry.port === canonical) return entry;
109
+
110
+ // Don't steal a canonical port another row legitimately holds — that would
111
+ // trip `upsertService`'s duplicate-port guard. Re-read live so we see the
112
+ // current on-disk state (another module may have just registered).
113
+ const others = readManifestLenient(manifestPath).services.filter((s) => s.name !== entry.name);
114
+ if (others.some((s) => s.port === canonical)) {
115
+ log(
116
+ `[supervisor] ${entry.name}: services.json port ${entry.port} ≠ canonical ${canonical}, ` +
117
+ `but ${canonical} is held by another row — not reconciling; surfacing the drift.`,
118
+ );
119
+ return entry;
120
+ }
121
+
122
+ const reconciled: ServiceEntry = { ...entry, port: canonical };
123
+ try {
124
+ upsertService(reconciled, manifestPath);
125
+ log(
126
+ `[supervisor] ${entry.name}: reconciled drifted services.json port ${entry.port} → canonical ${canonical} (channel#41).`,
127
+ );
128
+ return reconciled;
129
+ } catch (err) {
130
+ // Best-effort: a failed reconcile must not block the boot. The write can
131
+ // fail for an I/O reason (permissions, races against an external writer) OR
132
+ // because `upsertService`'s duplicate-port guard tripped — e.g. another row
133
+ // raced onto the canonical port between the check above and this write.
134
+ // Either way, spawn with the canonical port in-memory so at least the child
135
+ // binds + the probe checks the right port this run; the proxy still reads
136
+ // the (stale) row until the next reconcile succeeds.
137
+ log(
138
+ `[supervisor] ${entry.name}: could not reconcile services.json port to canonical (${entry.port} → ${canonical}; write failed or canonical held): ${err} — spawning with canonical in-memory.`,
139
+ );
140
+ return reconciled;
141
+ }
142
+ }
143
+
72
144
  /**
73
145
  * Build the `Supervisor.start` request for a single module, identically on
74
146
  * both the serve-boot path and the `POST /api/modules/:short/start` handler.
@@ -166,7 +238,12 @@ export async function bootSupervisedModules(
166
238
  continue;
167
239
  }
168
240
 
169
- const cmd = spec.startCmd?.(entry);
241
+ // Snap a drifted fixed-port row back to canonical before we derive PORT /
242
+ // probe / proxy from it (channel#41). No-op on the common path where the
243
+ // module already sits on its canonical port.
244
+ const spawnEntry = reconcilePortToCanonical(entry, opts.manifestPath, log);
245
+
246
+ const cmd = spec.startCmd?.(spawnEntry);
170
247
  if (!cmd || cmd.length === 0) {
171
248
  log(`[supervisor] ${short}: spec resolved but no startCmd — skipping (CLI-only module).`);
172
249
  results.push({
@@ -182,7 +259,7 @@ export async function bootSupervisedModules(
182
259
  // .env merge, and PARACHUTE_HUB_ORIGIN propagation (hub#365) all live in the
183
260
  // shared `buildModuleSpawnRequest` so the `POST /api/modules/:short/start`
184
261
  // handler builds an identical request (design 2026-06-01 §3.3).
185
- const req = buildModuleSpawnRequest(short, entry, cmd, {
262
+ const req = buildModuleSpawnRequest(short, spawnEntry, cmd, {
186
263
  configDir: opts.configDir,
187
264
  ...(opts.hubOrigin !== undefined ? { hubOrigin: opts.hubOrigin } : {}),
188
265
  });
@@ -74,7 +74,7 @@ interface ServiceChoice {
74
74
  manifestName: string;
75
75
  /** Per-service URL composer used in the final-summary banner. Optional. */
76
76
  urlForEntry?: (entry: ServiceEntry) => string | undefined;
77
- /** Full spec when available (FIRST_PARTY_FALLBACKS shorts: notes / channel). */
77
+ /** Full spec when available (FIRST_PARTY_FALLBACKS shorts: notes). */
78
78
  spec?: ServiceSpec;
79
79
  }
80
80
 
@@ -114,9 +114,9 @@ function defaultAvailability(): InteractiveAvailability {
114
114
  * the service has a row in services.json.
115
115
  *
116
116
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
117
- * shorts (notes / channel they carry a vendored manifest). KNOWN_MODULES
118
- * shorts (vault / scribe / runner) ship `.parachute/module.json` and
119
- * self-register; pre-install we know manifestName + the urlForEntry quirk
117
+ * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
118
+ * (vault / scribe / runner / channel / surface) ship `.parachute/module.json`
119
+ * and self-register; pre-install we know manifestName + the urlForEntry quirk
120
120
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
121
121
  */
122
122
  function surveyServices(manifestPath: string): ServiceChoice[] {
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `connections.json` — the persisted registry of operator-created Connections
3
+ * (2026-06-09 modular-UI architecture, P5).
4
+ *
5
+ * A **connection** wires "when [EVENT] in [source module] (filter) → do [ACTION]
6
+ * in [sink module]". The hub is the only thing with cross-module authority
7
+ * (mint tokens, register vault triggers), so connections are hub-native + the
8
+ * record of what got provisioned lives here, in the hub state dir.
9
+ *
10
+ * One file, a flat array of records. Each record carries enough to (a) render
11
+ * the Connections list and (b) tear down what was provisioned — notably the
12
+ * `provisioned.triggerName` + `provisioned.vault` so DELETE can remove the exact
13
+ * vault trigger that was registered. We keep the store deliberately small +
14
+ * synchronous (Bun file I/O); the cardinality is "a handful of connections per
15
+ * hub", not a hot path.
16
+ */
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname } from "node:path";
19
+
20
+ /** The source side — an event a module emits, with an operator-set filter. */
21
+ export interface ConnectionSource {
22
+ /** Source module short name, e.g. `vault`. */
23
+ readonly module: string;
24
+ /** For vault events, which vault instance the event is scoped to. */
25
+ readonly vault?: string;
26
+ /** Event key declared in the source module's `module.json`, e.g. `note.created`. */
27
+ readonly event: string;
28
+ /**
29
+ * Operator-set filter, shaped by the event's `filterSchema`. For a vault
30
+ * event this maps to the trigger predicate (`tags` / `has_metadata` /
31
+ * `missing_metadata` / `has_content`).
32
+ */
33
+ readonly filter?: Record<string, unknown>;
34
+ }
35
+
36
+ /** The sink side — an action a module accepts (the sink is ALWAYS an action). */
37
+ export interface ConnectionSink {
38
+ /** Sink module short name, e.g. `channel`. */
39
+ readonly module: string;
40
+ /** Action key declared in the sink module's `module.json`, e.g. `message.deliver`. */
41
+ readonly action: string;
42
+ /** Action params, shaped by the action's `inputSchema` (e.g. `{ channel }`). */
43
+ readonly params?: Record<string, unknown>;
44
+ }
45
+
46
+ /** What the provisioning engine actually wired, for teardown + display. */
47
+ export interface ConnectionProvisioned {
48
+ /** How the action was provisioned, e.g. `vault-trigger` or `credential`. */
49
+ readonly type: string;
50
+ /** The vault instance the trigger was registered on (vault-trigger), or
51
+ * the vault a credential connection grants access to (credential). Either
52
+ * way it's the field the vault-delete cascade matches on. */
53
+ readonly vault?: string;
54
+ /** The exact vault trigger name registered — DELETE removes this. */
55
+ readonly triggerName?: string;
56
+ /** Credential connections (H4): the exact scope minted, e.g.
57
+ * `vault:default:read` — renewal re-mints THIS, never request input. */
58
+ readonly scope?: string;
59
+ /** Credential connections (H4): the tag allowlist baked into the minted
60
+ * token's `permissions.scoped_tags`. Empty/absent = vault-wide (read
61
+ * scopes only — writes always carry tags). */
62
+ readonly scopedTags?: readonly string[];
63
+ /** Credential connections (H4): the declared credential key. */
64
+ readonly credentialKey?: string;
65
+ /** Credential connections (H4): the module's daemon-root-relative delivery
66
+ * endpoint — also the best-effort removal-notification target. */
67
+ readonly endpoint?: string;
68
+ /**
69
+ * jtis of the LONG-LIVED tokens minted for this connection (the webhook
70
+ * bearer, and for a channel sink the vault-write reply token). Each is
71
+ * registered in the hub's tokens table (`created_via='connection_provision'`)
72
+ * at mint time so teardown can revoke them — an unregistered long-lived
73
+ * token is unrevocable by construction (hub-module-boundary charter,
74
+ * registered-mint rule). Records written before this field existed read back
75
+ * as `undefined`; their tokens were never registered and ride to expiry
76
+ * (surfaced as `legacy: true` in the list wire shape).
77
+ */
78
+ readonly mintedJtis?: readonly string[];
79
+ }
80
+
81
+ export interface ConnectionRecord {
82
+ readonly id: string;
83
+ /**
84
+ * Connection kind discriminator (H4). Absent = the original event→action
85
+ * shape; `"credential"` = a standing tag-scoped vault credential held by a
86
+ * module (the source is the granting vault, the sink is the holding
87
+ * module). Optional for back-compat: pre-H4 records read back undefined.
88
+ */
89
+ readonly kind?: "credential";
90
+ /**
91
+ * Approval state (surface#113 claim/reconcile). Absent = active (every
92
+ * operator-provisioned record, and pre-claim records, read back undefined
93
+ * = active). `"pending"` = a module-initiated CLAIM for a directly-delivered
94
+ * credential, awaiting operator approval in the hub admin Connections view.
95
+ * A pending record grants nothing: renewal refuses it, and only the
96
+ * operator-gated approve endpoint flips it to active.
97
+ */
98
+ readonly status?: "pending";
99
+ readonly source: ConnectionSource;
100
+ readonly sink: ConnectionSink;
101
+ readonly provisioned: ConnectionProvisioned;
102
+ readonly createdAt: string;
103
+ /**
104
+ * Provenance — WHO requested this connection (modular-UI R2, module-initiated
105
+ * connections). A module-owned config UI that creates a connection on the
106
+ * operator's behalf (e.g. the channel admin page's "link to a vault" flow)
107
+ * labels itself here (e.g. `"channel"`); a connection built by hand in the
108
+ * hub's own Connections builder is `"custom"`. Lets the operator see which
109
+ * connections a module initiated vs which they wired themselves. Optional for
110
+ * back-compat: records written before R2 read back as `undefined`, which the
111
+ * SPA treats as `"custom"`.
112
+ */
113
+ readonly requestedBy?: string;
114
+ }
115
+
116
+ interface ConnectionsFile {
117
+ connections: ConnectionRecord[];
118
+ }
119
+
120
+ function emptyFile(): ConnectionsFile {
121
+ return { connections: [] };
122
+ }
123
+
124
+ /** Read the store. A missing/garbage file reads as empty (fresh hub). */
125
+ export function readConnections(storePath: string): ConnectionRecord[] {
126
+ let buf: string;
127
+ try {
128
+ buf = readFileSync(storePath, "utf8");
129
+ } catch {
130
+ return [];
131
+ }
132
+ let parsed: unknown;
133
+ try {
134
+ parsed = JSON.parse(buf);
135
+ } catch {
136
+ return [];
137
+ }
138
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
139
+ const arr = (parsed as { connections?: unknown }).connections;
140
+ if (!Array.isArray(arr)) return [];
141
+ // Lenient: drop any malformed row rather than failing the whole read, so one
142
+ // bad hand-edit doesn't take down the Connections view (mirrors the
143
+ // services.json lenient-read posture).
144
+ return arr.filter((r): r is ConnectionRecord => {
145
+ if (!r || typeof r !== "object") return false;
146
+ const rec = r as Record<string, unknown>;
147
+ return (
148
+ typeof rec.id === "string" &&
149
+ !!rec.source &&
150
+ typeof rec.source === "object" &&
151
+ !!rec.sink &&
152
+ typeof rec.sink === "object"
153
+ );
154
+ });
155
+ }
156
+
157
+ function writeAll(storePath: string, records: ConnectionRecord[]): void {
158
+ mkdirSync(dirname(storePath), { recursive: true });
159
+ const file: ConnectionsFile = { connections: records };
160
+ // Written WITHOUT 0o600 because this file holds NO secrets — the provisioned
161
+ // webhook bearer lives only in the vault trigger's row, never here; records
162
+ // carry source/sink/trigger-name metadata only. Consistent with the default
163
+ // perms on services.json / expose-state.json.
164
+ writeFileSync(storePath, `${JSON.stringify(file, null, 2)}\n`);
165
+ }
166
+
167
+ /** Upsert by id (replace an existing record with the same id, else append). */
168
+ export function putConnection(storePath: string, record: ConnectionRecord): void {
169
+ const records = readConnections(storePath);
170
+ const idx = records.findIndex((r) => r.id === record.id);
171
+ if (idx >= 0) records[idx] = record;
172
+ else records.push(record);
173
+ writeAll(storePath, records);
174
+ }
175
+
176
+ /** Remove a connection by id. Returns the removed record, or null if absent. */
177
+ export function removeConnection(storePath: string, id: string): ConnectionRecord | null {
178
+ const records = readConnections(storePath);
179
+ const idx = records.findIndex((r) => r.id === id);
180
+ if (idx < 0) return null;
181
+ const [removed] = records.splice(idx, 1);
182
+ writeAll(storePath, records);
183
+ return removed ?? null;
184
+ }
185
+
186
+ export function getConnection(storePath: string, id: string): ConnectionRecord | null {
187
+ return readConnections(storePath).find((r) => r.id === id) ?? null;
188
+ }
189
+
190
+ /** Re-export for the unused-import linter when only the type is consumed. */
191
+ export const _emptyConnectionsFile = emptyFile;
package/src/grants.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { Database } from "bun:sqlite";
29
+ import { vaultScopeName } from "./scope-explanations.ts";
29
30
 
30
31
  export interface Grant {
31
32
  userId: string;
@@ -324,3 +325,52 @@ export function revokeGrant(db: Database, userId: string, clientId: string): boo
324
325
  .run(userId, clientId);
325
326
  return res.changes > 0;
326
327
  }
328
+
329
+ /**
330
+ * Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): REWRITE
331
+ * every grant whose scope list names the deleted vault, removing the
332
+ * `vault:<name>:<verb>` entries; DROP the row only when the rewrite leaves
333
+ * it empty.
334
+ *
335
+ * Rewrite-not-drop matters: a grant row is keyed (user, client) and its
336
+ * scope set spans every vault the user ever approved for that client —
337
+ * dropping the whole row over a single vault would silently revoke the
338
+ * client's consent on the user's OTHER vaults (over-revocation).
339
+ *
340
+ * Matching is the exact `vaultScopeName` segment comparison (regex on the
341
+ * `vault:<name>:<read|write|admin>` shape), never SQL LIKE — `_` in a vault
342
+ * name is a LIKE wildcard. Unnamed scopes (`vault:read`) and non-vault
343
+ * scopes are preserved.
344
+ */
345
+ export function rewriteGrantsRemovingVault(
346
+ db: Database,
347
+ vaultName: string,
348
+ ): { rewritten: number; dropped: number } {
349
+ return db.transaction(() => {
350
+ const rows = db
351
+ .prepare("SELECT user_id, client_id, scopes, granted_at FROM grants")
352
+ .all() as GrantRow[];
353
+ let rewritten = 0;
354
+ let dropped = 0;
355
+ for (const row of rows) {
356
+ const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
357
+ const kept = scopes.filter((s) => vaultScopeName(s) !== vaultName);
358
+ if (kept.length === scopes.length) continue; // doesn't name the vault
359
+ if (kept.length === 0) {
360
+ db.prepare("DELETE FROM grants WHERE user_id = ? AND client_id = ?").run(
361
+ row.user_id,
362
+ row.client_id,
363
+ );
364
+ dropped++;
365
+ } else {
366
+ db.prepare("UPDATE grants SET scopes = ? WHERE user_id = ? AND client_id = ?").run(
367
+ kept.join(" "),
368
+ row.user_id,
369
+ row.client_id,
370
+ );
371
+ rewritten++;
372
+ }
373
+ }
374
+ return { rewritten, dropped };
375
+ })();
376
+ }
package/src/help.ts CHANGED
@@ -583,12 +583,19 @@ export function logsHelp(): string {
583
583
  Usage:
584
584
  parachute logs <service> print the last 200 lines
585
585
  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>\`.
586
+ parachute logs hub the full hub log (every module interleaved)
587
+
588
+ Where logs live:
589
+ Supervised modules (the normal shape — hub-as-supervisor) write through
590
+ the hub: the supervisor multiplexes each child's output into
591
+ ~/.parachute/hub/logs/hub.log with a \`[<service>]\` line prefix.
592
+ \`parachute logs <service>\` reads that stream filtered to the service's
593
+ lines, prefix stripped (-n caps the MATCHING lines, not raw hub-log
594
+ lines; \`logs hub\` is unfiltered). A legacy per-service file
595
+ (~/.parachute/<service>/logs/<service>.log) is read instead when it is
596
+ fresher than the hub log — the pre-supervised install shape.
597
+
598
+ If no log lines exist yet, prints a hint to \`parachute start <service>\`.
592
599
  `;
593
600
  }
594
601
 
@@ -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 {