clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix(tooling): Windows child environments, .env duplicates, traversal, dedup

Dami committed 940ffff
Closes out the tooling, reuse, simplification, and efficiency findings from
the full review: TOOL-1..6, REUSE-1/2 (ALT-3), ALT-1 (narrowed), ALT-2,
SIMP-1..8, EFF-1..4.

Correctness:
- TOOL-1: child environments match the allowlist case-insensitively
  (preserving source casing) and forward the Windows bootstrap variables
  (SystemRoot, ComSpec, PATHEXT, USERPROFILE, ...) that clearEnv children
  need to load DLLs and resolve executables.
- TOOL-2: mergeEnv rewrites every duplicate .env assignment; dotenv parsing
  takes the last occurrence, so updating only the first reported success
  while commands kept reading the stale value.
- TOOL-3: percent-encoded backslashes (and encoded dot segments) are
  rejected by the asset-path traversal check instead of escaping the
  project asset root under Windows path semantics.
- TOOL-4: a truncated PNG reports "not decodable" instead of throwing an
  uncaught RangeError.
- TOOL-5: delete the stale, unreferenced tools/preview.ts.
- TOOL-6 + EFF-3: inspector refreshes are coalesced (one in flight, at most
  one queued) and every recurring rejection is handled; previously only the
  initial refresh was caught and each diagnostics tick rebuilt the DOM.
- ALT-2: the inspector's restart and replica-clear reload through the
  documented shutdown-then-navigate boundary instead of a bare reload.
- ALT-1 (narrowed): access-operation settlement now updates runtime
  diagnostics, so share and membership mutations are visible to the
  inspector instead of bypassing pending-write and error counters. The full
  three-way settlement unification remains out of scope: store settlement
  is interwoven with snapshot generations stabilized in #86.

Reuse:
- REUSE-1: tools/env_contract.ts and tools/load_env.ts re-export the
  shipped package's environment contract (including LOFI_BASE_PATH) instead
  of maintaining a diverging copy.
- REUSE-2/ALT-3: manifest validation imports the related-app grammar
  (platform set + bounded control-character-free strings) from the runtime
  recipe, closing the divergence where the validator accepted ids the
  recipe rejects.

Simplification and efficiency:
- SIMP-1: replaceClient collapsed into destroyClient.
- SIMP-2: recreateResource/replaceResource share one swapResource body.
- SIMP-3: remove the caller-less documentBaseURI dependency and branch.
- SIMP-4: prfAssertion inlined into derivePrfSecret; the discarded
  credential object is no longer built.
- SIMP-5: fixture storage state is cloned once, at the createContext
  boundary.
- SIMP-6/7: the repeated action-URL and offline-route validation blocks
  share validateScopedActionUrl / requireEmittedRoute.
- SIMP-8: golden-recipe installers share one patchManifest read/patch/write
  helper.
- EFF-1: starter files are fetched concurrently during create.
- EFF-2: runtime readiness probes one row (limit 1) instead of
  materializing the first table on every open.
- EFF-4 (partial): build walks dist once for precache and route count; the
  fingerprint/scan reread spans the Astro build boundary and stays as is.
commit
940ffff14109211013bde5c73cbf7da77dfb570c
parent
22296c7f46d45745bdc70e01672e0be58ba8a7e8
author
Dami <dami.etoile@gmail.com>
21 files changed, +442 -413
Mpackage/access/operations.ts26+++-
Mpackage/commands/build.ts7+-
Mpackage/create_core.ts9+-
Mpackage/recipes/related-app-discovery.ts12+-
Mpackage/runtime/auth.ts34++---
Mpackage/runtime/inspector.ts29++++-
Mpackage/runtime/probe.ts21++-
Mpackage/runtime/pwa.ts9+-
Mpackage/runtime/resource-lifecycle.ts32++---
Mpackage/runtime/runtime.ts19+--
Mpackage/testing/fixture.ts7+-
Mpackage/tooling/environment.ts28++++-
Apackage/tooling/environment_test.ts43++++++
Mpackage/tooling/provision.ts22++-
Mpackage/tooling/provision_test.ts18+++
Mpackage/tooling/pwa-validation.ts211++++++++++++++++--------------
Mpackage/tooling/pwa-validation_test.ts50+++++++
Mtools/env_contract.ts103++-------------
Mtools/golden.ts88+++++++------
Mtools/load_env.ts35+----
Dtools/preview.ts52-------
M package/access/operations.ts +24 -2
@@ -1,6 +1,6 @@
11 import type { QueryBuilder, TableProxy } from "jazz-tools";22 import { syncing } from "../runtime/config.ts";3-import { getRuntime } from "../runtime/runtime.ts";3+import { getRuntime, updateRuntimeDiagnostics } from "../runtime/runtime.ts";44 import { AccessError } from "./errors.ts";55 import { decodeSharingIdentity, type SharingIdentity } from "./identity.ts";66 import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";
@@ -38,11 +38,33 @@ function requireSync(operation: string): void {
3838 }3939 }4040 41+// Share and membership writes settle through the same runtime diagnostics as42+// table mutations, so access operations stay visible to the inspector instead43+// of bypassing pending-write and error counters entirely.4144 async function settle<T>(write: DurableWrite<T>): Promise<T> {45+ updateRuntimeDiagnostics((diagnostics) => diagnostics.pendingLocalWrites += 1);46+ let localSettled = false;4247 try {4348 await write.wait({ tier: "local" });44- return await write.wait({ tier: "global" });49+ localSettled = true;50+ updateRuntimeDiagnostics((diagnostics) => {51+ diagnostics.pendingLocalWrites -= 1;52+ diagnostics.localWaitCalls += 1;53+ diagnostics.pendingGlobalWrites += 1;54+ });55+ const result = await write.wait({ tier: "global" });56+ updateRuntimeDiagnostics((diagnostics) => {57+ diagnostics.pendingGlobalWrites -= 1;58+ diagnostics.lastWriteDurability = "global";59+ });60+ return result;4561 } catch (cause) {62+ updateRuntimeDiagnostics((diagnostics) => {63+ if (localSettled) diagnostics.pendingGlobalWrites -= 1;64+ else diagnostics.pendingLocalWrites -= 1;65+ diagnostics.mutationErrors += 1;66+ diagnostics.lastWriteDurability = "failed";67+ });4668 throw new AccessError(4769 "mutation-rejected",4870 "Jazz rejected the access change. Refresh permissions and membership, then try again.",
M package/commands/build.ts +4 -3
M package/create_core.ts +7 -2
M package/recipes/related-app-discovery.ts +10 -2
M package/runtime/auth.ts +11 -23
@@ -378,13 +378,17 @@ export async function authenticateDeviceCredential(
378378 379379 // --- PRF-derived at-rest key --------------------------------------------------380380 381-// Runs a single user-verifying `get()` with the PRF extension and returns both382-// the PRF secret and the asserting credential's identity, so a caller can derive383-// the account and report which key unlocked it from one ceremony (one prompt).384-async function prfAssertion(381+/**382+ * Derives a credential-bound secret from the passkey via the WebAuthn PRF383+ * extension in one user-verifying `get()`. The same `salt` on the same384+ * authenticator yields the same 32-byte secret; it never leaves the device.385+ * Throws `prf-unavailable` if the client or authenticator does not return a386+ * PRF result — the secret is never faked.387+ */388+export async function derivePrfSecret(385389 salt: BufferSource,386- dependencies: AuthDependencies,387-): Promise<{ prfSecret: Uint8Array; credential: DeviceCredential }> {390+ dependencies: AuthDependencies = {},391+): Promise<Uint8Array> {388392 const rpId = requireStableRpId(dependencies);389393 const credentials = dependencies.credentials ?? browserCredentials();390394 try {
@@ -401,28 +405,12 @@ async function prfAssertion(
401405 (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))402406 .getClientExtensionResults?.().prf?.results;403407 if (!results?.first) throw new AuthError("prf-unavailable");404- return {405- prfSecret: new Uint8Array(results.first),406- credential: { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) },407- };408+ return new Uint8Array(results.first);408409 } catch (error) {409410 throw mapError(error);410411 }411412 }412413 413-/**414- * Derives a credential-bound secret from the passkey via the WebAuthn PRF415- * extension. The same `salt` on the same authenticator yields the same 32-byte416- * secret; it never leaves the device. Throws `prf-unavailable` if the client or417- * authenticator does not return a PRF result — the secret is never faked.418- */419-export async function derivePrfSecret(420- salt: BufferSource,421- dependencies: AuthDependencies = {},422-): Promise<Uint8Array> {423- return (await prfAssertion(salt, dependencies)).prfSecret;424-}425-426414 /**427415 * Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256.428416 * Bind unrelated data to different keys by varying `info`.
M package/runtime/inspector.ts +26 -3
M package/runtime/probe.ts +11 -10
@@ -3,7 +3,12 @@ import { serverUrl, syncing } from "./config.ts";
33 import { readDeviceCapabilityReport } from "./device-capabilities.ts";44 import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";55 import { getForegroundRecoveryState, subscribeForegroundRecovery } from "./lifecycle.ts";6-import { getRuntime, getRuntimeDiagnostics, subscribeRuntimeDiagnostics } from "./runtime.ts";6+import {7+ getRuntime,8+ getRuntimeDiagnostics,9+ reloadBrowserRuntime,10+ subscribeRuntimeDiagnostics,11+} from "./runtime.ts";712 import { isTransportPausedByInspector, setTransportPausedByInspector } from "./transport-gate.ts";813 914 export type LofiDevelopmentBridge = InspectorAdapter;
@@ -14,13 +19,6 @@ type LofiDevelopmentGlobal = typeof globalThis & {
1419 1520 const stateListeners = new Set<() => void>();1621 17-function reloadBrowserClient(): Promise<void> {18- globalThis.location.reload();19- // The current document must not report completion while its replacement is20- // still booting. Navigation destroys this pending promise with the page.21- return new Promise(() => undefined);22-}23-2422 function notifyState(): void {2523 for (const listener of stateListeners) listener();2624 }
@@ -116,12 +114,15 @@ const bridge: LofiDevelopmentBridge = {
116114 notifyState();117115 },118116 restartClient() {119- return reloadBrowserClient();117+ // The shutdown-then-navigate boundary is the supported clean-runtime path118+ // for the pinned Jazz alpha; reloading without it leaves the persistent119+ // worker attached during navigation.120+ return reloadBrowserRuntime();120121 },121122 async clearLocalReplica() {122123 const runtime = await getRuntime();123124 await runtime.db.deleteClientStorage();124- return await reloadBrowserClient();125+ return await reloadBrowserRuntime();125126 },126127 };127128
M package/runtime/pwa.ts +2 -7
M package/runtime/resource-lifecycle.ts +13 -19
@@ -38,13 +38,14 @@ export function getResource<T>(
3838 return tracked;3939 }4040 41-export function recreateResource<T>(41+function swapResource<T>(4242 state: SerializedResourceState<T>,43+ prepare: (() => Promise<void>) | null,4344 create: () => Promise<T>,4445 destroy: (value: T) => Promise<void>,4546 ): Promise<T> {46- if (state.recreation) return state.recreation;4747 const recreation = enqueue(state, async () => {48+ if (prepare) await prepare();4849 const previous = state.value;4950 state.value = null;5051 state.promise = null;
@@ -65,6 +66,15 @@ export function recreateResource<T>(
6566 return tracked;6667 }6768 69+export function recreateResource<T>(70+ state: SerializedResourceState<T>,71+ create: () => Promise<T>,72+ destroy: (value: T) => Promise<void>,73+): Promise<T> {74+ if (state.recreation) return state.recreation;75+ return swapResource(state, null, create, destroy);76+}77+6878 /**6979 * Replaces a serialized resource after an async preparation step. Unlike7080 * {@link recreateResource}, replacement carries a payload (the preparation),
@@ -77,23 +87,7 @@ export function replaceResource<T>(
7787 create: () => Promise<T>,7888 destroy: (value: T) => Promise<void>,7989 ): Promise<T> {80- const recreation = enqueue(state, async () => {81- await prepare();82- const previous = state.value;83- state.value = null;84- state.promise = null;85- if (previous) await destroy(previous);86- if (previous) await new Promise((resolve) => setTimeout(resolve, 50));87- const value = await create();88- state.value = value;89- state.promise = Promise.resolve(value);90- return value;91- });92- const tracked = recreation.finally(() => {93- if (state.recreation === tracked) state.recreation = null;94- });95- state.recreation = tracked;96- return tracked;90+ return swapResource(state, prepare, create, destroy);9791 }9892 9993 export function shutdownResource<T>(
M package/runtime/runtime.ts +8 -11
@@ -111,7 +111,11 @@ function runtimeTables(): RuntimeTable[] {
111111 112112 async function awaitLocalReady(db: Db): Promise<void> {113113 const firstTable = runtimeTables()[0];114- if (firstTable) await db.all(firstTable.where({}) as never, { tier: "local" });114+ if (!firstTable) return;115+ // One row proves the persistent bridge is attached; materializing the whole116+ // first table on every open pays row-count-proportional startup cost.117+ const query = firstTable.where({}) as { limit?: (n: number) => unknown };118+ await db.all((query.limit?.(1) ?? query) as never, { tier: "local" });115119 }116120 117121 async function migrateLocalRows(
@@ -201,21 +205,14 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
201205 }, record);202206 }203207 208+// Shutdown deliberately avoids `logout()`, which would clear local account209+// state that must carry forward into a restored or sync-enabled runtime.204210 async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {205211 await db.shutdown();206212 state.diagnostics.activeClients -= 1;207213 notifyDiagnostics(state);208214 }209215 210-async function replaceClient(state: RuntimeSlot, db: Db): Promise<void> {211- // The secret store was updated before this point. Shut down the old worker and212- // client without `logout()`, which would clear local account state that must213- // carry forward into the restored/sync-enabled runtime.214- await db.shutdown();215- state.diagnostics.activeClients -= 1;216- notifyDiagnostics(state);217-}218-219216 const adapter = new AdapterLifecycle<Db, LofiRuntime>();220217 let recreationPromise: Promise<LofiRuntime> | null = null;221218 let shutdownPromise: Promise<void> | null = null;
@@ -367,7 +364,7 @@ export function replaceRuntimePrincipal(
367364 state.client,368365 saveSecret,369366 () => createClient(state),370- (current) => replaceClient(state, current),367+ (current) => destroyClient(state, current),371368 );372369 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));373370 })();
M package/testing/fixture.ts +3 -4
M package/tooling/environment.ts +25 -3
@@ -159,16 +159,38 @@ const childEnvironmentAllowlist = [
159159 "TEMP",160160 "TMP",161161 "TMPDIR",162+ // Windows process bootstrap. Children run with a cleared environment, and163+ // without these Windows cannot load system DLLs, resolve executables, or164+ // locate per-user state.165+ "APPDATA",166+ "COMSPEC",167+ "HOMEDRIVE",168+ "HOMEPATH",169+ "LOCALAPPDATA",170+ "PATHEXT",171+ "PROGRAMDATA",172+ "SYSTEMDRIVE",173+ "SYSTEMROOT",174+ "USERPROFILE",175+ "WINDIR",162176 ] as const;163177 178+const childEnvironmentAllowedUpper = new Set<string>(179+ childEnvironmentAllowlist.map((name) => name.toUpperCase()),180+);181+164182 export function childEnvironment(165183 validation: Exclude<EnvironmentValidation, { ok: false }>,166184 source: Record<string, string> = Deno.env.toObject(),167185 ): Record<string, string> {168186 const environment: Record<string, string> = {};169- for (const name of childEnvironmentAllowlist) {170- const value = source[name];171- if (value) environment[name] = value;187+ // Windows environment names are case-insensitive and arrive with arbitrary188+ // casing ("Path", "SystemRoot"); match case-insensitively and preserve the189+ // source casing so the child sees the variables the platform expects.190+ for (const [name, value] of Object.entries(source)) {191+ if (value && childEnvironmentAllowedUpper.has(name.toUpperCase())) {192+ environment[name] = value;193+ }172194 }173195 if (validation.mode === "cloud-configured") {174196 environment.JAZZ_APP_ID = validation.client.JAZZ_APP_ID;
A package/tooling/environment_test.ts +43 -0
@@ -0,0 +1,43 @@
1+// Package contract tests for child-process environment projection.2+import { childEnvironment, validateEnvironment } from "./environment.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+Deno.test("child environments keep Windows-cased and bootstrap variables", () => {9+ const validation = validateEnvironment({});10+ assert(validation.ok, "an empty environment must validate as local-only");11+ const child = childEnvironment(validation, {12+ Path: "C:\\Windows;C:\\bin",13+ SystemRoot: "C:\\Windows",14+ ComSpec: "C:\\Windows\\system32\\cmd.exe",15+ USERPROFILE: "C:\\Users\\dev",16+ SECRET_TOKEN: "never",17+ });18+ // Children run with a cleared environment; Windows delivers names with19+ // arbitrary casing, and dropping them breaks DLL and executable resolution.20+ assert(child.Path === "C:\\Windows;C:\\bin", "Windows-cased Path must be forwarded");21+ assert(child.SystemRoot === "C:\\Windows", "SystemRoot must survive the cleared environment");22+ assert(child.ComSpec?.endsWith("cmd.exe"), "ComSpec must be forwarded");23+ assert(child.USERPROFILE === "C:\\Users\\dev", "per-user state must be forwarded");24+ assert(!("SECRET_TOKEN" in child), "unlisted variables must never be forwarded");25+ assert(26+ child.JAZZ_ADMIN_SECRET === "" && child.BACKEND_SECRET === "",27+ "server secrets remain explicitly cleared",28+ );29+});30+31+Deno.test("child environments still forward the POSIX allowlist exactly", () => {32+ const validation = validateEnvironment({});33+ assert(validation.ok, "an empty environment must validate as local-only");34+ const child = childEnvironment(validation, {35+ PATH: "/usr/bin",36+ HOME: "/home/dev",37+ LANG: "en_US.UTF-8",38+ LD_PRELOAD: "/tmp/evil.so",39+ });40+ assert(child.PATH === "/usr/bin" && child.HOME === "/home/dev", "allowlist must be forwarded");41+ assert(child.LANG === "en_US.UTF-8", "locale must be forwarded");42+ assert(!("LD_PRELOAD" in child), "non-allowlisted variables must be dropped");43+});
M package/tooling/provision.ts +13 -9
@@ -114,24 +114,28 @@ export function environmentForApp(
114114 115115 /**116116 * Merges `values` into the text of a `.env` file, updating existing assignments117- * in place and appending any that are absent. Every other line — comments,118- * blank lines, unrelated keys — is preserved verbatim.117+ * in place and appending any that are absent. Every duplicate assignment of a118+ * key is rewritten — dotenv parsing treats the last occurrence as119+ * authoritative, so leaving a later duplicate stale would make provisioning120+ * report success while commands keep reading the old value. Every other line —121+ * comments, blank lines, unrelated keys — is preserved verbatim.119122 */120123 export function mergeEnv(existing: string, values: Record<string, string>): string {121- const remaining = new Map(Object.entries(values));124+ const replacements = new Map(Object.entries(values));125+ const written = new Set<string>();122126 const lines = existing.length === 0 ? [] : existing.split("\n");123127 const updated = lines.map((line) => {124128 const match = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=)/.exec(line);125129 if (!match) return line;126130 const name = match[2];127- if (!remaining.has(name)) return line;128- const value = remaining.get(name)!;129- remaining.delete(name);130- return `${match[1]}${name}=${value}`;131+ if (!replacements.has(name)) return line;132+ written.add(name);133+ return `${match[1]}${name}=${replacements.get(name)!}`;131134 });132- if (remaining.size > 0) {135+ const missing = [...replacements].filter(([name]) => !written.has(name));136+ if (missing.length > 0) {133137 if (updated.length > 0 && updated[updated.length - 1].trim() !== "") updated.push("");134- for (const [name, value] of remaining) updated.push(`${name}=${value}`);138+ for (const [name, value] of missing) updated.push(`${name}=${value}`);135139 }136140 return `${updated.join("\n").replace(/\n+$/, "")}\n`;137141 }
M package/tooling/provision_test.ts +18 -0
M package/tooling/pwa-validation.ts +111 -100
@@ -1,5 +1,9 @@
11 import { extname, join, relative } from "node:path";22 import { normalizeDeploymentBase } from "./base-path.ts";3+import {4+ isBoundedManifestString,5+ relatedApplicationPlatforms,6+} from "../recipes/related-app-discovery.ts";37 48 export type PwaValidationIssue = {59 detail: string;
@@ -30,15 +34,9 @@ const launchClientModes = new Set([
3034 "navigate-existing",3135 "navigate-new",3236 ]);33-const relatedApplicationPlatforms = new Set([34- "amazon",35- "chrome_web_store",36- "chromeos_play",37- "f-droid",38- "play",39- "webapp",40- "windows",41-]);37+// `relatedApplicationPlatforms` and `isBoundedManifestString` come from the38+// runtime recipe so validation can never accept a declaration the recipe39+// rejects (the two grammars had already diverged once).4240 const imageTypes = new Map([4341 ["image/jpeg", new Set([".jpeg", ".jpg"])],4442 ["image/png", new Set([".png"])],
@@ -75,6 +73,52 @@ function withinScope(target: URL, scope: URL): boolean {
7573 return target.origin === scope.origin && target.pathname.startsWith(scope.pathname);7674 }7775 76+// One shared rule for every manifest action/URL member that must resolve to a77+// clean route inside manifest scope (file handlers, share targets, ...).78+function validateScopedActionUrl(79+ issues: PwaValidationIssue[],80+ label: string,81+ action: unknown,82+ manifestUrl: URL,83+ scope: URL,84+ remediation: string,85+): void {86+ if (!nonEmptyString(action)) {87+ issues.push(issue(`${label} must be a non-empty URL`, remediation));88+ return;89+ }90+ try {91+ const url = new URL(action, manifestUrl);92+ if (!withinScope(url, scope)) {93+ issues.push(issue(`${label} must stay inside manifest scope`, remediation));94+ }95+ if (url.search || url.hash) {96+ issues.push(issue(`${label} must not contain a query or fragment`, remediation));97+ }98+ } catch {99+ issues.push(issue(`${label} is not a valid URL`, remediation));100+ }101+}102+103+// One shared rule for every manifest entry point that must cold-start offline.104+function requireEmittedRoute(105+ issues: PwaValidationIssue[],106+ htmlFiles: readonly string[],107+ basePath: string,108+ label: string,109+ remediation: string,110+ resolveUrl: () => URL,111+): void {112+ try {113+ const route = emittedRoutePath(resolveUrl(), basePath);114+ if (!route || !htmlFiles.includes(route)) {115+ issues.push(issue(`${label} has no emitted offline route`, remediation));116+ }117+ } catch {118+ // Source validation already reports the malformed URL.119+ }120+}121+78122 function assetPath(url: URL, basePath: string): string | undefined {79123 if (url.origin !== syntheticOrigin || !url.pathname.startsWith(basePath)) return undefined;80124 let path: string;
@@ -83,13 +127,18 @@ function assetPath(url: URL, basePath: string): string | undefined {
83127 } catch {84128 return undefined;85129 }86- if (!path || path.startsWith("/") || path.split("/").some((part) => part === "..")) {87- return undefined;88- }130+ // Percent-encoded backslashes survive URL parsing and act as separators131+ // under Windows path semantics, so they are rejected outright rather than132+ // treated as ordinary characters a `/`-split traversal check would miss.133+ if (!path || path.startsWith("/") || path.includes("\\")) return undefined;134+ if (path.split("/").some((part) => part === "." || part === "..")) return undefined;89135 return path;90136 }91137 92138 function pngDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {139+ // A truncated file with a valid signature/IHDR prefix must report "not140+ // decodable" like any other malformed image, not throw a RangeError.141+ if (bytes.length < 24) return undefined;93142 const signature = [137, 80, 78, 71, 13, 10, 26, 10];94143 if (!signature.every((byte, index) => bytes[index] === byte)) return undefined;95144 if (new TextDecoder().decode(bytes.subarray(12, 16)) !== "IHDR") return undefined;
@@ -370,8 +419,8 @@ async function parseAndValidateManifest(
370419 if (!nonEmptyString(raw.platform) || !relatedApplicationPlatforms.has(raw.platform)) {371420 issues.push(issue(`${label}.platform is not supported by this recipe`, remediation));372421 }373- const hasId = nonEmptyString(raw.id) && raw.id.length <= 512;374- const hasUrl = nonEmptyString(raw.url) && raw.url.length <= 512;422+ const hasId = isBoundedManifestString(raw.id);423+ const hasUrl = isBoundedManifestString(raw.url);375424 if (!hasId && !hasUrl) {376425 issues.push(issue(`${label} needs a bounded id or HTTPS url`, remediation));377426 }
@@ -643,23 +692,14 @@ async function parseAndValidateManifest(
643692 if (unknown.length > 0) {644693 issues.push(issue(`${label} contains unsupported members`, remediation));645694 }646- if (!nonEmptyString(raw.action)) {647- issues.push(issue(`${label}.action must be a non-empty URL`, remediation));648- } else {649- try {650- const action = new URL(raw.action, manifestUrl);651- if (!withinScope(action, scope)) {652- issues.push(issue(`${label}.action must stay inside manifest scope`, remediation));653- }654- if (action.search || action.hash) {655- issues.push(656- issue(`${label}.action must not contain a query or fragment`, remediation),657- );658- }659- } catch {660- issues.push(issue(`${label}.action is not a valid URL`, remediation));661- }662- }695+ validateScopedActionUrl(696+ issues,697+ `${label}.action`,698+ raw.action,699+ manifestUrl,700+ scope,701+ remediation,702+ );663703 if (!isObject(raw.accept) || Object.keys(raw.accept).length === 0) {664704 issues.push(issue(`${label}.accept must be a non-empty object`, remediation));665705 } else {
@@ -775,23 +815,14 @@ async function parseAndValidateManifest(
775815 if (!isObject(target)) {776816 issues.push(issue(`${label} must be an object`, remediation));777817 } else {778- if (!nonEmptyString(target.action)) {779- issues.push(issue(`${label}.action must be a non-empty URL`, remediation));780- } else {781- try {782- const action = new URL(target.action, manifestUrl);783- if (!withinScope(action, scope)) {784- issues.push(issue(`${label}.action must stay inside manifest scope`, remediation));785- }786- if (action.search || action.hash) {787- issues.push(788- issue(`${label}.action must not contain a query or fragment`, remediation),789- );790- }791- } catch {792- issues.push(issue(`${label}.action is not a valid URL`, remediation));793- }794- }818+ validateScopedActionUrl(819+ issues,820+ `${label}.action`,821+ target.action,822+ manifestUrl,823+ scope,824+ remediation,825+ );795826 if (target.method !== undefined && target.method !== "GET") {796827 issues.push(797828 issue(
@@ -982,76 +1013,56 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
9821013 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);9831014 for (const [index, raw] of result.manifest.shortcuts.entries()) {9841015 if (!isObject(raw) || !nonEmptyString(raw.url)) continue;985- try {986- const route = emittedRoutePath(new URL(raw.url, manifestUrl), basePath);987- if (!route || !htmlFiles.includes(route)) {988- issues.push(989- issue(990- `dist/${manifestFile}: shortcuts[${index}].url has no emitted offline route`,991- remediation,992- ),993- );994- }995- } catch {996- // Source validation already reports malformed shortcut URLs.997- }1016+ requireEmittedRoute(1017+ issues,1018+ htmlFiles,1019+ basePath,1020+ `dist/${manifestFile}: shortcuts[${index}].url`,1021+ remediation,1022+ () => new URL(raw.url as string, manifestUrl),1023+ );9981024 }9991025 }10001026 if (isObject(result.manifest?.share_target)) {10011027 const target = result.manifest.share_target;10021028 if (nonEmptyString(target.action)) {10031029 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);1004- try {1005- const route = emittedRoutePath(new URL(target.action, manifestUrl), basePath);1006- if (!route || !htmlFiles.includes(route)) {1007- issues.push(1008- issue(1009- `dist/${manifestFile}: share_target.action has no emitted offline route`,1010- remediation,1011- ),1012- );1013- }1014- } catch {1015- // Source validation already reports malformed action URLs.1016- }1030+ requireEmittedRoute(1031+ issues,1032+ htmlFiles,1033+ basePath,1034+ `dist/${manifestFile}: share_target.action`,1035+ remediation,1036+ () => new URL(target.action as string, manifestUrl),1037+ );10171038 }10181039 }10191040 if (Array.isArray(result.manifest?.file_handlers)) {10201041 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);10211042 for (const [index, raw] of result.manifest.file_handlers.entries()) {10221043 if (!isObject(raw) || !nonEmptyString(raw.action)) continue;1023- try {1024- const route = emittedRoutePath(new URL(raw.action, manifestUrl), basePath);1025- if (!route || !htmlFiles.includes(route)) {1026- issues.push(1027- issue(1028- `dist/${manifestFile}: file_handlers[${index}].action has no emitted offline route`,1029- remediation,1030- ),1031- );1032- }1033- } catch {1034- // Source validation already reports malformed action URLs.1035- }1044+ requireEmittedRoute(1045+ issues,1046+ htmlFiles,1047+ basePath,1048+ `dist/${manifestFile}: file_handlers[${index}].action`,1049+ remediation,1050+ () => new URL(raw.action as string, manifestUrl),1051+ );10361052 }10371053 }10381054 if (Array.isArray(result.manifest?.protocol_handlers)) {10391055 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);10401056 for (const [index, raw] of result.manifest.protocol_handlers.entries()) {10411057 if (!isObject(raw) || !nonEmptyString(raw.url) || !raw.url.includes("%s")) continue;1042- try {1043- const route = emittedRoutePath(protocolHandlerUrl(raw.url, manifestUrl), basePath);1044- if (!route || !htmlFiles.includes(route)) {1045- issues.push(1046- issue(1047- `dist/${manifestFile}: protocol_handlers[${index}].url has no emitted offline route`,1048- remediation,1049- ),1050- );1051- }1052- } catch {1053- // Source validation already reports malformed handler URL templates.1054- }1058+ requireEmittedRoute(1059+ issues,1060+ htmlFiles,1061+ basePath,1062+ `dist/${manifestFile}: protocol_handlers[${index}].url`,1063+ remediation,1064+ () => protocolHandlerUrl(raw.url as string, manifestUrl),1065+ );10551066 }10561067 }10571068 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;
M package/tooling/pwa-validation_test.ts +50 -0
@@ -502,3 +502,53 @@ Deno.test("production preview maps install-critical content types", () => {
502502 );503503 assert(productionContentType("icon.webp") === "image/webp", "WebP MIME drifted");504504 });505+506+Deno.test("a truncated PNG reports not decodable instead of throwing", async () => {507+ const { root, project } = await makeProject();508+ try {509+ // A valid PNG signature and IHDR tag, truncated before the dimension bytes.510+ const truncated = new Uint8Array([511+ 137,512+ 80,513+ 78,514+ 71,515+ 13,516+ 10,517+ 26,518+ 10,519+ 0,520+ 0,521+ 0,522+ 13,523+ 73,524+ 72,525+ 68,526+ 82,527+ ]);528+ await Deno.writeFile(join(project, "public", "icon-192.png"), truncated);529+ const issues = await sourcePwaIssues(project);530+ assert(531+ hasIssue(issues, "not decodable as image/png"),532+ "a truncated image must produce the documented diagnostic, not a RangeError",533+ );534+ } finally {535+ await Deno.remove(root, { recursive: true });536+ }537+});538+539+Deno.test("encoded backslashes cannot escape the deployment scope", async () => {540+ const { root, project } = await makeProject();541+ try {542+ const manifest = await readManifest(project);543+ const icons = manifest.icons as Array<Record<string, unknown>>;544+ icons[0] = { ...icons[0], src: "..%5C..%5Cicon-192.png" };545+ await writeManifest(project, manifest);546+ const issues = await sourcePwaIssues(project);547+ assert(548+ hasIssue(issues, "must stay inside deployment scope"),549+ "an encoded-backslash path must be rejected as out of scope",550+ );551+ } finally {552+ await Deno.remove(root, { recursive: true });553+ }554+});
M tools/env_contract.ts +11 -92
@@ -1,92 +1,11 @@
1-export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;2-export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;3-export const environmentNames = [...clientEnvironmentNames, ...serverEnvironmentNames] as const;4-5-interface EnvironmentResultBase {6- presentClientNames: string[];7- presentServerNames: string[];8- warnings: string[];9-}10-11-export type EnvironmentValidation =12- & EnvironmentResultBase13- & (14- | {15- ok: true;16- mode: "local-only";17- client: Record<string, never>;18- }19- | {20- ok: true;21- mode: "cloud-configured";22- client: { JAZZ_APP_ID: string; JAZZ_SERVER_URL: string };23- }24- | {25- ok: false;26- mode: "invalid";27- errors: string[];28- }29- );30-31-function isPresent(env: Readonly<Record<string, string>>, name: string): boolean {32- return (env[name] ?? "").trim().length > 0;33-}34-35-export function validateEnvironment(36- env: Readonly<Record<string, string>>,37-): EnvironmentValidation {38- const presentClientNames = clientEnvironmentNames.filter((name) => isPresent(env, name));39- const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));40- const errors: string[] = [];41- const warnings: string[] = [];42-43- if (44- presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length45- ) {46- const missing = clientEnvironmentNames.filter((name) => !isPresent(env, name));47- errors.push(48- `Cloud sync cannot start because configuration is incomplete; set ${49- missing.join(", ")50- } or remove the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.`,51- );52- }53-54- if (isPresent(env, "JAZZ_SERVER_URL")) {55- try {56- const url = new URL(env.JAZZ_SERVER_URL);57- if (!new Set(["http:", "https:"]).has(url.protocol)) {58- errors.push(59- "Cloud sync cannot start because JAZZ_SERVER_URL must use http or https; correct the URL or remove the cloud configuration to run local-only.",60- );61- }62- } catch {63- errors.push(64- "Cloud sync cannot start because JAZZ_SERVER_URL is not a valid URL; correct it or remove the cloud configuration to run local-only.",65- );66- }67- }68-69- const base: EnvironmentResultBase = {70- presentClientNames: [...presentClientNames],71- presentServerNames: [...presentServerNames],72- warnings,73- };74- if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };75- if (presentClientNames.length === 0) {76- if (presentServerNames.length > 0) {77- warnings.push(78- "Server-only credentials are present without cloud client configuration; they remain isolated and unused by the client.",79- );80- }81- return { ...base, ok: true, mode: "local-only", client: {} };82- }83- return {84- ...base,85- ok: true,86- mode: "cloud-configured",87- client: {88- JAZZ_APP_ID: env.JAZZ_APP_ID.trim(),89- JAZZ_SERVER_URL: env.JAZZ_SERVER_URL.trim(),90- },91- };92-}1+// Repository tools share the shipped package's environment contract, so a2+// repo tool can never interpret the same `.env` differently from generated3+// commands (name allowlist, validation rules, and LOFI_BASE_PATH included).4+export {5+ clientEnvironmentNames,6+ deploymentEnvironmentNames,7+ environmentNames,8+ type EnvironmentValidation,9+ serverEnvironmentNames,10+ validateEnvironment,11+} from "../package/tooling/environment.ts";
M tools/golden.ts +47 -41
@@ -635,16 +635,28 @@ async function selectVirtualCredential(page: Page, credentialId: string): Promis
635635 }, credentialId);636636 }637637 638+// Every golden-recipe installer patches the same authored manifest; one639+// helper owns the read/patch/write cycle so the seven installers cannot640+// drift in how they load or serialize it.641+async function patchManifest(642+ projectRoot: string,643+ // deno-lint-ignore no-explicit-any644+ patch: (manifest: any) => void,645+): Promise<void> {646+ await patchManifest(projectRoot, (manifest) => {647+ patch(manifest);648+ });649+}650+638651 async function installWebShareRecipe(projectRoot: string) {639- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");640- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));641- manifest.share_target = {642- action: "./share/",643- method: "GET",644- enctype: "application/x-www-form-urlencoded",645- params: { title: "title", text: "text", url: "url" },646- };647- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);652+ await patchManifest(projectRoot, (manifest) => {653+ manifest.share_target = {654+ action: "./share/",655+ method: "GET",656+ enctype: "application/x-www-form-urlencoded",657+ params: { title: "title", text: "text", url: "url" },658+ };659+ });648660 await Deno.writeTextFile(649661 join(projectRoot, "src", "pages", "share.astro"),650662 `---
@@ -702,10 +714,9 @@ export default function ShareReceiver() {
702714 }703715 704716 async function installLaunchHandlerRecipe(projectRoot: string) {705- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");706- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));707- manifest.launch_handler = { client_mode: ["focus-existing", "auto"] };708- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);717+ await patchManifest(projectRoot, (manifest) => {718+ manifest.launch_handler = { client_mode: ["focus-existing", "auto"] };719+ });709720 await Deno.writeTextFile(710721 join(projectRoot, "src", "pages", "launch.astro"),711722 `---
@@ -760,13 +771,12 @@ export default function LaunchReceiver() {
760771 }761772 762773 async function installFileHandlerRecipe(projectRoot: string) {763- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");764- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));765- manifest.file_handlers = [{766- action: "./import/",767- accept: { "application/json": [".json"] },768- }];769- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);774+ await patchManifest(projectRoot, (manifest) => {775+ manifest.file_handlers = [{776+ action: "./import/",777+ accept: { "application/json": [".json"] },778+ }];779+ });770780 await Deno.writeTextFile(771781 join(projectRoot, "src", "pages", "import.astro"),772782 `---
@@ -854,10 +864,9 @@ export default function FileImport() {
854864 }855865 856866 async function installProtocolHandlerRecipe(projectRoot: string) {857- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");858- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));859- manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open-item/?url=%s" }];860- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);867+ await patchManifest(projectRoot, (manifest) => {868+ manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open-item/?url=%s" }];869+ });861870 await Deno.writeTextFile(862871 join(projectRoot, "src", "pages", "open-item.astro"),863872 `---
@@ -915,15 +924,14 @@ export default function ProtocolItem() {
915924 }916925 917926 async function installRelatedAppDiscoveryRecipe(projectRoot: string) {918- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");919- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));920- manifest.prefer_related_applications = false;921- manifest.related_applications = [{922- platform: "play",923- id: "com.example.companion",924- url: "https://play.google.com/store/apps/details?id=com.example.companion",925- }];926- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);927+ await patchManifest(projectRoot, (manifest) => {928+ manifest.prefer_related_applications = false;929+ manifest.related_applications = [{930+ platform: "play",931+ id: "com.example.companion",932+ url: "https://play.google.com/store/apps/details?id=com.example.companion",933+ }];934+ });927935 await Deno.writeTextFile(928936 join(projectRoot, "src", "pages", "companion.astro"),929937 `---
@@ -950,10 +958,9 @@ export default function CompanionDiscovery() {
950958 }951959 952960 async function installScopeExtensionRecipe(projectRoot: string) {953- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");954- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));955- manifest.scope_extensions = [{ type: "origin", origin: "https://help.example.com" }];956- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);961+ await patchManifest(projectRoot, (manifest) => {962+ manifest.scope_extensions = [{ type: "origin", origin: "https://help.example.com" }];963+ });957964 await Deno.writeTextFile(958965 join(projectRoot, "src", "pages", "scope-extension.astro"),959966 `---
@@ -982,10 +989,9 @@ const verified = verifyWebAppOriginAssociation(association, expected);
982989 }983990 984991 async function installWindowControlsOverlayRecipe(projectRoot: string) {985- const manifestPath = join(projectRoot, "public", "manifest.webmanifest");986- const manifest = JSON.parse(await Deno.readTextFile(manifestPath));987- manifest.display_override = ["window-controls-overlay"];988- await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);992+ await patchManifest(projectRoot, (manifest) => {993+ manifest.display_override = ["window-controls-overlay"];994+ });989995 await Deno.writeTextFile(990996 join(projectRoot, "src", "pages", "desktop-titlebar.astro"),991997 `---
M tools/load_env.ts +5 -30
@@ -1,27 +1,9 @@
1-import { environmentNames } from "./env_contract.ts";1+// Repository tools reuse the shipped package's dotenv parsing and loading so2+// both sides of the repo read `.env` identically. Only the named-subset3+// helpers below are repo-tool-specific.4+import { environmentNames, parseDotenv } from "../package/tooling/environment.ts";25 3-export function parseDotenv(source: string): Record<string, string> {4- const parsed: Record<string, string> = {};5- for (const originalLine of source.split(/\r?\n/)) {6- let line = originalLine.trim();7- if (line.length === 0 || line.startsWith("#")) continue;8- if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();9- const separator = line.indexOf("=");10- if (separator < 1) continue;11- const name = line.slice(0, separator).trim();12- if (!environmentNames.includes(name as (typeof environmentNames)[number])) continue;13- let value = line.slice(separator + 1).trim();14- if (15- value.length >= 2 &&16- ((value.startsWith('"') && value.endsWith('"')) ||17- (value.startsWith("'") && value.endsWith("'")))18- ) {19- value = value.slice(1, -1);20- }21- parsed[name] = value;22- }23- return parsed;24-}6+export { loadEnvironment, parseDotenv } from "../package/tooling/environment.ts";257 268 export function mergeEnvironment(279 fileEnvironment: Readonly<Record<string, string>>,
@@ -40,13 +22,6 @@ export function mergeNamedEnvironment(
4022 );4123 }4224 43-export async function loadEnvironment(44- path = ".env",45- processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),46-): Promise<Record<string, string>> {47- return await loadNamedEnvironment(environmentNames, path, processLookup);48-}49-5025 export async function loadNamedEnvironment(5126 names: readonly string[],5227 path = ".env",
D tools/preview.ts +0 -52
@@ -1,52 +0,0 @@
1-import { extname, join, normalize } from "node:path";2-3-const root = "apps/reference/dist";4-const portFlag = Deno.args.findIndex((arg) => arg === "--port");5-const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;6-if (!Number.isInteger(port) || port < 1 || port > 65_535) {7- console.error("error: --port must be an integer from 1 to 65535");8- Deno.exit(2);9-}10-11-const contentTypes: Record<string, string> = {12- ".css": "text/css; charset=utf-8",13- ".html": "text/html; charset=utf-8",14- ".js": "text/javascript; charset=utf-8",15- ".json": "application/json; charset=utf-8",16- ".svg": "image/svg+xml",17- ".wasm": "application/wasm",18- ".webmanifest": "application/manifest+json",19-};20-21-async function buildIdentity(): Promise<string> {22- try {23- const value = JSON.parse(await Deno.readTextFile(`${root}/lofi-build.json`));24- return typeof value.revision === "string" ? value.revision : "unknown";25- } catch {26- console.error("error: production build is missing or stale; run `deno task build` first");27- Deno.exit(1);28- }29-}30-31-const revision = await buildIdentity();32-console.log(`lofi preview: http://127.0.0.1:${port}/ (build ${revision})`);33-34-Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {35- const url = new URL(request.url);36- let pathname = decodeURIComponent(url.pathname);37- if (pathname.endsWith("/")) pathname += "index.html";38- const relative = normalize(pathname).replace(/^[/\\]+/, "");39- if (relative.startsWith("..")) return new Response("Not found", { status: 404 });40- const path = join(root, relative);41- try {42- const body = await Deno.readFile(path);43- return new Response(request.method === "HEAD" ? null : body, {44- headers: {45- "content-type": contentTypes[extname(path)] ?? "application/octet-stream",46- },47- });48- } catch (error) {49- if (error instanceof Deno.errors.NotFound) return new Response("Not found", { status: 404 });50- throw error;51- }52-});