- 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>
| M | README.md | 5 | +- |
| M | apps/ | 1 | + |
| M | deno.json | 1 | + |
| M | docs/ | 1 | + |
| M | docs/ | 2 | + |
| A | docs/ | 87 | +++++++++++++++++++++++ |
| M | package/ | 14 | ++++ |
| M | package/ | 2 | + |
| M | package/ | 6 | +- |
| M | package/ | 9 | ++- |
| A | package/ | 115 | ++++++++++++++++++++++++++++++ |
| A | package/ | 72 | +++++++++++++++++++ |
| M | package/ | 4 | +- |
| M | package/ | 43 | +++++++++++ |
| M | package/ | 32 | ++++++++ |
| M | tools/ | 1 | + |
| M | tools/ | 59 | +++++++++++++++- |
@@ -135,8 +135,9 @@ permission APIs.
135135 136136 OS-facing capabilities stay out of the starter until the product opts in. The137137 [installed-app recipe catalog](docs/recipes/README.md) contains tested, isolated entrypoints for Web138-Share, launch handling, file-import previews, custom-protocol links, and presentation-only companion139-discovery. Each keeps manifest changes opt-in and documents validation, behavior, and fallbacks.138+Share, launch handling, file-import previews, custom-protocol links, presentation-only companion139+discovery, and reciprocal cross-origin app-window scope. Each keeps manifest changes opt-in and140+documents validation, behavior, and fallbacks.140141 141142 ## Testing local-first behavior142143 @@ -16,6 +16,7 @@
1616 "@nzip/lofi/recipes/launch-handler": "../../package/recipes/launch-handler.ts",1717 "@nzip/lofi/recipes/protocol-handler": "../../package/recipes/protocol-handler.ts",1818 "@nzip/lofi/recipes/related-app-discovery": "../../package/recipes/related-app-discovery.ts",19+ "@nzip/lofi/recipes/scope-extension": "../../package/recipes/scope-extension.ts",1920 "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",2021 "@nzip/lofi/test": "../../package/commands/run_tests.ts",2122 "@nzip/lofi/testing": "../../package/testing/mod.ts",@@ -18,6 +18,7 @@
1818 "./recipes/launch-handler": "./package/recipes/launch-handler.ts",1919 "./recipes/protocol-handler": "./package/recipes/protocol-handler.ts",2020 "./recipes/related-app-discovery": "./package/recipes/related-app-discovery.ts",21+ "./recipes/scope-extension": "./package/recipes/scope-extension.ts",2122 "./recipes/web-share": "./package/recipes/web-share.ts",2223 "./testing": "./package/testing/mod.ts",2324 "./test": "./package/commands/run_tests.ts"@@ -72,6 +72,7 @@ The final package map is intentionally small:
7272 | `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |7373 | `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |7474 | `@nzip/lofi/recipes/related-app-discovery` | Optional presentation-only companion discovery |75+| `@nzip/lofi/recipes/scope-extension` | Optional reciprocal cross-origin app-window scope |7576 | `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |7677 | `@nzip/lofi/testing` | Local-first Playwright helpers |7778 | `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |@@ -24,6 +24,8 @@ flowchart LR
2424 allow-listed collaborative-list identifiers without arbitrary redirects.2525 - [Related-application discovery](related-app-discovery.md) — hide redundant companion onboarding2626 from exact verified-app matches without changing authentication or PWA installability.27+- [Cross-origin app-window scope](scope-extension.md) — keep product-owned secondary-origin paths28+ inside an installed window after reciprocal deployment verification.2729 2830 ## Contract for every recipe2931 @@ -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.@@ -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)@@ -18,6 +18,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1818 ".lofi/package/recipes/file-handler.ts",1919 ".lofi/package/recipes/protocol-handler.ts",2020 ".lofi/package/recipes/related-app-discovery.ts",21+ ".lofi/package/recipes/scope-extension.ts",2122 ".lofi/package/recipes/web-share.ts",2223 "^jsr:@nzip\\/lofi@[^/]+",2324 "^npm:preact@[^/]+",@@ -40,6 +41,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
4041 await Deno.stat(`${root}/.lofi/package/recipes/launch-handler.ts`);4142 await Deno.stat(`${root}/.lofi/package/recipes/protocol-handler.ts`);4243 await Deno.stat(`${root}/.lofi/package/recipes/related-app-discovery.ts`);44+ await Deno.stat(`${root}/.lofi/package/recipes/scope-extension.ts`);4345 await Deno.stat(`${root}/.lofi/package/recipes/web-share.ts`);4446 } finally {4547 await Deno.remove(root, { recursive: true });@@ -85,7 +85,7 @@ OS-facing capabilities remain absent until the product opts in. Start with the
8585 for tested manifest patches, feature detection, input validation, offline behavior, and normal-web8686 fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated8787 file-import previews, custom protocol links, and presentation-only companion discovery without adding88-any to the starter.88+any to the starter. Cross-origin app-window scope remains a reciprocal deployment opt-in.8989 9090 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:9191 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.@@ -146,6 +146,10 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
146146 subpath: "recipes/related-app-discovery",147147 localPath: "recipes/related-app-discovery.ts",148148 },149+ "@nzip/lofi/recipes/scope-extension": {150+ subpath: "recipes/scope-extension",151+ localPath: "recipes/scope-extension.ts",152+ },149153 "@nzip/lofi/recipes/web-share": {150154 subpath: "recipes/web-share",151155 localPath: "recipes/web-share.ts",@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 16,110- `expected one package prefix, access, two integrations, six commands, five recipes, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 17,110+ `expected one package prefix, access, two integrations, six commands, six recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),@@ -155,6 +155,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
155155 "./recipes/launch-handler",156156 "./recipes/protocol-handler",157157 "./recipes/related-app-discovery",158+ "./recipes/scope-extension",158159 "./recipes/web-share",159160 "./test",160161 "./testing",@@ -179,6 +180,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
179180 packageManifest.exports["./recipes/related-app-discovery"],180181 "./package/recipes/related-app-discovery.ts",181182 );183+ assertEquals(184+ packageManifest.exports["./recipes/scope-extension"],185+ "./package/recipes/scope-extension.ts",186+ );182187 assertEquals(183188 packageManifest.exports["./recipes/web-share"],184189 "./package/recipes/web-share.ts",@@ -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+}@@ -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+});@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "c83e98279e71d5f540093e0845509de3758cad82cb72712b0e2950fe12967cf1",4+ "deno.json": "1fe481e9665f611accf1dc00a72eb826d01d31c238deb56d7b6570ad1b7f1c0d",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -11,7 +11,7 @@
1111 "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "c12eb7b97332ed138d3a777bbb72194066213b25db014cf853279361be5e9224",14+ "README.md": "67a60351470cd1a61c2033581fb50f617a54ce139dbd499f9f131bb939aa9c27",1515 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -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));@@ -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 {@@ -13,6 +13,7 @@ const entrypoints = [
1313 "package/recipes/launch-handler.ts",1414 "package/recipes/protocol-handler.ts",1515 "package/recipes/related-app-discovery.ts",16+ "package/recipes/scope-extension.ts",1617 "package/recipes/web-share.ts",1718 "package/testing/mod.ts",1819 "package/commands/run_tests.ts",@@ -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);