clone

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

main
lofi / package / recipes / scope-extension.ts
scope-extension.ts 4.0K
1/**2 * Opt-in deployment helpers for reciprocal PWA scope extensions.3 *4 * @module5 */67/** A manifest `scope_extensions` entry owned by this product. */8export type ScopeExtensionDeclaration = {9 readonly type: "origin";10 readonly origin: string;11};1213/** The value published under an exact manifest ID in an origin association file. */14export type WebAppOriginAssociationEntry = {15 readonly scope: string;16};1718/** Options shared by association generation and verification. */19export 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};2526function isObject(value: unknown): value is Record<string, unknown> {27 return value !== null && typeof value === "object" && !Array.isArray(value);28}2930function 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}4546function 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}5960function 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}8384/** Create one validated manifest declaration without adding it to the starter. */85export function createScopeExtension(origin: string): ScopeExtensionDeclaration {86 return { type: "origin", origin: normalizeOrigin(origin) };87}8889/**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 */95export function createWebAppOriginAssociation(96 options: WebAppOriginAssociationOptions,97): Readonly<Record<string, WebAppOriginAssociationEntry>> {98 const manifestId = normalizeManifestId(options.manifestId);99 return { [manifestId]: { scope: normalizeScope(options.scope) } };100}101102/**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 */106export 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}