@openparachute/app 0.2.0-rc.4

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/src/auth.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Bearer-token auth for parachute-app's HTTP admin endpoints.
3
+ *
4
+ * Mirrors `parachute-runner/src/auth.ts` deliberately — same trust kernel
5
+ * (`@openparachute/scope-guard`), same hub-origin resolution, same shape for
6
+ * the 401/403 responses. The single difference is the audience: `aud === "app"`.
7
+ *
8
+ * Two scopes apps defines:
9
+ * - `app:read` — list UIs, read per-UI info. Read-only.
10
+ * - `app:admin` — add / remove / reload UIs + DCR registration on add.
11
+ *
12
+ * Endpoints that stay unauthenticated:
13
+ * - `/healthz`, `/app/healthz` (liveness, hub probes)
14
+ * - `/.parachute/info`, `/.parachute/config/schema` (open module-protocol
15
+ * surface — module identity + schema shape leak nothing)
16
+ * - Per-UI bundle serving under `/app/<name>/*` (static assets; hub gates
17
+ * these at the reverse-proxy layer per design doc section 9)
18
+ * - `/app/<name>/oauth-client` (UIs need this at page load before they
19
+ * have any token — public OAuth client_id is by definition public)
20
+ *
21
+ * Admin endpoints take `app:admin`. Read-only admin endpoints accept
22
+ * `app:admin` OR `app:read` per design doc section 13.
23
+ *
24
+ * Hub-origin resolution follows the same shape every other module uses:
25
+ * - `PARACHUTE_HUB_ORIGIN` env var when set
26
+ * - `config.hub_url` from `loadConfig()` as the daemon-config-aware override
27
+ * - `http://127.0.0.1:1939` loopback fallback (v0.6 single-container)
28
+ *
29
+ * `getHubOrigin()` re-resolves on every call so tests can swap the env
30
+ * mid-run; production callers set the env once at boot.
31
+ */
32
+
33
+ import { HubJwtError, type ScopeGuard, createScopeGuard } from "@openparachute/scope-guard";
34
+
35
+ export const SCOPE_ADMIN = "app:admin" as const;
36
+ export const SCOPE_READ = "app:read" as const;
37
+
38
+ /** Hub loopback for v0.6 single-container; deploys override via env. */
39
+ const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
40
+
41
+ /** Audience the daemon declares — hub#300 mints with `aud: "app"` for our endpoints. */
42
+ export const AUDIENCE = "app" as const;
43
+
44
+ /**
45
+ * Resolve the hub origin used for JWT validation.
46
+ *
47
+ * Honors `PARACHUTE_HUB_ORIGIN` first (the canonical override every committed-
48
+ * core module respects), then `hubUrl` (the runtime config field), then falls
49
+ * back to the loopback. `hubUrl` is the same string `config.hub_url` carries —
50
+ * callers in `http-server.ts` pass `state.config.hub_url` so a runtime config
51
+ * change (Phase 1.3 admin SPA toggles) takes effect without a daemon restart.
52
+ */
53
+ export function getHubOrigin(hubUrl?: string): string {
54
+ const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
55
+ if (env && env.length > 0) return env;
56
+ if (hubUrl && hubUrl.length > 0) return hubUrl.replace(/\/$/, "");
57
+ return DEFAULT_HUB_LOOPBACK;
58
+ }
59
+
60
+ let guard: ScopeGuard | null = null;
61
+ let guardHubOrigin: string | null = null;
62
+
63
+ /**
64
+ * Lazy process-wide guard. The resolver form lets tests flip
65
+ * `PARACHUTE_HUB_ORIGIN` between cases without restarting the harness; the
66
+ * lib re-resolves on every `validateHubJwt` call. JWKS + revocation caches
67
+ * live inside the guard and survive across requests in production.
68
+ *
69
+ * If `hubUrl` is supplied and differs from the cached guard's origin, the
70
+ * cached guard is replaced — daemon-config writes can change `hub_url` at
71
+ * runtime and we want subsequent validations to track the new origin.
72
+ */
73
+ function getGuard(hubUrl?: string): ScopeGuard {
74
+ const wanted = getHubOrigin(hubUrl);
75
+ if (!guard || guardHubOrigin !== wanted) {
76
+ guard = createScopeGuard({ hubOrigin: () => wanted });
77
+ guardHubOrigin = wanted;
78
+ }
79
+ return guard;
80
+ }
81
+
82
+ /**
83
+ * Test seam: forget the cached guard so a beforeEach that swaps the
84
+ * `PARACHUTE_HUB_ORIGIN` env var picks up the new origin on the next call.
85
+ */
86
+ export function resetGuard(): void {
87
+ if (guard) {
88
+ guard.resetJwksCache();
89
+ guard.resetRevocationCache();
90
+ }
91
+ guard = null;
92
+ guardHubOrigin = null;
93
+ }
94
+
95
+ export function extractBearer(authHeader: string | null | undefined): string | undefined {
96
+ if (!authHeader) return undefined;
97
+ const m = authHeader.match(/^Bearer\s+(.+)$/i);
98
+ return m?.[1]?.trim() || undefined;
99
+ }
100
+
101
+ export type AuthResult =
102
+ | { ok: true; scopes: readonly string[] }
103
+ | { ok: false; status: 401 | 403; body: { error: string; message: string } };
104
+
105
+ /**
106
+ * Validate the presented bearer against the hub. Returns the granted scope
107
+ * list on success; on failure returns a typed 401 or 403 the caller forwards
108
+ * verbatim.
109
+ *
110
+ * `aud === "app"` enforced via `expectedAudience` — a token minted for a
111
+ * different module can't reach our admin surface even if its bearer carries
112
+ * `app:admin` (which it can't, but defense-in-depth).
113
+ */
114
+ export async function validateBearer(
115
+ token: string | undefined,
116
+ opts: { hubUrl?: string } = {},
117
+ ): Promise<AuthResult> {
118
+ if (!token) {
119
+ return {
120
+ ok: false,
121
+ status: 401,
122
+ body: { error: "unauthorized", message: "Authorization: Bearer <token> required" },
123
+ };
124
+ }
125
+ try {
126
+ const claims = await getGuard(opts.hubUrl).validateHubJwt(token, {
127
+ expectedAudience: AUDIENCE,
128
+ });
129
+ return { ok: true, scopes: claims.scopes };
130
+ } catch (err) {
131
+ if (err instanceof HubJwtError && err.code === "revoked") {
132
+ console.warn(`[app-auth] hub JWT rejected: ${err.message}`);
133
+ return {
134
+ ok: false,
135
+ status: 401,
136
+ body: { error: "unauthorized", message: "token has been revoked" },
137
+ };
138
+ }
139
+ if (err instanceof HubJwtError && err.code === "revocation_unavailable") {
140
+ console.warn(`[app-auth] hub JWT rejected: ${err.message}`);
141
+ return {
142
+ ok: false,
143
+ status: 401,
144
+ body: {
145
+ error: "unauthorized",
146
+ message: "token cannot be validated: revocation list unavailable",
147
+ },
148
+ };
149
+ }
150
+ const message =
151
+ err instanceof HubJwtError
152
+ ? err.message
153
+ : err instanceof Error
154
+ ? err.message
155
+ : "JWT validation failed";
156
+ return {
157
+ ok: false,
158
+ status: 401,
159
+ body: { error: "unauthorized", message },
160
+ };
161
+ }
162
+ }
163
+
164
+ /** Exact-match scope check. Non-vault scopes don't inherit per oauth-scopes.md. */
165
+ export function hasScope(granted: readonly string[], required: string): boolean {
166
+ return granted.includes(required);
167
+ }
168
+
169
+ /**
170
+ * Pass-through scope check that treats `app:admin` as implying `app:read`.
171
+ * Read-only admin endpoints (GET /app/list, GET /app/<name>/info) accept
172
+ * either scope; admin endpoints (add/remove/reload) require `app:admin`
173
+ * exactly.
174
+ */
175
+ export function hasReadAccess(granted: readonly string[]): boolean {
176
+ return granted.includes(SCOPE_READ) || granted.includes(SCOPE_ADMIN);
177
+ }
178
+
179
+ /**
180
+ * Resolve auth + scope. Returns either a Response to forward (401/403) or
181
+ * the granted scopes for the caller to use in finer-grained checks.
182
+ *
183
+ * `requiredScope` is one of:
184
+ * - `app:admin` — exact match required
185
+ * - `app:read` — accepts `app:read` OR `app:admin` (admin implies read)
186
+ */
187
+ export async function enforceScope(
188
+ req: Request,
189
+ requiredScope: typeof SCOPE_ADMIN | typeof SCOPE_READ,
190
+ opts: { hubUrl?: string } = {},
191
+ ): Promise<Response | { scopes: readonly string[] }> {
192
+ const token = extractBearer(req.headers.get("authorization"));
193
+ const result = await validateBearer(token, opts);
194
+ if (!result.ok) {
195
+ return Response.json(result.body, { status: result.status });
196
+ }
197
+ const granted = result.scopes;
198
+ const ok = requiredScope === SCOPE_READ ? hasReadAccess(granted) : hasScope(granted, SCOPE_ADMIN);
199
+ if (!ok) {
200
+ return Response.json(
201
+ {
202
+ error: "Forbidden",
203
+ error_type: "insufficient_scope",
204
+ message: `This endpoint requires the '${requiredScope}' scope.`,
205
+ required_scope: requiredScope,
206
+ granted_scopes: granted,
207
+ },
208
+ { status: 403 },
209
+ );
210
+ }
211
+ return { scopes: granted };
212
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * First-boot default-app bootstrap — Phase 2.1.
3
+ *
4
+ * On `parachute-app serve` startup, when `$PARACHUTE_HOME/app/uis/` is
5
+ * empty and `config.bootstrap_default_apps.enabled` is true, apps
6
+ * auto-installs each entry in `config.bootstrap_default_apps.apps` via
7
+ * the same npm-fetch pipeline `parachute-app add` uses.
8
+ *
9
+ * Friend-deploy story: spin up a hub + run `parachute-app serve`, and
10
+ * Notes is there waiting — no manual `add` step. The operator can
11
+ * always disable this by flipping `bootstrap_default_apps.enabled =
12
+ * false` or setting `apps: []`.
13
+ *
14
+ * Design rationale (design doc Section 16): Notes is the canonical
15
+ * first app installed under parachute-app. The bootstrap registry is
16
+ * the implementation of "Notes ships with app." Future committed-core
17
+ * apps may join the default list; today it's just notes-ui.
18
+ *
19
+ * Failure mode: if npm-fetch fails (network down, package not on
20
+ * registry, registry timeout), log a warning and continue. The
21
+ * operator can run `parachute-app add @openparachute/notes-ui` later
22
+ * to retry. We never block daemon startup on the bootstrap — the
23
+ * daemon's primary job is hosting whatever's in `uis/` already (which,
24
+ * in the empty case, is "nothing"), and a failed bootstrap is just
25
+ * "nothing got added."
26
+ */
27
+
28
+ import { readdirSync, statSync } from "node:fs";
29
+
30
+ import type { AppConfig } from "./config.ts";
31
+ import type { NpmSpawnFn, fetchNpmPackage } from "./npm-fetch.ts";
32
+
33
+ /**
34
+ * Minimal `add` surface bootstrap needs. The full admin handler does
35
+ * staging + meta-merge + DCR + state-swap + services.json refresh;
36
+ * bootstrap reuses that same flow by passing a callback that performs
37
+ * the equivalent (in practice, the caller in `index.ts` adapts the
38
+ * admin handler so bootstrap stays decoupled from admin-routes.ts's
39
+ * `AppState` mutation pattern).
40
+ */
41
+ export type BootstrapAddFn = (source: string) => Promise<{ name: string; path: string }>;
42
+
43
+ export type BootstrapOpts = {
44
+ config: AppConfig;
45
+ /** Resolved uis dir; allows tests to inject a tempdir. */
46
+ uisDir: string;
47
+ /** The npm-fetch entry-point — overridable for tests. */
48
+ npmFetch?: typeof fetchNpmPackage;
49
+ /** The `add` callback — orchestrator wires this to admin-routes' add path. */
50
+ add: BootstrapAddFn;
51
+ /** Logger override; default console. */
52
+ logger?: Pick<Console, "log" | "warn" | "error">;
53
+ /** npm-spawn override (tests). Passed to `npmFetch`. */
54
+ npmSpawnFn?: NpmSpawnFn;
55
+ };
56
+
57
+ export type BootstrapResult = {
58
+ /** npm specs of packages successfully added. */
59
+ bootstrapped: string[];
60
+ /** npm specs skipped (per-spec reason). */
61
+ skipped: Array<{ pkg: string; reason: string }>;
62
+ /** npm specs that failed (per-spec error). */
63
+ failed: Array<{ pkg: string; error: string }>;
64
+ /** Why the whole pass was skipped, if it was (else undefined). */
65
+ skipReason?: string;
66
+ };
67
+
68
+ /**
69
+ * Inspect `uisDir` + `config`, then maybe run bootstrap. Returns a
70
+ * summary the caller (`serve()` in index.ts) logs.
71
+ *
72
+ * Skip conditions (any one triggers an early return):
73
+ * - `config.bootstrap_default_apps.enabled === false`
74
+ * - `config.bootstrap_default_apps.apps` is empty
75
+ * - `uisDir` exists AND contains at least one entry (we don't touch
76
+ * installs the operator already manages)
77
+ *
78
+ * When none of the skip conditions fire: iterate `apps` and call
79
+ * `add(spec)` for each. Each call is independent — a failure on one
80
+ * doesn't stop the rest.
81
+ */
82
+ export async function maybeBootstrapDefaultApps(opts: BootstrapOpts): Promise<BootstrapResult> {
83
+ const logger = opts.logger ?? console;
84
+ const result: BootstrapResult = {
85
+ bootstrapped: [],
86
+ skipped: [],
87
+ failed: [],
88
+ };
89
+
90
+ if (!opts.config.bootstrap_default_apps.enabled) {
91
+ result.skipReason = "config.bootstrap_default_apps.enabled is false";
92
+ return result;
93
+ }
94
+ if (opts.config.bootstrap_default_apps.apps.length === 0) {
95
+ result.skipReason = "config.bootstrap_default_apps.apps is empty";
96
+ return result;
97
+ }
98
+
99
+ if (uisDirHasEntries(opts.uisDir)) {
100
+ result.skipReason = "uisDir is non-empty (existing operator install)";
101
+ return result;
102
+ }
103
+
104
+ for (const spec of opts.config.bootstrap_default_apps.apps) {
105
+ try {
106
+ const added = await opts.add(spec);
107
+ result.bootstrapped.push(spec);
108
+ logger.log(`[app] bootstrap: installed ${spec} as ${added.name} at ${added.path}`);
109
+ } catch (e) {
110
+ const msg = (e as Error).message ?? String(e);
111
+ result.failed.push({ pkg: spec, error: msg });
112
+ logger.warn(`[app] bootstrap: failed to install ${spec}: ${msg}`);
113
+ }
114
+ }
115
+
116
+ if (result.bootstrapped.length > 0) {
117
+ logger.log(
118
+ `[app] bootstrap: installed ${result.bootstrapped.length} default app(s) — ${result.bootstrapped.join(", ")}`,
119
+ );
120
+ }
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Predicate: does `uisDir` exist + contain at least one entry that
126
+ * looks like a UI install candidate? A pure missing-directory or a
127
+ * directory containing only hidden files (e.g. `.DS_Store`) counts as
128
+ * "empty" — operators don't deliberately seed UIs as dotfile dirs.
129
+ *
130
+ * We deliberately accept "exists + has at least one non-dotfile
131
+ * entry" — even an entry that doesn't pass `scanUis` (broken meta,
132
+ * missing dist/) signals "operator was here," and bootstrap shouldn't
133
+ * trample.
134
+ */
135
+ function uisDirHasEntries(uisDir: string): boolean {
136
+ try {
137
+ const st = statSync(uisDir);
138
+ if (!st.isDirectory()) return false;
139
+ } catch {
140
+ return false;
141
+ }
142
+ let entries: string[];
143
+ try {
144
+ entries = readdirSync(uisDir);
145
+ } catch {
146
+ return false;
147
+ }
148
+ for (const e of entries) {
149
+ if (e.startsWith(".")) continue;
150
+ return true;
151
+ }
152
+ return false;
153
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Smart cache headers per design doc section 18.
3
+ *
4
+ * The intent: solve [parachute-notes#151](https://github.com/ParachuteComputer/parachute-notes/issues/151)
5
+ * at the platform level so future apps inherit a clean default. The rules:
6
+ *
7
+ * - `index.html` (SPA entrypoints): `no-cache, no-store, must-revalidate`.
8
+ * Always-fresh; this is the document that points at the hashed assets.
9
+ * - Content-hashed assets (Vite/Webpack/esbuild/Parcel default convention,
10
+ * e.g. `app.a3b9f2.js`, `style.7e1c8d.css`): `public, max-age=31536000,
11
+ * immutable`. Cache forever — the filename changes on rebuild.
12
+ * - Non-hashed assets: `public, max-age=3600`. Sensible default.
13
+ * - PWA service worker (when `meta.pwa === true` and `filename` matches
14
+ * `meta.pwa_service_worker`): `no-cache`. SW updates need to propagate
15
+ * immediately on rebuild.
16
+ *
17
+ * The hash detector is conservative: require ≥8 hex characters in the
18
+ * second-to-last dot-separated segment. This matches Vite/Webpack output
19
+ * (`app.a3b9f2c1.js`) without false-positiving on filenames like
20
+ * `vendor-1234.js` or `image-2024.png`. We deliberately reject 6-7 char
21
+ * hashes — Vite + Webpack both default to ≥8 — to keep the false-positive
22
+ * rate low.
23
+ */
24
+
25
+ import type { UiMeta } from "./meta-schema.ts";
26
+
27
+ /**
28
+ * Regex testing whether a filename looks content-hashed. Examples that pass:
29
+ * - `app.a3b9f2c1.js`
30
+ * - `vendor.7e1c8d.chunk.js` (hash is in the middle)
31
+ * - `style.deadbeef12345.css`
32
+ *
33
+ * Examples that don't pass (caching at the 1-hour default):
34
+ * - `index.html` (handled separately)
35
+ * - `app.js`
36
+ * - `app-2024.js` (no hex run that long; "2024" is 4 chars)
37
+ * - `vendor-1234.js`
38
+ * - `app-20240101.js` (date stamp — 8 digits but pure-decimal, no a-f)
39
+ * - `icon.svg`
40
+ *
41
+ * The match looks for a dot-separated segment of ≥8 hex chars anywhere in
42
+ * the filename, AND requires at least one a-f character in that segment.
43
+ * The lookahead `(?=[a-f0-9]*[a-f])` is the load-bearing line: it filters
44
+ * out pure-digit runs (date stamps like `20240101`) that would otherwise
45
+ * masquerade as hex hashes. Vite/Webpack hashes are random hex so they
46
+ * almost always contain at least one letter — and on the rare run that
47
+ * doesn't, the 1-hour fallback is harmless. False-positive avoidance wins.
48
+ */
49
+ const HASHED_ASSET_REGEX = /(^|[.\-_])(?=[a-f0-9]*[a-f])[a-f0-9]{8,}(\.|$)/;
50
+
51
+ /**
52
+ * Type signature exposed by section 5 of the brief — explicit per-asset
53
+ * shape for use anywhere we need to set headers. `filename` is the basename
54
+ * (e.g. `app.a3b9f2.js`) — pass the full URL path's basename, not the
55
+ * absolute filesystem path.
56
+ *
57
+ * `meta` is optional so caller can elide it for the `/.parachute/*` admin
58
+ * endpoints; those skip the PWA-aware branch.
59
+ *
60
+ * `devMode` (Phase 1.3) overrides every other branch with
61
+ * `no-cache, no-store, must-revalidate`. When the operator runs
62
+ * `parachute-app dev <name>` we want zero caching from any layer — index,
63
+ * hashed assets, SW, the lot. The smart-cache rules below are silent
64
+ * during dev iteration.
65
+ */
66
+ export function cacheHeadersFor(
67
+ filename: string,
68
+ meta?: UiMeta,
69
+ devMode = false,
70
+ ): Record<string, string> {
71
+ // Dev mode trumps every other branch — no caching anywhere. Even
72
+ // content-hashed assets get no-cache so an operator who manually
73
+ // edits a hashed bundle (e.g. swapped from disk) sees the change
74
+ // without reasoning about the filename.
75
+ if (devMode) {
76
+ return { "Cache-Control": "no-cache, no-store, must-revalidate" };
77
+ }
78
+
79
+ // PWA service worker — always no-cache so updates propagate immediately
80
+ // on rebuild. The SW path is meta-driven so each UI controls its own.
81
+ if (meta?.pwa && meta.pwa_service_worker && filename === meta.pwa_service_worker) {
82
+ return { "Cache-Control": "no-cache" };
83
+ }
84
+
85
+ // index.html: always-fresh.
86
+ if (filename === "index.html") {
87
+ return { "Cache-Control": "no-cache, no-store, must-revalidate" };
88
+ }
89
+
90
+ // Content-hashed: cache forever.
91
+ if (HASHED_ASSET_REGEX.test(filename)) {
92
+ return { "Cache-Control": "public, max-age=31536000, immutable" };
93
+ }
94
+
95
+ // Non-hashed assets: 1-hour default.
96
+ return { "Cache-Control": "public, max-age=3600" };
97
+ }
98
+
99
+ /**
100
+ * Convenience predicate exposed for tests. Returns true when `filename`
101
+ * matches the hash-in-filename convention (used to confirm the regex stays
102
+ * tight as we extend it).
103
+ */
104
+ export function looksContentHashed(filename: string): boolean {
105
+ return HASHED_ASSET_REGEX.test(filename);
106
+ }