@openparachute/hub 0.7.6-rc.1 → 0.7.6-rc.4

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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Hub instance identity + loopback-hijack detection (hub#737).
3
+ *
4
+ * ## The incident this defends against (2026-07-02 P0)
5
+ *
6
+ * The hub binds `*:1939` (INADDR_ANY / `0.0.0.0`). An OrbStack Linux machine
7
+ * auto-forwarded ITS port 1939 onto the host as a SPECIFIC bind on
8
+ * `127.0.0.1:1939` — and a specific loopback bind WINS over a wildcard bind for
9
+ * all loopback traffic. Every module's JWKS/API call to `127.0.0.1:1939`
10
+ * silently reached the WRONG hub (a fresh container DB → empty JWKS, no admin),
11
+ * so every hub-JWT validation failed `no applicable key found in the JWKS` and
12
+ * the ecosystem 401-looped for hours. `lsof -nP -i :1939` showed two LISTENs;
13
+ * the tell was `/health` reporting the container's version, not the checkout's.
14
+ *
15
+ * ## The primitive: a per-process instance nonce
16
+ *
17
+ * Each `parachute serve` process generates a random nonce at boot, (a) exposes
18
+ * it as `instance` in `/health`, and (b) writes it to
19
+ * `~/.parachute/hub-instance.json` (0644). That file is the linchpin: an
20
+ * EXTERNAL process (`parachute status`, `parachute doctor`) can learn THIS
21
+ * hub's true identity from disk WITHOUT traversing the (possibly hijacked)
22
+ * loopback — then compare it to what a loopback `GET /health` actually returns.
23
+ * A mismatch means another process owns `127.0.0.1:<port>`.
24
+ *
25
+ * The in-process self-probe (armed by `serve`) compares its own in-memory nonce
26
+ * to the loopback `/health` it fetches, logs loudly on mismatch, and records the
27
+ * verdict back into the same file's `selfProbe` field so external tools surface
28
+ * the serve process's own authoritative reading without re-probing.
29
+ *
30
+ * Every side effect (fs, network probe) is behind an injectable seam so the
31
+ * whole module runs deterministically in tests with no real network / disk.
32
+ */
33
+
34
+ import { randomUUID } from "node:crypto";
35
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
36
+ import { dirname, join } from "node:path";
37
+ import { CONFIG_DIR } from "./config.ts";
38
+
39
+ /** The public incident reference operators grep for. */
40
+ export const HIJACK_INCIDENT_REF = "hub#737 / team-vault Log/2026-07-02-port-exhaustion-incident";
41
+
42
+ /** Self-probe verdicts. `ok` = loopback reaches us; `hijacked` = someone else owns loopback. */
43
+ export type SelfProbeStatus = "ok" | "hijacked" | "unreachable";
44
+
45
+ /**
46
+ * The serve process's own most-recent loopback self-probe reading, persisted
47
+ * into the instance file so external readers (`status`) see the authoritative
48
+ * verdict without re-probing the (possibly hijacked) loopback themselves.
49
+ */
50
+ export interface SelfProbeState {
51
+ status: SelfProbeStatus;
52
+ /** ISO timestamp of the reading. */
53
+ checkedAt: string;
54
+ /** The `instance` the loopback `/health` actually returned (present on a `hijacked` reading). */
55
+ observedInstance?: string;
56
+ /** One-line human detail (loud message on a hijack; the probe error class on unreachable). */
57
+ detail?: string;
58
+ }
59
+
60
+ /** The `~/.parachute/hub-instance.json` record. */
61
+ export interface HubInstanceRecord {
62
+ /** Per-process random nonce (`crypto.randomUUID`) minted at serve boot. */
63
+ instance: string;
64
+ /** The serve process PID (informational — helps an operator map the file to a process). */
65
+ pid: number;
66
+ /** The port this serve bound. */
67
+ port: number;
68
+ /** ISO timestamp of serve boot. */
69
+ startedAt: string;
70
+ /** Last self-probe reading, patched in by the running serve process. */
71
+ selfProbe?: SelfProbeState;
72
+ }
73
+
74
+ /** Mint a fresh per-process nonce. */
75
+ export function generateInstanceNonce(): string {
76
+ return randomUUID();
77
+ }
78
+
79
+ /** Path to the instance file under a config dir (default `~/.parachute`). */
80
+ export function hubInstancePath(configDir: string = CONFIG_DIR): string {
81
+ return join(configDir, "hub-instance.json");
82
+ }
83
+
84
+ /**
85
+ * Atomically write the instance record (tmp + rename, 0644). Best-effort: a
86
+ * write failure must NEVER take the hub down — the file is a diagnostic aid, not
87
+ * a load-bearing runtime dependency. Returns true on success.
88
+ */
89
+ export function writeHubInstanceFile(
90
+ record: HubInstanceRecord,
91
+ opts: { configDir?: string; log?: (line: string) => void } = {},
92
+ ): boolean {
93
+ const path = hubInstancePath(opts.configDir);
94
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
95
+ try {
96
+ const dir = dirname(path);
97
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
98
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o644 });
99
+ renameSync(tmp, path);
100
+ return true;
101
+ } catch (err) {
102
+ // Don't leave a half-written tmp behind if the rename (or write) failed.
103
+ try {
104
+ if (existsSync(tmp)) rmSync(tmp, { force: true });
105
+ } catch {
106
+ // Best-effort cleanup — nothing more we can do.
107
+ }
108
+ opts.log?.(
109
+ `parachute serve: could not write ${path} (${err instanceof Error ? err.message : String(err)}); loopback-hijack detection for external tools is degraded, hub start continues.`,
110
+ );
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Remove the instance file (best-effort). Called on graceful shutdown so a
117
+ * cleanly-stopped hub doesn't leave a stale identity/self-probe verdict on disk
118
+ * for `status` / `doctor` to read as a phantom. A hard kill (SIGKILL) can't run
119
+ * this — the readers additionally gate on live hub liveness, so a leftover file
120
+ * from a hard kill never surfaces as a false hijack.
121
+ */
122
+ export function clearHubInstanceFile(configDir: string = CONFIG_DIR): void {
123
+ try {
124
+ rmSync(hubInstancePath(configDir), { force: true });
125
+ } catch {
126
+ // Best-effort — a missing / unremovable file is not worth surfacing.
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Read + validate the instance file. Returns null on absence / unreadable /
132
+ * malformed — a missing file is the benign "no nonce-aware serve wrote one yet"
133
+ * state, never an error.
134
+ */
135
+ export function readHubInstanceFile(configDir: string = CONFIG_DIR): HubInstanceRecord | null {
136
+ const path = hubInstancePath(configDir);
137
+ let raw: unknown;
138
+ try {
139
+ raw = JSON.parse(readFileSync(path, "utf8"));
140
+ } catch {
141
+ return null;
142
+ }
143
+ if (!raw || typeof raw !== "object") return null;
144
+ const r = raw as Record<string, unknown>;
145
+ if (typeof r.instance !== "string" || r.instance.length === 0) return null;
146
+ if (typeof r.port !== "number") return null;
147
+ const rec: HubInstanceRecord = {
148
+ instance: r.instance,
149
+ pid: typeof r.pid === "number" ? r.pid : -1,
150
+ port: r.port,
151
+ startedAt: typeof r.startedAt === "string" ? r.startedAt : "",
152
+ };
153
+ const sp = r.selfProbe;
154
+ if (sp && typeof sp === "object") {
155
+ const s = sp as Record<string, unknown>;
156
+ if (s.status === "ok" || s.status === "hijacked" || s.status === "unreachable") {
157
+ const state: SelfProbeState = {
158
+ status: s.status,
159
+ checkedAt: typeof s.checkedAt === "string" ? s.checkedAt : "",
160
+ };
161
+ if (typeof s.observedInstance === "string") state.observedInstance = s.observedInstance;
162
+ if (typeof s.detail === "string") state.detail = s.detail;
163
+ rec.selfProbe = state;
164
+ }
165
+ }
166
+ return rec;
167
+ }
168
+
169
+ /** The result of probing a loopback `/health`. */
170
+ export interface LoopbackProbe {
171
+ /** The socket answered at all (any HTTP status). */
172
+ reachable: boolean;
173
+ /** HTTP status, when reachable. */
174
+ status?: number;
175
+ /** The `instance` field of the JSON body, when present + parseable. */
176
+ instance?: string;
177
+ /** True when the body self-identifies as a parachute hub (`service: "parachute-hub"`). */
178
+ isHub?: boolean;
179
+ }
180
+
181
+ /**
182
+ * Probe `http://127.0.0.1:<port>/health` and extract the instance identity.
183
+ * Bounded (default 1.5s); never throws — a network error is `{ reachable: false }`.
184
+ */
185
+ export async function probeLoopbackInstance(
186
+ port: number,
187
+ opts: { timeoutMs?: number; fetchFn?: typeof fetch } = {},
188
+ ): Promise<LoopbackProbe> {
189
+ const fetchFn = opts.fetchFn ?? fetch;
190
+ try {
191
+ const res = await fetchFn(`http://127.0.0.1:${port}/health`, {
192
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1500),
193
+ });
194
+ const out: LoopbackProbe = { reachable: true, status: res.status };
195
+ try {
196
+ const body = (await res.json()) as Record<string, unknown>;
197
+ if (typeof body.instance === "string") out.instance = body.instance;
198
+ if (body.service === "parachute-hub") out.isHub = true;
199
+ } catch {
200
+ // A non-JSON / unparseable body still counts as "reachable" — a foreign
201
+ // process answering the port with junk is exactly the hijack shape.
202
+ }
203
+ return out;
204
+ } catch {
205
+ return { reachable: false };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Classify a loopback probe against our TRUE nonce.
211
+ * - not reachable → `unreachable` (we bound, but loopback refused/timed out — suspicious but soft).
212
+ * - reachable, instance === ours → `ok`.
213
+ * - reachable, instance !== ours → `hijacked` (a DIFFERENT process owns loopback: another hub, or a foreign
214
+ * server answering `/health` with no/other instance — the OrbStack-shadow class).
215
+ */
216
+ export function classifyLoopback(ourNonce: string, probe: LoopbackProbe): SelfProbeStatus {
217
+ if (!probe.reachable) return "unreachable";
218
+ if (probe.instance === ourNonce) return "ok";
219
+ return "hijacked";
220
+ }
221
+
222
+ /**
223
+ * The LOUD, structured hijack alert. Names the class + the exact diagnosis
224
+ * commands + the incident reference so an operator scanning logs can act
225
+ * immediately. Repeated verbatim on every probe while mismatched (by design —
226
+ * a single line scrolls away; a hijack is a standing emergency).
227
+ */
228
+ export function hijackAlertMessage(port: number, observedInstance?: string): string {
229
+ const observed = observedInstance
230
+ ? `a DIFFERENT hub (instance=${observedInstance})`
231
+ : "a foreign process (no hub instance nonce in its /health)";
232
+ return [
233
+ `parachute serve: LOOPBACK HIJACK on 127.0.0.1:${port} — this hub bound the port but loopback /health is answered by ${observed}.`,
234
+ " Loopback traffic (module JWKS/API calls, CLI probes) is NOT reaching this hub — every hub-JWT validation downstream will fail.",
235
+ ` A specific 127.0.0.1:${port} bind (commonly an OrbStack/container port-forward) wins over this hub's wildcard bind.`,
236
+ ` Diagnose: lsof -nP -iTCP:${port} -sTCP:LISTEN (expect ONE listener — this hub)`,
237
+ ` orb list (stop/delete any VM auto-forwarding ${port}, e.g. a leftover smoke-test machine)`,
238
+ ` Incident: ${HIJACK_INCIDENT_REF}`,
239
+ ].join("\n");
240
+ }
241
+
242
+ /** The softer "we're listening but loopback didn't answer" note (logged once per state change). */
243
+ export function unreachableNote(port: number): string {
244
+ return `parachute serve: loopback /health on 127.0.0.1:${port} did not answer, yet this hub is bound — transient, or another process is interfering with loopback. Watching (will re-probe).`;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Self-probe timer (armed by `serve` after the listener is up)
249
+ // ---------------------------------------------------------------------------
250
+
251
+ export interface HubSelfProbe {
252
+ /** Stop the interval. */
253
+ stop(): void;
254
+ /** Run exactly one probe now (used for the immediate startup check + tests). */
255
+ probeOnce(): Promise<SelfProbeStatus>;
256
+ /** The most recent in-memory verdict (tests). */
257
+ getState(): SelfProbeState | undefined;
258
+ }
259
+
260
+ export interface HubSelfProbeDeps<H = unknown> {
261
+ /** Poll cadence in ms. Default 300_000 (5 min) — a safety net, not a hot path. */
262
+ intervalMs?: number;
263
+ /** Loopback probe (default {@link probeLoopbackInstance}). */
264
+ probe?: (port: number) => Promise<LoopbackProbe>;
265
+ /** Persist the verdict (default: patch the instance file's `selfProbe`). */
266
+ writeState?: (state: SelfProbeState) => void;
267
+ /** Loud log sink (default `console.error`). */
268
+ log?: (line: string) => void;
269
+ /** Clock seam (default `() => new Date()`). */
270
+ now?: () => Date;
271
+ /** Injectable scheduler (default `setInterval`). Tests drive ticks manually. */
272
+ setIntervalFn?: (cb: () => void, ms: number) => H;
273
+ /** Injectable clear (default `clearInterval`). */
274
+ clearIntervalFn?: (handle: H) => void;
275
+ }
276
+
277
+ /**
278
+ * Arm the loopback self-probe. On each tick (and on the immediate startup
279
+ * `probeOnce`) it fetches loopback `/health`, compares the returned instance to
280
+ * OUR nonce, logs per the incident-severity rules, and persists the verdict:
281
+ *
282
+ * - `hijacked` → LOUD structured alert EVERY tick (standing emergency), verdict persisted.
283
+ * - `unreachable`→ softer note, logged ONLY on a state change (avoid a spinning log on a flaky loopback).
284
+ * - `ok` → recovery line logged once when clearing a prior non-ok verdict.
285
+ *
286
+ * The verdict is written to the instance file's `selfProbe` field so external
287
+ * tools (`status`) read the authoritative reading without re-probing the
288
+ * hijacked loopback. Overlapping ticks are guarded (a slow probe never stacks).
289
+ * The interval is `unref`'d so it never keeps the event loop alive on its own.
290
+ */
291
+ export function armHubSelfProbe<H = ReturnType<typeof setInterval>>(
292
+ args: { port: number; nonce: string; record: HubInstanceRecord; configDir?: string },
293
+ deps: HubSelfProbeDeps<H> = {},
294
+ ): HubSelfProbe {
295
+ const { port, nonce, record } = args;
296
+ const intervalMs = deps.intervalMs ?? 300_000;
297
+ const probe = deps.probe ?? probeLoopbackInstance;
298
+ const log = deps.log ?? ((line: string) => console.error(line));
299
+ const now = deps.now ?? (() => new Date());
300
+ const writeState =
301
+ deps.writeState ??
302
+ ((state: SelfProbeState) =>
303
+ writeHubInstanceFile(
304
+ { ...record, selfProbe: state },
305
+ { ...(args.configDir !== undefined ? { configDir: args.configDir } : {}), log },
306
+ ));
307
+ const setIntervalFn =
308
+ deps.setIntervalFn ?? ((cb: () => void, ms: number) => setInterval(cb, ms) as unknown as H);
309
+ const clearIntervalFn =
310
+ deps.clearIntervalFn ??
311
+ ((h: H) => clearInterval(h as unknown as ReturnType<typeof setInterval>));
312
+
313
+ let last: SelfProbeState | undefined;
314
+ let inFlight = false;
315
+
316
+ async function probeOnce(): Promise<SelfProbeStatus> {
317
+ if (inFlight) return last?.status ?? "ok";
318
+ inFlight = true;
319
+ try {
320
+ const result = await probe(port);
321
+ const status = classifyLoopback(nonce, result);
322
+ const state: SelfProbeState = { status, checkedAt: now().toISOString() };
323
+ if (status === "hijacked") {
324
+ if (result.instance !== undefined) state.observedInstance = result.instance;
325
+ state.detail = hijackAlertMessage(port, result.instance);
326
+ // LOUD every tick — a hijack is a standing emergency, not a one-shot notice.
327
+ log(state.detail);
328
+ } else if (status === "unreachable") {
329
+ state.detail = unreachableNote(port);
330
+ if (last?.status !== "unreachable") log(state.detail);
331
+ } else {
332
+ // ok — announce recovery once when clearing a prior non-ok verdict.
333
+ if (last && last.status !== "ok") {
334
+ log(
335
+ `parachute serve: loopback /health on 127.0.0.1:${port} is back to this hub (instance=${nonce}). Hijack cleared.`,
336
+ );
337
+ }
338
+ }
339
+ last = state;
340
+ try {
341
+ writeState(state);
342
+ } catch {
343
+ // Persisting the verdict is best-effort; the loud log already fired.
344
+ }
345
+ return status;
346
+ } finally {
347
+ inFlight = false;
348
+ }
349
+ }
350
+
351
+ const handle = setIntervalFn(() => {
352
+ void probeOnce();
353
+ }, intervalMs);
354
+ (handle as { unref?: () => void }).unref?.();
355
+
356
+ return {
357
+ stop() {
358
+ clearIntervalFn(handle);
359
+ },
360
+ probeOnce,
361
+ getState() {
362
+ return last;
363
+ },
364
+ };
365
+ }
package/src/hub-server.ts CHANGED
@@ -782,6 +782,52 @@ export function wsCapBucketKey(req: Request, peerAddr: string | null): string {
782
782
  return peer ?? WS_CAP_SHARED_BUCKET;
783
783
  }
784
784
 
785
+ /**
786
+ * Hop-by-hop headers (RFC 9110 §7.6.1) — connection-scoped, meaningful only
787
+ * on a single transport hop. An intermediary MUST NOT forward them, and the
788
+ * hub is exactly such an intermediary between the client and each loopback
789
+ * module.
790
+ *
791
+ * Forwarding a client's `Connection: close` verbatim was the P0 amplifier in
792
+ * the 2026-07-02 port exhaustion (hub#738): Bun's fetch honors the forwarded
793
+ * `Connection: close` and opens a FRESH ephemeral socket per proxied request
794
+ * instead of reusing its per-origin keep-alive pool. A hot client loop then
795
+ * converts request volume 1:1 into 30s TIME_WAIT entries (macOS: ~16k
796
+ * ephemeral ports), exhausting the range and taking out all host outbound.
797
+ * With these stripped, Bun reuses a handful of pooled sockets to each 127.0.0.1
798
+ * upstream regardless of what the client sends.
799
+ */
800
+ const HOP_BY_HOP_HEADERS = [
801
+ "connection",
802
+ "keep-alive",
803
+ "proxy-authenticate",
804
+ "proxy-authorization",
805
+ "te",
806
+ "trailer",
807
+ "transfer-encoding",
808
+ "upgrade",
809
+ ] as const;
810
+
811
+ /**
812
+ * Delete hop-by-hop headers from an outgoing proxy header bag (mutates in
813
+ * place). The `Connection` field-value can NAME further headers to drop
814
+ * (RFC 9110 §7.6.1 — e.g. `Connection: close, X-Custom`), so those tokens are
815
+ * collected and deleted before the standard set. WebSocket upgrades never
816
+ * reach the fetch-based proxy (the Bun-native bridge handles them before
817
+ * dispatch — see `proxyRequest`'s docstring), so dropping `Upgrade`/`Connection`
818
+ * here only ever touches non-declaring mounts, which see a plain request.
819
+ */
820
+ export function stripHopByHopHeaders(headers: Headers): void {
821
+ const connectionValue = headers.get("connection");
822
+ if (connectionValue) {
823
+ for (const token of connectionValue.split(",")) {
824
+ const named = token.trim().toLowerCase();
825
+ if (named) headers.delete(named);
826
+ }
827
+ }
828
+ for (const name of HOP_BY_HOP_HEADERS) headers.delete(name);
829
+ }
830
+
785
831
  /**
786
832
  * Forward a request to a loopback service on `127.0.0.1:<port>`. By default
787
833
  * the incoming pathname + query are preserved verbatim; pass `targetPath` to
@@ -847,6 +893,12 @@ async function proxyRequest(
847
893
  // Host comes from the requester (tailnet FQDN); the loopback target wants
848
894
  // its own. Bun's fetch fills it in when omitted.
849
895
  headers.delete("host");
896
+ // Strip hop-by-hop headers before forwarding (RFC 9110 §7.6.1). Critically
897
+ // this drops a client-supplied `Connection: close`, which Bun's fetch would
898
+ // otherwise honor by disabling keep-alive and burning a fresh ephemeral
899
+ // socket per request — the P0 amplifier in hub#738. Bun refills the
900
+ // connection framing for the upstream hop itself.
901
+ stripHopByHopHeaders(headers);
850
902
  // Force upstreams to reply with uncompressed bodies. The chrome-strip
851
903
  // injector (workstream G) buffers + TextDecoders the HTML response to
852
904
  // inject the persistent chrome; without this, a gzip- or br-compressed
@@ -887,6 +939,13 @@ async function proxyRequest(
887
939
  method: req.method,
888
940
  headers,
889
941
  redirect: "manual",
942
+ // Forward the incoming request's abort signal to the upstream hop. When a
943
+ // client hangs up mid-response, this aborts the loopback fetch so the
944
+ // upstream stops streaming to a gone client and its socket is released
945
+ // back to the pool (or closed) instead of running the full body out and
946
+ // holding the connection — a secondary socket-retention leak alongside the
947
+ // TIME_WAIT churn (hub#738). `req.signal` is present on Bun.serve requests.
948
+ signal: req.signal,
890
949
  };
891
950
  if (req.method !== "GET" && req.method !== "HEAD") {
892
951
  init.body = req.body;
@@ -895,6 +954,14 @@ async function proxyRequest(
895
954
  try {
896
955
  return await fetch(upstream, init);
897
956
  } catch (err) {
957
+ // Client hung up mid-flight: the upstream fetch was aborted via req.signal
958
+ // (forwarded above), not an upstream failure. The client is gone, so the
959
+ // response is discarded — don't run the boot-readiness classifier or
960
+ // render a "module unreachable" page that would misclassify a normal
961
+ // disconnect. 499 = client closed request (nginx convention).
962
+ if (req.signal?.aborted) {
963
+ return new Response(null, { status: 499 });
964
+ }
898
965
  const msg = err instanceof Error ? err.message : String(err);
899
966
  // Classify the failure (transient boot-window vs persistent crash) and
900
967
  // render either an HTML page or a JSON error per the request's Accept.
@@ -1261,6 +1328,17 @@ export interface HubFetchDeps {
1261
1328
  * rejected" because Origin ≠ tailnet issuer).
1262
1329
  */
1263
1330
  loopbackPort?: number;
1331
+ /**
1332
+ * This serve process's per-boot instance nonce (hub#737). When present it's
1333
+ * echoed as `instance` in `/health` so an external reader can tell whether a
1334
+ * loopback `/health` actually reached THIS hub or a foreign process that has
1335
+ * shadowed the port (the OrbStack loopback-hijack class). `serve` mints it,
1336
+ * writes it to `~/.parachute/hub-instance.json`, and threads it here; absent
1337
+ * on the DB-less / test / `bun src/hub-server.ts` paths, where `/health`
1338
+ * simply omits the field (additive — no consumer parses `/health` for
1339
+ * `instance` strictly).
1340
+ */
1341
+ instanceNonce?: string;
1264
1342
  /**
1265
1343
  * Test seam for reading `expose-state.json`'s `hubOrigin`. Production reads
1266
1344
  * the operator's `~/.parachute/expose-state.json` via `readExposeState`;
@@ -1341,7 +1419,7 @@ async function loadManagementUrls(
1341
1419
  * earlier "vaults browse via Notes — no tile" rule retired with PR 1 of
1342
1420
  * workstream C; operators administer per-vault tokens / config / MCP via
1343
1421
  * the vault admin SPA, which is a different audience from Notes' content
1344
- * browse. See [`module-ui-declaration.md` §"Use vs admin"](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/module-ui-declaration.md#use-vs-admin--both-can-be-true).
1422
+ * browse. See [`module-ui-declaration.md` §"Use vs admin"](https://github.com/ParachuteComputer/parachute-hub/blob/main/docs/contracts/module-ui-declaration.md#use-vs-admin--both-can-be-true).
1345
1423
  *
1346
1424
  * `loadManagementUrls` continues to handle vault's `managementUrl` for
1347
1425
  * the hub admin SPA's vault-list "Manage" link — a different surface
@@ -2314,7 +2392,17 @@ export function hubFetch(
2314
2392
  }
2315
2393
  }
2316
2394
  return new Response(
2317
- JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version, db }),
2395
+ JSON.stringify({
2396
+ status: "ok",
2397
+ service: "parachute-hub",
2398
+ version: pkg.version,
2399
+ db,
2400
+ // Per-boot instance nonce (hub#737): lets an external reader detect a
2401
+ // loopback hijack (foreign process shadowing 127.0.0.1:<port>) by
2402
+ // comparing this to the nonce serve wrote to hub-instance.json.
2403
+ // Omitted when unset (DB-less / test / dev-entrypoint paths).
2404
+ ...(deps?.instanceNonce ? { instance: deps.instanceNonce } : {}),
2405
+ }),
2318
2406
  {
2319
2407
  headers: {
2320
2408
  "content-type": "application/json",
package/src/hub.ts CHANGED
@@ -484,7 +484,7 @@ const DISCOVERY_SCRIPT = `<script>
484
484
  // The previous SERVICE_LABELS / SERVICE_ORDER / isVaultName hardcoding
485
485
  // is retired: vault has no uiUrl, so the "skip vault" rule emerges
486
486
  // from data rather than a name check; ordering is alphabetical-by-
487
- // displayName per the module-json-extensibility pattern doc.
487
+ // displayName per docs/contracts/module-json-extensibility.md.
488
488
 
489
489
  // Admin entries: always visible. Even a fresh hub with zero vaults wants
490
490
  // the operator to find /admin/vaults. Hardcoded — they live in the
@@ -617,7 +617,7 @@ const DISCOVERY_SCRIPT = `<script>
617
617
  });
618
618
  }
619
619
 
620
- // Alphabetical-by-displayName per the module-json-extensibility pattern.
620
+ // Alphabetical-by-displayName per docs/contracts/module-json-extensibility.md.
621
621
  // Stable for shared-prefix labels (Notes, Notes-Lite would sort that way).
622
622
  const tiles = Array.from(byShort.values()).sort((a, b) =>
623
623
  a.title.localeCompare(b.title),
@@ -630,7 +630,7 @@ const DISCOVERY_SCRIPT = `<script>
630
630
  empty.innerHTML =
631
631
  'No services with a UI declared yet. Modules surface their UIs by ' +
632
632
  'declaring <code>uiUrl</code> in <code>module.json</code> ' +
633
- '(see the module-json-extensibility pattern).';
633
+ '(see parachute-hub/docs/contracts/module-json-extensibility.md).';
634
634
  servicesGrid.appendChild(empty);
635
635
  return;
636
636
  }
@@ -3,7 +3,7 @@
3
3
  * module. Author-controlled, shipped in the published artifact, read by the
4
4
  * CLI on `parachute install <package>`.
5
5
  *
6
- * The shape mirrors `parachute-patterns/patterns/module-json-extensibility.md`.
6
+ * The shape mirrors `docs/contracts/module-json-extensibility.md`.
7
7
  * Third-party modules are first-class: no `@openparachute/` scope or
8
8
  * `parachute-*` prefix required — `module.json` is what makes a package a
9
9
  * module. First-party modules will eventually ship their own `module.json`
@@ -271,7 +271,7 @@ export interface ModuleManifest {
271
271
  readonly configSchema?: ConfigSchema;
272
272
  /**
273
273
  * Where the module's admin UI lives. Hub renders a "Manage" link when set
274
- * (see `parachute-patterns/patterns/module-json-extensibility.md`).
274
+ * (see `docs/contracts/module-json-extensibility.md`).
275
275
  *
276
276
  * Three shapes (unified URL-resolution semantics — B4 of the 2026-06-09
277
277
  * hub-module-boundary migration):
@@ -293,7 +293,7 @@ export interface ModuleManifest {
293
293
  /**
294
294
  * Where the module's primary user-facing UI lives. Hub renders a tile on
295
295
  * the discovery page (`/`) Services section when set (see
296
- * `parachute-patterns/patterns/module-json-extensibility.md` and the
296
+ * `docs/contracts/module-json-extensibility.md` and the
297
297
  * `loadUiUrls` resolver in `hub-server.ts`).
298
298
  *
299
299
  * Two shapes — same rules as `managementUrl`:
@@ -25,7 +25,7 @@
25
25
  * vault's named scopes, lock the picker to `<name>`, and mint named scopes so
26
26
  * `inferAudience` stamps `aud=vault.<name>`.
27
27
  *
28
- * Source of truth for scope shape: `parachute-patterns/patterns/oauth-scopes.md`.
28
+ * Source of truth for scope shape: `docs/contracts/oauth-scopes.md`.
29
29
  */
30
30
 
31
31
  import { VAULT_VERBS } from "./jwt-audience.ts";
@@ -18,7 +18,7 @@
18
18
  * hardcoded here.
19
19
  *
20
20
  * Source of truth for the scope shape:
21
- * `parachute-patterns/patterns/oauth-scopes.md`.
21
+ * `docs/contracts/oauth-scopes.md`.
22
22
  */
23
23
 
24
24
  export interface ScopeExplanation {
@@ -7,7 +7,7 @@
7
7
  * downstream service should be rejected, but defense-in-depth says don't
8
8
  * sign claims you don't understand. This module is the gate.
9
9
  *
10
- * Source of truth for scope shape: `parachute-patterns/patterns/oauth-scopes.md`.
10
+ * Source of truth for scope shape: `docs/contracts/oauth-scopes.md`.
11
11
  *
12
12
  * Declared scopes come from two places:
13
13
  * 1. `FIRST_PARTY_SCOPES` — the canonical Parachute scopes hardcoded in
@@ -25,8 +25,8 @@ export type PublicExposure = "allowed" | "loopback" | "auth-required";
25
25
 
26
26
  /**
27
27
  * Visible-to-discovery state of a UI sub-unit. Aligns with the four-state
28
- * supervisor vocabulary in [parachute-patterns/patterns/design-system.md
29
- * §6](../parachute-patterns/patterns/design-system.md) (workstream F).
28
+ * supervisor vocabulary in [docs/contracts/design-system.md
29
+ * §6](../docs/contracts/design-system.md) (workstream F).
30
30
  * Absent → hub treats the sub-unit as "active" (the discovery default).
31
31
  *
32
32
  * "active" — UI is installed, OAuth client is approved, ready to serve.
@@ -827,7 +827,7 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
827
827
  });
828
828
 
829
829
  console.error(
830
- `${where}: dropped legacy short-name row(s) [${dropped.join(", ")}] in favor of same-port manifestName row(s). This is the parachute-app#13 / parachute-runner#4 self-register fixup. See parachute-patterns/patterns/services-json-row-conventions.md.`,
830
+ `${where}: dropped legacy short-name row(s) [${dropped.join(", ")}] in favor of same-port manifestName row(s). This is the parachute-app#13 / parachute-runner#4 self-register fixup. See parachute-hub/docs/contracts/services-json-row-conventions.md.`,
831
831
  );
832
832
 
833
833
  return {
package/src/well-known.ts CHANGED
@@ -47,7 +47,7 @@ export interface WellKnownVaultEntry {
47
47
  version: string;
48
48
  /**
49
49
  * Where the vault's admin SPA lives. Path-or-URL per
50
- * `parachute-patterns/patterns/module-json-extensibility.md`. Hub renders
50
+ * `docs/contracts/module-json-extensibility.md`. Hub renders
51
51
  * a "Manage" link when present. Sourced from the vault module's
52
52
  * `.parachute/module.json:managementUrl`.
53
53
  */