@openparachute/hub 0.7.6 → 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.
Files changed (83) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-command.test.ts +70 -2
  25. package/src/__tests__/hub-server.test.ts +322 -1
  26. package/src/__tests__/hub-settings.test.ts +110 -6
  27. package/src/__tests__/hub.test.ts +29 -0
  28. package/src/__tests__/install.test.ts +359 -5
  29. package/src/__tests__/migrate.test.ts +3 -1
  30. package/src/__tests__/notes-serve.test.ts +216 -0
  31. package/src/__tests__/oauth-handlers.test.ts +19 -8
  32. package/src/__tests__/operator-token.test.ts +1 -2
  33. package/src/__tests__/port-assign.test.ts +37 -17
  34. package/src/__tests__/root-serve.test.ts +139 -0
  35. package/src/__tests__/scope-explanations.test.ts +22 -2
  36. package/src/__tests__/serve-boot.test.ts +25 -36
  37. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  38. package/src/__tests__/services-manifest.test.ts +372 -132
  39. package/src/__tests__/sessions.test.ts +75 -28
  40. package/src/__tests__/setup-wizard.test.ts +7 -10
  41. package/src/__tests__/setup.test.ts +13 -14
  42. package/src/__tests__/status.test.ts +0 -5
  43. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  44. package/src/account-api.ts +677 -0
  45. package/src/account-session.ts +132 -0
  46. package/src/account-token.ts +200 -0
  47. package/src/admin-connections.ts +4 -2
  48. package/src/admin-lock.ts +1 -2
  49. package/src/admin-module-token.ts +13 -8
  50. package/src/admin-surfaces.ts +2 -1
  51. package/src/api-hub-upgrade.ts +2 -1
  52. package/src/api-invites.ts +19 -0
  53. package/src/api-mint-token.ts +2 -1
  54. package/src/api-modules-ops.ts +2 -1
  55. package/src/api-modules.ts +10 -8
  56. package/src/api-revoke-token.ts +2 -1
  57. package/src/api-settings-hub-origin.ts +2 -1
  58. package/src/api-settings-root-redirect.ts +97 -20
  59. package/src/api-tokens.ts +2 -1
  60. package/src/audience-gate.ts +2 -1
  61. package/src/chrome-strip.ts +16 -4
  62. package/src/commands/hub.ts +104 -0
  63. package/src/commands/install.ts +97 -8
  64. package/src/commands/migrate.ts +5 -1
  65. package/src/commands/setup.ts +9 -6
  66. package/src/help.ts +6 -6
  67. package/src/hub-server.ts +318 -55
  68. package/src/hub-settings.ts +162 -1
  69. package/src/hub.ts +64 -31
  70. package/src/invites.ts +42 -0
  71. package/src/module-ops-client.ts +2 -1
  72. package/src/notes-serve.ts +73 -31
  73. package/src/oauth-handlers.ts +1 -11
  74. package/src/operator-token.ts +0 -1
  75. package/src/origin-check.ts +2 -2
  76. package/src/root-serve.ts +156 -0
  77. package/src/scope-explanations.ts +35 -5
  78. package/src/service-spec.ts +129 -74
  79. package/src/services-manifest.ts +112 -52
  80. package/src/sessions.ts +66 -30
  81. package/src/surface-notes-alias.ts +126 -0
  82. package/src/__tests__/admin-agent-token.test.ts +0 -173
  83. package/src/admin-agent-token.ts +0 -147
@@ -92,6 +92,16 @@ export type HubSettingKey =
92
92
  // the deprecation window. Stored as the literal string "true" /
93
93
  // "false"; any other value parses as "redirect on" (the migration
94
94
  // default — operators must opt out, not opt in).
95
+ //
96
+ // Mount vocabulary post-W2-12: `/surface/notes` is the LEGACY notes-ui
97
+ // mount (what existing installs serve — this redirect's target);
98
+ // `/surface/parachute` is the app's surface mount under its renamed
99
+ // identity. A separate CONDITIONAL alias (surface-notes-alias.ts, no
100
+ // hub_settings row — it keys off the manifest's uis{} mounts) bridges
101
+ // `/surface/notes/*` → `/surface/parachute/*` only on installs where
102
+ // the legacy mount is gone and the new one exists, so the two
103
+ // redirects compose for post-rename installs: /notes/x →
104
+ // /surface/notes/x → /surface/parachute/x.
95
105
  | "notes_redirect_disabled"
96
106
  // Admin-UI screen-lock PIN (hub admin-lock feature). The argon2id hash of
97
107
  // the operator's lock PIN. Absent row = lock feature OFF (today's behavior
@@ -122,7 +132,40 @@ export type HubSettingKey =
122
132
  // wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
123
133
  // not-yet-set-up hub still lands on setup, not a surface that can't work
124
134
  // yet.
125
- | "root_redirect";
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"
155
+ // hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
156
+ // `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
157
+ // can advertise `signup_path` without being able to reconstruct it from the
158
+ // `invites` table (which stores only `sha256(token)`, see invites.ts's
159
+ // module doc). Written by `api-invites.ts`'s `handleCreateInvite` right
160
+ // after a successful multi-use `issueInvite`; read (and lazily cleared once
161
+ // stale) by `invites.ts`'s `activePublicSignupPath`.
162
+ //
163
+ // This does NOT weaken the hash-only posture of single-use friend invites
164
+ // (`max_uses === 1`, the default) — those NEVER write this row. A
165
+ // multi-use link is, by construction, the operator's deliberately PUBLIC
166
+ // signup page (minted to broadcast), so persisting its raw token is no
167
+ // more sensitive than the link itself already being handed out widely.
168
+ | "public_signup_token";
126
169
 
127
170
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
128
171
 
@@ -593,3 +636,121 @@ export function resolveRootRedirect(
593
636
  ): string {
594
637
  return resolveRootRedirectDetailed(db, opts).value;
595
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
+ }
package/src/hub.ts CHANGED
@@ -541,43 +541,76 @@ const DISCOVERY_SCRIPT = `<script>
541
541
  /**
542
542
  * Render the "Get started" section (hub#342) above the Services grid.
543
543
  *
544
- * One hardcoded target, conditional on its prerequisite being installed:
545
- * - "Open Notes" → /surface/notes/ (requires parachute-surface installed;
546
- * Surface auto-bootstraps Notes-as-UI per parachute-surface §17, so the
547
- * mere presence of Surface means /surface/notes/ is live)
544
+ * Driven by the uis[] sub-unit arrays the well-known doc already carries
545
+ * (W2-12 — retires the hardcoded "Open Notes" → /surface/notes/ tile):
546
+ * one tile per ACTIVE uis[] entry across every services[] row, each
547
+ * carrying its own displayName + tagline + mount path. Under the app's
548
+ * notes→parachute surface-identity rename this stays correct whichever
549
+ * mount an install serves — an existing notes-ui install renders its
550
+ * "notes" entry at /surface/notes, a post-rename install renders its
551
+ * "parachute" entry at /surface/parachute, no hub-side name knowledge
552
+ * required. Entries named "parachute" / "notes" sort first (they're the
553
+ * end-user "browse my content" CTA — the audience that needs the
554
+ * strongest above-the-fold prompt); the rest follow alphabetically.
548
555
  *
549
- * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
550
- * once vault declared uiUrl in its module.json (per patterns#96). With
551
- * the multi-instance prefix lifted into buildWellKnown, every vault
552
- * instance now renders its own tile in the Services grid below — one
553
- * per instance, with the actual instance name in the discovery doc
554
- * rather than a hub-side guess at "first vault."
555
- *
556
- * Notes stays here because Notes is the end-user CTA (the audience
557
- * that needs the strongest above-the-fold prompt); the Services grid
558
- * positions it equally with admin tiles, which underweights the
559
- * "browse my content" path for a returning operator.
556
+ * Legacy fallback: a surface-host row that predates uis{}
557
+ * self-registration carries no uis[] even while it serves notes-ui at
558
+ * /surface/notes (the hub proxies /surface/* blind mounts are
559
+ * surface-host's on-disk state, not the manifest's). When no uis[]
560
+ * entries exist anywhere but parachute-surface IS installed, keep
561
+ * rendering the pre-W2-12 "Open Notes" tile so those installs' discovery
562
+ * page is untouched. The fallback dies naturally the first time the
563
+ * surface-host re-registers with uis{}.
560
564
  *
561
- * If the prerequisite is not met (fresh install pre-wizard, no app)
562
- * the section stays hidden. The hardcoded shape mirrors the wizard's
563
- * own done-screen "Start using your vault" tile — same architectural
564
- * shape (single obvious entry point) at a different surface.
565
+ * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
566
+ * once vault declared uiUrl in its module.json (per patterns#96) — vault
567
+ * instances render in the Services grid below.
565
568
  *
566
- * Not driven by /api/modules because discovery is unauth the
567
- * services list from /.well-known/parachute.json is sufficient
568
- * (it carries the same install-detection signal we need, no
569
- * Bearer required).
569
+ * If nothing qualifies (fresh install pre-wizard, no app) the section
570
+ * stays hidden. Not driven by /api/modules because discovery is unauth —
571
+ * the services list from /.well-known/parachute.json is sufficient (it
572
+ * carries the uis[] fan-out, no Bearer required).
570
573
  */
571
574
  function renderGetStarted(services) {
572
575
  if (!getStartedGrid || !getStartedSection) return;
573
- const tiles = [];
574
- const hasSurface = services.some((s) => s && s.name === 'parachute-surface');
575
- if (hasSurface) {
576
- tiles.push({
577
- title: 'Open Notes',
578
- desc: 'Browse + capture in the Notes app reads from your vault.',
579
- href: '/surface/notes/',
580
- });
576
+ const uiEntries = [];
577
+ const seenPaths = new Set();
578
+ for (const svc of services) {
579
+ if (!svc || !Array.isArray(svc.uis)) continue;
580
+ for (const ui of svc.uis) {
581
+ if (!ui || typeof ui.path !== 'string' || ui.path === '') continue;
582
+ // Absent status means active (services-manifest.ts UiSubUnitStatus).
583
+ if (ui.status && ui.status !== 'active') continue;
584
+ // Multi-path parent rows repeat the same uis[] on every path —
585
+ // de-duplicate by mount (well-known.ts's documented consumer duty).
586
+ if (seenPaths.has(ui.path)) continue;
587
+ seenPaths.add(ui.path);
588
+ uiEntries.push(ui);
589
+ }
590
+ }
591
+ const rank = (ui) => (ui.name === 'parachute' ? 0 : ui.name === 'notes' ? 1 : 2);
592
+ uiEntries.sort(
593
+ (a, b) =>
594
+ rank(a) - rank(b) ||
595
+ (a.displayName || a.name || '').localeCompare(b.displayName || b.name || ''),
596
+ );
597
+ const tiles = uiEntries.map((ui) => ({
598
+ title: ui.displayName || ui.name || ui.path,
599
+ desc: ui.tagline || '',
600
+ // Trailing slash keeps relative asset URLs resolving under the mount
601
+ // (same reason the retired hardcode pointed at /surface/notes/).
602
+ href: ui.path.endsWith('/') ? ui.path : ui.path + '/',
603
+ }));
604
+ if (tiles.length === 0) {
605
+ // Legacy fallback (see docstring): pre-uis surface-host installs.
606
+ const hasSurface = services.some((s) => s && s.name === 'parachute-surface');
607
+ if (hasSurface) {
608
+ tiles.push({
609
+ title: 'Open Notes',
610
+ desc: 'Browse + capture in the Notes app — reads from your vault.',
611
+ href: '/surface/notes/',
612
+ });
613
+ }
581
614
  }
582
615
  if (tiles.length === 0) {
583
616
  getStartedSection.setAttribute('hidden', '');
package/src/invites.ts CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
  import type { Database } from "bun:sqlite";
59
59
  import { createHash, randomBytes } from "node:crypto";
60
+ import { deleteSetting, getSetting } from "./hub-settings.ts";
60
61
 
61
62
  /** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
62
63
  export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -502,3 +503,44 @@ export function revokeInvitesForVault(
502
503
  .run(now.toISOString(), vaultName);
503
504
  return Number(res.changes);
504
505
  }
506
+
507
+ /**
508
+ * Q2 (hub-parity P2) — the account descriptor's conditional `signup_path`.
509
+ * The hub cannot derive a redemption URL from the `invites` table (only
510
+ * `sha256(token)` is stored, never the raw value — see the module doc), so
511
+ * this reads back the ONE raw token persisted at mint time for a
512
+ * deliberately PUBLIC (multi-use) invite (`api-invites.ts`'s
513
+ * `handleCreateInvite`, which writes `hub_settings.public_signup_token`
514
+ * after `issueInvite` when `maxUses > 1`) and re-validates it's still live.
515
+ *
516
+ * Returns `/account/setup/<token>` when the persisted invite is still
517
+ * `"pending"`, still multi-use (`maxUses > 1`), AND provisioning
518
+ * (`provisionVault` — each redeemer gets their own new vault); `null` on ANY
519
+ * miss (never set, redeemed, revoked, exhausted, expired, or a non-provisioning
520
+ * shared-vault link) — and lazily clears the stale setting in that case. No
521
+ * separate revoke/exhaust/expire hook is needed: `inviteStatus` already derives
522
+ * all four terminal states from the invite's own columns, so every miss flows
523
+ * through this one status check.
524
+ *
525
+ * The `maxUses > 1` AND `provisionVault` re-checks are defense-in-depth mirrors
526
+ * of `handleCreateInvite`'s write condition (which only persists for a
527
+ * multi-use provisioning link): even a hand-edited settings row pointing at a
528
+ * single-use OR a shared-vault (`provision_vault=false`) invite must never be
529
+ * advertised as public signup — a shared-vault link would leak team-vault write
530
+ * on the anonymous, wildcard-CORS descriptor.
531
+ */
532
+ export function activePublicSignupPath(db: Database, now: Date = new Date()): string | null {
533
+ const raw = getSetting(db, "public_signup_token");
534
+ if (!raw) return null;
535
+ const invite = findInviteByRawToken(db, raw);
536
+ if (
537
+ !invite ||
538
+ inviteStatus(invite, now) !== "pending" ||
539
+ invite.maxUses <= 1 ||
540
+ !invite.provisionVault
541
+ ) {
542
+ deleteSetting(db, "public_signup_token");
543
+ return null;
544
+ }
545
+ return `/account/setup/${encodeURIComponent(raw)}`;
546
+ }
@@ -376,7 +376,8 @@ export async function fetchModuleStates(deps: DriveModuleOpDeps): Promise<Module
376
376
  try {
377
377
  res = await doFetch(`${baseUrl}/api/modules`, {
378
378
  method: "GET",
379
- // `/api/modules` parses the scheme-cased `Bearer ` prefix; match it exactly.
379
+ // `/api/modules` parses the Bearer scheme case-insensitively (RFC 7235,
380
+ // V1.4/C1.3 parity); we still emit the canonical `Bearer ` prefix.
380
381
  headers: { authorization: `Bearer ${bearer}` },
381
382
  // Bound the read so a wedged hub handler degrades `status` rather than
382
383
  // hanging it. AbortSignal.timeout fires `AbortError` once the ceiling
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Tiny static-file server for the @openparachute/notes PWA bundle.
4
+ * Tiny static-file server for a Parachute PWA bundle — originally written
5
+ * for @openparachute/notes, generalized in hub-parity P5 (2026-07-11) to
6
+ * also serve @openparachute/parachute-app (the super-surface front door,
7
+ * port 1944, mount `/app`) via the same shim. Any package matching this
8
+ * shape (a prebuilt SPA `dist/` with no server of its own) can reuse it.
5
9
  *
6
- * Notes is a SPA — no backend of its own. `parachute start notes` invokes
7
- * this shim with the installed `dist/` path so the PWA is served at a
8
- * known port and can be reverse-proxied by `parachute expose` alongside
9
- * the other services.
10
+ * A served bundle is a SPA — no backend of its own. `parachute start
11
+ * <svc>` invokes this shim with the installed `dist/` path so the PWA is
12
+ * served at a known port and can be reverse-proxied by `parachute expose`
13
+ * alongside the other services.
10
14
  *
11
15
  * Invoked as:
12
- * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>]
16
+ * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>] [--package <npmName>]
13
17
  *
14
18
  * `--mount` (default `/notes`) is the path prefix the reverse proxy hands
15
19
  * us. We strip it before resolving against `dist/` so a request for
@@ -17,27 +21,43 @@
17
21
  * `{dist}/notes/sw.js`. Without the strip, the SW + .webmanifest both
18
22
  * SPA-fall-back to index.html with content-type text/html, and the PWA
19
23
  * install prompt never fires. Pass `--mount ""` (or `--mount /`) when the
20
- * bundle is served at the origin root.
24
+ * bundle is served at the origin root. THIS IS LOAD-BEARING for every
25
+ * package served by this shim, not just notes — keep it exactly as-is.
21
26
  *
22
- * If --dist is omitted, we resolve @openparachute/notes's dist directory
23
- * via Bun.resolveSync. If that fails (package not installed globally, or
27
+ * `--package` (default `@openparachute/notes`, back-compat) names the npm
28
+ * package whose `dist/` we resolve when `--dist` is omitted. Passed by
29
+ * FIRST_PARTY_FALLBACKS entries whose startCmd composes this shim for a
30
+ * package other than notes (e.g. `app`'s `--package @openparachute/parachute-app`).
31
+ *
32
+ * If --dist is omitted, we resolve the package's dist directory via
33
+ * Bun.resolveSync. If that fails (package not installed globally, or
24
34
  * package doesn't ship dist/), exit 1 with a clear error.
35
+ *
36
+ * `/health` (post-mount-strip) always answers 2xx — the doctor/status
37
+ * probe (`probeModuleHealth`) only cares about the status code, but we
38
+ * answer explicitly rather than relying on the SPA-shell catch-all so a
39
+ * missing/corrupt `dist/index.html` can't take the health check down with it.
25
40
  */
26
41
 
27
42
  import { existsSync } from "node:fs";
28
43
  import { homedir } from "node:os";
29
44
  import { dirname, join, resolve } from "node:path";
30
45
 
46
+ /** Back-compat default — the shim's original (and still primary) consumer. */
47
+ const DEFAULT_PACKAGE = "@openparachute/notes";
48
+
31
49
  interface Args {
32
50
  port: number;
33
51
  dist?: string;
34
52
  mount: string;
53
+ pkg: string;
35
54
  }
36
55
 
37
56
  function parseArgs(argv: string[]): Args {
38
57
  let port = 5173;
39
58
  let dist: string | undefined;
40
59
  let mount = "/notes";
60
+ let pkg = DEFAULT_PACKAGE;
41
61
  for (let i = 0; i < argv.length; i++) {
42
62
  const a = argv[i];
43
63
  if (a === "--port") {
@@ -56,11 +76,15 @@ function parseArgs(argv: string[]): Args {
56
76
  const v = argv[++i];
57
77
  if (v === undefined) throw new Error("--mount requires a value");
58
78
  mount = normalizeMount(v);
79
+ } else if (a === "--package") {
80
+ const v = argv[++i];
81
+ if (!v) throw new Error("--package requires a value");
82
+ pkg = v;
59
83
  } else {
60
84
  throw new Error(`unknown argument: ${a}`);
61
85
  }
62
86
  }
63
- return { port, dist, mount };
87
+ return { port, dist, mount, pkg };
64
88
  }
65
89
 
66
90
  export function normalizeMount(raw: string): string {
@@ -70,22 +94,22 @@ export function normalizeMount(raw: string): string {
70
94
 
71
95
  /**
72
96
  * Candidate base directories that `Bun.resolveSync` walks from when looking
73
- * for `@openparachute/notes/package.json`. Order matters:
97
+ * for `<package>/package.json`. Order matters:
74
98
  *
75
- * 1. `process.cwd()` — works when notes-serve is invoked from inside the
76
- * notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
77
- * any project that depends on `@openparachute/notes`.
99
+ * 1. `process.cwd()` — works when the shim is invoked from inside the
100
+ * package's own checkout (e.g. via `installDir` cwd in lifecycle.ts) or
101
+ * from any project that depends on the package.
78
102
  * 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
79
- * layout. This is where `bun add -g @openparachute/notes` lands the
80
- * package, and where `bun link @openparachute/notes` symlinks it.
103
+ * layout. This is where `bun add -g <package>` lands the package, and
104
+ * where `bun link <package>` symlinks it.
81
105
  * 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
82
106
  *
83
- * Hub itself does NOT depend on `@openparachute/notes`, so when
84
- * `parachute start notes` is run from the hub repo dir, the cwd-relative
85
- * resolve walks ancestral node_modules and finds nothing. Bun does not
86
- * auto-consult the global install dir, so bun-linked installs fail to
87
- * resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
88
- * `/notes/` because of this — fixed by trying the global install dirs.
107
+ * Hub itself does NOT depend on the served package, so when `parachute
108
+ * start <svc>` is run from the hub repo dir, the cwd-relative resolve walks
109
+ * ancestral node_modules and finds nothing. Bun does not auto-consult the
110
+ * global install dir, so bun-linked installs fail to resolve without
111
+ * (2)/(3). hub#194: Aaron hit silent 502 on tailnet `/notes/` because of
112
+ * this — fixed by trying the global install dirs.
89
113
  *
90
114
  * Exported (and parameterized via `cwd`/`home`) so tests can drive the
91
115
  * resolution order against a real fixture install without monkey-patching
@@ -98,6 +122,8 @@ export function notesDistCandidates(cwd: string, home: string): string[] {
98
122
  export interface ResolveNotesDistDeps {
99
123
  cwd?: string;
100
124
  home?: string;
125
+ /** npm package name to resolve. Defaults to `@openparachute/notes` (back-compat). */
126
+ pkg?: string;
101
127
  /** Override `Bun.resolveSync` for tests. */
102
128
  resolveSync?: (specifier: string, base: string) => string;
103
129
  existsSync?: (path: string) => boolean;
@@ -106,6 +132,7 @@ export interface ResolveNotesDistDeps {
106
132
  export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
107
133
  const cwd = deps.cwd ?? process.cwd();
108
134
  const home = deps.home ?? homedir();
135
+ const pkg = deps.pkg ?? DEFAULT_PACKAGE;
109
136
  const resolveSync = deps.resolveSync ?? Bun.resolveSync;
110
137
  const exists = deps.existsSync ?? existsSync;
111
138
  const candidates = notesDistCandidates(cwd, home);
@@ -113,7 +140,7 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
113
140
  for (const base of candidates) {
114
141
  let pkgPath: string;
115
142
  try {
116
- pkgPath = resolveSync("@openparachute/notes/package.json", base);
143
+ pkgPath = resolveSync(`${pkg}/package.json`, base);
117
144
  } catch (err) {
118
145
  resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
119
146
  continue;
@@ -126,18 +153,18 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
126
153
  // other candidates — they'd resolve to the same package and report
127
154
  // the same problem.
128
155
  throw new Error(
129
- `@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
156
+ `${pkg} resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the ${pkg} maintainer to add a prepublishOnly build step.`,
130
157
  );
131
158
  }
132
159
  return dist;
133
160
  }
134
161
  throw new Error(
135
- `Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
162
+ `Could not resolve ${pkg} from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g ${pkg}\` or the matching \`parachute install <short>\`.`,
136
163
  );
137
164
  }
138
165
 
139
- function resolveNotesDist(): string {
140
- return resolveNotesDistFrom();
166
+ function resolveNotesDist(pkg: string): string {
167
+ return resolveNotesDistFrom({ pkg });
141
168
  }
142
169
 
143
170
  function mimeFor(path: string): string | undefined {
@@ -160,6 +187,17 @@ export function notesFetch(dist: string, mount: string): (req: Request) => Respo
160
187
  if (mount && (pathname === mount || pathname.startsWith(`${mount}/`))) {
161
188
  pathname = pathname.slice(mount.length) || "/";
162
189
  }
190
+ if (pathname === "/health") {
191
+ // Explicit rather than falling through to the SPA shell: the doctor /
192
+ // status probe (`probeModuleHealth`) only checks for a 2xx status, but
193
+ // answering directly means a missing/corrupt `dist/index.html` can't
194
+ // take the health check down with it. Every FIRST_PARTY_FALLBACKS
195
+ // entry served by this shim declares `health` under its own mount
196
+ // (e.g. `/notes/health`, `/app/health`) — this answers all of them.
197
+ return new Response(JSON.stringify({ status: "ok" }), {
198
+ headers: { "content-type": "application/json" },
199
+ });
200
+ }
163
201
  if (pathname === "/" || pathname.endsWith("/")) {
164
202
  return spaShell();
165
203
  }
@@ -200,17 +238,21 @@ export function notesServeOptions(
200
238
  }
201
239
 
202
240
  if (import.meta.main) {
203
- const { port, dist: distArg, mount } = parseArgs(process.argv.slice(2));
241
+ const { port, dist: distArg, mount, pkg } = parseArgs(process.argv.slice(2));
204
242
 
205
243
  let dist: string;
206
244
  try {
207
- dist = distArg ?? resolveNotesDist();
245
+ dist = distArg ?? resolveNotesDist(pkg);
208
246
  } catch (err) {
209
- console.error(`parachute-notes-serve: ${err instanceof Error ? err.message : String(err)}`);
247
+ console.error(
248
+ `parachute-static-serve (${pkg}): ${err instanceof Error ? err.message : String(err)}`,
249
+ );
210
250
  process.exit(1);
211
251
  }
212
252
 
213
253
  Bun.serve(notesServeOptions(port, dist, mount));
214
254
 
215
- console.log(`notes static-serve listening on :${port} (dist=${dist}, mount=${mount || "/"})`);
255
+ console.log(
256
+ `static-serve listening on :${port} (pkg=${pkg}, dist=${dist}, mount=${mount || "/"})`,
257
+ );
216
258
  }
@@ -418,13 +418,9 @@ function oauthErrorRedirect(
418
418
  * Keyed on the SHORT name (resolved from each services.json row via
419
419
  * `shortNameForManifest`) rather than the raw manifest name so a row written
420
420
  * under a legacy manifest name still counts as installed — e.g. an un-upgraded
421
- * `parachute-channel` row aliases to short `agent` (channel→agent rename
422
- * 2026-06-17), so `agent:send` is still correctly advertised until the daemon
423
- * re-registers under `parachute-agent`.
424
421
  */
425
422
  const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, short: string]> = [
426
423
  ["scribe:", "scribe"],
427
- ["agent:", "agent"],
428
424
  ["surface:", "surface"],
429
425
  ];
430
426
 
@@ -1903,13 +1899,7 @@ export async function handleApproveClientPost(
1903
1899
  // Headers logged are non-sensitive (Origin/Referer/Host are public);
1904
1900
  // the bound set is hub's own configuration. Body content not logged.
1905
1901
  console.warn(
1906
- `[oauth] approve POST same-origin check failed. headers: ` +
1907
- `origin=${JSON.stringify(req.headers.get("origin"))} ` +
1908
- `referer=${JSON.stringify(req.headers.get("referer"))} ` +
1909
- `host=${JSON.stringify(req.headers.get("host"))} ` +
1910
- `xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} ` +
1911
- `xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. ` +
1912
- `bound origins: ${JSON.stringify(bound)}`,
1902
+ `[oauth] approve POST same-origin check failed. headers: origin=${JSON.stringify(req.headers.get("origin"))} referer=${JSON.stringify(req.headers.get("referer"))} host=${JSON.stringify(req.headers.get("host"))} xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. bound origins: ${JSON.stringify(bound)}`,
1913
1903
  );
1914
1904
  return htmlError(
1915
1905
  "Cross-origin request rejected",
@@ -104,7 +104,6 @@ export const OPERATOR_TOKEN_SCOPE_SETS: Readonly<Record<OperatorScopeSet, readon
104
104
  "parachute:host:vault",
105
105
  "vault:admin",
106
106
  "scribe:admin",
107
- "agent:send",
108
107
  ],
109
108
  };
110
109
 
@@ -198,8 +198,8 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
198
198
  *
199
199
  * NOT belted, and why:
200
200
  * - GET/HEAD/OPTIONS — read-shaped; the mint GETs
201
- * (`/admin/host-admin-token`, `/admin/agent-token`,
202
- * `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
201
+ * (`/admin/host-admin-token`, `/admin/module-token/<short>`,
202
+ * `/admin/vault-admin-token/<name>`)
203
203
  * enforce GET-only with a 405 and their response bodies are unreadable
204
204
  * cross-origin (no CORS on these routes).
205
205
  * - Bearer-authed requests — a cross-site page cannot attach an