@openparachute/hub 0.5.7 → 0.5.10-rc.10

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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Per-module child supervisor for container-mode hub.
3
+ *
4
+ * The on-box flow (`parachute start <svc>`) spawns module daemons
5
+ * detached + unref'd, writes a pidfile, and walks away — process
6
+ * lifecycle becomes the operator's problem (launchd, systemd, or a
7
+ * follow-up `parachute restart`). That shape doesn't work in a
8
+ * container:
9
+ *
10
+ * - There's no external supervisor watching the children. If vault
11
+ * crashes, nothing brings it back.
12
+ * - Render's log viewer only surfaces hub's stdout. A detached child
13
+ * whose stdout goes to `~/.parachute/<svc>/logs/<svc>.log` is
14
+ * invisible to the operator clicking through the dashboard.
15
+ *
16
+ * This supervisor solves both. It spawns each module attached (no
17
+ * `detached: true`, no `unref()`), pipes their stdout/stderr through a
18
+ * line-prefixing tap into hub's own stdout (`[vault] …`,
19
+ * `[scribe] …`), watches `proc.exited`, and restarts crashed children
20
+ * up to a small budget before giving up + marking the module
21
+ * `crashed`. The budget keeps a wedged-on-boot module from chewing
22
+ * forever; once it's exhausted the operator sees the crash via /api/modules
23
+ * (or, post-1B, the per-module log view).
24
+ *
25
+ * Out of scope for this module: spawning the hub HTTP server itself
26
+ * (that's `Bun.serve` in the same process), driving the on-box
27
+ * `parachute start <svc>` path (still uses `commands/lifecycle.ts`),
28
+ * persisting child state to disk (transient — re-derived from
29
+ * services.json on boot).
30
+ */
31
+
32
+ export type ModuleStatus = "starting" | "running" | "stopped" | "crashed" | "restarting";
33
+
34
+ export interface ModuleState {
35
+ /** Short name (vault / notes / scribe / …). */
36
+ readonly short: string;
37
+ /** Last-observed lifecycle phase. */
38
+ readonly status: ModuleStatus;
39
+ /** PID of the current Bun.spawn child, if any. */
40
+ readonly pid?: number;
41
+ /** ISO timestamp of the most recent spawn. */
42
+ readonly startedAt?: string;
43
+ /** Crash count within the current restart window. Resets after the window passes without a crash. */
44
+ readonly restartsInWindow: number;
45
+ /** ISO timestamp of the most recent crash, or undefined if never crashed. */
46
+ readonly lastCrashAt?: string;
47
+ /** Exit code of the most recent crash. */
48
+ readonly lastExitCode?: number | null;
49
+ }
50
+
51
+ export interface SpawnRequest {
52
+ /** Short name — used as the log prefix and the supervisor map key. */
53
+ readonly short: string;
54
+ /** argv passed to `Bun.spawn`. */
55
+ readonly cmd: readonly string[];
56
+ /** Optional cwd for the child. */
57
+ readonly cwd?: string;
58
+ /**
59
+ * Optional env merged on top of `process.env`. The supervisor doesn't
60
+ * mutate `process.env`; the merge happens at spawn time.
61
+ */
62
+ readonly env?: Record<string, string>;
63
+ }
64
+
65
+ export interface SupervisorOpts {
66
+ /**
67
+ * Max crashes within `restartWindowMs` before the supervisor gives up
68
+ * and marks the module `crashed`. Default 3 — enough to ride out a
69
+ * transient race (DB lock, port still releasing), few enough to stop
70
+ * a wedged-on-boot module from looping forever.
71
+ */
72
+ readonly maxRestarts?: number;
73
+ /**
74
+ * Sliding window for the restart budget, in ms. A crash older than
75
+ * this falls out of the count. Default 60_000 (1 minute).
76
+ */
77
+ readonly restartWindowMs?: number;
78
+ /**
79
+ * Delay between a crash being observed and the restart spawn, in ms.
80
+ * Default 500 — gives sockets time to release on EADDRINUSE.
81
+ */
82
+ readonly restartDelayMs?: number;
83
+ /**
84
+ * Max time to wait for a child to exit after SIGTERM before
85
+ * escalating to SIGKILL, in ms. Default 5000 — long enough for a
86
+ * well-behaved module to flush its log buffer + drop its listeners,
87
+ * short enough that a wedged child doesn't keep `stop()` (and the
88
+ * container shutdown path that calls it) hanging indefinitely.
89
+ *
90
+ * Tests pass a short timeout (1–10ms) to exercise the SIGKILL
91
+ * escalation path without real waiting.
92
+ */
93
+ readonly killTimeoutMs?: number;
94
+ /**
95
+ * Where prefixed child output goes. Default `process.stdout.write`.
96
+ * Tests inject a collector so they can assert on the multiplexed
97
+ * stream without spelunking stdout.
98
+ */
99
+ readonly output?: (line: string) => void;
100
+ /**
101
+ * Test seam over `Bun.spawn`. Returns a Subprocess-shaped handle.
102
+ */
103
+ readonly spawnFn?: SpawnFn;
104
+ /**
105
+ * Test seam over wall-clock. Production passes `Date.now`.
106
+ */
107
+ readonly now?: () => number;
108
+ /**
109
+ * Test seam over `setTimeout`. Production resolves a real Promise
110
+ * with `setTimeout`. Tests stub to advance time deterministically.
111
+ */
112
+ readonly sleep?: (ms: number) => Promise<void>;
113
+ }
114
+
115
+ /**
116
+ * Subprocess-shaped seam. Production passes through to `Bun.spawn`;
117
+ * tests construct a fake that exposes a controllable `exited` Promise
118
+ * and pipe-able stdout/stderr.
119
+ */
120
+ export type SpawnFn = (req: SpawnRequest) => SupervisedProc;
121
+
122
+ /**
123
+ * The minimal Subprocess shape the supervisor depends on. Bun's real
124
+ * `Subprocess` matches this; the test fake mirrors it.
125
+ */
126
+ export interface SupervisedProc {
127
+ readonly pid: number;
128
+ readonly exited: Promise<number | null>;
129
+ readonly stdout: ReadableStream<Uint8Array> | null;
130
+ readonly stderr: ReadableStream<Uint8Array> | null;
131
+ kill(signal?: NodeJS.Signals | number): void;
132
+ }
133
+
134
+ const DEFAULT_MAX_RESTARTS = 3;
135
+ const DEFAULT_RESTART_WINDOW_MS = 60_000;
136
+ const DEFAULT_RESTART_DELAY_MS = 500;
137
+ const DEFAULT_KILL_TIMEOUT_MS = 5_000;
138
+
139
+ /**
140
+ * Per-module supervisor. Owns the spawn → watch → restart loop.
141
+ *
142
+ * Single-process semantics: instances of `Supervisor` aren't safe to
143
+ * share across processes (the underlying Subprocess handles are
144
+ * process-local). Hub creates one Supervisor per `parachute serve`
145
+ * boot and threads it into the API handlers.
146
+ */
147
+ export class Supervisor {
148
+ private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
149
+ readonly spawnFn: SpawnFn;
150
+ };
151
+ private readonly modules = new Map<string, ModuleEntry>();
152
+
153
+ constructor(opts: SupervisorOpts = {}) {
154
+ this.opts = {
155
+ maxRestarts: opts.maxRestarts ?? DEFAULT_MAX_RESTARTS,
156
+ restartWindowMs: opts.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS,
157
+ restartDelayMs: opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS,
158
+ killTimeoutMs: opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS,
159
+ output: opts.output ?? ((line) => process.stdout.write(line)),
160
+ spawnFn: opts.spawnFn ?? defaultSpawnFn,
161
+ now: opts.now ?? Date.now,
162
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Spawn a module under supervision. Idempotent: re-spawning an
168
+ * already-running module is a no-op (returns the existing state).
169
+ * Re-spawning a previously-crashed module clears the crash budget
170
+ * and starts fresh.
171
+ */
172
+ async start(req: SpawnRequest): Promise<ModuleState> {
173
+ const existing = this.modules.get(req.short);
174
+ if (existing && (existing.state.status === "running" || existing.state.status === "starting")) {
175
+ return existing.state;
176
+ }
177
+ // Crashed → operator intent is "try again." Wipe the budget.
178
+ const entry: ModuleEntry = {
179
+ req,
180
+ state: {
181
+ short: req.short,
182
+ status: "starting",
183
+ restartsInWindow: 0,
184
+ },
185
+ crashStamps: [],
186
+ };
187
+ this.modules.set(req.short, entry);
188
+ this.spawnAndWatch(entry);
189
+ return entry.state;
190
+ }
191
+
192
+ /**
193
+ * Stop a supervised module. Sends SIGTERM, awaits the child's exit
194
+ * (so the log-pump drains the final flush before our stdout closes),
195
+ * and escalates to SIGKILL if the child doesn't exit within
196
+ * `killTimeoutMs`. Marks the state `stopped` and detaches the exit
197
+ * watcher so a normal termination isn't seen as a crash. Idempotent
198
+ * on already-stopped modules.
199
+ *
200
+ * The await matters in two places:
201
+ * - Container shutdown (hub PID 1 receiving SIGTERM from Render):
202
+ * without it, children's final log lines never make it through
203
+ * hub's stdout pipe before the platform reaps the pod.
204
+ * - `restart()`: a fresh spawn that races a still-listening prior
205
+ * PID will fail with EADDRINUSE.
206
+ *
207
+ * The SIGKILL escalation handles a wedged module (e.g. a broken
208
+ * native binding ignoring SIGTERM). Without it, `stop()` would hang
209
+ * forever and a re-deploy would leak the orphaned child until the
210
+ * container itself was recycled.
211
+ */
212
+ async stop(short: string): Promise<ModuleState | undefined> {
213
+ const entry = this.modules.get(short);
214
+ if (!entry) return undefined;
215
+ entry.stopRequested = true;
216
+ const proc = entry.proc;
217
+ if (proc) {
218
+ try {
219
+ proc.kill("SIGTERM");
220
+ } catch {
221
+ // Process may already be dead — fall through.
222
+ }
223
+ // Race the child's exit against the kill timeout. If the timer
224
+ // wins, escalate to SIGKILL. Either way we end up awaiting the
225
+ // exit promise so the log pump drains.
226
+ let timer: ReturnType<typeof setTimeout> | undefined;
227
+ const timeout = new Promise<"timeout">((resolve) => {
228
+ timer = setTimeout(() => resolve("timeout"), this.opts.killTimeoutMs);
229
+ });
230
+ try {
231
+ const winner = await Promise.race([proc.exited.then(() => "exited" as const), timeout]);
232
+ if (winner === "timeout") {
233
+ this.opts.output(
234
+ `[supervisor] ${entry.req.short} did not exit ${this.opts.killTimeoutMs}ms after SIGTERM — escalating to SIGKILL.\n`,
235
+ );
236
+ try {
237
+ proc.kill("SIGKILL");
238
+ } catch {
239
+ // Process may already be dead between the timeout firing
240
+ // and us reaching kill() — fall through to the await.
241
+ }
242
+ try {
243
+ await proc.exited;
244
+ // SIGKILL cannot be caught; OS reaps the child promptly.
245
+ } catch {
246
+ // exited rejection is non-fatal — we're stopping anyway.
247
+ }
248
+ }
249
+ } finally {
250
+ clearTimeout(timer!);
251
+ }
252
+ }
253
+ entry.state = { ...entry.state, status: "stopped" };
254
+ return entry.state;
255
+ }
256
+
257
+ /** Snapshot of every supervised module's current state. */
258
+ list(): ModuleState[] {
259
+ return Array.from(this.modules.values(), (e) => e.state);
260
+ }
261
+
262
+ /** Snapshot of a single module's state, or undefined if not supervised. */
263
+ get(short: string): ModuleState | undefined {
264
+ return this.modules.get(short)?.state;
265
+ }
266
+
267
+ /**
268
+ * Restart a supervised module: stop, wait for exit, start with the
269
+ * same SpawnRequest. Used by the `/api/modules/:name/restart`
270
+ * handler. The on-box `parachute restart <svc>` path stays on
271
+ * `commands/lifecycle.ts` — different surface, different ownership.
272
+ */
273
+ async restart(short: string): Promise<ModuleState | undefined> {
274
+ const entry = this.modules.get(short);
275
+ if (!entry) return undefined;
276
+ const req = entry.req;
277
+ entry.state = { ...entry.state, status: "restarting" };
278
+ // stop() now awaits the prior process's exit (with SIGKILL
279
+ // escalation) before returning, so the fresh spawn below doesn't
280
+ // race on EADDRINUSE — no separate await needed here.
281
+ await this.stop(short);
282
+ // Drop the entry so `start` treats this as a clean spawn.
283
+ this.modules.delete(short);
284
+ return this.start(req);
285
+ }
286
+
287
+ private spawnAndWatch(entry: ModuleEntry): void {
288
+ const proc = this.opts.spawnFn(entry.req);
289
+ entry.proc = proc;
290
+ entry.state = {
291
+ ...entry.state,
292
+ status: "running",
293
+ pid: proc.pid,
294
+ startedAt: new Date(this.opts.now()).toISOString(),
295
+ };
296
+ this.pipeOutput(entry.req.short, proc);
297
+ void proc.exited.then((exitCode) => this.handleExit(entry, exitCode));
298
+ }
299
+
300
+ private async handleExit(entry: ModuleEntry, exitCode: number | null): Promise<void> {
301
+ // Operator-driven stop: not a crash, don't restart.
302
+ if (entry.stopRequested) {
303
+ entry.state = {
304
+ ...entry.state,
305
+ status: "stopped",
306
+ pid: undefined,
307
+ lastExitCode: exitCode,
308
+ };
309
+ return;
310
+ }
311
+
312
+ const now = this.opts.now();
313
+ // Drop crashes older than the window before counting.
314
+ const cutoff = now - this.opts.restartWindowMs;
315
+ entry.crashStamps = entry.crashStamps.filter((t) => t >= cutoff);
316
+ entry.crashStamps.push(now);
317
+
318
+ if (entry.crashStamps.length >= this.opts.maxRestarts) {
319
+ this.opts.output(
320
+ `[supervisor] ${entry.req.short} crashed ${entry.crashStamps.length}x within ${this.opts.restartWindowMs}ms — giving up.\n`,
321
+ );
322
+ entry.state = {
323
+ ...entry.state,
324
+ status: "crashed",
325
+ pid: undefined,
326
+ lastCrashAt: new Date(now).toISOString(),
327
+ lastExitCode: exitCode,
328
+ restartsInWindow: entry.crashStamps.length,
329
+ };
330
+ return;
331
+ }
332
+
333
+ entry.state = {
334
+ ...entry.state,
335
+ status: "restarting",
336
+ pid: undefined,
337
+ lastCrashAt: new Date(now).toISOString(),
338
+ lastExitCode: exitCode,
339
+ restartsInWindow: entry.crashStamps.length,
340
+ };
341
+ this.opts.output(
342
+ `[supervisor] ${entry.req.short} exited (code=${exitCode ?? "?"}); restart ${entry.crashStamps.length}/${this.opts.maxRestarts} in window.\n`,
343
+ );
344
+ await this.opts.sleep(this.opts.restartDelayMs);
345
+ // Operator may have called stop() during the sleep — re-check.
346
+ if (entry.stopRequested) return;
347
+ this.spawnAndWatch(entry);
348
+ }
349
+
350
+ /**
351
+ * Tap a child's stdout + stderr into the supervisor's `output`
352
+ * callback (hub's stdout by default), prefixing each line with the
353
+ * module's short name. Line-buffered: partial chunks accumulate
354
+ * until a newline arrives so multi-byte log lines don't get
355
+ * scrambled across modules.
356
+ */
357
+ private pipeOutput(short: string, proc: SupervisedProc): void {
358
+ const prefix = `[${short}] `;
359
+ if (proc.stdout) void pumpLines(proc.stdout, prefix, this.opts.output);
360
+ if (proc.stderr) void pumpLines(proc.stderr, prefix, this.opts.output);
361
+ }
362
+ }
363
+
364
+ interface ModuleEntry {
365
+ req: SpawnRequest;
366
+ state: ModuleState;
367
+ proc?: SupervisedProc;
368
+ crashStamps: number[];
369
+ stopRequested?: boolean;
370
+ }
371
+
372
+ async function pumpLines(
373
+ stream: ReadableStream<Uint8Array>,
374
+ prefix: string,
375
+ output: (line: string) => void,
376
+ ): Promise<void> {
377
+ const reader = stream.getReader();
378
+ const decoder = new TextDecoder();
379
+ let buf = "";
380
+ try {
381
+ while (true) {
382
+ const { done, value } = await reader.read();
383
+ if (done) break;
384
+ buf += decoder.decode(value, { stream: true });
385
+ // Flush every complete line; keep the partial tail buffered.
386
+ let nl = buf.indexOf("\n");
387
+ while (nl !== -1) {
388
+ const line = buf.slice(0, nl + 1);
389
+ output(prefix + line);
390
+ buf = buf.slice(nl + 1);
391
+ nl = buf.indexOf("\n");
392
+ }
393
+ }
394
+ // Flush any trailing partial line so we don't drop a module's
395
+ // last log message (it's likely the most important one — the
396
+ // exit cause).
397
+ if (buf.length > 0) output(`${prefix + buf}\n`);
398
+ } finally {
399
+ reader.releaseLock();
400
+ }
401
+ }
402
+
403
+ const defaultSpawnFn: SpawnFn = (req) => {
404
+ const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
405
+ stdio: ["ignore", "pipe", "pipe"],
406
+ };
407
+ if (req.cwd) spawnOpts.cwd = req.cwd;
408
+ if (req.env) spawnOpts.env = { ...process.env, ...req.env };
409
+ const proc = Bun.spawn([...req.cmd], spawnOpts);
410
+ return proc as unknown as SupervisedProc;
411
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Vault-name validation, mirrored from `@openparachute/vault`'s
3
+ * `src/vault-name.ts`.
4
+ *
5
+ * The vault package owns the canonical validator (used by `init`, the
6
+ * `--vault-name` flag, and the `PARACHUTE_VAULT_NAME` env var on
7
+ * first-boot). Hub doesn't depend on vault at runtime, so we keep a
8
+ * byte-identical contract here and pin parity with a test that exercises
9
+ * the same rule set:
10
+ *
11
+ * * lowercase alphanumeric + hyphens or underscores
12
+ * * 2–32 chars
13
+ * * `list` is reserved
14
+ *
15
+ * If vault's validator changes (e.g. additional reserved name, length
16
+ * relaxation), the two must move in lockstep — hub passing the typed
17
+ * name through `PARACHUTE_VAULT_NAME` only works as long as vault accepts
18
+ * what hub validates. Cross-repo drift here would silently fall back to
19
+ * `default` at vault first-boot (vault's `resolveFirstBootVaultName`
20
+ * downgrades env-invalid values).
21
+ *
22
+ * Out of scope: collision against existing vaults on the same hub — the
23
+ * wizard only ever creates the first vault, so name reuse can't happen.
24
+ * Subsequent vaults go through the admin SPA, which talks to vault's own
25
+ * `/vault/list` endpoint.
26
+ */
27
+
28
+ const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
29
+ const VAULT_NAME_MIN_LEN = 2;
30
+ const VAULT_NAME_MAX_LEN = 32;
31
+
32
+ const RESERVED_NAMES = new Set([
33
+ // Mirrors vault's reservation. Collides with the legacy `/vaults/list`
34
+ // discovery endpoint; the routes have moved under `/vault/<name>/` but
35
+ // vault's `cmdCreate` still rejects "list" and cross-repo consistency
36
+ // is cheap.
37
+ "list",
38
+ ]);
39
+
40
+ export type VaultNameValidation = { ok: true; name: string } | { ok: false; error: string };
41
+
42
+ /**
43
+ * Validate a vault name against vault's strict contract. Trims
44
+ * surrounding whitespace before checking. Returns the trimmed name on
45
+ * success so callers don't double-trim.
46
+ */
47
+ export function validateVaultName(raw: string): VaultNameValidation {
48
+ const name = raw.trim();
49
+ if (!name) {
50
+ return { ok: false, error: "vault name cannot be empty." };
51
+ }
52
+ if (name.length < VAULT_NAME_MIN_LEN || name.length > VAULT_NAME_MAX_LEN) {
53
+ return {
54
+ ok: false,
55
+ error: `vault names must be ${VAULT_NAME_MIN_LEN}–${VAULT_NAME_MAX_LEN} characters long.`,
56
+ };
57
+ }
58
+ if (!VAULT_NAME_RE.test(name)) {
59
+ return {
60
+ ok: false,
61
+ error: "vault names must be lowercase alphanumeric with hyphens or underscores.",
62
+ };
63
+ }
64
+ if (RESERVED_NAMES.has(name)) {
65
+ return { ok: false, error: `"${name}" is a reserved vault name.` };
66
+ }
67
+ return { ok: true, name };
68
+ }
69
+
70
+ /** The default vault name when the operator leaves the field blank. */
71
+ export const DEFAULT_VAULT_NAME = "default";
package/src/well-known.ts CHANGED
@@ -26,6 +26,14 @@ export interface WellKnownVaultEntry {
26
26
  * to iterate without having to know every service's shortName ahead of time.
27
27
  * `infoUrl` points at the service's `/.parachute/info` endpoint (relative to
28
28
  * its mount path) which the hub fetches client-side for displayName/tagline.
29
+ *
30
+ * `displayName` and `uiUrl` are both optional — the discovery page renders
31
+ * a Services tile when `uiUrl` is present, falling back to the manifest
32
+ * short name when `displayName` is absent. Both are sourced via hub-server's
33
+ * `loadUiUrls`/`loadManagementUrls`-style readers from the module's
34
+ * `installDir/.parachute/module.json`, NOT from services.json (which gets
35
+ * overwritten on service boot per the "services own the write side"
36
+ * contract — see hub#238 commit message for the C-not-B trace).
29
37
  */
30
38
  export interface WellKnownServicesEntry {
31
39
  name: string;
@@ -33,6 +41,16 @@ export interface WellKnownServicesEntry {
33
41
  path: string;
34
42
  version: string;
35
43
  infoUrl: string;
44
+ /**
45
+ * Human-readable label for the discovery page. Sourced from
46
+ * `module.json:displayName` when available; falls back to
47
+ * `services.json:displayName` written at install time.
48
+ */
49
+ displayName?: string;
50
+ /** Where the service's primary user-facing UI lives, sourced from `module.json:uiUrl`. */
51
+ uiUrl?: string;
52
+ /** One-line subtitle for the discovery tile, sourced from `services.json:tagline`. */
53
+ tagline?: string;
36
54
  }
37
55
 
38
56
  /**
@@ -107,6 +125,19 @@ export interface BuildWellKnownOpts {
107
125
  * in. Returning `undefined` means "no admin SPA" and hub renders no link.
108
126
  */
109
127
  managementUrlFor?: (entry: ServiceEntry) => string | undefined;
128
+ /**
129
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:uiUrl`,
130
+ * if any. Same shape as `managementUrlFor`. Returning `undefined` means
131
+ * "no user-facing UI" and discovery omits the Services tile (e.g. vault
132
+ * has no `uiUrl` — its content browses through Notes).
133
+ */
134
+ uiUrlFor?: (entry: ServiceEntry) => string | undefined;
135
+ /**
136
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:displayName`.
137
+ * Hub-server reads this at request time; falls back to the entry's own
138
+ * `displayName` (from services.json) when absent.
139
+ */
140
+ displayNameFor?: (entry: ServiceEntry) => string | undefined;
110
141
  }
111
142
 
112
143
  /** Join a base origin and a path without double slashes — "/" stays "/". */
@@ -131,7 +162,29 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
131
162
  for (const path of pathsToEmit) {
132
163
  const url = new URL(path, `${base}/`).toString();
133
164
  const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();
134
- doc.services.push({ name: s.name, url, path, version: s.version, infoUrl });
165
+ const entry: WellKnownServicesEntry = {
166
+ name: s.name,
167
+ url,
168
+ path,
169
+ version: s.version,
170
+ infoUrl,
171
+ };
172
+ const displayName = opts.displayNameFor?.(s) ?? s.displayName;
173
+ if (displayName !== undefined) entry.displayName = displayName;
174
+ // Tagline rides on services.json (set by service-spec at install or
175
+ // by the service's own boot-time upsert). Read directly from the
176
+ // entry — no installDir round-trip needed since it's already
177
+ // persisted server-side and reasonably stable across reboots.
178
+ if (s.tagline !== undefined) entry.tagline = s.tagline;
179
+ // Resolve uiUrl: relative path → absolute URL against `base`; full
180
+ // http(s) URL → verbatim. Same rule managementUrl uses.
181
+ const uiUrlRaw = opts.uiUrlFor?.(s);
182
+ if (uiUrlRaw !== undefined) {
183
+ entry.uiUrl = /^https?:\/\//i.test(uiUrlRaw)
184
+ ? uiUrlRaw
185
+ : new URL(uiUrlRaw, `${base}/`).toString();
186
+ }
187
+ doc.services.push(entry);
135
188
  if (isVault) {
136
189
  const managementUrl = opts.managementUrlFor?.(s);
137
190
  const entry: WellKnownVaultEntry = {
@@ -0,0 +1 @@
1
+ :root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select,textarea{font:inherit;background:#fff;border:1px solid var(--border);border-radius:6px;padding:.55rem .75rem;color:var(--fg)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;gap:1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}.nav .nav-divider{display:inline-block;width:1px;height:1.1em;background:var(--border);align-self:center}.nav .auth-spa{font-size:.85rem;color:var(--fg-muted)}.nav .auth-spa strong{font-weight:600;color:var(--fg)}.nav .auth-spa-signout{background:none;border:none;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}.nav .auth-spa-signout:hover:not(:disabled){color:var(--accent-hover)}.nav .auth-spa-signout:disabled{color:var(--fg-dim);cursor:not-allowed}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}.empty-rich{text-align:left;padding:2rem 1.75rem;background:#fff;border:1px solid var(--border)}.empty-rich .empty-headline{font-size:1.05rem;color:var(--fg);margin:0 0 .5rem;font-weight:500}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h2{margin:0}.tag{display:inline-block;padding:.1em .55em;background:var(--accent-soft);color:var(--accent);border-radius:4px;font-size:.78rem;font-weight:500}.tag.muted{background:var(--bg-soft);color:var(--fg-muted)}.tag.source-oauth{background:#4a7cc61f;color:#3b6aa6}.tag.source-operator{background:#c6984a24;color:#8a5e1f}.tag.source-cli{background:#4a7c5924;color:#2f5a3f}.tag.source-unknown{background:var(--bg-soft);color:var(--fg-muted)}@media(prefers-color-scheme:dark){.tag.source-oauth{background:#7a9cdc24;color:#9bb6d8}.tag.source-operator{background:#dcb46e24;color:#d4b27a}.tag.source-cli{background:#7ab08a24;color:#8fc49e}.tag.source-unknown{background:#e8e4dc0f;color:#a8a49a}}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .url{margin-top:.25rem;word-break:break-all}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}form .row{margin-bottom:1rem}form label{display:block;font-size:.9rem;color:var(--fg-muted);margin-bottom:.3rem;font-weight:500}form input[type=text]{width:100%}form .actions{display:flex;gap:.6rem;align-items:center;margin-top:1rem}form .field-hint{margin-top:.35rem;font-size:.82rem;color:var(--fg-dim)}form .field-error{margin-top:.35rem;font-size:.85rem;color:var(--error)}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem;display:flex;gap:.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}.channel-toggle{margin:1.25rem 0 1.5rem;padding:.75rem 1rem;border:1px solid var(--border, #ddd);border-radius:6px;background:var(--bg-soft, #fafafa)}.channel-toggle legend{padding:0 .25rem;font-weight:600;font-size:.95rem}.channel-toggle label{display:inline-flex;align-items:center;gap:.4rem;margin-right:1.5rem;cursor:pointer;font-size:.95rem}.channel-toggle label input[type=radio]:disabled+*{opacity:.5}.channel-toggle code{font-size:.85em}.channel-toggle p.muted{margin:.4rem 0 0;font-size:.85rem}