@openparachute/hub 0.7.7-rc.12 → 0.7.7-rc.13

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.
@@ -133,6 +133,25 @@ export type HubSettingKey =
133
133
  // not-yet-set-up hub still lands on setup, not a surface that can't work
134
134
  // yet.
135
135
  | "root_redirect"
136
+ // hub: how the hub answers its origin root. `"redirect"` (default / absent)
137
+ // is the historical behavior — the bare `/` 302s to `root_redirect` (a safe
138
+ // same-origin path, default `/admin`). `"serve-app"` makes the hub SERVE the
139
+ // installed Parachute app's built bundle AT the origin root (index.html at
140
+ // `/`, its assets from the same dist, SPA-fallback for HTML deep links) — the
141
+ // same front-door experience as the hosted door, no redirect hop. The app's
142
+ // build is root-based (absolute `/assets`, PWA scope `/`, root OAuth redirect
143
+ // URIs) so serving its dist verbatim at `/` is asset-correct with no rebuild.
144
+ //
145
+ // Precedence on each request (resolveRootMode): this row, then
146
+ // `PARACHUTE_HUB_ROOT_MODE` env, then the `"redirect"` default. DB-first (like
147
+ // `root_redirect`) so an operator can flip the front door from the admin SPA /
148
+ // CLI without a redeploy. When `serve-app` is set but the app isn't installed
149
+ // (its dist can't be resolved), the hub FALLS BACK to redirect behavior (using
150
+ // `root_redirect` → env → `/admin`) with a one-time log line — so an operator
151
+ // who set the mode before installing the app never sees a broken root. The
152
+ // fresh-hub wizard funnel + pre-admin 503 lockout still run BEFORE this, so a
153
+ // not-yet-set-up hub lands on setup, never a half-working app shell.
154
+ | "root_mode"
136
155
  // hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
137
156
  // `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
138
157
  // can advertise `signup_path` without being able to reconstruct it from the
@@ -617,3 +636,121 @@ export function resolveRootRedirect(
617
636
  ): string {
618
637
  return resolveRootRedirectDetailed(db, opts).value;
619
638
  }
639
+
640
+ // --- domain helpers: root mode (redirect vs serve the app at `/`) ---------
641
+
642
+ /**
643
+ * How the hub answers its origin root.
644
+ *
645
+ * - `"redirect"` — historical behavior: the bare `/` 302s to the resolved
646
+ * `root_redirect` (default `/admin`). Every unclaimed path keeps the
647
+ * branded 404.
648
+ * - `"serve-app"` — the hub serves the installed Parachute app's built
649
+ * bundle AT the origin root (index.html at `/`, its assets from the same
650
+ * dist, SPA-fallback for HTML deep links), matching the hosted door's
651
+ * front-door experience. Falls back to `redirect` when the app isn't
652
+ * installed (dist unresolvable).
653
+ */
654
+ export type RootMode = "redirect" | "serve-app";
655
+
656
+ /** Exported so the API handler + the CLI can share validation. */
657
+ export const ROOT_MODES: readonly RootMode[] = ["redirect", "serve-app"];
658
+
659
+ export function isRootMode(s: unknown): s is RootMode {
660
+ return typeof s === "string" && ROOT_MODES.includes(s as RootMode);
661
+ }
662
+
663
+ /** Env override for the root mode. Below the DB row, above the default. */
664
+ export const PARACHUTE_HUB_ROOT_MODE_ENV = "PARACHUTE_HUB_ROOT_MODE";
665
+
666
+ /** Fallback when neither DB row nor env is set — historical redirect behavior. */
667
+ export const DEFAULT_ROOT_MODE: RootMode = "redirect";
668
+
669
+ /**
670
+ * Read the raw stored root mode from hub_settings (or `null` when absent),
671
+ * WITHOUT applying env / default precedence — mirrors `getRootRedirect`. The
672
+ * admin GET surfaces this raw value so the operator sees exactly what's stored.
673
+ */
674
+ export function getRootMode(db: Database): RootMode | null {
675
+ const raw = getSetting(db, "root_mode");
676
+ if (raw === undefined) return null;
677
+ return isRootMode(raw) ? raw : null;
678
+ }
679
+
680
+ /**
681
+ * Write or clear the root mode. Passing `null` (or the `"redirect"` default)
682
+ * deletes the row, reverting to env / default precedence — the absent-row
683
+ * state IS the redirect default, so leaving an explicit `"redirect"` in the
684
+ * row would be a footgun if a future default flip ever made absence mean
685
+ * something else (mirrors `setRootRedirect` / `setHubOrigin` semantics). The
686
+ * caller must have validated via `isRootMode`; the typed signature enforces it
687
+ * at TypeScript-clean call sites.
688
+ */
689
+ export function setRootMode(db: Database, mode: RootMode | null): void {
690
+ if (mode === null || mode === DEFAULT_ROOT_MODE) {
691
+ deleteSetting(db, "root_mode");
692
+ return;
693
+ }
694
+ setSetting(db, "root_mode", mode);
695
+ }
696
+
697
+ /** Which precedence layer the resolved mode came from. */
698
+ export type RootModeSource = "db" | "env" | "default";
699
+
700
+ export interface ResolvedRootMode {
701
+ /** The mode the origin root should apply. */
702
+ value: RootMode;
703
+ /** Which layer it came from (for admin-UI attribution + install set-if-unset). */
704
+ source: RootModeSource;
705
+ }
706
+
707
+ /**
708
+ * Resolve the root mode with source attribution.
709
+ *
710
+ * Precedence: hub_settings.root_mode → `PARACHUTE_HUB_ROOT_MODE` env →
711
+ * `"redirect"` default. An invalid value at any layer is warned + skipped so a
712
+ * hand-edited row / typo'd env can never wedge the root (worst case falls to
713
+ * the redirect default — today's behavior).
714
+ *
715
+ * `db` may be `null` (hub-server running without state) — the DB layer is then
716
+ * skipped and resolution starts from env. The `env` / `warn` knobs are test
717
+ * seams (production uses `process.env` + `console.warn`).
718
+ */
719
+ export function resolveRootModeDetailed(
720
+ db: Database | null,
721
+ opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
722
+ ): ResolvedRootMode {
723
+ const env = opts.env ?? process.env;
724
+ const warn = opts.warn ?? ((msg: string) => console.warn(msg));
725
+
726
+ // 1. DB row (operator-set via the admin PUT / `parachute hub set-root-mode`).
727
+ if (db) {
728
+ const fromDb = getSetting(db, "root_mode");
729
+ if (fromDb !== undefined) {
730
+ if (isRootMode(fromDb)) return { value: fromDb, source: "db" };
731
+ warn(
732
+ `[hub-settings] root_mode="${fromDb}" in hub_settings is not a valid mode (expected one of ${ROOT_MODES.join(", ")}) — ignoring (falling through to env/default).`,
733
+ );
734
+ }
735
+ }
736
+
737
+ // 2. Env override.
738
+ const fromEnv = env[PARACHUTE_HUB_ROOT_MODE_ENV];
739
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
740
+ if (isRootMode(fromEnv)) return { value: fromEnv, source: "env" };
741
+ warn(
742
+ `[hub-settings] ${PARACHUTE_HUB_ROOT_MODE_ENV}="${fromEnv}" is not a valid mode — falling back to "${DEFAULT_ROOT_MODE}".`,
743
+ );
744
+ }
745
+
746
+ // 3. Default — historical redirect behavior.
747
+ return { value: DEFAULT_ROOT_MODE, source: "default" };
748
+ }
749
+
750
+ /** Convenience: just the resolved mode (see `resolveRootModeDetailed`). */
751
+ export function resolveRootMode(
752
+ db: Database | null,
753
+ opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
754
+ ): RootMode {
755
+ return resolveRootModeDetailed(db, opts).value;
756
+ }
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Serving the Parachute app AT the hub's origin root (`root_mode = serve-app`).
5
+ *
6
+ * When an operator flips the root mode to `serve-app` (or a fresh hub installs
7
+ * the app during setup), the hub answers its own origin root with the installed
8
+ * `@openparachute/parachute-app` bundle instead of 302-ing to `/admin`. This is
9
+ * the self-hosted mirror of the hosted door: hit your box's URL, land in the
10
+ * app — no redirect hop.
11
+ *
12
+ * Why this is asset-correct with no rebuild: the app's Vite build is ROOT-based
13
+ * (absolute `/assets/*`, PWA scope `/`, OAuth redirect URIs at the origin root).
14
+ * The `/app` service mount serves the SAME dist through the `notes-serve.ts`
15
+ * shim with a `/app` prefix-strip; here we serve that identical dist verbatim at
16
+ * `/`, which is exactly the origin the build assumes. So `serveAppAtRoot` and
17
+ * the `/app` mount hand back byte-identical bundle files — the only difference
18
+ * is the URL prefix.
19
+ *
20
+ * Precedence is the caller's job (hub-server.ts): the fresh-hub setup wizard
21
+ * funnel and the pre-admin 503 lockout run BEFORE the root `/` handler, and
22
+ * every hub-owned route (/admin, /oauth, /.well-known, /vault, /git, /api,
23
+ * service mounts) dispatches BEFORE the SPA-fallback tail. This module only
24
+ * decides "given an already-unclaimed GET, does the app dist answer it?".
25
+ *
26
+ * NO chrome injection ever rides a root-served response — the app owns its whole
27
+ * page (same posture as the public/surface chrome opt-out). We build the
28
+ * responses here directly rather than routing through `decorateWithChrome`.
29
+ */
30
+
31
+ import { existsSync, statSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { resolveNotesDistFrom } from "./notes-serve.ts";
34
+
35
+ /** The npm package whose built `dist/` is the app front door. */
36
+ export const APP_PACKAGE = "@openparachute/parachute-app";
37
+
38
+ /**
39
+ * Namespaces that keep the hub's branded 404 even in serve-app mode. These are
40
+ * hub / protocol surfaces, never app client-side routes, so an SPA shell there
41
+ * would be wrong — and would mask a genuine 404 for an API/OAuth/well-known
42
+ * typo. (Real dist files never live under these prefixes anyway; this makes the
43
+ * "don't shell it" decision explicit rather than incidental.)
44
+ */
45
+ export const ROOT_SERVE_RESERVED_PREFIXES: readonly string[] = [
46
+ "/api/",
47
+ "/oauth/",
48
+ "/.well-known/",
49
+ ];
50
+
51
+ /**
52
+ * Build a resolver for the installed app's `dist/` directory.
53
+ *
54
+ * Resolution goes through the SAME `resolveNotesDistFrom` machinery the `/app`
55
+ * mount's static-serve shim uses (so root-serve and `/app` resolve to one dist).
56
+ * A SUCCESSFUL resolution is memoized for the process lifetime — a resolved dist
57
+ * path doesn't move while the hub runs, and re-walking `Bun.resolveSync` on every
58
+ * asset request would be needless work. A FAILURE is NOT cached: an operator who
59
+ * set `serve-app` before installing the app, then runs `parachute install app`,
60
+ * is picked up on the next request without a hub restart (dynamic recovery).
61
+ *
62
+ * `resolve` is the test seam (defaults to the real package resolution). Return
63
+ * value is the dist dir, or `null` when the app can't be resolved (not installed
64
+ * / ships no `dist/`).
65
+ */
66
+ export function makeAppDistResolver(
67
+ resolve: () => string = () => resolveNotesDistFrom({ pkg: APP_PACKAGE }),
68
+ ): () => string | null {
69
+ let cached: string | null = null;
70
+ return () => {
71
+ if (cached !== null) return cached;
72
+ try {
73
+ cached = resolve();
74
+ return cached;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ }
80
+
81
+ /** MIME overrides Bun.file doesn't infer (mirrors notes-serve.ts). */
82
+ function mimeFor(path: string): string | undefined {
83
+ // Without this the PWA install prompt sees text/html for the manifest + bails.
84
+ if (path.endsWith(".webmanifest")) return "application/manifest+json";
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Answer an already-unclaimed request from the app's root-based `dist/`, or
90
+ * return `null` to tell the caller to fall through to its normal handling
91
+ * (the `/` 302 fallback, or the branded 404 tail).
92
+ *
93
+ * Contract:
94
+ * - non-GET → null (fall through)
95
+ * - a reserved hub/protocol prefix → null (keep the branded 404)
96
+ * - GET + an existing file under dist → that file (asset-correct MIME)
97
+ * - GET `/` or a trailing-slash "dir" request → dist/index.html (SPA shell)
98
+ * - GET + no file + Accept: text/html → dist/index.html (SPA deep link)
99
+ * - GET + no file + non-HTML (API probe, etc.) → null (branded 404)
100
+ *
101
+ * `dist` is a resolved directory (from `makeAppDistResolver`); if its
102
+ * `index.html` has since vanished (app uninstalled after a successful resolve)
103
+ * we return `null` rather than serving a broken shell.
104
+ */
105
+ export function serveAppAtRoot(dist: string, req: Request, pathname: string): Response | null {
106
+ // Only GET is served (HEAD/POST/etc. keep the caller's default — a non-GET to
107
+ // an unclaimed path stays a 404, and a non-GET `/` keeps its 302).
108
+ if (req.method !== "GET") return null;
109
+
110
+ // Hub/protocol namespaces are never the app's — leave their 404 branded.
111
+ for (const prefix of ROOT_SERVE_RESERVED_PREFIXES) {
112
+ if (pathname.startsWith(prefix)) return null;
113
+ }
114
+
115
+ const indexHtml = join(dist, "index.html");
116
+ // The resolved bundle lost its shell (uninstalled mid-flight) → fall through.
117
+ if (!existsSync(indexHtml)) return null;
118
+
119
+ const spaShell = () =>
120
+ new Response(Bun.file(indexHtml), {
121
+ headers: { "content-type": "text/html; charset=utf-8" },
122
+ });
123
+
124
+ // Bare root or a trailing-slash "directory" request → the SPA shell.
125
+ if (pathname === "/" || pathname.endsWith("/")) return spaShell();
126
+
127
+ // An existing dist file: /assets/*, /icon.svg, /manifest.webmanifest, /sw.js …
128
+ // Malformed percent-encoding (e.g. `/foo%`, `/assets/%ZZ`) makes
129
+ // `decodeURIComponent` throw a URIError. Catch it and fall through to the
130
+ // branded 404 (redirect-mode parity) — WITHOUT the guard the throw escapes to
131
+ // the dispatch outer catch, which classifies a non-DB error as "other" and
132
+ // re-throws → a bare 500. This path is reachable pre-auth on a public expose,
133
+ // so a garbage-encoded asset URL must 404, never 500.
134
+ let decodedPath: string;
135
+ try {
136
+ decodedPath = decodeURIComponent(pathname);
137
+ } catch {
138
+ return null;
139
+ }
140
+ // Path-traversal guard: the joined path must stay strictly under dist/.
141
+ const filePath = join(dist, decodedPath);
142
+ if (filePath.startsWith(`${dist}/`) && existsSync(filePath) && statSync(filePath).isFile()) {
143
+ const mime = mimeFor(filePath);
144
+ return new Response(
145
+ Bun.file(filePath),
146
+ mime ? { headers: { "content-type": mime } } : undefined,
147
+ );
148
+ }
149
+
150
+ // No matching file. Serve the SPA shell ONLY for HTML navigations (a deep link
151
+ // like /some-note the app routes client-side). A browser navigation sends
152
+ // `Accept: text/html`; a non-HTML unclaimed request (API probe, a missing
153
+ // asset fetched with `Accept: */*`) falls through to the branded 404.
154
+ const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
155
+ return wantsHtml ? spaShell() : null;
156
+ }
@@ -382,8 +382,9 @@ const APP_FALLBACK: FirstPartyFallback = {
382
382
  postInstallFooter: () => [
383
383
  "",
384
384
  "Open your Parachute at <origin>/app — it signs in with your hub account.",
385
- "The hub's front page (`/`) now opens the app too, unless you've already",
386
- "set a custom root redirect (`parachute hub set-root-redirect` or the admin SPA).",
385
+ "The hub's front page (`/`) now serves the app directly too, unless you've",
386
+ "already customized the root (`parachute hub set-root-mode` / `set-root-redirect`",
387
+ "or the admin SPA).",
387
388
  ],
388
389
  },
389
390
  };