@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.
- package/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
package/src/hub-settings.ts
CHANGED
|
@@ -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
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
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
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* the
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
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
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
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
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
+
}
|
package/src/module-ops-client.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/notes-serve.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Tiny static-file server for
|
|
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
|
-
*
|
|
7
|
-
* this shim with the installed `dist/` path so the PWA is
|
|
8
|
-
* known port and can be reverse-proxied by `parachute expose`
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
|
97
|
+
* for `<package>/package.json`. Order matters:
|
|
74
98
|
*
|
|
75
|
-
* 1. `process.cwd()` — works when
|
|
76
|
-
*
|
|
77
|
-
* any project that depends on
|
|
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
|
|
80
|
-
*
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
255
|
+
console.log(
|
|
256
|
+
`static-serve listening on :${port} (pkg=${pkg}, dist=${dist}, mount=${mount || "/"})`,
|
|
257
|
+
);
|
|
216
258
|
}
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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",
|
package/src/operator-token.ts
CHANGED
package/src/origin-check.ts
CHANGED
|
@@ -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/
|
|
202
|
-
* `/admin/
|
|
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
|