clone

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

feat(pwa): add scope extension recipe

Dami committed bc24f57
- validate exact HTTPS scope extension origins and reciprocal association files

- document deployment, fallback, revocation, and origin security boundaries

- cover generated-package, manifest, build, and browser golden paths
commit
bc24f57788a01c3c0ce46f96c9cd6b3d9f010194
parent
cbdec3328861450f71a99a6c6193326f5585b5d1
author
Dami <dami.etoile@gmail.com>
17 files changed, +446 -8
MREADME.md5+-
Mapps/reference/deno.json1+
Mdeno.json1+
Mdocs/getting-started.md1+
Mdocs/recipes/README.md2+
Adocs/recipes/scope-extension.md87+++++++++++++++++++++++
Mpackage/astro/mod.ts14++++
Mpackage/astro/mod_test.ts2+
Mpackage/create_core.ts6+-
Mpackage/create_core_test.ts9++-
Apackage/recipes/scope-extension.ts115++++++++++++++++++++++++++++++
Apackage/recipes/scope-extension_test.ts72+++++++++++++++++++
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/tooling/pwa-validation.ts43+++++++++++
Mpackage/tooling/pwa-validation_test.ts32++++++++
Mtools/doc_lint.ts1+
Mtools/golden.ts59+++++++++++++++-
M README.md +3 -2
M apps/reference/deno.json +1 -0
M deno.json +1 -0
M docs/getting-started.md +1 -0
M docs/recipes/README.md +2 -0
A docs/recipes/scope-extension.md +87 -0
@@ -0,0 +1,87 @@
1+# Cross-origin app-window scope2+3+Use this recipe only when one product owns both an installed PWA and a separate HTTPS origin that4+should remain inside the installed app window, such as product-owned help or a regional surface.5+[`scope_extensions`](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/scope_extensions)6+is experimental. It changes window presentation after both origins opt in; it does not join their7+security, storage, or service-worker boundaries.8+9+The generated starter stays single-origin and omits this capability.10+11+## Declare the extending origin12+13+Add an exact origin to the primary app's `manifest.webmanifest`:14+15+```json16+{17+ "id": "https://app.example.com/notes",18+ "scope_extensions": [19+ { "type": "origin", "origin": "https://help.example.com" }20+ ]21+}22+```23+24+The origin must be HTTPS and contain no credentials, path, query, or fragment. Lofi's manifest25+validation rejects malformed, duplicate, and invented entries.26+27+## Publish reciprocal ownership28+29+On `https://help.example.com`, publish this JSON at the exact path30+`/.well-known/web-app-origin-association`:31+32+```json33+{34+ "https://app.example.com/notes": {35+ "scope": "/notes/"36+ }37+}38+```39+40+The key is the exact absolute manifest ID, not merely the app origin. The value limits which paths41+on the extending origin receive app-window presentation. Generate and verify it at deploy time:42+43+```ts44+import {45+ createScopeExtension,46+ createWebAppOriginAssociation,47+ verifyWebAppOriginAssociation,48+} from "@nzip/lofi/recipes/scope-extension";49+50+const declaration = createScopeExtension("https://help.example.com");51+const expected = {52+ manifestId: "https://app.example.com/notes",53+ scope: "/notes/",54+};55+const association = createWebAppOriginAssociation(expected);56+57+// After fetching the deployed JSON in trusted deployment tooling:58+if (!verifyWebAppOriginAssociation(deployedJson, expected)) {59+ throw new Error("reciprocal scope association is missing");60+}61+```62+63+Do not fetch the association from application runtime code. A true verification result confirms64+deployment configuration only; it is not authentication or authorization.65+66+## Deploy, test, and revoke67+68+Deploy in this order:69+70+1. Publish the association on the extending origin with JSON content type, HTTPS, and no redirect.71+2. Fetch that exact well-known URL without credentials and verify the exact manifest ID and scope.72+3. Add `scope_extensions` to the primary manifest, then allow browser manifest caches to refresh.73+4. Test a fresh install and an existing install on supported and unsupported browsers.74+75+During a partial rollout, unsupported browsers, a missing association, or a failed ownership check,76+links continue as ordinary out-of-scope navigation and may open browser UI. Build the feature so77+that external navigation is useful. For revocation, first remove the primary manifest declaration78+and let installed clients refresh; then remove the remote association.79+80+The primary service worker cannot control or cache the extending origin. Offline navigation there81+requires that origin's own offline design. The origins retain separate cookies, storage, passkey82+relying-party behavior, Jazz configuration, CSP, CORS, authentication, and authorization. Never use83+scope extension as a way to share any of them or to bypass an origin boundary.84+85+Test exact and mismatched manifest IDs, invalid and duplicate origins, path-limited navigation,86+ordinary external fallback, partial rollout, revocation, offline navigation, manifest refresh, and87+passkey/storage/Jazz isolation before shipping.
M package/astro/mod.ts +14 -0
@@ -66,6 +66,7 @@ const recipeFiles = [
6666 "launch-handler.ts",6767 "protocol-handler.ts",6868 "related-app-discovery.ts",69+ "scope-extension.ts",6970 "web-share.ts",7071 ] as const;7172
@@ -105,6 +106,13 @@ function renderConfig(projectRoot: string): string {
105106 "recipes",106107 "related-app-discovery.ts",107108 );109+ const scopeExtensionRecipe = join(110+ projectRoot,111+ ".lofi",112+ "package",113+ "recipes",114+ "scope-extension.ts",115+ );108116 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");109117 return `import preact from "@astrojs/preact";110118 import { defineConfig } from "astro/config";
@@ -156,6 +164,9 @@ export default defineConfig({
156164 } },157165 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/related-app-discovery$/, replacement: ${158166 JSON.stringify(relatedAppDiscoveryRecipe)167+ } },168+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/scope-extension$/, replacement: ${169+ JSON.stringify(scopeExtensionRecipe)159170 } },160171 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${161172 JSON.stringify(webShareRecipe)
@@ -178,6 +189,9 @@ export default defineConfig({
178189 } },179190 { find: "@nzip/lofi/recipes/related-app-discovery", replacement: ${180191 JSON.stringify(relatedAppDiscoveryRecipe)192+ } },193+ { find: "@nzip/lofi/recipes/scope-extension", replacement: ${194+ JSON.stringify(scopeExtensionRecipe)181195 } },182196 { find: "@nzip/lofi/recipes/file-handler", replacement: ${183197 JSON.stringify(fileHandlerRecipe)
M package/astro/mod_test.ts +2 -0
M package/create_core.ts +5 -1
M package/create_core_test.ts +7 -2
A package/recipes/scope-extension.ts +115 -0
@@ -0,0 +1,115 @@
1+/**2+ * Opt-in deployment helpers for reciprocal PWA scope extensions.3+ *4+ * @module5+ */6+7+/** A manifest `scope_extensions` entry owned by this product. */8+export type ScopeExtensionDeclaration = {9+ readonly type: "origin";10+ readonly origin: string;11+};12+13+/** The value published under an exact manifest ID in an origin association file. */14+export type WebAppOriginAssociationEntry = {15+ readonly scope: string;16+};17+18+/** Options shared by association generation and verification. */19+export type WebAppOriginAssociationOptions = {20+ /** Exact absolute manifest ID, including any path. */21+ manifestId: string;22+ /** Absolute-path scope hosted by the extending origin. */23+ scope: string;24+};25+26+function isObject(value: unknown): value is Record<string, unknown> {27+ return value !== null && typeof value === "object" && !Array.isArray(value);28+}29+30+function normalizeManifestId(value: string): string {31+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) {32+ throw new TypeError("manifest ID must be a bounded absolute HTTPS URL");33+ }34+ const parsed = new URL(value);35+ if (36+ parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search ||37+ parsed.hash || parsed.href !== value38+ ) {39+ throw new TypeError(40+ "manifest ID must be an exact credential-free HTTPS URL without query or fragment",41+ );42+ }43+ return parsed.href;44+}45+46+function normalizeOrigin(value: string): string {47+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) {48+ throw new TypeError("scope extension origin must be a bounded HTTPS origin");49+ }50+ const parsed = new URL(value);51+ if (52+ parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" ||53+ parsed.search || parsed.hash || parsed.origin !== value54+ ) {55+ throw new TypeError("scope extension origin must be an exact credential-free HTTPS origin");56+ }57+ return parsed.origin;58+}59+60+function normalizeScope(value: string): string {61+ if (62+ typeof value !== "string" || value.length === 0 || value.length > 2048 ||63+ !value.startsWith("/") || value.startsWith("//") || value.includes("\\")64+ ) {65+ throw new TypeError("association scope must be a bounded absolute path");66+ }67+ const parsed = new URL(value, "https://scope.invalid");68+ let decoded: string;69+ try {70+ decoded = decodeURIComponent(parsed.pathname);71+ } catch {72+ throw new TypeError("association scope must have valid path encoding");73+ }74+ if (75+ parsed.origin !== "https://scope.invalid" || parsed.search || parsed.hash ||76+ decoded.split("/").some((segment) => segment === "." || segment === "..") ||77+ parsed.pathname !== value78+ ) {79+ throw new TypeError("association scope must be a normalized absolute path without traversal");80+ }81+ return parsed.pathname;82+}83+84+/** Create one validated manifest declaration without adding it to the starter. */85+export function createScopeExtension(origin: string): ScopeExtensionDeclaration {86+ return { type: "origin", origin: normalizeOrigin(origin) };87+}88+89+/**90+ * Create the JSON value for `/.well-known/web-app-origin-association`.91+ *92+ * Deploy this file on the extending origin before adding its declaration to93+ * the primary app manifest.94+ */95+export function createWebAppOriginAssociation(96+ options: WebAppOriginAssociationOptions,97+): Readonly<Record<string, WebAppOriginAssociationEntry>> {98+ const manifestId = normalizeManifestId(options.manifestId);99+ return { [manifestId]: { scope: normalizeScope(options.scope) } };100+}101+102+/**103+ * Verify that unknown association JSON contains the exact manifest ID and scope.104+ * Extra manifest entries are allowed, but the matched entry may contain only `scope`.105+ */106+export function verifyWebAppOriginAssociation(107+ value: unknown,108+ options: WebAppOriginAssociationOptions,109+): boolean {110+ const manifestId = normalizeManifestId(options.manifestId);111+ const scope = normalizeScope(options.scope);112+ if (!isObject(value)) return false;113+ const entry = value[manifestId];114+ return isObject(entry) && Object.keys(entry).length === 1 && entry.scope === scope;115+}
A package/recipes/scope-extension_test.ts +72 -0
@@ -0,0 +1,72 @@
1+import {2+ createScopeExtension,3+ createWebAppOriginAssociation,4+ verifyWebAppOriginAssociation,5+} from "./scope-extension.ts";6+7+function assert(condition: unknown, message: string): asserts condition {8+ if (!condition) throw new Error(message);9+}10+11+Deno.test("scope extension helpers produce reciprocal deployment values", () => {12+ const declaration = createScopeExtension("https://help.example.com");13+ assert(declaration.origin === "https://help.example.com", "origin changed");14+ const association = createWebAppOriginAssociation({15+ manifestId: "https://app.example.com/products/notes",16+ scope: "/notes/",17+ });18+ assert(19+ association["https://app.example.com/products/notes"]?.scope === "/notes/",20+ "association did not use the exact manifest ID",21+ );22+ assert(23+ verifyWebAppOriginAssociation(association, {24+ manifestId: "https://app.example.com/products/notes",25+ scope: "/notes/",26+ }),27+ "generated association failed verification",28+ );29+});30+31+Deno.test("association verification fails closed for partial or unexpected values", () => {32+ const expected = { manifestId: "https://app.example.com/app", scope: "/help/" };33+ for (34+ const value of [35+ null,36+ {},37+ { "https://app.example.com/": { scope: "/help/" } },38+ { [expected.manifestId]: { scope: "/other/" } },39+ { [expected.manifestId]: { scope: "/help/", authorization: true } },40+ ]41+ ) {42+ assert(!verifyWebAppOriginAssociation(value, expected), "invalid association passed");43+ }44+});45+46+Deno.test("scope extension helpers reject unsafe URLs and paths", () => {47+ for (48+ const run of [49+ () => createScopeExtension("http://help.example.com"),50+ () => createScopeExtension("https://help.example.com/path"),51+ () => createScopeExtension("https://user:secret@help.example.com"),52+ () =>53+ createWebAppOriginAssociation({54+ manifestId: "https://app.example.com/app?version=1",55+ scope: "/help/",56+ }),57+ () =>58+ createWebAppOriginAssociation({59+ manifestId: "https://app.example.com/app",60+ scope: "/help/../private/",61+ }),62+ ]63+ ) {64+ let rejected = false;65+ try {66+ run();67+ } catch (error) {68+ rejected = error instanceof TypeError;69+ }70+ assert(rejected, "unsafe scope extension input passed");71+ }72+});
M package/testdata/starter.snapshot.json +2 -2
M package/tooling/pwa-validation.ts +43 -0
@@ -369,6 +369,49 @@ async function parseAndValidateManifest(
369369 }370370 }371371 }372+ if (manifest.scope_extensions !== undefined) {373+ if (!Array.isArray(manifest.scope_extensions) || manifest.scope_extensions.length === 0) {374+ issues.push(375+ issue(`${manifestFile}: scope_extensions must be a non-empty array`, remediation),376+ );377+ } else {378+ const origins = new Set<string>();379+ for (const [index, raw] of manifest.scope_extensions.entries()) {380+ const label = `${manifestFile}: scope_extensions[${index}]`;381+ if (!isObject(raw)) {382+ issues.push(issue(`${label} must be an object`, remediation));383+ continue;384+ }385+ if (Object.keys(raw).some((member) => !["type", "origin"].includes(member))) {386+ issues.push(issue(`${label} contains unsupported members`, remediation));387+ }388+ if (raw.type !== "origin") {389+ issues.push(issue(`${label}.type must be origin`, remediation));390+ }391+ if (!nonEmptyString(raw.origin)) {392+ issues.push(issue(`${label}.origin must be an HTTPS origin`, remediation));393+ continue;394+ }395+ try {396+ const origin = new URL(raw.origin);397+ if (398+ origin.protocol !== "https:" || origin.username || origin.password ||399+ origin.pathname !== "/" || origin.search || origin.hash || origin.origin !== raw.origin400+ ) {401+ issues.push(402+ issue(`${label}.origin must be an exact credential-free HTTPS origin`, remediation),403+ );404+ }405+ if (origins.has(origin.origin)) {406+ issues.push(issue(`${label}.origin must not repeat`, remediation));407+ }408+ origins.add(origin.origin);409+ } catch {410+ issues.push(issue(`${label}.origin is not a valid URL`, remediation));411+ }412+ }413+ }414+ }372415 for (const member of ["background_color", "theme_color"] as const) {373416 if (!isLikelyCssColor(manifest[member])) {374417 issues.push(issue(`${manifestFile}: ${member} must be a CSS color`, remediation));
M package/tooling/pwa-validation_test.ts +32 -0
@@ -299,6 +299,38 @@ Deno.test("source PWA validation checks opt-in related applications without pref
299299 }300300 });301301 302+Deno.test("source PWA validation checks opt-in scope extensions", async () => {303+ const { root, project } = await makeProject();304+ try {305+ const manifest = await readManifest(project);306+ manifest.scope_extensions = [307+ {308+ type: "invented",309+ origin: "http://user:secret@help.example.com/path?unsafe=1",310+ authorization: true,311+ },312+ { type: "origin", origin: "https://help.example.com" },313+ {314+ type: "origin",315+ origin: "https://help.example.com",316+ },317+ ];318+ await writeManifest(project, manifest);319+ const issues = await sourcePwaIssues(project);320+ assert(hasIssue(issues, "type must be origin"), "invented extension type was accepted");321+ assert(hasIssue(issues, "exact credential-free HTTPS origin"), "unsafe origin was accepted");322+ assert(hasIssue(issues, "contains unsupported members"), "unknown extension member passed");323+ assert(hasIssue(issues, "origin must not repeat"), "duplicate origin was accepted");324+325+ manifest.scope_extensions = [{ type: "origin", origin: "https://help.example.com" }];326+ await writeManifest(project, manifest);327+ const valid = await sourcePwaIssues(project);328+ assert(valid.length === 0, `valid scope extension was rejected: ${JSON.stringify(valid)}`);329+ } finally {330+ await Deno.remove(root, { recursive: true });331+ }332+});333+302334 Deno.test("source PWA validation permits replacement branding and optional members", async () => {303335 const { root, project } = await makeProject();304336 try {
M tools/doc_lint.ts +1 -0
M tools/golden.ts +58 -1
@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 17, `expected 17 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 18, `expected 18 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;
@@ -949,6 +949,38 @@ export default function CompanionDiscovery() {
949949 );950950 }951951 952+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`);957+ await Deno.writeTextFile(958+ join(projectRoot, "src", "pages", "scope-extension.astro"),959+ `---960+import {961+ createScopeExtension,962+ createWebAppOriginAssociation,963+ verifyWebAppOriginAssociation,964+} from "@nzip/lofi/recipes/scope-extension";965+import Shell from "../layouts/Shell.astro";966+import "../styles/global.css";967+const declaration = createScopeExtension("https://help.example.com");968+const expected = { manifestId: "https://app.example.com/notes", scope: "/notes/" };969+const association = createWebAppOriginAssociation(expected);970+const verified = verifyWebAppOriginAssociation(association, expected);971+---972+<Shell title="Product help">973+ <section class="island">974+ <h1>Product help origin</h1>975+ <output data-scope-origin>{declaration.origin}</output>976+ <output data-scope-association>{verified ? JSON.stringify(association) : "invalid"}</output>977+ <a data-scope-fallback href="https://help.example.com/notes/">Open product help</a>978+ </section>979+</Shell>980+`,981+ );982+}983+952984 async function main() {953985 const { source, version } = parseArgs(Deno.args);954986 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {
@@ -1203,6 +1235,10 @@ async function main() {
12031235 "install related-app-discovery recipe",12041236 () => installRelatedAppDiscoveryRecipe(projectRoot!),12051237 );1238+ await stage(1239+ "install scope-extension recipe",1240+ () => installScopeExtensionRecipe(projectRoot!),1241+ );12061242 12071243 await stage(12081244 "build",
@@ -1632,6 +1668,27 @@ async function main() {
16321668 description: "task app after related-app discovery exercise",16331669 });16341670 });1671+ await stage("scope-extension recipe browser", async () => {1672+ await page!.goto(`${origin}scope-extension/`, { waitUntil: "domcontentloaded" });1673+ assert(1674+ await page!.locator("[data-scope-origin]").textContent() === "https://help.example.com",1675+ "scope extension origin was not normalized",1676+ );1677+ const association = await page!.locator("[data-scope-association]").textContent() ?? "";1678+ assert(1679+ association.includes('"https://app.example.com/notes":{"scope":"/notes/"}'),1680+ "reciprocal association was not generated and verified",1681+ );1682+ assert(1683+ await page!.locator("[data-scope-fallback]").getAttribute("href") ===1684+ "https://help.example.com/notes/",1685+ "unsupported scope extension lost ordinary external navigation",1686+ );1687+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1688+ await waitForReady(page!, taskAppReady, undefined, {1689+ description: "task app after scope-extension exercise",1690+ });1691+ });16351692 await stage("waiting worker update browser", async () => {16361693 const workerPath = join(projectRoot!, "dist", "sw.js");16371694 const worker = await Deno.readTextFile(workerPath);