@openparachute/hub 0.7.7-rc.8 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -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__/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 +0 -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__/setup-wizard.test.ts +301 -22
- 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/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -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-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/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- 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 +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- 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.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/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
|
|
@@ -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
|
+
}
|
|
@@ -46,7 +46,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
46
46
|
"Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
|
|
47
47
|
level: "admin",
|
|
48
48
|
},
|
|
49
|
-
// Optional-module scopes (scribe /
|
|
49
|
+
// Optional-module scopes (scribe / surface). These are in
|
|
50
50
|
// FIRST_PARTY_SCOPES (= Object.keys(this map)) but the modules may not be
|
|
51
51
|
// installed — so they're GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts)
|
|
52
52
|
// and only advertised in `scopes_supported` when the service is in
|
|
@@ -61,10 +61,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
61
61
|
label: "Manage Scribe configuration (provider keys, models, quotas).",
|
|
62
62
|
level: "admin",
|
|
63
63
|
},
|
|
64
|
-
|
|
65
|
-
label: "Post messages to your Agent.",
|
|
66
|
-
level: "send",
|
|
67
|
-
},
|
|
64
|
+
|
|
68
65
|
// Surface Git Transport scopes (surface-host). `surface:read` = clone/fetch a
|
|
69
66
|
// surface's hub-hosted git repo; `surface:write` = push to it. Named forms
|
|
70
67
|
// (`surface:<name>:<verb>`) collapse to these via the 3→2-segment rule in
|