@openparachute/app 0.2.0-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.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * HTTP surface for `parachute-app serve` — Phase 1.1.
3
+ *
4
+ * Phase 1.1 ships core UI hosting: discovery + mount + SPA fallback + smart
5
+ * cache headers + PWA opt-in. Admin endpoints (`POST /app/add`,
6
+ * `DELETE /app/<name>`, OAuth DCR, the admin SPA) land in Phase 1.2; the
7
+ * routing shape below leaves a clean seam to drop them in.
8
+ *
9
+ * Endpoints (Phase 1.1):
10
+ * - GET /healthz, GET /app/healthz — liveness, open
11
+ * - GET /.parachute/info — module identity (open)
12
+ * - GET /.parachute/config/schema — Draft-07 schema (open)
13
+ * - GET /.parachute/config — current config (open;
14
+ * no secrets in app config)
15
+ * - GET /<meta.path>/[anything] — per-UI bundle serving
16
+ *
17
+ * Per-UI routing:
18
+ * - GET /<meta.path>/ — serves `dist/index.html`
19
+ * - GET /<meta.path> — same (no-trailing-slash variant)
20
+ * - GET /<meta.path>/<asset> — serves `dist/<asset>` if it exists
21
+ * - GET /<meta.path>/<anything-else> — SPA fallback: serves `dist/index.html`
22
+ *
23
+ * SPA fallback discipline: any URL under `<meta.path>` that doesn't resolve
24
+ * to a file in `dist/` serves `index.html` with the no-cache headers.
25
+ * Client-side routers (React Router, hash routing, BrowserRouter) all
26
+ * benefit. Asset extensions that would normally 404 (a missing `.png`)
27
+ * still serve index.html — the bundle decides whether to render a 404 page
28
+ * or fall through to the router. That tradeoff matches design doc section 9.
29
+ *
30
+ * Hostname defaults to `127.0.0.1` (loopback-only) — same security posture
31
+ * as runner. Hub forwards `/app/*` traffic from the public origin.
32
+ */
33
+
34
+ import { existsSync, readFileSync, statSync } from "node:fs";
35
+ import * as path from "node:path";
36
+
37
+ import { type AdminHandlerOpts, routeAdmin } from "./admin-routes.ts";
38
+ import { getHubOrigin } from "./auth.ts";
39
+ import { cacheHeadersFor } from "./cache-headers.ts";
40
+ import { type AppConfig, loadConfig } from "./config.ts";
41
+ import { injectDevReloadScript } from "./dev-injection.ts";
42
+ import { isDevMode } from "./dev-mode.ts";
43
+ import { type DevRoutesOpts, routeDev } from "./dev-routes.ts";
44
+ import type { UiMeta } from "./meta-schema.ts";
45
+ import { injectTenancyContract } from "./tenancy-injection.ts";
46
+ import { type RegisteredUi, scanUis } from "./ui-registry.ts";
47
+
48
+ export type AppState = {
49
+ /** Currently-resolved config. Phase 1.2 PUTs will mutate this in place. */
50
+ config: AppConfig;
51
+ /** Mounted UIs. Phase 1.2's `reload` will swap this list. */
52
+ registeredUis: RegisteredUi[];
53
+ /**
54
+ * Skipped UIs from the last scan — surfaced in `/app/healthz`'s diagnostic
55
+ * payload so operators can spot broken UIs without leaving the daemon.
56
+ */
57
+ skippedUis: Array<{ dirName: string; status: string; reason: string }>;
58
+ };
59
+
60
+ export type HttpServerOpts = {
61
+ /** Mutable state. */
62
+ state: AppState;
63
+ /** Bind port. Use 0 in tests to let the OS pick. */
64
+ port: number;
65
+ /** Process start time, for `/healthz` uptime. */
66
+ startedAt: Date;
67
+ /**
68
+ * Bind address. Defaults to `127.0.0.1` — loopback-only because the admin
69
+ * endpoints (Phase 1.2) leak state. Hub forwards `/app/*` over loopback.
70
+ */
71
+ hostname?: string;
72
+ /** Override for tests — defaults to `Bun.serve`. */
73
+ serveFn?: typeof Bun.serve;
74
+ /** Logger override; default console. */
75
+ logger?: Pick<Console, "log" | "warn" | "error">;
76
+ /**
77
+ * Override the `.parachute/` manifest dir. Defaults to the repo's
78
+ * `.parachute/` next to `package.json`. Tests inject a tmpdir.
79
+ */
80
+ parachuteDir?: string;
81
+ /**
82
+ * Override the absolute path to the `dist/admin/` directory that holds the
83
+ * built admin SPA. Tests inject a tmpdir with a fake index.html. Production
84
+ * resolves to `<package-root>/dist/admin/` via `defaultAdminDir()`.
85
+ */
86
+ adminDir?: string;
87
+ /**
88
+ * Phase 1.2 admin-route handlers need a couple of side-channel hooks
89
+ * (tests inject the uis-dir, services.json path, npm-spawn, fetch). The
90
+ * server exposes them so callers don't have to thread them in by hand.
91
+ */
92
+ adminOpts?: Omit<AdminHandlerOpts, "state">;
93
+ /**
94
+ * Phase 1.3 dev-route opts (test seam for enforceScope override).
95
+ */
96
+ devOpts?: Omit<DevRoutesOpts, "state">;
97
+ };
98
+
99
+ /**
100
+ * Spin up the app HTTP server. Returns the running Bun.Server so the CLI
101
+ * can `server.stop()` during graceful shutdown.
102
+ */
103
+ export function startHttpServer(opts: HttpServerOpts): ReturnType<typeof Bun.serve> {
104
+ const { port, startedAt } = opts;
105
+ const hostname = opts.hostname ?? "127.0.0.1";
106
+ const serve = opts.serveFn ?? Bun.serve;
107
+ const parachuteDir = opts.parachuteDir ?? defaultParachuteDir();
108
+ const logger = opts.logger ?? console;
109
+
110
+ const adminDir = opts.adminDir ?? defaultAdminDir();
111
+ const adminOpts = opts.adminOpts ?? {};
112
+ const devOpts = opts.devOpts ?? {};
113
+ return serve({
114
+ port,
115
+ hostname,
116
+ fetch: (req) =>
117
+ handle(req, opts.state, { startedAt, parachuteDir, logger, adminDir, adminOpts, devOpts }),
118
+ });
119
+ }
120
+
121
+ type HandleCtx = {
122
+ startedAt: Date;
123
+ parachuteDir: string;
124
+ logger: Pick<Console, "log" | "warn" | "error">;
125
+ adminDir: string;
126
+ adminOpts: Omit<AdminHandlerOpts, "state">;
127
+ devOpts: Omit<DevRoutesOpts, "state">;
128
+ };
129
+
130
+ function handle(req: Request, state: AppState, ctx: HandleCtx): Response | Promise<Response> {
131
+ const url = new URL(req.url);
132
+ const pathname = url.pathname;
133
+ const method = req.method;
134
+
135
+ // /healthz: open, both with and without /app prefix so hub-as-supervisor
136
+ // (forwards via /app) and direct localhost probes both work.
137
+ //
138
+ // When `config.disabled` is true, surface `status: "disabled"` so probes
139
+ // can distinguish "daemon's up but intentionally not hosting" from "ok".
140
+ // The JSON key is `skippedUis` (matching the `AppState.skippedUis` field
141
+ // name) per reviewer Open Q 2 — keeps the shape consistent across the
142
+ // wire format and the internal state.
143
+ if (
144
+ (method === "GET" || method === "HEAD") &&
145
+ (pathname === "/healthz" || pathname === "/app/healthz")
146
+ ) {
147
+ return Response.json({
148
+ status: state.config.disabled ? "disabled" : "ok",
149
+ uis: state.registeredUis.length,
150
+ skippedUis: state.skippedUis.length,
151
+ uptime_seconds: Math.floor((Date.now() - ctx.startedAt.getTime()) / 1000),
152
+ });
153
+ }
154
+
155
+ // .parachute/* — module-protocol surface, open (no secrets in app config).
156
+ if (method === "GET" && pathname === "/.parachute/info") {
157
+ return serveStaticFile(path.join(ctx.parachuteDir, "info"), "application/json");
158
+ }
159
+ if (method === "GET" && pathname === "/.parachute/config/schema") {
160
+ return serveStaticFile(path.join(ctx.parachuteDir, "config", "schema"), "application/json");
161
+ }
162
+ if (method === "GET" && pathname === "/.parachute/config") {
163
+ return Response.json(state.config);
164
+ }
165
+
166
+ // Phase 1.2 admin SPA — mounted at /app/admin/. Reserved namespace: hosted
167
+ // UIs are rejected from claiming `/app/admin` by `meta-schema` + admin
168
+ // /app/add. The bundle is `dist/admin/` shipped inside the npm package.
169
+ // Bundle path serving deliberately runs BEFORE the per-UI matcher so
170
+ // /app/admin/* always resolves to admin assets, even if a malformed UI
171
+ // somehow registers `/app/admin` (path-pattern + reserved-path checks
172
+ // prevent that, but defense-in-depth).
173
+ if (
174
+ (method === "GET" || method === "HEAD") &&
175
+ (pathname === "/app/admin" || pathname === "/app/admin/" || pathname.startsWith("/app/admin/"))
176
+ ) {
177
+ return serveAdminAsset(req, ctx.adminDir, pathname);
178
+ }
179
+
180
+ // Phase 1.3 dev-mode routes: SSE reload stream + trigger endpoint.
181
+ // Matched ahead of admin so the per-UI `/_dev/reload` SSE path doesn't
182
+ // race with the admin regex (different prefix shapes — defense in depth).
183
+ const dev = routeDev(req, { state, ...ctx.devOpts });
184
+ if (dev.handled) {
185
+ return dev.response;
186
+ }
187
+
188
+ // Phase 1.2 admin endpoints (POST /app/add, DELETE /app/<name>, etc.).
189
+ const admin = routeAdmin(req, { state, ...ctx.adminOpts });
190
+ if (admin.handled) {
191
+ return admin.response;
192
+ }
193
+
194
+ // Per-UI mount paths. Find the matching UI (longest mount-path wins,
195
+ // though Phase 1.1's PATH_PATTERN constrains mounts to single-segment
196
+ // so there's no overlap in practice — the longest-prefix loop is
197
+ // forward-defensive for Phase 2's multi-segment relaxation).
198
+ if (method === "GET" || method === "HEAD") {
199
+ const ui = matchUi(pathname, state.registeredUis);
200
+ if (ui) {
201
+ // Resolve the hub origin per-request so a config flip
202
+ // (admin-SPA-toggled `hub_url` or env override) takes effect on the
203
+ // very next index.html serve. Read by `injectTenancyContract` below
204
+ // via `serveFileWithHeaders`'s `hubOrigin` parameter.
205
+ const hubOrigin = getHubOrigin(state.config.hub_url);
206
+ return serveUiAsset(req, ui, pathname, hubOrigin, ctx.logger);
207
+ }
208
+ }
209
+
210
+ // Fall-through: unknown route for an unsupported method → 405, otherwise 404.
211
+ if (method !== "GET" && method !== "HEAD" && method !== "POST" && method !== "DELETE") {
212
+ return new Response("Method Not Allowed", { status: 405 });
213
+ }
214
+ return new Response("Not Found", { status: 404 });
215
+ }
216
+
217
+ /**
218
+ * Find the UI whose mount path is a prefix of `pathname`. Longest-prefix
219
+ * wins so a UI at `/app/foo-bar` doesn't shadow `/app/foo` — though the
220
+ * current PATH_PATTERN forbids that exact case, the search shape is
221
+ * forward-defensive.
222
+ */
223
+ function matchUi(pathname: string, uis: ReadonlyArray<RegisteredUi>): RegisteredUi | undefined {
224
+ let best: RegisteredUi | undefined;
225
+ for (const ui of uis) {
226
+ const mount = ui.meta.path;
227
+ if (pathname === mount || pathname.startsWith(`${mount}/`)) {
228
+ if (!best || ui.meta.path.length > best.meta.path.length) best = ui;
229
+ }
230
+ }
231
+ return best;
232
+ }
233
+
234
+ /**
235
+ * File extensions that identify a request as an asset (vs a client-side
236
+ * navigation). Asset requests that miss return 404; navigation requests
237
+ * (no extension, or `.html`) fall through to the SPA shell so the
238
+ * client-side router can handle the path.
239
+ *
240
+ * Why this matters: if the SPA shell is served in response to an asset
241
+ * miss (a missing JS chunk, a missing `manifest.webmanifest`), the
242
+ * browser tries to parse HTML as JS / JSON / a PWA manifest and the
243
+ * resulting error ("Expected JavaScript-or-Wasm module, got
244
+ * 'text/html'", "Manifest: Line: 1, column: 1, Syntax error") masks
245
+ * the real cause — a missing or misnamed asset.
246
+ */
247
+ const STATIC_ASSET_EXTENSIONS = new Set([
248
+ ".js",
249
+ ".mjs",
250
+ ".cjs",
251
+ ".css",
252
+ ".json",
253
+ ".webmanifest",
254
+ ".map",
255
+ ".svg",
256
+ ".png",
257
+ ".jpg",
258
+ ".jpeg",
259
+ ".gif",
260
+ ".webp",
261
+ ".ico",
262
+ ".avif",
263
+ ".woff",
264
+ ".woff2",
265
+ ".ttf",
266
+ ".otf",
267
+ ".mp3",
268
+ ".mp4",
269
+ ".webm",
270
+ ".wasm",
271
+ ".txt",
272
+ ".gz",
273
+ ".br",
274
+ ]);
275
+
276
+ function looksLikeAssetRequest(rel: string): boolean {
277
+ const ext = path.extname(rel).toLowerCase();
278
+ return STATIC_ASSET_EXTENSIONS.has(ext);
279
+ }
280
+
281
+ /**
282
+ * Serve an asset for a registered UI. The flow:
283
+ *
284
+ * 1. If `pathname === meta.path` or `pathname === meta.path + "/"`, serve
285
+ * `dist/index.html` (the root document).
286
+ * 2. Otherwise compute the relative path after `meta.path/`. If the file
287
+ * exists under `dist/`, serve it with content-type + cache headers.
288
+ * 3. If it doesn't exist:
289
+ * - If the request looks like an asset (extension like `.js`, `.css`,
290
+ * `.webmanifest`), return 404 — never serve HTML in response to an
291
+ * asset request, or the browser parses the SPA shell as JS/JSON.
292
+ * - Otherwise (no extension / `.html` — a client-side route), serve
293
+ * `dist/index.html` with no-cache headers and let the bundle's
294
+ * router decide what to render.
295
+ *
296
+ * Path traversal: `path.resolve(distDir, rel)` is checked against `distDir`
297
+ * via a containment test (`resolved.startsWith(distDir + path.sep)`). Any
298
+ * attempt to escape (`../etc/passwd`) gets a 404. Bun's URL parser
299
+ * already collapses `..` segments but the explicit check is the load-bearing
300
+ * line — defense in depth. A traversal attempt with an asset-shaped suffix
301
+ * (e.g. `../etc/passwd.js`) is 404'd too — defense in depth for the
302
+ * "HTML returned for a JS request" foot-gun above.
303
+ */
304
+ function serveUiAsset(
305
+ req: Request,
306
+ ui: RegisteredUi,
307
+ pathname: string,
308
+ hubOrigin: string,
309
+ logger: Pick<Console, "log" | "warn" | "error">,
310
+ ): Response {
311
+ const mount = ui.meta.path;
312
+ const distDir = ui.distDir;
313
+ const indexHtmlPath = path.join(distDir, "index.html");
314
+ // Per-request dev-mode check — flipping `parachute-app dev <name>` takes
315
+ // effect on the very next request without restarting the server.
316
+ const devMode = isDevMode(ui.meta.name);
317
+
318
+ // Root document: /app/foo or /app/foo/
319
+ if (pathname === mount || pathname === `${mount}/`) {
320
+ return serveFileWithHeaders(
321
+ req,
322
+ indexHtmlPath,
323
+ "index.html",
324
+ ui.meta,
325
+ devMode,
326
+ hubOrigin,
327
+ logger,
328
+ );
329
+ }
330
+
331
+ // Strip the mount prefix; rel is the path within dist/.
332
+ const rel = pathname.slice(mount.length + 1); // +1 to drop the leading '/'
333
+ // Defense in depth: reject any explicit traversal segments before resolve.
334
+ if (rel.includes("\0") || rel.split("/").some((seg) => seg === "..")) {
335
+ if (looksLikeAssetRequest(rel)) {
336
+ return new Response("Not Found", { status: 404 });
337
+ }
338
+ // Fall through to SPA fallback — the bundle's router handles unknown routes.
339
+ return serveFileWithHeaders(
340
+ req,
341
+ indexHtmlPath,
342
+ "index.html",
343
+ ui.meta,
344
+ devMode,
345
+ hubOrigin,
346
+ logger,
347
+ );
348
+ }
349
+
350
+ const resolved = path.resolve(distDir, rel);
351
+ // Containment check — the resolved path must live under distDir.
352
+ if (resolved !== distDir && !resolved.startsWith(`${distDir}${path.sep}`)) {
353
+ if (looksLikeAssetRequest(rel)) {
354
+ return new Response("Not Found", { status: 404 });
355
+ }
356
+ return serveFileWithHeaders(
357
+ req,
358
+ indexHtmlPath,
359
+ "index.html",
360
+ ui.meta,
361
+ devMode,
362
+ hubOrigin,
363
+ logger,
364
+ );
365
+ }
366
+
367
+ if (existsSync(resolved)) {
368
+ try {
369
+ const st = statSync(resolved);
370
+ if (st.isFile()) {
371
+ const basename = path.basename(resolved);
372
+ return serveFileWithHeaders(req, resolved, basename, ui.meta, devMode, hubOrigin, logger);
373
+ }
374
+ } catch {
375
+ // Race with file deletion — fall through to SPA-fallback-or-404 below.
376
+ }
377
+ }
378
+
379
+ // Miss. Asset-shaped requests get 404; navigation requests get the SPA
380
+ // shell so the client-side router runs.
381
+ if (looksLikeAssetRequest(rel)) {
382
+ return new Response("Not Found", { status: 404 });
383
+ }
384
+ return serveFileWithHeaders(
385
+ req,
386
+ indexHtmlPath,
387
+ "index.html",
388
+ ui.meta,
389
+ devMode,
390
+ hubOrigin,
391
+ logger,
392
+ );
393
+ }
394
+
395
+ /**
396
+ * Read a file from disk and wrap it with content-type + cache headers.
397
+ * Returns 404 if the file is unreadable — caller passes a path it already
398
+ * stat'd, so the only realistic 404 is a race with deletion mid-request.
399
+ *
400
+ * Body is the literal string `"Not Found"` — we deliberately don't include
401
+ * the underlying error message because ENOENT's `Error.message` leaks the
402
+ * absolute filesystem path (e.g. `ENOENT: no such file or directory, open
403
+ * '/Users/.../app/uis/notes/dist/missing.js'`). That information is fine in
404
+ * logs but not in a client-visible response. We log the path-loss event
405
+ * server-side and return the generic body to the client.
406
+ */
407
+ function serveFileWithHeaders(
408
+ req: Request,
409
+ filePath: string,
410
+ filenameForHeaders: string,
411
+ meta: UiMeta,
412
+ devMode = false,
413
+ hubOrigin?: string,
414
+ logger: Pick<Console, "log" | "warn" | "error"> = console,
415
+ ): Response {
416
+ let body: Buffer;
417
+ try {
418
+ body = readFileSync(filePath);
419
+ } catch (e) {
420
+ // Log the actual path server-side for debugging — never returns to the client.
421
+ logger.warn(`[app] serve: file vanished mid-request: ${filePath} (${(e as Error).message})`);
422
+ return new Response("Not Found", { status: 404 });
423
+ }
424
+
425
+ // When we're serving the index.html document, run two layered HTML
426
+ // post-processors:
427
+ //
428
+ // 1. Runtime tenancy contract — inject `<base href>` + meta tags so the
429
+ // bundle (and `@openparachute/app-client`) can resolve its mount,
430
+ // hub origin, etc. without baking them in at build time. Always-on:
431
+ // `injectTenancyContract` skips itself if the source already
432
+ // declared the tags (idempotent).
433
+ // 2. Dev-mode reload script — when dev mode is enabled for this UI,
434
+ // inject the EventSource shim. Tenancy runs first so dev's
435
+ // `</head>` insertion never collides with our `<head>` insertion.
436
+ //
437
+ // Both passes are string-scan based; neither parses HTML. The contract
438
+ // for both is the same: idempotent + non-destructive on malformed
439
+ // documents (no `<head>` → warn + serve raw).
440
+ let payload: Buffer | string = body;
441
+ if (filenameForHeaders === "index.html") {
442
+ let html = body.toString("utf8");
443
+
444
+ // Pass 1: runtime tenancy contract (always-on when hubOrigin is supplied).
445
+ if (hubOrigin) {
446
+ try {
447
+ const result = injectTenancyContract(html, meta.path, hubOrigin);
448
+ if (result.skipped === "no-head") {
449
+ logger.warn(
450
+ `[app] inject-tenancy: no <head> in index.html for ${meta.name}; serving unmodified`,
451
+ );
452
+ }
453
+ html = result.html;
454
+ } catch (e) {
455
+ // Should never throw — string ops only. Fall back to the raw bytes
456
+ // (string-form, so the dev-mode pass below still runs).
457
+ logger.warn(`[app] inject-tenancy: failed for ${meta.name}: ${(e as Error).message}`);
458
+ }
459
+ }
460
+
461
+ // Pass 2: dev-mode reload script (only when dev mode is on).
462
+ if (devMode) {
463
+ const endpoint = `${meta.path}/_dev/reload`;
464
+ try {
465
+ const { html: injected, fallback } = injectDevReloadScript(html, endpoint);
466
+ if (fallback) {
467
+ logger.warn(
468
+ `[app] dev: injected reload script via ${fallback} fallback for ${meta.name} (no </head> in index.html)`,
469
+ );
470
+ }
471
+ html = injected;
472
+ } catch (e) {
473
+ logger.warn(`[app] dev: inject failed for ${meta.name}: ${(e as Error).message}`);
474
+ }
475
+ }
476
+
477
+ payload = html;
478
+ }
479
+
480
+ const bodyLen = typeof payload === "string" ? Buffer.byteLength(payload) : payload.length;
481
+ const headers: Record<string, string> = {
482
+ "content-type": contentTypeFor(filenameForHeaders),
483
+ ...cacheHeadersFor(filenameForHeaders, meta, devMode),
484
+ };
485
+ if (req.method === "HEAD") {
486
+ // HEAD: include Content-Length but no body.
487
+ headers["content-length"] = String(bodyLen);
488
+ return new Response(null, { status: 200, headers });
489
+ }
490
+ // Bun's Response accepts string / Buffer / Uint8Array / ArrayBuffer
491
+ // interchangeably; pass whichever shape we have.
492
+ return new Response(payload, { status: 200, headers });
493
+ }
494
+
495
+ /**
496
+ * Minimal content-type table — the common SPA bundle assets. Falls through
497
+ * to application/octet-stream for anything not listed. Operators can override
498
+ * via meta.json `cache_headers` extension in Phase 2 (designed-but-not-shipped);
499
+ * for MVP this table covers Vite's default output.
500
+ */
501
+ function contentTypeFor(filename: string): string {
502
+ const ext = path.extname(filename).toLowerCase();
503
+ switch (ext) {
504
+ case ".html":
505
+ return "text/html; charset=utf-8";
506
+ case ".js":
507
+ case ".mjs":
508
+ return "application/javascript; charset=utf-8";
509
+ case ".css":
510
+ return "text/css; charset=utf-8";
511
+ case ".json":
512
+ return "application/json; charset=utf-8";
513
+ case ".svg":
514
+ return "image/svg+xml";
515
+ case ".png":
516
+ return "image/png";
517
+ case ".jpg":
518
+ case ".jpeg":
519
+ return "image/jpeg";
520
+ case ".gif":
521
+ return "image/gif";
522
+ case ".webp":
523
+ return "image/webp";
524
+ case ".ico":
525
+ return "image/x-icon";
526
+ case ".woff":
527
+ return "font/woff";
528
+ case ".woff2":
529
+ return "font/woff2";
530
+ case ".ttf":
531
+ return "font/ttf";
532
+ case ".otf":
533
+ return "font/otf";
534
+ case ".map":
535
+ return "application/json; charset=utf-8";
536
+ case ".txt":
537
+ return "text/plain; charset=utf-8";
538
+ case ".wasm":
539
+ return "application/wasm";
540
+ case ".webmanifest":
541
+ return "application/manifest+json";
542
+ default:
543
+ return "application/octet-stream";
544
+ }
545
+ }
546
+
547
+ function serveStaticFile(filePath: string, contentType: string): Response {
548
+ try {
549
+ const body = readFileSync(filePath, "utf8");
550
+ return new Response(body, { status: 200, headers: { "content-type": contentType } });
551
+ } catch (e) {
552
+ // Same posture as serveFileWithHeaders: log the path server-side but
553
+ // return a generic body so ENOENT's absolute filesystem path doesn't
554
+ // leak to the client.
555
+ console.warn(`[app] serve-static: ${filePath} unreadable (${(e as Error).message})`);
556
+ return new Response(JSON.stringify({ error: "not_found" }), {
557
+ status: 404,
558
+ headers: { "content-type": "application/json" },
559
+ });
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Default location of `.parachute/` relative to the installed package.
565
+ * The files we serve (info, config/schema) are checked into the npm
566
+ * package via `package.json#files`.
567
+ */
568
+ function defaultParachuteDir(): string {
569
+ return path.resolve(import.meta.dir, "..", ".parachute");
570
+ }
571
+
572
+ /**
573
+ * Default location of the built admin SPA. Shipped via `package.json#files`
574
+ * (`dist/admin/**`) so `bunx @openparachute/app` resolves it.
575
+ */
576
+ function defaultAdminDir(): string {
577
+ return path.resolve(import.meta.dir, "..", "dist", "admin");
578
+ }
579
+
580
+ /**
581
+ * Serve a file from the admin SPA's dist directory. Same cache shape Phase 1.1
582
+ * used for hosted UIs: `index.html` no-cache; hashed assets immutable;
583
+ * everything else 1-hour. SPA-fallback: anything that doesn't resolve serves
584
+ * index.html (react-router runs).
585
+ *
586
+ * If the admin SPA bundle isn't present (e.g. tests, fresh dev checkout
587
+ * before `bun run build`), we return a friendly placeholder so operators
588
+ * see "admin SPA not built" instead of a bare 404. Production npm installs
589
+ * ship the bundle so this branch is the dev affordance.
590
+ *
591
+ * Note: this path intentionally does NOT inject the runtime tenancy
592
+ * contract (`<base href>` + `<meta name="parachute-mount">` etc.). The
593
+ * admin SPA is app's own surface — it's not a hosted tenant. Tenancy
594
+ * injection runs only in `serveUiAsset` for the `/app/<name>/*` mounts.
595
+ */
596
+ function serveAdminAsset(req: Request, adminDir: string, pathname: string): Response {
597
+ const indexHtmlPath = path.join(adminDir, "index.html");
598
+
599
+ if (!existsSync(indexHtmlPath)) {
600
+ // Dev / pre-build branch: return a static placeholder so the operator
601
+ // sees the daemon is healthy but the bundle isn't shipped yet.
602
+ return new Response(adminSpaPlaceholder(), {
603
+ status: 200,
604
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" },
605
+ });
606
+ }
607
+
608
+ // Strip the `/app/admin/` prefix, falling back to index.html on root.
609
+ if (pathname === "/app/admin" || pathname === "/app/admin/") {
610
+ return serveAdminFile(req, indexHtmlPath, "index.html");
611
+ }
612
+ const rel = pathname.slice("/app/admin/".length);
613
+ if (rel.includes("\0") || rel.split("/").some((seg) => seg === "..")) {
614
+ return serveAdminFile(req, indexHtmlPath, "index.html");
615
+ }
616
+ const resolved = path.resolve(adminDir, rel);
617
+ if (resolved !== adminDir && !resolved.startsWith(`${adminDir}${path.sep}`)) {
618
+ return serveAdminFile(req, indexHtmlPath, "index.html");
619
+ }
620
+ if (existsSync(resolved)) {
621
+ try {
622
+ const st = statSync(resolved);
623
+ if (st.isFile()) {
624
+ return serveAdminFile(req, resolved, path.basename(resolved));
625
+ }
626
+ } catch {
627
+ // race with deletion — SPA fallback
628
+ }
629
+ }
630
+ return serveAdminFile(req, indexHtmlPath, "index.html");
631
+ }
632
+
633
+ function serveAdminFile(req: Request, filePath: string, filenameForHeaders: string): Response {
634
+ let body: Buffer;
635
+ try {
636
+ body = readFileSync(filePath);
637
+ } catch (e) {
638
+ console.warn(`[app] admin serve: ${filePath} unreadable (${(e as Error).message})`);
639
+ return new Response("Not Found", { status: 404 });
640
+ }
641
+ const headers: Record<string, string> = {
642
+ "content-type": contentTypeFor(filenameForHeaders),
643
+ // Mirror the hosted-UI policy: index.html → no-cache; everything else
644
+ // → 1 year + immutable when content-hashed, 1h otherwise. The admin
645
+ // SPA doesn't have meta.json so we use a `pwa: false`-equivalent shim.
646
+ ...cacheHeadersFor(filenameForHeaders, {
647
+ name: "admin",
648
+ displayName: "Admin",
649
+ path: "/app/admin",
650
+ scopes_required: [],
651
+ pwa: false,
652
+ public: false,
653
+ } as UiMeta),
654
+ };
655
+ if (req.method === "HEAD") {
656
+ headers["content-length"] = String(body.length);
657
+ return new Response(null, { status: 200, headers });
658
+ }
659
+ return new Response(body, { status: 200, headers });
660
+ }
661
+
662
+ /**
663
+ * Placeholder served when the admin SPA bundle isn't built. The dev
664
+ * affordance — production installs always ship `dist/admin/`. Keeps the
665
+ * /app/admin/ route from 404'ing in a fresh checkout.
666
+ */
667
+ function adminSpaPlaceholder(): string {
668
+ return `<!doctype html><html><head><meta charset="utf-8"><title>parachute-app admin</title></head>
669
+ <body style="font-family:system-ui;margin:2rem;color:#222;max-width:42rem;">
670
+ <h1>parachute-app admin</h1>
671
+ <p>The admin SPA bundle isn't present. Run <code>bun run build</code> from this checkout to build it.</p>
672
+ <p>API endpoints under <code>/app/list</code>, <code>/app/add</code>, etc. are live; CLI <code>parachute-app list</code> works.</p>
673
+ </body></html>`;
674
+ }
675
+
676
+ /**
677
+ * Force-load the app config — used by callers that want defaults applied
678
+ * without going through the full daemon boot path (e.g. CI healthchecks).
679
+ */
680
+ export function loadOrDefaultConfig(opts: Parameters<typeof loadConfig>[0] = {}): AppConfig {
681
+ return loadConfig(opts);
682
+ }