@openparachute/hub 0.5.1 → 0.5.7

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +92 -0
  3. package/src/__tests__/expose-2fa-warning.test.ts +125 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  5. package/src/__tests__/expose.test.ts +199 -340
  6. package/src/__tests__/hub-server.test.ts +1227 -1
  7. package/src/__tests__/install.test.ts +50 -31
  8. package/src/__tests__/lifecycle.test.ts +97 -2
  9. package/src/__tests__/module-manifest.test.ts +13 -0
  10. package/src/__tests__/notes-serve.test.ts +154 -2
  11. package/src/__tests__/oauth-handlers.test.ts +737 -1
  12. package/src/__tests__/port-assign.test.ts +41 -52
  13. package/src/__tests__/rate-limit.test.ts +190 -0
  14. package/src/__tests__/services-manifest.test.ts +367 -0
  15. package/src/__tests__/setup.test.ts +12 -9
  16. package/src/__tests__/status.test.ts +173 -0
  17. package/src/admin-handlers.ts +38 -13
  18. package/src/commands/expose-2fa-warning.ts +82 -0
  19. package/src/commands/expose-cloudflare.ts +27 -0
  20. package/src/commands/expose-public-auto.ts +3 -7
  21. package/src/commands/expose.ts +88 -173
  22. package/src/commands/install.ts +11 -13
  23. package/src/commands/lifecycle.ts +53 -4
  24. package/src/commands/status.ts +28 -1
  25. package/src/help.ts +3 -3
  26. package/src/hub-server.ts +266 -32
  27. package/src/module-manifest.ts +19 -0
  28. package/src/notes-serve.ts +70 -9
  29. package/src/oauth-handlers.ts +249 -12
  30. package/src/oauth-ui.ts +167 -0
  31. package/src/port-assign.ts +28 -35
  32. package/src/rate-limit.ts +163 -0
  33. package/src/service-spec.ts +66 -13
  34. package/src/services-manifest.ts +83 -3
  35. package/src/sessions.ts +19 -0
@@ -136,6 +136,24 @@ export interface LifecycleOpts {
136
136
  killWaitMs?: number;
137
137
  /** Poll interval while waiting for SIGTERM to land. */
138
138
  pollIntervalMs?: number;
139
+ /**
140
+ * How long `start` sleeps before re-checking `alive(pid)` to catch the
141
+ * spawn-then-immediately-die failure shape (hub#194: notes-serve crashed
142
+ * 50ms in on Bun.resolveSync, but `start` reported success because the
143
+ * spawn returned a pid). 250ms is the default in production — long
144
+ * enough to catch real silent-crashes (resolve failures, port
145
+ * collisions, missing args) without making `parachute start` feel
146
+ * laggy.
147
+ *
148
+ * Defaulting policy: if `alive` is not overridden, the settle defaults
149
+ * to 0 (skipped). Stub spawners hand back fake pids that the real
150
+ * `defaultAlive` would mark as dead, which would make every existing
151
+ * stub-spawner test fail spuriously. Tests that want to exercise the
152
+ * settle path inject both `alive` and `startSettleMs` explicitly.
153
+ * Production paths use the real `defaultAlive` and get the real 250ms
154
+ * settle.
155
+ */
156
+ startSettleMs?: number;
139
157
  /**
140
158
  * Override the hub origin passed to services as PARACHUTE_HUB_ORIGIN. If
141
159
  * unset, `start` derives it from `expose-state.json` (when exposed) or
@@ -168,6 +186,7 @@ interface Resolved {
168
186
  log: (line: string) => void;
169
187
  killWaitMs: number;
170
188
  pollIntervalMs: number;
189
+ startSettleMs: number;
171
190
  hubOrigin: string | undefined;
172
191
  ensureHub: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
173
192
  stopHubFn: (opts: StopHubOpts) => Promise<boolean>;
@@ -186,6 +205,14 @@ function resolve(opts: LifecycleOpts): Resolved {
186
205
  log: opts.log ?? ((line) => console.log(line)),
187
206
  killWaitMs: opts.killWaitMs ?? 10_000,
188
207
  pollIntervalMs: opts.pollIntervalMs ?? 200,
208
+ // See `LifecycleOpts.startSettleMs` doc. Production (no spawner
209
+ // override, no alive override) gets the 250ms settle. Tests that
210
+ // inject a stub spawner without a stub alive get 0 — `defaultAlive`
211
+ // against a fake pid would always report dead and break unrelated
212
+ // tests. Tests that want to exercise the settle path explicitly
213
+ // override `alive`, which re-enables the default 250ms.
214
+ startSettleMs:
215
+ opts.startSettleMs ?? (opts.spawner === undefined || opts.alive !== undefined ? 250 : 0),
189
216
  hubOrigin: resolveHubOrigin(opts.hubOrigin, configDir),
190
217
  ensureHub: opts.hub?.ensureRunning ?? ensureHubRunning,
191
218
  stopHubFn: opts.hub?.stop ?? stopHub,
@@ -371,16 +398,38 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
371
398
  if (entry.installDir) spawnerOpts.cwd = entry.installDir;
372
399
  const passOpts =
373
400
  spawnerOpts.env !== undefined || spawnerOpts.cwd !== undefined ? spawnerOpts : undefined;
401
+ let pid: number;
374
402
  try {
375
- const pid = r.spawner.spawn(cmd, logFile, passOpts);
376
- writePid(short, pid, r.configDir);
377
- r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
378
- if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
403
+ pid = r.spawner.spawn(cmd, logFile, passOpts);
379
404
  } catch (err) {
380
405
  failures++;
381
406
  const msg = err instanceof Error ? err.message : String(err);
382
407
  r.log(`✗ ${short} failed to start: ${msg}`);
408
+ continue;
409
+ }
410
+ writePid(short, pid, r.configDir);
411
+
412
+ // Settle-poll for spawn-then-immediately-die (hub#194). A spawn returning
413
+ // a pid only proves the kernel forked the process; the child may exit
414
+ // microseconds later if its main code path throws before listening
415
+ // (e.g. notes-serve's Bun.resolveSync failing for bun-linked installs).
416
+ // Without this poll, we'd report success and the operator would chase
417
+ // a phantom 502.
418
+ if (r.startSettleMs > 0) {
419
+ await r.sleep(r.startSettleMs);
420
+ if (!r.alive(pid)) {
421
+ clearPid(short, r.configDir);
422
+ failures++;
423
+ r.log(
424
+ `✗ ${short} failed to start: spawned pid ${pid} but the process exited within ${r.startSettleMs}ms.`,
425
+ );
426
+ r.log(` Tail the log for details: tail -50 ${logFile}`);
427
+ continue;
428
+ }
383
429
  }
430
+
431
+ r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
432
+ if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
384
433
  }
385
434
  return failures === 0 ? 0 : 1;
386
435
  }
@@ -1,7 +1,7 @@
1
1
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
2
2
  import { HUB_SVC, readHubPort } from "../hub-control.ts";
3
3
  import { type AliveFn, defaultAlive, formatUptime, processState } from "../process-state.ts";
4
- import { getSpec, shortNameForManifest } from "../service-spec.ts";
4
+ import { canonicalPortForManifest, getSpec, shortNameForManifest } from "../service-spec.ts";
5
5
  import { type ServiceEntry, readManifest } from "../services-manifest.ts";
6
6
 
7
7
  export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
@@ -74,6 +74,14 @@ interface StatusRow {
74
74
  url: string | undefined;
75
75
  healthy: boolean;
76
76
  skipped: boolean;
77
+ /**
78
+ * Canonical-port drift warning. Set when the entry has a known canonical
79
+ * port (first-party / known short) AND the actual port differs. Surfaced
80
+ * as a continuation line under the row so operators see a silent miswire
81
+ * (e.g. parachute-hub#195: scribe + agent both at 1944) without us
82
+ * hard-erroring on a deliberate operator port change.
83
+ */
84
+ driftWarning?: string;
77
85
  }
78
86
 
79
87
  /**
@@ -157,6 +165,19 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
157
165
 
158
166
  const url = urlForEntry(entry, short);
159
167
 
168
+ // Canonical-port drift detection (hub#195). Only fires for known
169
+ // first-party services where we have a canonical assignment. Third-party
170
+ // rows have no canonical to compare against. Warning is informational —
171
+ // operators may have moved a service off canonical deliberately.
172
+ // Note: multi-vault instance rows (`parachute-vault-<instance>`) don't
173
+ // match a canonical manifest name, so drift warnings don't fire for
174
+ // them. Intentional — see `canonicalPortForManifest` for the rationale.
175
+ const canonical = canonicalPortForManifest(entry.name);
176
+ const driftWarning =
177
+ canonical !== undefined && canonical !== entry.port
178
+ ? `canonical port is ${canonical}`
179
+ : undefined;
180
+
160
181
  // Only skip probe when we know the process is dead (PID file was
161
182
  // present but kill(pid, 0) failed). "unknown" status (no PID file)
162
183
  // still probes — externally-managed services should report health.
@@ -173,6 +194,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
173
194
  url,
174
195
  healthy: false,
175
196
  skipped: true,
197
+ driftWarning,
176
198
  };
177
199
  }
178
200
 
@@ -194,6 +216,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
194
216
  url,
195
217
  healthy: p.healthy,
196
218
  skipped: false,
219
+ driftWarning,
197
220
  };
198
221
  }),
199
222
  );
@@ -228,6 +251,10 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
228
251
  if (!cells || !row) continue;
229
252
  print(formatRow(cells, widths));
230
253
  if (row.url) print(` → ${row.url}`);
254
+ // Drift warning rides as its own continuation line. Plain ASCII (no
255
+ // emoji / unicode glyphs) for terminal compatibility — the same
256
+ // surface that prints to scripts piping `parachute status`.
257
+ if (row.driftWarning) print(` ! ${row.driftWarning}`);
231
258
  }
232
259
 
233
260
  /**
package/src/help.ts CHANGED
@@ -44,9 +44,9 @@ Services:
44
44
  What it does:
45
45
  1. bun add -g @openparachute/<service>[@<tag>]
46
46
  2. run any service-specific init (e.g. \`parachute-vault init\`)
47
- 3. assign a canonical port (1939–1949) and write \`PORT=<port>\` into
48
- \`~/.parachute/<service>/.env\`. Idempotent an existing PORT wins, so
49
- re-installs and operator-edited ports survive across upgrades.
47
+ 3. assign a canonical port (1939–1949) and reflect it in
48
+ \`~/.parachute/services.json\`the single source of truth at boot
49
+ (services follow a 4-tier resolvePort ladder; services.json wins).
50
50
  4. verify the service registered itself in ~/.parachute/services.json
51
51
  5. for scribe in a TTY: prompt for transcription provider + API key
52
52
  (or take \`--scribe-provider\` / \`--scribe-key\`)
package/src/hub-server.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * /.well-known/jwks.json → JWKS from hub.db
17
17
  * /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
18
18
  * /oauth/authorize (GET + POST) → login → consent → auth code
19
+ * /oauth/authorize/approve (POST) → inline DCR approve form (#208)
19
20
  * /oauth/token (POST) → authorization_code + refresh_token grants
20
21
  * /oauth/register (POST) → RFC 7591 dynamic client registration
21
22
  * anything else → 404
@@ -60,6 +61,7 @@ import {
60
61
  } from "./module-manifest.ts";
61
62
  import {
62
63
  authorizationServerMetadata,
64
+ handleApproveClientPost,
63
65
  handleAuthorizeGet,
64
66
  handleAuthorizePost,
65
67
  handleRegister,
@@ -67,6 +69,11 @@ import {
67
69
  handleToken,
68
70
  } from "./oauth-handlers.ts";
69
71
  import { clearPid, writePid } from "./process-state.ts";
72
+ import {
73
+ FIRST_PARTY_FALLBACKS,
74
+ effectivePublicExposure,
75
+ shortNameForManifest,
76
+ } from "./service-spec.ts";
70
77
  import { type ServiceEntry, readManifest } from "./services-manifest.ts";
71
78
  import { getAllPublicKeys } from "./signing-keys.ts";
72
79
  import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
@@ -136,9 +143,16 @@ export function findVaultUpstream(
136
143
  for (const s of services) {
137
144
  if (!isVaultEntry(s)) continue;
138
145
  for (const path of s.paths) {
139
- if (pathname === path || pathname.startsWith(`${path}/`)) {
140
- if (!best || path.length > best.mount.length) {
141
- best = { port: s.port, mount: path, entry: s };
146
+ // Normalize trailing slashes before comparison (#197). A services.json
147
+ // entry written with `paths: ["/vault/default/"]` would otherwise only
148
+ // match the exact pathname `/vault/default/` and never any sub-path,
149
+ // because `pathname.startsWith("/vault/default//")` is always false.
150
+ // The "|| '/'" branch keeps a bare-root mount "/" stable rather than
151
+ // collapsing it to an empty string.
152
+ const norm = path.replace(/\/+$/, "") || "/";
153
+ if (pathname === norm || pathname.startsWith(`${norm}/`)) {
154
+ if (!best || norm.length > best.mount.length) {
155
+ best = { port: s.port, mount: norm, entry: s };
142
156
  }
143
157
  }
144
158
  }
@@ -147,40 +161,98 @@ export function findVaultUpstream(
147
161
  }
148
162
 
149
163
  /**
150
- * Reverse-proxy a `/vault/<name>/*` request onto the vault backend's loopback
151
- * port. The path is preserved end-to-end (vault since paraclaw#18 expects
152
- * requests at `/vault/<name>/...` not stripped to `/...`), so the upstream URL
153
- * mirrors the incoming pathname exactly.
164
+ * The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
165
+ * every request reaches it via one of three trusted forwarders (or directly
166
+ * over loopback). The forwarder injects characteristic headers that we use to
167
+ * classify; nothing else can reach the listener, so spoofing isn't a concern.
154
168
  *
155
- * `manifestPath` is the services.json path from `HubFetchDeps`. Read on every
156
- * proxied request so a vault created seconds ago is reachable without a
157
- * re-expose same dynamism as the well-known doc (#135).
169
+ * "loopback" direct localhost call (CLI, on-box service, dev shell).
170
+ * "tailnet" — `tailscale serve` forwarding an authed tailnet user.
171
+ * "public" `tailscale funnel` (public-over-tailnet, unauthed) OR a
172
+ * cloudflared tunnel forwarding from the public internet.
173
+ *
174
+ * Used to gate `publicExposure: "loopback"` services on the generic
175
+ * `/<svc>/*` dispatch (the hub's only layer-gate). Hub-owned paths (`/`,
176
+ * `/admin/*`, `/api/*`, `/hub/*`, `/oauth/*`, `/.well-known/*`, `/vault/*`,
177
+ * `/vaults`) reach all layers and rely on app-level auth (admin session
178
+ * cookie + 2FA, OAuth, per-service tokens) — they are NOT layer-blocked.
179
+ */
180
+ export type RequestLayer = "loopback" | "tailnet" | "public";
181
+
182
+ /**
183
+ * Classify the trust layer for an incoming request by inspecting proxy
184
+ * headers. Order matters: cloudflared headers come first because cloudflared
185
+ * could in principle be deployed alongside tailscale on the same node.
186
+ *
187
+ * Header reference (verified against tailscale serve.go on 2026-05-08):
188
+ * - `Tailscale-User-Login` is set ONLY by `tailscale serve` for an authed
189
+ * tailnet user. Tagged-source nodes don't get it. Funnel never sets it.
190
+ * - `Tailscale-Funnel-Request: ?1` is set ONLY by Tailscale Funnel.
191
+ * Mutually exclusive with `Tailscale-User-Login` (the serve.go path
192
+ * returns early when funneled).
193
+ * - `CF-Ray` and `CF-Connecting-IP` are set by Cloudflare's edge for
194
+ * anything proxied through a cloudflared tunnel.
195
+ *
196
+ * Spoofing isn't a concern: hub binds `127.0.0.1:1939`, so external requests
197
+ * can't reach the listener except via these trusted forwarders. Tailscale
198
+ * specifically strips the same headers from incoming requests before
199
+ * re-injecting them, so even a malicious tailnet peer can't impersonate a
200
+ * different user. We could mirror that strip-on-arrival defense, but it's
201
+ * belt-and-braces given the bind shape.
202
+ *
203
+ * Default to "loopback" when no proxy headers are present — that's the
204
+ * direct-localhost case. Funnel without `Tailscale-Funnel-Request` would
205
+ * also fall here, but Tailscale always sets the header on funneled
206
+ * requests, so this branch only fires for true loopback callers.
207
+ */
208
+ export function layerOf(req: Request): RequestLayer {
209
+ const h = req.headers;
210
+ if (h.get("cf-ray") !== null || h.get("cf-connecting-ip") !== null) return "public";
211
+ // Match the structured-header value (`?1`) rather than mere presence:
212
+ // serve.go only ever emits `?1`, so insisting on the canonical value keeps
213
+ // the classifier's intent obvious to a future reader (don't loosen this to
214
+ // `!== null` — Tailscale's contract is the value, not the header name).
215
+ // CF-Ray / CF-Connecting-IP are open-string identifiers with no canonical
216
+ // value to compare against, hence the presence-check above.
217
+ if (h.get("tailscale-funnel-request") === "?1") return "public";
218
+ if (h.get("tailscale-user-login") !== null) return "tailnet";
219
+ return "loopback";
220
+ }
221
+
222
+ /**
223
+ * Forward a request to a loopback service on `127.0.0.1:<port>`. By default
224
+ * the incoming pathname + query are preserved verbatim; pass `targetPath` to
225
+ * rewrite the path (e.g. when the caller has stripped a mount prefix because
226
+ * the backend serves bare routes). Query string is always preserved from the
227
+ * incoming URL.
158
228
  *
159
- * Returns `undefined` when no vault is currently mounted at this pathname so
160
- * the caller falls through to the catch-all 404. Returns a 502 response when
161
- * the upstream connection fails (vault crashed, port shifted) — the upstream
162
- * URL was valid; we just couldn't reach it.
229
+ * Note: this is **not** equivalent to the tailscale convention. `tailscale
230
+ * serve <mount>=<target>` strips the mount before forwarding, so
231
+ * `serviceProxyTarget` in `commands/expose.ts` works by making mount and
232
+ * target byte-equal. The hub's fetch-based proxy does no stripping unless the
233
+ * caller asks; per-service preferences vary (scribe wants bare paths, notes
234
+ * / agent / vault want the prefix), so the decision lives one layer up in
235
+ * `proxyToService` / `proxyToVault`.
236
+ *
237
+ * Returns 502 when the loopback fetch fails — port valid, target unreachable
238
+ * (service crashed, port shifted, mid-restart). `serviceLabel` is folded into
239
+ * the error message so 502 bodies say `vault upstream unreachable` /
240
+ * `scribe upstream unreachable` etc.
163
241
  *
164
242
  * Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
165
- * fetch-based proxies cleanly. Vault uses neither today; if a future service
166
- * needs them, switch to a Node http.IncomingMessage / http.request pair.
243
+ * fetch-based proxies cleanly. No on-box service uses either today; if one
244
+ * eventually needs them, switch to a Node http.IncomingMessage / http.request
245
+ * pair.
167
246
  */
168
- async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
169
- let services: readonly ServiceEntry[];
170
- try {
171
- services = readManifest(manifestPath).services;
172
- } catch (err) {
173
- const msg = err instanceof Error ? err.message : String(err);
174
- return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
175
- status: 500,
176
- headers: { "content-type": "application/json" },
177
- });
178
- }
247
+ async function proxyRequest(
248
+ req: Request,
249
+ port: number,
250
+ serviceLabel: string,
251
+ targetPath?: string,
252
+ ): Promise<Response> {
179
253
  const url = new URL(req.url);
180
- const match = findVaultUpstream(services, url.pathname);
181
- if (!match) return undefined;
182
-
183
- const upstream = `http://127.0.0.1:${match.port}${url.pathname}${url.search}`;
254
+ const path = targetPath ?? url.pathname;
255
+ const upstream = `http://127.0.0.1:${port}${path}${url.search}`;
184
256
  const headers = new Headers(req.headers);
185
257
  // Host comes from the requester (tailnet FQDN); the loopback target wants
186
258
  // its own. Bun's fetch fills it in when omitted.
@@ -199,13 +271,156 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
199
271
  return await fetch(upstream, init);
200
272
  } catch (err) {
201
273
  const msg = err instanceof Error ? err.message : String(err);
202
- return new Response(JSON.stringify({ error: `vault upstream unreachable: ${msg}` }), {
274
+ return new Response(JSON.stringify({ error: `${serviceLabel} upstream unreachable: ${msg}` }), {
203
275
  status: 502,
204
276
  headers: { "content-type": "application/json" },
205
277
  });
206
278
  }
207
279
  }
208
280
 
281
+ /**
282
+ * Reverse-proxy a `/vault/<name>/*` request onto the vault backend.
283
+ * `manifestPath` is the services.json path from `HubFetchDeps`. Read on every
284
+ * proxied request so a vault created seconds ago is reachable without a
285
+ * re-expose — same dynamism as the well-known doc (#135).
286
+ *
287
+ * Returns `undefined` when no vault claims this pathname so the caller can
288
+ * fall through to the SPA shell fallback for unknown vault names (the seam
289
+ * #173 introduced).
290
+ */
291
+ async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
292
+ let services: readonly ServiceEntry[];
293
+ try {
294
+ services = readManifest(manifestPath).services;
295
+ } catch (err) {
296
+ const msg = err instanceof Error ? err.message : String(err);
297
+ return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
298
+ status: 500,
299
+ headers: { "content-type": "application/json" },
300
+ });
301
+ }
302
+ const url = new URL(req.url);
303
+ const match = findVaultUpstream(services, url.pathname);
304
+ if (!match) return undefined;
305
+ // Layer-gate on `publicExposure: "loopback"` — hide the entry from non-
306
+ // loopback callers as if it doesn't exist. "allowed" / "auth-required"
307
+ // pass through; the service does its own auth.
308
+ if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
309
+ return new Response("not found", { status: 404 });
310
+ }
311
+ // Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
312
+ // PARTY_FALLBACKS as a fallback source. No first-party vault fallback
313
+ // declares stripPrefix today (vault expects the full `/vault/<name>/*`
314
+ // path), so this is a no-op in practice — but reading the same shape in
315
+ // both proxies keeps the dispatch surface consistent for future readers.
316
+ const stripPrefix = stripPrefixFor(match.entry);
317
+ const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
318
+ return proxyRequest(req, match.port, "vault", targetPath);
319
+ }
320
+
321
+ /**
322
+ * Resolve which (non-vault) ServiceEntry should handle a given request.
323
+ * Generic longest-prefix match across every service's `paths[]`. Vault
324
+ * entries are filtered out — they're routed by `findVaultUpstream` /
325
+ * `proxyToVault`, which encode the vault-specific SPA-fallback seam.
326
+ *
327
+ * Returns `undefined` when no service claims the pathname; the caller 404s.
328
+ */
329
+ export function findServiceUpstream(
330
+ services: readonly ServiceEntry[],
331
+ pathname: string,
332
+ ): { port: number; mount: string; entry: ServiceEntry } | undefined {
333
+ let best: { port: number; mount: string; entry: ServiceEntry } | undefined;
334
+ for (const s of services) {
335
+ if (isVaultEntry(s)) continue;
336
+ for (const path of s.paths) {
337
+ // Normalize trailing slashes before comparison (#197). A services.json
338
+ // entry written with `paths: ["/notes/"]` would otherwise only match
339
+ // the exact pathname `/notes/` and never `/notes/assets/index.js` —
340
+ // `pathname.startsWith("/notes//")` is always false because URLs
341
+ // don't have double slashes. Result: SPA shell loads but every asset
342
+ // 404s (notes blank-screen on Aaron's box, 2026-05-08).
343
+ // The "|| '/'" branch keeps a bare-root mount "/" stable rather than
344
+ // collapsing it to an empty string.
345
+ const norm = path.replace(/\/+$/, "") || "/";
346
+ if (pathname === norm || pathname.startsWith(`${norm}/`)) {
347
+ if (!best || norm.length > best.mount.length) {
348
+ best = { port: s.port, mount: norm, entry: s };
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return best;
354
+ }
355
+
356
+ /**
357
+ * Reverse-proxy a request onto whichever non-vault service registers a
358
+ * matching `paths[]` prefix in services.json. Wired after every specific
359
+ * handler in `hubFetch` so the exclusion list (`/`, `/admin/*`, `/oauth/*`,
360
+ * `/.well-known/*`, `/hub/*`, `/vault/*`, `/api/*`) is enforced by ordering:
361
+ * those specific handlers run first and never reach this dispatch.
362
+ *
363
+ * Read services.json on every request so a `parachute install <svc>` made
364
+ * seconds ago is reachable without a hub restart — same dynamism as the
365
+ * well-known doc and `proxyToVault`.
366
+ *
367
+ * Honors `entry.stripPrefix`: when `true` the matched mount prefix is
368
+ * removed from the forwarded path so the backend sees a bare route
369
+ * (`/scribe/health` becomes `/health`). Default (`false` / absent) forwards
370
+ * the full path — matches what notes / agent / vault expect.
371
+ *
372
+ * Returns `undefined` when no service claims the pathname; caller 404s.
373
+ */
374
+ async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
375
+ let services: readonly ServiceEntry[];
376
+ try {
377
+ services = readManifest(manifestPath).services;
378
+ } catch (err) {
379
+ const msg = err instanceof Error ? err.message : String(err);
380
+ return new Response(JSON.stringify({ error: `service routing failed: ${msg}` }), {
381
+ status: 500,
382
+ headers: { "content-type": "application/json" },
383
+ });
384
+ }
385
+ const url = new URL(req.url);
386
+ const match = findServiceUpstream(services, url.pathname);
387
+ if (!match) return undefined;
388
+ // Layer-gate on `publicExposure: "loopback"`. From the perspective of a
389
+ // tailnet/public caller, a loopback-only service must be indistinguishable
390
+ // from "not installed" — 404, not 403, so we don't leak the existence of
391
+ // the route. "allowed" / "auth-required" pass through; the service does
392
+ // its own auth.
393
+ if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
394
+ return new Response("not found", { status: 404 });
395
+ }
396
+ // Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
397
+ // Scribe v0.4.0 doesn't write `stripPrefix: true` to its services.json
398
+ // entry — the declaration only lives in hub's SCRIBE_FALLBACK manifest.
399
+ // Pre-#187 this didn't matter because the per-service tailscale serve
400
+ // plan baked the path into the target URL; post-#187 routing went through
401
+ // hub which wasn't consulting the fallback registry. Same shape as how
402
+ // `effectivePublicExposure` already handles fallback derivation in
403
+ // service-spec.ts. Explicit-on-entry still wins; absent → fallback →
404
+ // false (preserving existing keep-prefix default for unknown services).
405
+ const stripPrefix = stripPrefixFor(match.entry);
406
+ const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
407
+ return proxyRequest(req, match.port, match.entry.name, targetPath);
408
+ }
409
+
410
+ /**
411
+ * Resolve effective `stripPrefix` for a service entry. Explicit on-entry
412
+ * wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (so
413
+ * scribe's vendored fallback supplies `stripPrefix: true` even when scribe's
414
+ * own boot doesn't write it). Defaults to `false` — keep the prefix —
415
+ * matching the pre-#196 dispatch behavior for unknown / third-party services.
416
+ */
417
+ function stripPrefixFor(entry: ServiceEntry): boolean {
418
+ if (entry.stripPrefix !== undefined) return entry.stripPrefix;
419
+ const short = shortNameForManifest(entry.name);
420
+ const fb = short !== undefined ? FIRST_PARTY_FALLBACKS[short] : undefined;
421
+ return fb?.manifest.stripPrefix ?? false;
422
+ }
423
+
209
424
  export interface HubFetchDeps {
210
425
  /**
211
426
  * Lazily opens (or returns a cached handle to) the hub DB. Optional so
@@ -539,6 +754,18 @@ export function hubFetch(
539
754
  return new Response("method not allowed", { status: 405 });
540
755
  }
541
756
 
757
+ // Inline approve form for the operator-driven pending-client flow (#208).
758
+ // Receives `client_id` + `csrf_token` + `return_to` from the form rendered
759
+ // by handleAuthorizeGet when the operator hits a pending client. Three
760
+ // gates inside the handler: CSRF, active session, same-origin Origin.
761
+ if (pathname === "/oauth/authorize/approve") {
762
+ if (!getDb) {
763
+ return new Response("hub db not configured", { status: 503 });
764
+ }
765
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
766
+ return handleApproveClientPost(getDb(), req, oauthDeps(req));
767
+ }
768
+
542
769
  if (pathname === "/oauth/token") {
543
770
  if (!getDb) {
544
771
  return new Response("hub db not configured", { status: 503 });
@@ -690,6 +917,13 @@ export function hubFetch(
690
917
  return serveSpa(spaDistDir, pathname, "/vault");
691
918
  }
692
919
 
920
+ // Generic services.json-driven dispatch for non-vault modules. Reaches
921
+ // here only after every hub-owned prefix above has had its turn — so
922
+ // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
923
+ // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
924
+ const proxied = await proxyToService(req, manifestPath);
925
+ if (proxied) return proxied;
926
+
693
927
  return new Response("not found", { status: 404 });
694
928
  };
695
929
  }
@@ -114,6 +114,15 @@ export interface ModuleManifest {
114
114
  * as `hasAuth` / `init` / `urlForEntry`.
115
115
  */
116
116
  readonly managementUrl?: string;
117
+ /**
118
+ * When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
119
+ * before forwarding (so the backend sees `/health` rather than
120
+ * `/<name>/health`). Default `false` matches the prefix-aware convention
121
+ * notes / agent / vault already follow. Carried into services.json via
122
+ * `seedEntryFromManifest`. See `ServiceEntry.stripPrefix` for the full
123
+ * per-module rationale.
124
+ */
125
+ readonly stripPrefix?: boolean;
117
126
  }
118
127
 
119
128
  export class ModuleManifestError extends Error {
@@ -365,6 +374,13 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
365
374
  const dependencies = asDependencies(m.dependencies, where);
366
375
  const configSchema = asConfigSchema(m.configSchema, where);
367
376
  const managementUrl = asManagementUrl(m.managementUrl, where);
377
+ let stripPrefix: boolean | undefined;
378
+ if (m.stripPrefix !== undefined) {
379
+ if (typeof m.stripPrefix !== "boolean") {
380
+ throw new ModuleManifestError(`${where}: "stripPrefix" must be a boolean if present`);
381
+ }
382
+ stripPrefix = m.stripPrefix;
383
+ }
368
384
 
369
385
  const out: ModuleManifest = { name, manifestName, kind, port, paths, health };
370
386
  if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
@@ -380,6 +396,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
380
396
  if (managementUrl !== undefined) {
381
397
  (out as { managementUrl?: string }).managementUrl = managementUrl;
382
398
  }
399
+ if (stripPrefix !== undefined) {
400
+ (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
401
+ }
383
402
  return out;
384
403
  }
385
404