@openparachute/hub 0.5.7 → 0.5.10-rc.2
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__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
* Where prefixed child output goes. Default `process.stdout.write`.
|
|
85
|
+
* Tests inject a collector so they can assert on the multiplexed
|
|
86
|
+
* stream without spelunking stdout.
|
|
87
|
+
*/
|
|
88
|
+
readonly output?: (line: string) => void;
|
|
89
|
+
/**
|
|
90
|
+
* Test seam over `Bun.spawn`. Returns a Subprocess-shaped handle.
|
|
91
|
+
*/
|
|
92
|
+
readonly spawnFn?: SpawnFn;
|
|
93
|
+
/**
|
|
94
|
+
* Test seam over wall-clock. Production passes `Date.now`.
|
|
95
|
+
*/
|
|
96
|
+
readonly now?: () => number;
|
|
97
|
+
/**
|
|
98
|
+
* Test seam over `setTimeout`. Production resolves a real Promise
|
|
99
|
+
* with `setTimeout`. Tests stub to advance time deterministically.
|
|
100
|
+
*/
|
|
101
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Subprocess-shaped seam. Production passes through to `Bun.spawn`;
|
|
106
|
+
* tests construct a fake that exposes a controllable `exited` Promise
|
|
107
|
+
* and pipe-able stdout/stderr.
|
|
108
|
+
*/
|
|
109
|
+
export type SpawnFn = (req: SpawnRequest) => SupervisedProc;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The minimal Subprocess shape the supervisor depends on. Bun's real
|
|
113
|
+
* `Subprocess` matches this; the test fake mirrors it.
|
|
114
|
+
*/
|
|
115
|
+
export interface SupervisedProc {
|
|
116
|
+
readonly pid: number;
|
|
117
|
+
readonly exited: Promise<number | null>;
|
|
118
|
+
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
119
|
+
readonly stderr: ReadableStream<Uint8Array> | null;
|
|
120
|
+
kill(signal?: NodeJS.Signals | number): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const DEFAULT_MAX_RESTARTS = 3;
|
|
124
|
+
const DEFAULT_RESTART_WINDOW_MS = 60_000;
|
|
125
|
+
const DEFAULT_RESTART_DELAY_MS = 500;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Per-module supervisor. Owns the spawn → watch → restart loop.
|
|
129
|
+
*
|
|
130
|
+
* Single-process semantics: instances of `Supervisor` aren't safe to
|
|
131
|
+
* share across processes (the underlying Subprocess handles are
|
|
132
|
+
* process-local). Hub creates one Supervisor per `parachute serve`
|
|
133
|
+
* boot and threads it into the API handlers.
|
|
134
|
+
*/
|
|
135
|
+
export class Supervisor {
|
|
136
|
+
private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
|
|
137
|
+
readonly spawnFn: SpawnFn;
|
|
138
|
+
};
|
|
139
|
+
private readonly modules = new Map<string, ModuleEntry>();
|
|
140
|
+
|
|
141
|
+
constructor(opts: SupervisorOpts = {}) {
|
|
142
|
+
this.opts = {
|
|
143
|
+
maxRestarts: opts.maxRestarts ?? DEFAULT_MAX_RESTARTS,
|
|
144
|
+
restartWindowMs: opts.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS,
|
|
145
|
+
restartDelayMs: opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS,
|
|
146
|
+
output: opts.output ?? ((line) => process.stdout.write(line)),
|
|
147
|
+
spawnFn: opts.spawnFn ?? defaultSpawnFn,
|
|
148
|
+
now: opts.now ?? Date.now,
|
|
149
|
+
sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Spawn a module under supervision. Idempotent: re-spawning an
|
|
155
|
+
* already-running module is a no-op (returns the existing state).
|
|
156
|
+
* Re-spawning a previously-crashed module clears the crash budget
|
|
157
|
+
* and starts fresh.
|
|
158
|
+
*/
|
|
159
|
+
async start(req: SpawnRequest): Promise<ModuleState> {
|
|
160
|
+
const existing = this.modules.get(req.short);
|
|
161
|
+
if (existing && (existing.state.status === "running" || existing.state.status === "starting")) {
|
|
162
|
+
return existing.state;
|
|
163
|
+
}
|
|
164
|
+
// Crashed → operator intent is "try again." Wipe the budget.
|
|
165
|
+
const entry: ModuleEntry = {
|
|
166
|
+
req,
|
|
167
|
+
state: {
|
|
168
|
+
short: req.short,
|
|
169
|
+
status: "starting",
|
|
170
|
+
restartsInWindow: 0,
|
|
171
|
+
},
|
|
172
|
+
crashStamps: [],
|
|
173
|
+
};
|
|
174
|
+
this.modules.set(req.short, entry);
|
|
175
|
+
this.spawnAndWatch(entry);
|
|
176
|
+
return entry.state;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Stop a supervised module. Sends SIGTERM, marks the state
|
|
181
|
+
* `stopped`, and detaches the exit watcher so a normal termination
|
|
182
|
+
* isn't seen as a crash. Idempotent on already-stopped modules.
|
|
183
|
+
*/
|
|
184
|
+
async stop(short: string): Promise<ModuleState | undefined> {
|
|
185
|
+
const entry = this.modules.get(short);
|
|
186
|
+
if (!entry) return undefined;
|
|
187
|
+
entry.stopRequested = true;
|
|
188
|
+
if (entry.proc) {
|
|
189
|
+
try {
|
|
190
|
+
entry.proc.kill("SIGTERM");
|
|
191
|
+
} catch {
|
|
192
|
+
// Process may already be dead — fall through.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
entry.state = { ...entry.state, status: "stopped" };
|
|
196
|
+
return entry.state;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Snapshot of every supervised module's current state. */
|
|
200
|
+
list(): ModuleState[] {
|
|
201
|
+
return Array.from(this.modules.values(), (e) => e.state);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Snapshot of a single module's state, or undefined if not supervised. */
|
|
205
|
+
get(short: string): ModuleState | undefined {
|
|
206
|
+
return this.modules.get(short)?.state;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Restart a supervised module: stop, wait for exit, start with the
|
|
211
|
+
* same SpawnRequest. Used by the `/api/modules/:name/restart`
|
|
212
|
+
* handler. The on-box `parachute restart <svc>` path stays on
|
|
213
|
+
* `commands/lifecycle.ts` — different surface, different ownership.
|
|
214
|
+
*/
|
|
215
|
+
async restart(short: string): Promise<ModuleState | undefined> {
|
|
216
|
+
const entry = this.modules.get(short);
|
|
217
|
+
if (!entry) return undefined;
|
|
218
|
+
const req = entry.req;
|
|
219
|
+
entry.state = { ...entry.state, status: "restarting" };
|
|
220
|
+
await this.stop(short);
|
|
221
|
+
// Wait for the prior process to actually exit so the new spawn
|
|
222
|
+
// doesn't race on EADDRINUSE.
|
|
223
|
+
if (entry.proc) {
|
|
224
|
+
try {
|
|
225
|
+
await entry.proc.exited;
|
|
226
|
+
} catch {
|
|
227
|
+
// exited promise rejection is non-fatal — we're stopping anyway.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Drop the entry so `start` treats this as a clean spawn.
|
|
231
|
+
this.modules.delete(short);
|
|
232
|
+
return this.start(req);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private spawnAndWatch(entry: ModuleEntry): void {
|
|
236
|
+
const proc = this.opts.spawnFn(entry.req);
|
|
237
|
+
entry.proc = proc;
|
|
238
|
+
entry.state = {
|
|
239
|
+
...entry.state,
|
|
240
|
+
status: "running",
|
|
241
|
+
pid: proc.pid,
|
|
242
|
+
startedAt: new Date(this.opts.now()).toISOString(),
|
|
243
|
+
};
|
|
244
|
+
this.pipeOutput(entry.req.short, proc);
|
|
245
|
+
void proc.exited.then((exitCode) => this.handleExit(entry, exitCode));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async handleExit(entry: ModuleEntry, exitCode: number | null): Promise<void> {
|
|
249
|
+
// Operator-driven stop: not a crash, don't restart.
|
|
250
|
+
if (entry.stopRequested) {
|
|
251
|
+
entry.state = {
|
|
252
|
+
...entry.state,
|
|
253
|
+
status: "stopped",
|
|
254
|
+
pid: undefined,
|
|
255
|
+
lastExitCode: exitCode,
|
|
256
|
+
};
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const now = this.opts.now();
|
|
261
|
+
// Drop crashes older than the window before counting.
|
|
262
|
+
const cutoff = now - this.opts.restartWindowMs;
|
|
263
|
+
entry.crashStamps = entry.crashStamps.filter((t) => t >= cutoff);
|
|
264
|
+
entry.crashStamps.push(now);
|
|
265
|
+
|
|
266
|
+
if (entry.crashStamps.length >= this.opts.maxRestarts) {
|
|
267
|
+
this.opts.output(
|
|
268
|
+
`[supervisor] ${entry.req.short} crashed ${entry.crashStamps.length}x within ${this.opts.restartWindowMs}ms — giving up.\n`,
|
|
269
|
+
);
|
|
270
|
+
entry.state = {
|
|
271
|
+
...entry.state,
|
|
272
|
+
status: "crashed",
|
|
273
|
+
pid: undefined,
|
|
274
|
+
lastCrashAt: new Date(now).toISOString(),
|
|
275
|
+
lastExitCode: exitCode,
|
|
276
|
+
restartsInWindow: entry.crashStamps.length,
|
|
277
|
+
};
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
entry.state = {
|
|
282
|
+
...entry.state,
|
|
283
|
+
status: "restarting",
|
|
284
|
+
pid: undefined,
|
|
285
|
+
lastCrashAt: new Date(now).toISOString(),
|
|
286
|
+
lastExitCode: exitCode,
|
|
287
|
+
restartsInWindow: entry.crashStamps.length,
|
|
288
|
+
};
|
|
289
|
+
this.opts.output(
|
|
290
|
+
`[supervisor] ${entry.req.short} exited (code=${exitCode ?? "?"}); restart ${entry.crashStamps.length}/${this.opts.maxRestarts} in window.\n`,
|
|
291
|
+
);
|
|
292
|
+
await this.opts.sleep(this.opts.restartDelayMs);
|
|
293
|
+
// Operator may have called stop() during the sleep — re-check.
|
|
294
|
+
if (entry.stopRequested) return;
|
|
295
|
+
this.spawnAndWatch(entry);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Tap a child's stdout + stderr into the supervisor's `output`
|
|
300
|
+
* callback (hub's stdout by default), prefixing each line with the
|
|
301
|
+
* module's short name. Line-buffered: partial chunks accumulate
|
|
302
|
+
* until a newline arrives so multi-byte log lines don't get
|
|
303
|
+
* scrambled across modules.
|
|
304
|
+
*/
|
|
305
|
+
private pipeOutput(short: string, proc: SupervisedProc): void {
|
|
306
|
+
const prefix = `[${short}] `;
|
|
307
|
+
if (proc.stdout) void pumpLines(proc.stdout, prefix, this.opts.output);
|
|
308
|
+
if (proc.stderr) void pumpLines(proc.stderr, prefix, this.opts.output);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface ModuleEntry {
|
|
313
|
+
req: SpawnRequest;
|
|
314
|
+
state: ModuleState;
|
|
315
|
+
proc?: SupervisedProc;
|
|
316
|
+
crashStamps: number[];
|
|
317
|
+
stopRequested?: boolean;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function pumpLines(
|
|
321
|
+
stream: ReadableStream<Uint8Array>,
|
|
322
|
+
prefix: string,
|
|
323
|
+
output: (line: string) => void,
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
const reader = stream.getReader();
|
|
326
|
+
const decoder = new TextDecoder();
|
|
327
|
+
let buf = "";
|
|
328
|
+
try {
|
|
329
|
+
while (true) {
|
|
330
|
+
const { done, value } = await reader.read();
|
|
331
|
+
if (done) break;
|
|
332
|
+
buf += decoder.decode(value, { stream: true });
|
|
333
|
+
// Flush every complete line; keep the partial tail buffered.
|
|
334
|
+
let nl = buf.indexOf("\n");
|
|
335
|
+
while (nl !== -1) {
|
|
336
|
+
const line = buf.slice(0, nl + 1);
|
|
337
|
+
output(prefix + line);
|
|
338
|
+
buf = buf.slice(nl + 1);
|
|
339
|
+
nl = buf.indexOf("\n");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Flush any trailing partial line so we don't drop a module's
|
|
343
|
+
// last log message (it's likely the most important one — the
|
|
344
|
+
// exit cause).
|
|
345
|
+
if (buf.length > 0) output(`${prefix + buf}\n`);
|
|
346
|
+
} finally {
|
|
347
|
+
reader.releaseLock();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const defaultSpawnFn: SpawnFn = (req) => {
|
|
352
|
+
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
353
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
354
|
+
};
|
|
355
|
+
if (req.cwd) spawnOpts.cwd = req.cwd;
|
|
356
|
+
if (req.env) spawnOpts.env = { ...process.env, ...req.env };
|
|
357
|
+
const proc = Bun.spawn([...req.cmd], spawnOpts);
|
|
358
|
+
return proc as unknown as SupervisedProc;
|
|
359
|
+
};
|
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
|
-
|
|
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 = {
|