cascivo 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-BcfPWm1F.mjs","names":["t","e","detectPackageManager","THEMES","writeFileSafe","positionalArgs","resolvePackageManagerFlag","flagValue","CASCIVO_VERSIONS","SIGNALS_PEER","normalizePath","escapeRegex","compilePath","compareSpecificity","f","fileToRoutePath","generateRoutes","i","a","e","n","t","specifier","r","o","s","V","installAllCommand","pm","runScriptCommand","script","runExplicitCommand","FRAMEWORKS","RUNTIMES","EXAMPLES","isExample","value","isRuntime","slug","label","pascalCase","safe","p","brandName","words","name","w","kept","word","packageName","resolveSections","seenKeys","seenComponents","sections","labels","trimmed","key","component","packageJson","pkg","opts","formatJson","items","tsconfig","viteConfig","indexHtml","mainTsx","viteEnv","appTsx","sectionImports","unionType","navItems","renderedSections","shellTsx","appShellOpenTag","sectionTsx","section","eslintConfig","header","nav","line","prettierrc","prettierIgnore","gitignore","staticShareSection","COMPATIBILITY_DATE","readme","agentsMd","sectionPath","index","sectionPageFile","astroPackageJson","astroConfig","astroTsconfig","astroLayersCss","astroLayout","astroShellTsx","astroSectionTsx","astroPage","astroGitignore","astroReadme","routes","cfPackageJson","agent","hasExample","workerTypes","needsWorkerTypes","preact","runtimeOf","bindings","cfBindingDescriptions","cfTsconfig","cfg","cfWorkerTsconfig","cfViteConfig","ai","usesWorkersAi","plugin","runtime","call","clause","jsoncArray","indent","wranglerJsonc","publish","rooms","usesRooms","usesAgents","objects","comments","usesLimiter","emailSignIn","c","wranglerVars","SOCIAL_CRON","usesD1","hasAccounts","usageDataset","wranglerQueues","digest","social","checkout","newsletter","emailAuth","names","entries","vars","entry","comment","queues","newsletterQueue","q","usesBilling","oauthSignIn","parseAuth","parts","raw","example","cfApiTs","imports","files","usage","crud","live","webhooks","search","billing","cfWorkerTs","exports","voice","d1","limiter","access","oauth","accounts","guards","custom","isAsync","serverRooms","signedPaths","readerPaths","openPaths","item","prefix","cfLiveTs","cfLiveCardTsx","cfSectionPath","cfSectionFile","cfRouterTs","cfAppTsx","cfMainTsx","cfFirstRouteTsx","cfRouteTsx","cf404Tsx","cfBoardTs","cfBoardRouteTsx","cfBoardCss","cfAssistantTs","cfAssistantWorkerTs","cfScriptedModelTs","cfAssistantRouteTsx","cfAssistantCss","cfNotesTs","cfNotesRouteTsx","cfImportJobTs","cfImportPageTs","cfImportWorkflowTs","cfImportRouteTsx","cfUploadPolicyTs","cfFilesTs","cfFilesRouteTsx","cfFilesCss","cfReportRouteTsx","cfUsageTs","cfUsageWorkerTs","cfUsageRouteTsx","cfSearchTs","cfSearchWorkerTs","cfArticlesTs","cfSearchRouteTsx","cfDigestTs","cfDigestWorkerTs","cfDigestRouteTsx","cfWebhooksTs","cfWebhooksWorkerTs","cfWebhooksRouteTsx","cfCheckoutTs","cfCheckoutWorkerTs","cfCheckoutRouteTsx","cfOrderRouteTsx","cfNewsletterTs","cfNewsletterEmailTs","cfNewsletterWorkerTs","cfNewsletterRouteTsx","cfNewsletterConfirmRouteTsx","cfNewsletterUnsubscribeRouteTsx","cfNewsletterSendRouteTsx","cfBillingTs","cfBillingWorkerTs","cfBillingRouteTsx","cfSocialTs","cfSocialWorkerTs","cfSocialPostTs","cfSocialRouteTsx","cfAuthTs","cfAuthWorkerTs","cfAccountRouteTsx","email","lead","cfVerifyRouteTsx","cfPagesTs","cfPagesWorkerTs","cfPageHtmlTs","cfPagePreviewTs","cfPublishRouteTsx","cfPageRouteTsx","cfVoiceTs","cfVoiceWorkerTs","cfScriptedVoiceTs","cfVoiceRouteTsx","cfOpsTs","cfOpsRouteTsx","cfCustomersTs","cfCustomersPageTs","cfCustomersWorkerTs","cfMigrationsTs","cfCustomersRouteTsx","cfPrettierIgnore","cfGitignore","cfReadme","cfAgentsMd","cfShellTsx","shell","start","end","cfDevVars","cfDevVarsExample","describe","description","buildCloudflareScaffold","routeFiles","file","buildScaffold","buildAstroScaffold","examples","auth","DEFAULT_SECTIONS","create","yes","args","nameArg","themeArg","sectionsArg","frameworkArg","runtimeArg","exampleArgs","badExample","authArg","pmFlag","cwd","rl","stdin","createInterface","stdout","theme","resolvedTheme","sectionsInput","framework","resolvedFramework","targetDir","join","existsSync","readdirSync","templateSpec","add","loadConfig"],"sources":["../src/generated/versions.ts","../../app/dist/path-DJjeYL9s.js","../../app/dist/vite.js","../src/commands/create.ts"],"sourcesContent":["// GENERATED by scripts/registry/cli-versions.ts — do not edit by hand.\n/**\n * Exact published versions, baked in at build time.\n *\n * Scaffolded apps pin these rather than `\"latest\"`: the cascivo packages version\n * independently on 0.x, so a floating specifier can resolve a mutually incompatible set,\n * and GETTING-STARTED.md tells adopters to pin exactly. Regenerate with `pnpm regen`.\n */\nexport const CASCIVO_VERSIONS: Record<string, string> = {\n '@cascivo/react': '1.7.0',\n '@cascivo/themes': '1.0.2',\n '@cascivo/charts': '1.7.0',\n '@cascivo/icons': '1.1.1',\n '@cascivo/eslint-config': '0.4.2',\n '@cascivo/app': '1.7.0',\n '@cascivo/render': '1.7.0',\n '@cascivo/storage': '1.7.0',\n '@cascivo/email': '0.6.0',\n}\n\n/** `@cascivo/core`'s declared `@preact/signals-react` peer range. */\nexport const SIGNALS_PEER = '>=3.0.0'\n","function normalizePath(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\\/+$/,``)||`/`:t}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\\]\\\\]/g,`\\\\$&`)}function compilePath(e){let t=normalizePath(e).split(`/`).filter(Boolean),n=[],r=[],i=``;return t.forEach((a,o)=>{if(a===`*`){if(o!==t.length-1)throw Error(`\"*\" must be the last segment of a path pattern: \"${e}\"`);n.push(`*`),r.push(1),i+=`(?:/(.*))?`}else if(a.startsWith(`:`)){let t=a.slice(1);if(!/^[A-Za-z_$][\\w$]*$/.test(t))throw Error(`Invalid param name \"${t}\" in path pattern \"${e}\"`);n.push(t),r.push(2),i+=`/([^/]+)`}else r.push(3),i+=`/${escapeRegex(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function matchPath(e,t){let n=e.regex.exec(normalizePath(t));if(!n)return null;let r={};try{e.keys.forEach((e,t)=>{let i=n[t+1]??``;r[e]=e===`*`?i.split(`/`).map(decodeURIComponent).join(`/`):decodeURIComponent(i)})}catch{return null}return r}function compareSpecificity(e,t){let n=Math.max(e.score.length,t.score.length);for(let r=0;r<n;r++){let n=(t.score[r]??0)-(e.score[r]??0);if(n!==0)return n}return 0}function buildPath(e,t){let n=t;return`/${normalizePath(e).split(`/`).filter(Boolean).map(t=>{if(t===`*`)return(n[`*`]??``).split(`/`).map(encodeURIComponent).join(`/`);if(t.startsWith(`:`)){let r=n[t.slice(1)];if(r===void 0)throw Error(`Missing param \"${t.slice(1)}\" for \"${e}\"`);return encodeURIComponent(r)}return t}).filter(e=>e!==``).join(`/`)}`}export{normalizePath as a,matchPath as i,compareSpecificity as n,compilePath as r,buildPath as t};","import{n as e,r as t}from\"./path-DJjeYL9s.js\";import{existsSync as n,readFileSync as r,readdirSync as i,statSync as a,writeFileSync as o}from\"node:fs\";import{dirname as s,join as c,relative as l,resolve as u,sep as d}from\"node:path\";var f=/\\.(tsx|jsx)$/;function fileToRoutePath(e){if(!f.test(e)||/\\.(test|spec)\\.[jt]sx$/.test(e))return;let t=e.replace(f,``).split(`/`);if(!t.some(e=>e.startsWith(`_`)))return t.length===1&&t[0]===`404`?null:(t.at(-1)===`index`&&t.pop(),`/${t.map((n,r)=>{if(/^\\[\\.\\.\\.[A-Za-z_$][\\w$]*\\]$/.test(n)){if(r!==t.length-1)throw Error(`routes/${e}: a [...rest] segment must be last`);return`*`}let i=/^\\[([A-Za-z_$][\\w$]*)\\]$/.exec(n);if(i)return`:${i[1]}`;if(/[[\\]]/.test(n))throw Error(`routes/${e}: malformed segment \"${n}\"`);return n}).join(`/`)}`)}function scanRoutes(e){if(!n(e))return[];let t=[],walk=n=>{for(let r of i(n).sort()){let i=c(n,r);a(i).isDirectory()?walk(i):t.push(l(e,i).split(d).join(`/`))}};return walk(e),t}function generateRoutes(n,r){let i=[],a=/* @__PURE__ */ new Map;for(let e of n){let t=fileToRoutePath(e);if(t===void 0)continue;let n=a.get(t);if(n)throw Error(`routes/${n} and routes/${e} both define ${t??`the 404 route`}`);a.set(t,e),i.push({file:e,path:t})}let specifier=e=>`${r}/${e.replace(f,``)}`,o=i.filter(e=>e.path!==null).map(e=>({...e,compiled:t(e.path)})).sort((t,n)=>e(t.compiled,n.compiled)||t.path.localeCompare(n.path)),s=i.find(e=>e.path===null);return[`// Generated by @cascivo/app/vite from the routes directory — do not edit.`,`// Add, rename or delete a route file and this file is rewritten.`,`import { lazyRoute } from '@cascivo/app'`,`import type { Route } from '@cascivo/app'`,``,`export const routes: Route[] = [`,...o.map(e=>` lazyRoute('${e.path}', () => import('${specifier(e.file)}')),`),`]`,``,s?`export const notFound: Route | undefined = lazyRoute('*', () => import('${specifier(s.file)}'))`:`export const notFound: Route | undefined = undefined`,``,\"/** Every route pattern. Fill one with `buildPath` for a typed link. */\",`export type AppPath = ${o.length>0?o.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`\n`)}function cascivoRoutes(e={}){let t=process.cwd(),dir=()=>u(t,e.dir??`src/routes`),out=()=>u(t,e.out??`src/routes.gen.ts`);function write(){let e=l(s(out()),dir()).split(d).join(`/`);e.startsWith(`.`)||(e=`./${e}`);let t=generateRoutes(scanRoutes(dir()),e);(n(out())?r(out(),`utf8`):null)!==t&&o(out(),t)}return{name:`cascivo:routes`,enforce:`pre`,configResolved(e){t=e.root},buildStart(){write()},configureServer(e){e.watcher.add(dir());let onChange=e=>{u(e).startsWith(dir()+d)&&write()};e.watcher.on(`add`,onChange),e.watcher.on(`unlink`,onChange)}}}export{cascivoRoutes,fileToRoutePath,generateRoutes,scanRoutes};","import { existsSync, readdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { stdin, stdout } from 'node:process'\nimport { createInterface } from 'node:readline/promises'\nimport {\n detectPackageManager,\n THEMES,\n type PackageManager,\n type ThemeName,\n} from '../utils/config.js'\nimport { flagValue, positionalArgs, resolvePackageManagerFlag } from '../utils/args.js'\nimport { writeFileSafe } from '../utils/fs.js'\nimport { CASCIVO_VERSIONS, SIGNALS_PEER } from '../generated/versions.js'\n// Bundled into the CLI (a devDependency): the scaffold writes the same routes.gen.ts the\n// app's own Vite plugin will, so the two cannot drift.\nimport { generateRoutes } from '@cascivo/app/vite'\n\n/**\n * Exact published versions, baked in at build time by `scripts/registry/cli-versions.ts`.\n *\n * This used to be the literal `'latest'` for every cascivo dependency — the loosest\n * possible specifier on a set of independently-versioned 0.x packages, and the direct\n * opposite of GETTING-STARTED.md's \"pin **exact** versions (no `^`)\". A scaffold that\n * contradicts the docs on the very first file an adopter opens undermines every other rule\n * those docs state, so the pins are generated rather than hand-written.\n */\nconst V = CASCIVO_VERSIONS\n\n/** Install-everything command for a package manager (`pnpm install`, `yarn`, …). */\nfunction installAllCommand(pm: PackageManager): string {\n return pm === 'yarn' ? 'yarn' : `${pm} install`\n}\n\n/** Run-a-script command for a package manager (`npm run dev` vs `pnpm dev`). */\nfunction runScriptCommand(pm: PackageManager, script: string): string {\n return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`\n}\n\n/**\n * Like `runScriptCommand`, but always through `run`. A script named after a built-in\n * command needs it: `pnpm deploy` is pnpm's own workspace deploy, not the `deploy` script.\n */\nfunction runExplicitCommand(pm: PackageManager, script: string): string {\n return `${pm} run ${script}`\n}\n\n/** Project shape `create` emits. */\nexport type Framework = 'react-vite' | 'astro' | 'cloudflare'\n\nexport const FRAMEWORKS = ['react-vite', 'astro', 'cloudflare'] as const\n\n/** Client runtime for `--framework cloudflare`. The source is identical for both. */\nexport type Runtime = 'preact' | 'react'\n\nexport const RUNTIMES = ['preact', 'react'] as const\n\n/** Optional demo pages for `--framework cloudflare`. */\nexport type Example =\n | 'board'\n | 'agent'\n | 'notes'\n | 'import'\n | 'files'\n | 'export'\n | 'usage'\n | 'crud'\n | 'live'\n | 'voice'\n | 'publish'\n | 'webhooks'\n | 'digest'\n | 'search'\n | 'checkout'\n | 'newsletter'\n | 'social'\n\nexport const EXAMPLES = [\n 'board',\n 'agent',\n 'notes',\n 'import',\n 'files',\n 'export',\n 'usage',\n 'crud',\n 'live',\n 'voice',\n 'publish',\n 'webhooks',\n 'digest',\n 'search',\n 'checkout',\n 'newsletter',\n 'social',\n] as const\n\nfunction isExample(value: string): value is Example {\n return (EXAMPLES as readonly string[]).includes(value)\n}\n\nfunction isRuntime(value: string): value is Runtime {\n return (RUNTIMES as readonly string[]).includes(value)\n}\n\nexport interface ScaffoldOptions {\n /** Project directory + package name. */\n name: string\n /** Project shape. Defaults to `react-vite`. */\n framework?: Framework\n /** Theme imported in the entry CSS and set on `<html data-theme>`. */\n theme: ThemeName\n /** Display labels for the side-nav sections (one section component each). */\n sections: string[]\n /** Package manager for the generated README's commands (default npm). */\n pm?: PackageManager\n /**\n * Client runtime for the `cloudflare` framework. Defaults to `preact`, or `react` with the\n * `agent` example, whose Agents SDK hooks need React (see {@link runtimeOf}).\n */\n runtime?: Runtime\n /** Extra demo pages for the `cloudflare` framework. */\n examples?: Example[]\n /**\n * `cloudflare` framework only. `access`: the Worker refuses every request Cloudflare Access\n * did not let through. `email`: accounts with emailed sign-in links; `oauth`: accounts with\n * GitHub, Google and LinkedIn sign-in; `email,oauth`: both on one page. With accounts, every API write\n * needs a signed-in user.\n */\n auth?: Auth\n}\n\nexport type Auth = 'access' | 'email' | 'oauth' | 'email,oauth'\n\nexport interface ScaffoldFile {\n /** Path relative to the project root. */\n path: string\n contents: string\n}\n\ninterface Section {\n /** Discriminant used in the section signal + conditional render. */\n key: string\n /** Human-readable label shown in the nav and section heading. */\n label: string\n /** PascalCase component + file name. */\n component: string\n}\n\n/** Slug suitable for a union-member string literal: lower-kebab, alnum only. */\nfunction slug(label: string): string {\n return (\n label\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'section'\n )\n}\n\n/** PascalCase identifier derived from a free-text label. */\nfunction pascalCase(label: string): string {\n const parts = label\n .trim()\n .split(/[^a-zA-Z0-9]+/)\n .filter(Boolean)\n const joined = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')\n const safe = joined || 'Section'\n return /^[0-9]/.test(safe) ? `Section${safe}` : safe\n}\n\n/**\n * Short, human-readable brand for the shell header.\n *\n * The directory name is the wrong thing to render verbatim: a dated demo directory\n * (`vercel-dashboard-2026-07-30-take2`) produced a 45-character brand in the top-left of\n * every page. Take the leading words, title-case them, and stop — a brand is a label, not a\n * slug. Bare numbers and `v2`-style segments are dropped rather than counted.\n */\nexport function brandName(name: string): string {\n const words = name\n .trim()\n .split(/[^a-zA-Z0-9]+/)\n .filter((w) => w !== '' && !/^\\d+$/.test(w) && !/^v\\d+$/i.test(w))\n const kept: string[] = []\n for (const word of words) {\n if (kept.length >= 3) break\n if (kept.length > 0 && kept.join(' ').length + 1 + word.length > 24) break\n kept.push(word.charAt(0).toUpperCase() + word.slice(1))\n }\n return kept.join(' ') || 'App'\n}\n\n/** Normalize the project name into a valid npm package name. */\nfunction packageName(name: string): string {\n return (\n name\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'cascivo-app'\n )\n}\n\n/** Turn raw section labels into unique keyed/component-named sections. */\nfunction resolveSections(labels: string[]): Section[] {\n const seenKeys = new Set<string>()\n const seenComponents = new Set<string>()\n const sections: Section[] = []\n for (const label of labels) {\n const trimmed = label.trim()\n if (!trimmed) continue\n let key = slug(trimmed)\n let component = pascalCase(trimmed)\n let n = 2\n while (seenKeys.has(key) || seenComponents.has(component)) {\n key = `${slug(trimmed)}-${n}`\n component = `${pascalCase(trimmed)}${n}`\n n++\n }\n seenKeys.add(key)\n seenComponents.add(component)\n sections.push({ key, label: trimmed, component })\n }\n return sections.length > 0 ? sections : [{ key: 'home', label: 'Home', component: 'Home' }]\n}\n\nfunction packageJson(opts: ScaffoldOptions): string {\n const pkg = {\n name: packageName(opts.name),\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'vite',\n build: 'tsc && vite build',\n preview: 'vite preview',\n typecheck: 'tsc --noEmit',\n lint: 'eslint .',\n format: 'prettier --write .',\n 'format:check': 'prettier --check .',\n },\n // Prebuilt path (Path B): `@cascivo/react` and `@cascivo/themes` only.\n //\n // `@cascivo/core` and `@cascivo/tokens` are deliberately absent. AI-RULES.md: \"**Never**\n // add `@cascivo/core` to a prebuilt-path app's package.json — it is only a transitive\n // dependency there, and everything is re-exported from `@cascivo/react`.\"\n // GETTING-STARTED.md says the same of `@cascivo/tokens`, which arrives with\n // `@cascivo/themes` as a direct dependency. The scaffold used to declare both and then\n // import from them, so it violated two documented rules and taught the pattern by example.\n //\n // `@preact/signals-react` IS declared: it is a required peer that the generated\n // `App.tsx` calls into via `useSignals()`. It used to be omitted entirely and survived\n // only by hoisting — a phantom dependency that breaks under pnpm's strict layout.\n dependencies: {\n '@cascivo/react': V['@cascivo/react']!,\n '@cascivo/themes': V['@cascivo/themes']!,\n '@preact/signals-react': SIGNALS_PEER,\n react: '^19.0.0',\n 'react-dom': '^19.0.0',\n },\n devDependencies: {\n '@cascivo/eslint-config': V['@cascivo/eslint-config']!,\n '@eslint/js': '^9.0.0',\n '@types/react': '^19.0.0',\n '@types/react-dom': '^19.0.0',\n '@vitejs/plugin-react': '^5.0.0',\n eslint: '^9.0.0',\n 'eslint-plugin-react-hooks': '^7.0.0',\n prettier: '^3.0.0',\n typescript: '^5.7.0',\n // Registers the TypeScript parser AND the `files` patterns that make ESLint look at\n // .ts/.tsx at all. Without it ESLint 9's default `files` is **/*.{js,cjs,mjs}, so\n // `lint` on a TypeScript-only app exits 0 having inspected zero files — a green check\n // that proves nothing (2026-08-08 report B).\n 'typescript-eslint': '^8.0.0',\n vite: '^7.0.0',\n },\n }\n return JSON.stringify(pkg, null, 2) + '\\n'\n}\n\n/**\n * JSON as Prettier prints it: an array of strings stays on one line. `JSON.stringify` breaks\n * every array across lines, so a fresh app failed its own `format:check` on tsconfig.json.\n */\nfunction formatJson(value: unknown): string {\n const json = JSON.stringify(value, null, 2).replace(\n /\\[\\n\\s+(\"[^\"\\n]*\"(?:,\\n\\s+\"[^\"\\n]*\")*)\\n\\s*\\]/g,\n (_, items: string) => `[${items.split(/,\\n\\s+/).join(', ')}]`,\n )\n return json + '\\n'\n}\n\nfunction tsconfig(): string {\n const cfg = {\n compilerOptions: {\n target: 'ES2022',\n useDefineForClassFields: true,\n lib: ['ES2022', 'DOM', 'DOM.Iterable'],\n module: 'ESNext',\n skipLibCheck: true,\n moduleResolution: 'bundler',\n allowImportingTsExtensions: true,\n resolveJsonModule: true,\n isolatedModules: true,\n moduleDetection: 'force',\n noEmit: true,\n jsx: 'react-jsx',\n strict: true,\n noUnusedLocals: true,\n noUnusedParameters: true,\n noFallthroughCasesInSwitch: true,\n },\n include: ['src'],\n }\n return formatJson(cfg)\n}\n\nfunction viteConfig(): string {\n return `import react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [react()],\n})\n`\n}\n\nfunction indexHtml(opts: ScaffoldOptions): string {\n return `<!doctype html>\n<html lang=\"en\" data-theme=\"${opts.theme}\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>${brandName(opts.name)}</title>\n <style>\n @layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component,\n cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.example, cascivo.override;\n /* cascivo.example is this app's own slot — above the component/blocks layers so your\n styles win, below cascivo.override which stays free for one-off hotfixes. The\n generated AGENTS.md tells the agent to write there, and this statement is what makes\n that legal: a layer used but never declared falls to the end of the cascade and\n beats everything, which is the opposite of what the ordering is for. */\n /* Third-party CSS goes in the low-priority vendor layer so it can't beat cascivo:\n @import url('some-lib/styles.css') layer(vendor); — see docs/THIRD-PARTY-CSS.md */\n @layer cascivo.reset {\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n }\n @layer cascivo.base {\n html,\n body,\n #root {\n height: 100%;\n }\n }\n </style>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.tsx\"></script>\n </body>\n</html>\n`\n}\n\nfunction mainTsx(): string {\n return `import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\n\nconst root = document.getElementById('root')\nif (root) {\n ReactDOM.createRoot(root).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>,\n )\n}\n`\n}\n\nfunction viteEnv(): string {\n return `/// <reference types=\"vite/client\" />\\n`\n}\n\n// No `opts`: the theme import and the brand moved to `Shell.tsx`, which owns the app chrome.\nfunction appTsx(sections: Section[]): string {\n const sectionImports = sections\n .map((s) => `import { ${s.component} } from './sections/${s.component}'`)\n .join('\\n')\n\n const unionType = sections.map((s) => `'${s.key}'`).join(' | ')\n\n const navItems = sections\n .map(\n (s) => ` {\n label: '${s.label.replace(/'/g, \"\\\\'\")}',\n active: section.value === '${s.key}',\n onClick: (e) => {\n e.preventDefault()\n section.value = '${s.key}'\n },\n },`,\n )\n .join('\\n')\n\n const renderedSections = sections\n .map((s) => ` {section.value === '${s.key}' && <${s.component} />}`)\n .join('\\n')\n\n // Everything comes from `@cascivo/react` on the prebuilt path — it re-exports the\n // `@cascivo/core` primitives, so importing from `@cascivo/core` directly would make it a\n // phantom dependency (and AI-RULES.md forbids declaring it here). There is likewise no\n // `import '@cascivo/tokens'`: the tokens arrive with the theme stylesheet.\n return `'use client'\nimport { signal, useSignals, type SideNavItem } from '@cascivo/react'\nimport { Shell } from './Shell'\n${sectionImports}\n\ntype Section = ${unionType}\n\nconst section = signal<Section>('${sections[0]!.key}')\n\nexport default function App() {\n useSignals()\n\n const navItems: SideNavItem[] = [\n${navItems}\n ]\n\n return (\n <Shell navItems={navItems}>\n${renderedSections}\n </Shell>\n )\n}\n`\n}\n\n/**\n * The app shell, as its own component with a `children` slot.\n *\n * Split out of `App.tsx` because the shell composition — AppShell + ShellHeader + SideNav —\n * is the valuable part of the scaffold, and it used to be welded to the signal-driven\n * section switcher. A 2026-08-14 adopter prompted for \"a dashboard with Vite and React\n * Router\", then deleted `App.tsx` and all of `src/sections/` — the majority of what `create`\n * generated — and re-derived this wiring by hand.\n *\n * Now adding a router means deleting `App.tsx` + `src/sections/` and rendering `<Shell>` from\n * the root route's layout, with `navItems` carrying `href` instead of `onClick`. Nothing in\n * here needs to change.\n */\nfunction shellTsx(opts: ScaffoldOptions): string {\n return `'use client'\nimport { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'\nimport type { ReactNode } from 'react'\n\nimport '@cascivo/themes/${opts.theme}.css'\n// No '@cascivo/react/styles.css' here. On a bundler build each component imports its\n// own CSS, so you ship exactly what you use — this app emits well under 100 kB of entry\n// CSS instead of the ~273 kB aggregate sheet. Import the aggregate ONLY if you drop the\n// bundler (CDN / single-file setup). See https://cascivo.com/docs/getting-started.md\n// The theme import above is always required — themes are never automatic.\n\nexport interface ShellProps {\n /** Side-nav entries. Use \\`href\\` for a routed app, \\`onClick\\` for local state. */\n navItems: SideNavItem[]\n children: ReactNode\n}\n\n/**\n * App shell: header + side nav + a content slot.\n *\n * Adding a router? Keep this file. Delete \\`App.tsx\\` and \\`src/sections/\\`, render\n * \\`<Shell navItems={…}>\\` from your root route's layout with your \\`<Outlet />\\` as children,\n * and give each nav item an \\`href\\` instead of an \\`onClick\\`.\n *\n * For those hrefs to become real router links, call \\`setLinkComponent\\` ONCE at startup in\n * \\`main.tsx\\` — see the \"Adding a router\" section of README.md for the exact snippet, or\n * https://cascivo.com/docs/using-with-a-router.md for the full recipe.\n */\nexport function Shell({ navItems, children }: ShellProps) {\n return (\n${appShellOpenTag(opts, 'navItems')}\n {children}\n </AppShell>\n )\n}\n`\n}\n\nfunction sectionTsx(section: Section): string {\n // No inline styles: the generated AGENTS.md tells the agent not to write them, and a\n // scaffold that models the opposite teaches the opposite. The page inset comes from\n // AppShell's own \\`padding\\` prop, and the stacking from \\`Flex\\` — note \\`gap\\` takes a\n // NUMBER (a space-scale step), not a string.\n return `import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'\n\nexport function ${section.component}() {\n return (\n <Flex gap={6}>\n <Flex gap={2}>\n <Heading level={1}>${section.label}</Heading>\n <Text muted>\n Edit <code>src/sections/${section.component}.tsx</code> to build out this page.\n </Text>\n </Flex>\n\n <Card>\n <CardHeader>\n <CardTitle>Get started</CardTitle>\n </CardHeader>\n <CardContent>\n <Text>\n This page is wired into the app shell. Add components with{' '}\n <code>npx cascivo add <component></code>.\n </Text>\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\n/**\n * ESLint flat config for the scaffold.\n *\n * `@cascivo/eslint-config` is wired in from the start because\n * `eslint-plugin-react-hooks@7`'s `recommended-latest` reports every `signal.value = next`\n * — the idiom AI-RULES.md mandates and the generated `App.tsx` below uses — as\n * `Error: This value cannot be modified`. Without this, `pnpm lint` on a freshly scaffolded\n * app errors on every piece of state it ships with.\n */\nfunction eslintConfig(): string {\n return `import js from '@eslint/js'\nimport tseslint from 'typescript-eslint'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport cascivo from '@cascivo/eslint-config'\n\nexport default [\n { ignores: ['dist/**'] },\n js.configs.recommended,\n // Registers the TypeScript parser and the .ts/.tsx \\`files\\` patterns. WITHOUT THIS ESLINT\n // LINTS NOTHING: its default \\`files\\` is **/*.{js,cjs,mjs}, so every file in this app is\n // skipped with \"File ignored because no matching configuration was supplied\" and the\n // \\`lint\\` script exits 0 having checked zero files.\n ...tseslint.configs.recommended,\n // NOTE the \\`.flat\\` — the plugin exports both \\`configs['recommended-latest']\\` (the legacy\n // eslintrc shape, which applies NOTHING here and reports no error) and this one.\n reactHooks.configs.flat['recommended-latest'],\n // Spread LAST — flat config is last-wins. This turns off \\`react-hooks/immutability\\`,\n // which reports cascivo's signal writes (\\`signal.value = next\\`) as errors. That rule\n // fires on the very first \\`signal.value = x\\` you write, so this is not optional wiring:\n // see https://cascivo.com/docs/using-with-strict-eslint.md\n ...cascivo,\n]\n`\n}\n\n/** Prettier config matching the style the scaffold's own generated source is written in. */\n/**\n * The shell's `<AppShell …>` opening tag, laid out as Prettier lays it out: on one line when it\n * fits in the 100-column print width (a short app name), split over its attributes otherwise.\n * Always writing the split form made a fresh app with a short name fail its own format:check.\n */\nfunction appShellOpenTag(opts: ScaffoldOptions, items: string): string {\n const header = `header={<ShellHeader brand={{ name: '${brandName(opts.name).replace(/'/g, \"\\\\'\")}' }} />}`\n const nav = `nav={<SideNav items={${items}} />}`\n const line = ` <AppShell ${header} ${nav}>`\n return line.length <= 100 ? line : ` <AppShell\\n ${header}\\n ${nav}\\n >`\n}\n\nfunction prettierrc(): string {\n return JSON.stringify({ semi: false, singleQuote: true, printWidth: 100 }, null, 2) + '\\n'\n}\n\nfunction prettierIgnore(): string {\n return `dist\nnode_modules\n# Vendored cascivo source is formatted upstream — reformatting it makes every\n# \\`cascivo add\\` update a merge conflict.\nsrc/components/ui/\n`\n}\n\nfunction gitignore(): string {\n return `node_modules\ndist\n*.local\n.DS_Store\n`\n}\n\n/**\n * \"Share a preview\" for a static build: Cloudflare Drop takes the folder with no account, and\n * `wrangler deploy --temporary --assets` is the same thing from a terminal (or an agent).\n */\nfunction staticShareSection(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n return `\n## Share a preview\n\nNo Cloudflare account needed. Run \\`${runScriptCommand(pm, 'build')}\\`, then either:\n\n- drag the \\`dist/\\` folder onto [Cloudflare Drop](https://www.cloudflare.com/drop/), or\n- from a terminal: \\`npx wrangler deploy --temporary --assets dist --name ${packageName(opts.name)} --compatibility-date ${COMPATIBILITY_DATE}\\`\n\nEither way you get a public URL that lasts 60 minutes. Sign in to Cloudflare within that hour\nto keep it.\n`\n}\n\nfunction readme(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n return `# ${opts.name}\n\nA [cascivo](https://cascivo.com) app — Vite + React + TypeScript, pre-wired with\nthe cascivo app shell, side navigation, and the \\`${opts.theme}\\` theme.\n\n## Develop\n\n\\`\\`\\`sh\n${installAllCommand(pm)}\n${runScriptCommand(pm, 'dev')}\n\\`\\`\\`\n\n## Structure\n\n- \\`src/Shell.tsx\\` — the app shell (header + side nav + content slot). Router-agnostic.\n- \\`src/App.tsx\\` — nav items and which section is showing\n- \\`src/sections/\\` — one component per nav item\n\nAdd more components with \\`npx cascivo add <component>\\`.\n\n## Adding a router\n\nThis app switches sections with a signal, not a router. To add one (React Router,\nTanStack Router, …):\n\n1. **Keep \\`src/Shell.tsx\\`.** Delete \\`src/App.tsx\\` and \\`src/sections/\\`.\n2. Render \\`<Shell navItems={…}>\\` from your root route's layout, with your \\`<Outlet />\\`\n as its children.\n3. Give each nav item an \\`href\\` instead of \\`onClick\\`.\n4. Register your router's Link **once** at startup, in \\`src/main.tsx\\`:\n\n\\`\\`\\`tsx\nimport { setLinkComponent } from '@cascivo/react'\nimport type { LinkComponentProps } from '@cascivo/react'\nimport { Link } from 'react-router'\n\nsetLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href ?? '#'} {...rest} />)\n\\`\\`\\`\n\nThat one call makes \\`SideNav\\`, \\`ShellHeader\\` and \\`Breadcrumb\\` render real router links.\nLinks you write in page content use \\`<Link asChild>\\` instead — two kinds of link, two\nmechanisms. Full recipe: https://cascivo.com/docs/using-with-a-router.md\n${staticShareSection(opts)}`\n}\n\nfunction agentsMd(opts: ScaffoldOptions): string {\n return `# Agent instructions — ${opts.name}\n\nThis is a [cascivo](https://cascivo.com) app. When generating or editing CSS, follow\nthe **layer contract** — cascivo styles live in cascade layers, and layer order beats\nselector specificity.\n\n## CSS layer contract\n\n1. Every declaration goes inside an \\`@layer\\` block. Unlayered CSS beats all layers\n regardless of specificity — never emit it.\n2. Never invent layer names. Write only: your app slot \\`cascivo.example\\` (declared in\n the order statement in \\`index.html\\`) for page styles, and\n \\`@layer cascivo.override { … }\\` for hotfixes / one-off overrides — it beats\n everything cascivo ships.\n3. Never nest layers deeper than the shipped \\`cascivo.blocks.<name>\\` pattern. For\n sub-elements, use native CSS nesting inside one layer block, not new sublayers.\n4. Third-party CSS: \\`@import url('lib/styles.css') layer(vendor);\\` with \\`vendor\\`\n declared before the cascivo layers. Don't import vendor CSS from JavaScript — route it\n through a CSS file, or use \\`@cascivo/vite-plugin\\` (\\`cascivoLayers\\`) to layer it.\n5. Style with \\`--cascivo-*\\` tokens, not raw values.\n\nThis app's declared layer order (in \\`index.html\\`):\n\n\\`\\`\\`css\n@layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component,\n cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.example, cascivo.override;\n\\`\\`\\`\n\n### Worked example — nesting, not new layers\n\n\\`\\`\\`css\n@layer cascivo.override {\n .projectCard {\n /* Sub-elements nest inside the one block — no cascivo.card.status sublayer. */\n .statusBadge {\n color: var(--cascivo-color-text-subtle);\n .pulseDot {\n background: var(--cascivo-color-success);\n }\n }\n }\n}\n\\`\\`\\`\n\n## Routing\n\nIf you add a router, keep \\`src/Shell.tsx\\` and delete \\`src/App.tsx\\` + \\`src/sections/\\`.\n\ncascivo links come in **two kinds**, wired two different ways. Do not intercept\n\\`onClick\\`, and do not hand-wrap nav items:\n\n1. **Config-driven navs** (\\`SideNav\\`, \\`ShellHeader\\`, \\`Breadcrumb\\`, \\`Switcher\\`) render\n through a module singleton. Register your router's Link once, in \\`src/main.tsx\\`:\n \\`setLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href ?? '#'} {...rest} />)\\`\n2. **Links in page content** use \\`asChild\\`:\n \\`<Link asChild><RouterLink to=\"/x\">x</RouterLink></Link>\\`\n\nFull recipe: https://cascivo.com/docs/using-with-a-router.md\n\n## Types\n\nThe vocabulary types are on a subpath: \\`import type { Tone } from '@cascivo/react/types'\\`\n(also \\`Progress\\`, \\`SpaceStep\\`). \\`Status.status\\` and \\`Badge.variant\\` use them, so a\n\\`Record<MyState, Tone>\\` is the supported way to map domain states onto tones. **Never**\nadd \\`@cascivo/core\\` to this app's dependencies — it is transitive here.\n\nMore: cascivo's machine-readable guide is at https://cascivo.com/llms.txt.\n`\n}\n\n/** Build the full set of files for a new cascivo app. Pure — no filesystem I/O. */\n/* ------------------------------------------------------------------------- *\n * Astro scaffold (`--framework astro`)\n *\n * Shaped around what Astro is actually for, rather than transliterating the Vite SPA:\n * pages are real routes (no router to add later), page CONTENT is cascivo components\n * rendered with no client directive at all — server HTML, zero JS — and the only island\n * is the shell, which needs JS for its mobile nav drawer.\n *\n * The `astro.config.mjs` this emits carries one non-obvious required line —\n * `vite.resolve.noExternal` — without which SSR'd islands render unstyled. Baking that in\n * is most of why this scaffold is worth having; see the comment on `astroConfig` and\n * docs/USING-WITH-ASTRO.md.\n * ------------------------------------------------------------------------- */\n\n/** Route for a section. The first section owns the index route. */\nfunction sectionPath(section: Section, index: number): string {\n return index === 0 ? '/' : `/${section.key}`\n}\n\n/** Page file for a section, relative to `src/pages/`. */\nfunction sectionPageFile(section: Section, index: number): string {\n return index === 0 ? 'index.astro' : `${section.key}.astro`\n}\n\nfunction astroPackageJson(opts: ScaffoldOptions): string {\n const pkg = {\n name: packageName(opts.name),\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'astro dev',\n build: 'astro build',\n preview: 'astro preview',\n typecheck: 'astro check',\n lint: 'eslint .',\n format: 'prettier --write .',\n 'format:check': 'prettier --check .',\n },\n // Same prebuilt-path rule as the Vite scaffold: `@cascivo/react` + `@cascivo/themes`\n // only. `@cascivo/core` and `@cascivo/tokens` are transitive and must not be declared.\n dependencies: {\n '@astrojs/react': '^5.0.0',\n '@cascivo/react': V['@cascivo/react']!,\n '@cascivo/themes': V['@cascivo/themes']!,\n '@preact/signals-react': SIGNALS_PEER,\n astro: '^7.0.0',\n react: '^19.0.0',\n 'react-dom': '^19.0.0',\n },\n devDependencies: {\n '@astrojs/check': '^0.9.0',\n '@cascivo/eslint-config': V['@cascivo/eslint-config']!,\n '@eslint/js': '^9.0.0',\n '@types/react': '^19.0.0',\n '@types/react-dom': '^19.0.0',\n eslint: '^9.0.0',\n 'eslint-plugin-react-hooks': '^7.0.0',\n prettier: '^3.0.0',\n typescript: '^5.7.0',\n 'typescript-eslint': '^8.0.0',\n },\n }\n return JSON.stringify(pkg, null, 2) + '\\n'\n}\n\nfunction astroConfig(): string {\n return `// @ts-check\nimport { defineConfig } from 'astro/config'\nimport react from '@astrojs/react'\n\nexport default defineConfig({\n integrations: [react()],\n vite: {\n resolve: {\n // REQUIRED, and the single least obvious line in this app.\n //\n // Vite externalizes node_modules packages in its server build, so an externalized\n // @cascivo/react is imported by Node at runtime and its module graph is never walked\n // — and Astro collects a page's CSS by walking that graph. Without this, SSR'd\n // islands (client:load, client:visible) render with correct class names and no rules\n // anywhere in the output. Nothing warns; it reads as a theming problem.\n //\n // It must be \\`resolve.noExternal\\`, NOT \\`ssr.noExternal\\`: Astro prerenders static\n // routes in its own Vite environment, which \\`ssr.*\\` does not reach.\n // https://cascivo.com/docs/using-with-astro.md\n noExternal: [/^@cascivo\\\\//],\n },\n },\n})\n`\n}\n\nfunction astroTsconfig(): string {\n const cfg = {\n extends: 'astro/tsconfigs/strict',\n include: ['.astro/types.d.ts', '**/*'],\n exclude: ['dist'],\n compilerOptions: { jsx: 'react-jsx' },\n }\n return JSON.stringify(cfg, null, 2) + '\\n'\n}\n\n/**\n * The cascade layer order plus this app's own slot.\n *\n * A standalone CSS file imported BEFORE the theme, rather than a `<style>` in the layout:\n * layers take their order from first appearance, so a statement that lands after the\n * theme's CSS cannot reorder anything, and `vendor` would end up winning over every\n * cascivo layer instead of losing to all of them.\n */\nfunction astroLayersCss(): string {\n return `/* Cascade layer order. Must load before any cascivo CSS — see the note in\n src/layouts/Layout.astro. Later layers beat earlier ones regardless of specificity.\n\n cascivo.example is this app's own slot: above the component and blocks layers so your\n page styles win, below cascivo.override which stays free for one-off hotfixes.\n Third-party CSS goes in vendor so it cannot beat cascivo:\n @import url('some-lib/styles.css') layer(vendor);\n See https://cascivo.com/docs/third-party-css.md */\n@layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component,\n cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.example, cascivo.override;\n\n@layer cascivo.reset {\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n}\n\n@layer cascivo.base {\n html,\n body {\n block-size: 100%;\n }\n}\n`\n}\n\nfunction astroLayout(opts: ScaffoldOptions): string {\n return `---\n// Import order matters: the layer statement must be established before any cascivo CSS.\nimport '../styles/layers.css'\nimport '@cascivo/themes/${opts.theme}.css'\n\n// No '@cascivo/react/styles.css'. Each component brings its own CSS through the module\n// graph, so you ship only what your pages use. Import the aggregate ONLY if you drop the\n// bundler entirely. The theme import above is always required — themes are never automatic.\n\ninterface Props {\n title: string\n}\n\nconst { title } = Astro.props\n---\n\n<!doctype html>\n<html lang=\"en\" data-theme=\"${opts.theme}\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>{title} · ${brandName(opts.name)}</title>\n </head>\n <body>\n <slot />\n </body>\n</html>\n`\n}\n\n/**\n * The app shell — the ONLY island in the scaffold.\n *\n * It is hydrated because AppShell's nav drawer is interactive on small screens. Page\n * content is slotted in as children and stays server-rendered HTML, so a page costs the\n * shell's JS and nothing more.\n */\nfunction astroShellTsx(opts: ScaffoldOptions, sections: Section[]): string {\n const navItems = sections\n .map((s, i) => ` { label: '${s.label.replace(/'/g, \"\\\\'\")}', href: '${sectionPath(s, i)}' },`)\n .join('\\n')\n\n return `import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'\nimport type { ReactNode } from 'react'\n\nconst NAV: { label: string; href: string }[] = [\n${navItems}\n]\n\nexport interface ShellProps {\n /** Current route, e.g. \\`Astro.url.pathname\\`. Marks the matching nav item active. */\n activePath: string\n children: ReactNode\n}\n\n/**\n * Header + side nav + a content slot.\n *\n * Nav items carry \\`href\\`, so navigation is a real page load and Astro's router handles\n * it — there is no client router to register and no \\`setLinkComponent\\` call to make.\n */\nexport function Shell({ activePath, children }: ShellProps) {\n const items: SideNavItem[] = NAV.map((item) => ({\n ...item,\n active: item.href === activePath,\n }))\n\n return (\n${appShellOpenTag(opts, 'items')}\n {children}\n </AppShell>\n )\n}\n`\n}\n\n/**\n * A section's content as a plain React component with NO \\`'use client'\\`.\n *\n * Astro renders it on the server and ships no JavaScript for it. Add a client directive at\n * the call site in the page only when a section actually needs interactivity.\n */\nfunction astroSectionTsx(section: Section): string {\n return `import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'\n\nexport function ${section.component}() {\n return (\n <Flex gap={6}>\n <Flex gap={2}>\n <Heading level={1}>${section.label}</Heading>\n <Text muted>\n Edit <code>src/components/${section.component}.tsx</code> to build out this page.\n </Text>\n </Flex>\n\n <Card>\n <CardHeader>\n <CardTitle>Get started</CardTitle>\n </CardHeader>\n <CardContent>\n <Text>\n This page is server-rendered — no JavaScript ships for it. Add components with{' '}\n <code>npx cascivo add <component></code>.\n </Text>\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\nfunction astroPage(section: Section, index: number): string {\n return `---\nimport Layout from '../layouts/Layout.astro'\nimport { Shell } from '../components/Shell'\nimport { ${section.component} } from '../components/${section.component}'\n---\n\n<Layout title=\"${section.label}\">\n {/* client:load hydrates the shell for its mobile nav drawer. The section below stays\n server-rendered HTML — it is slotted in as children and ships no JS. */}\n <Shell client:load activePath=\"${sectionPath(section, index)}\">\n <${section.component} />\n </Shell>\n</Layout>\n`\n}\n\nfunction astroGitignore(): string {\n return `node_modules\ndist\n.astro\n*.local\n.DS_Store\n`\n}\n\nfunction astroReadme(opts: ScaffoldOptions, sections: Section[]): string {\n const pm = opts.pm ?? 'npm'\n const routes = sections\n .map((s, i) => `- \\`${sectionPath(s, i)}\\` — \\`src/pages/${sectionPageFile(s, i)}\\``)\n .join('\\n')\n\n return `# ${opts.name}\n\nA [cascivo](https://cascivo.com) app — Astro + React islands + TypeScript, pre-wired with\nthe cascivo app shell, side navigation, and the \\`${opts.theme}\\` theme.\n\n## Develop\n\n\\`\\`\\`sh\n${installAllCommand(pm)}\n${runScriptCommand(pm, 'dev')}\n\\`\\`\\`\n\n## Routes\n\n${routes}\n\nAdd a page by dropping a new \\`.astro\\` file in \\`src/pages/\\` — Astro routes it by filename.\nAdd it to \\`NAV\\` in \\`src/components/Shell.tsx\\` to get a nav entry.\n\n## What hydrates, and what doesn't\n\nOnly \\`Shell\\` carries a client directive (\\`client:load\\`), because its nav drawer is\ninteractive on small screens. Everything inside it is slotted in as children and stays\nserver-rendered HTML, so a page ships the shell's JavaScript and nothing else.\n\nNeed a section to be interactive? Give it a directive at the call site in the page:\n\n\\`\\`\\`astro\n<${sections[0]!.component} client:visible />\n\\`\\`\\`\n\nA component that reads \\`signal.value\\` during render must also call \\`useSignals()\\` from\n\\`@cascivo/react\\` as its first statement — Astro applies no signals transform.\n\n## Styling\n\n\\`src/styles/layers.css\\` declares the cascade layer order and this app's own\n\\`cascivo.example\\` slot. It is imported **before** the theme in \\`src/layouts/Layout.astro\\`,\nand that order matters: layers take their position from first appearance, so a statement\nloaded after the theme cannot reorder anything.\n\nAdd more components with \\`npx cascivo add <component>\\`.\n${staticShareSection(opts)}`\n}\n\n/* ------------------------------------------------------------------------- *\n * Cloudflare scaffold (`--framework cloudflare`)\n *\n * A client-rendered app and its API, deployed as ONE Worker with static assets. No SSR:\n * the browser gets the SPA, `/api/*` reaches the Worker, and `@cloudflare/vite-plugin`\n * runs that Worker in workerd during `vite dev`, so dev and production execute the same\n * runtime. The shape comes from `apps/examples/chat` (its FINDINGS.md), not from a design\n * exercise: the source is typed against React and the runtime is picked in the bundler,\n * shared wire types live in one file both sides import, and streaming goes through\n * `@cascivo/data`.\n * ------------------------------------------------------------------------- */\n\n/** Workers runtime date the scaffold targets. Bump deliberately, never to \"today\". */\nconst COMPATIBILITY_DATE = '2026-09-01'\n\nfunction cfPackageJson(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n const agent = hasExample(opts, 'agent')\n const workerTypes = needsWorkerTypes(opts)\n const preact = runtimeOf(opts) === 'preact'\n const pkg = {\n name: packageName(opts.name),\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'vite',\n build: workerTypes ? 'tsc && tsc -p tsconfig.worker.json && vite build' : 'tsc && vite build',\n preview: 'vite preview',\n deploy: `${runScriptCommand(pm, 'build')} && wrangler deploy`,\n // No account: a temporary one, live for 60 minutes unless claimed (see README).\n 'deploy:preview': `${runScriptCommand(pm, 'build')} && wrangler deploy --temporary`,\n typecheck: workerTypes\n ? 'tsc --noEmit && tsc --noEmit -p tsconfig.worker.json'\n : 'tsc --noEmit',\n lint: 'eslint .',\n format: 'prettier --write .',\n 'format:check': 'prettier --check .',\n },\n // Same prebuilt-path rules as the Vite scaffold (see `packageJson`): no @cascivo/core,\n // no @cascivo/tokens. `@cascivo/app` is the router + typed API this app imports;\n // `@cascivo/data` arrives with it.\n dependencies: {\n '@cascivo/app': V['@cascivo/app']!,\n '@cascivo/react': V['@cascivo/react']!,\n '@cascivo/themes': V['@cascivo/themes']!,\n '@preact/signals-react': SIGNALS_PEER,\n ...(preact ? { preact: '^10.29.0' } : { react: '^19.0.0', 'react-dom': '^19.0.0' }),\n // The /usage and /ops pages' charts.\n ...(hasExample(opts, 'usage') || hasExample(opts, 'live')\n ? { '@cascivo/charts': V['@cascivo/charts']! }\n : {}),\n // The /report page's PDF/PNG export drives Browser Run through Cloudflare's puppeteer.\n ...(hasExample(opts, 'export') ? { '@cloudflare/puppeteer': '^1.4.0' } : {}),\n // The /notes page keeps its room in IndexedDB.\n ...(hasExample(opts, 'notes') ? { '@cascivo/storage': V['@cascivo/storage']! } : {}),\n // The /assistant page: Cloudflare's Agents SDK on the AI SDK, and @cascivo/render to\n // draw (and validate) the views the model builds.\n ...(agent\n ? {\n '@ai-sdk/react': '^4.0.0',\n '@cascivo/render': V['@cascivo/render']!,\n '@cloudflare/ai-chat': '^0.12.1',\n agents: '^0.26.0',\n ai: '^7.0.0',\n 'workers-ai-provider': '^4.0.0',\n zod: '^4.0.0',\n }\n : {}),\n // The /voice page: the Agents SDK's voice pipeline and its browser client.\n ...(hasExample(opts, 'voice') && !agent ? { agents: '^0.26.0' } : {}),\n // The /publish page and published pages render views with @cascivo/render.\n ...(hasExample(opts, 'publish') && !agent\n ? { '@cascivo/render': V['@cascivo/render']! }\n : {}),\n // The Worker renders receipts and newsletters with @cascivo/email (react-dom/server\n // underneath).\n ...(hasExample(opts, 'checkout') || hasExample(opts, 'newsletter')\n ? { '@cascivo/email': V['@cascivo/email']! }\n : {}),\n // The Worker server-renders published pages and emails; under Preact, react-dom/server\n // is preact/compat/server, which needs this.\n ...((hasExample(opts, 'publish') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter')) &&\n preact\n ? { 'preact-render-to-string': '^6.5.0' }\n : {}),\n },\n devDependencies: {\n '@cascivo/eslint-config': V['@cascivo/eslint-config']!,\n '@cloudflare/vite-plugin': '^1.62.0',\n // Types for worker/ (tsconfig.worker.json): the Agents SDK and Workflows extend runtime\n // classes only these declare. Kept out of the app's DOM-typed tsconfig.\n ...(workerTypes ? { '@cloudflare/workers-types': '^5.0.0' } : {}),\n '@eslint/js': '^9.0.0',\n // The source is typed against React even when Preact runs it (see vite.config.ts), so\n // React's types are always installed. Under Preact, `react`/`react-dom` are dev-only:\n // they satisfy cascivo's peer ranges and are aliased away at build time.\n '@types/react': '^19.0.0',\n '@types/react-dom': '^19.0.0',\n ...(preact\n ? {\n '@babel/core': '^7.0.0',\n '@preact/preset-vite': '^2.10.0',\n react: '^19.0.0',\n 'react-dom': '^19.0.0',\n }\n : { '@vitejs/plugin-react': '^6.0.0' }),\n eslint: '^9.0.0',\n 'eslint-plugin-react-hooks': '^7.0.0',\n prettier: '^3.0.0',\n typescript: '^5.7.0',\n 'typescript-eslint': '^8.0.0',\n vite: '^8.0.0',\n wrangler: '^4.143.0',\n },\n }\n const bindings = cfBindingDescriptions(opts)\n return (\n JSON.stringify(\n Object.keys(bindings).length > 0 ? { ...pkg, cloudflare: { bindings } } : pkg,\n null,\n 2,\n ) + '\\n'\n )\n}\n\nfunction cfTsconfig(opts: ScaffoldOptions): string {\n const cfg = JSON.parse(tsconfig()) as { include: string[] }\n // `worker/` is type-checked with the app: it imports `src/api.ts`, and a contract change\n // should fail `tsc` on whichever side was not updated. When the Worker needs Cloudflare's\n // runtime types (see needsWorkerTypes), which clash with the DOM's, it gets its own config\n // (tsconfig.worker.json) and both run in `typecheck`.\n cfg.include = needsWorkerTypes(opts) ? ['src'] : ['src', 'worker']\n return formatJson(cfg)\n}\n\nfunction cfWorkerTsconfig(): string {\n return formatJson({\n extends: './tsconfig.json',\n compilerOptions: {\n lib: ['ES2022'],\n types: ['@cloudflare/workers-types', 'vite/client'],\n },\n include: ['worker'],\n })\n}\n\nfunction cfViteConfig(runtime: Runtime, opts: ScaffoldOptions): string {\n const agent = hasExample(opts, 'agent')\n const ai = usesWorkersAi(opts)\n const plugin =\n runtime === 'preact'\n ? `import preact from '@preact/preset-vite'`\n : `import react from '@vitejs/plugin-react'`\n const call = runtime === 'preact' ? 'preact()' : 'react()'\n const note =\n runtime === 'preact'\n ? `// The source is written against React's types; @preact/preset-vite aliases react and\n// react-dom to preact/compat, so the bundle runs on Preact (about a third of React's JS in\n// this starter: ~27 KB gzip against ~85 KB). To run on React instead, swap this plugin\n// for @vitejs/plugin-react — no source changes.`\n : agent\n ? `// Runs on React: the /assistant page uses the Agents SDK's hooks, which call React 19's\n// use() — preact/compat does not implement it, so this app cannot switch to Preact.`\n : `// Runs on React. To ship Preact instead (about a third of the JS in this starter), swap\n// this plugin for @preact/preset-vite — it aliases react/react-dom to preact/compat; no\n// source changes.`\n return `${plugin}\nimport { cascivoRoutes } from '@cascivo/app/vite'\nimport { cloudflare } from '@cloudflare/vite-plugin'\nimport { defineConfig } from 'vite'\n\n${note}\n//\n// cascivoRoutes() writes src/routes.gen.ts from src/routes/ — one file per page.\n// cloudflare() runs worker/index.ts in workerd during \\`vite dev\\` and builds it with the\n// client, so dev and production execute the same runtime. Request routing is in wrangler.jsonc.${\n ai\n ? `\n//\n// Workers AI has no local mode: with remote bindings on, \\`vite dev\\` needs a Cloudflare login.\n// So they are off, ${\n hasExample(opts, 'search')\n ? `and:${[\n agent ? 'the assistant answers from worker/scripted-model.ts' : '',\n hasExample(opts, 'voice')\n ? 'the voice page uses the stand-ins in worker/scripted-voice.ts'\n : '',\n 'search runs by keyword, on SQLite full-text search (worker/search.ts)',\n ]\n .filter(Boolean)\n .map((clause) => `\\n// - ${clause}`)\n .join('')}`\n : agent && hasExample(opts, 'voice')\n ? `the assistant answers from worker/scripted-model.ts, and the voice\n// page uses the stand-ins in worker/scripted-voice.ts`\n : agent\n ? 'and the assistant answers from worker/scripted-model.ts'\n : 'and the voice page uses the stand-ins in worker/scripted-voice.ts'\n }. Run\n// \\`VITE_REAL_AI=1 vite dev\\` (after \\`wrangler login\\`) to use Workers AI.`\n : ''\n }\nexport default defineConfig({\n${\n ai\n ? ` plugins: [\n ${call},\n cascivoRoutes(),\n cloudflare({ remoteBindings: process.env['VITE_REAL_AI'] === '1' }),\n ],`\n : ` plugins: [${call}, cascivoRoutes(), cloudflare()],`\n}${\n hasExample(opts, 'publish')\n ? `\n // A published page's HTML links the stylesheets its route needs, found in this manifest\n // (worker/page-html.ts), so the page is styled for readers without JavaScript too.\n build: { manifest: 'asset-manifest.json' },`\n : ''\n }\n})\n`\n}\n\n/** A JSONC array property laid out as Prettier does: one line when it fits in 100 columns. */\nfunction jsoncArray(indent: string, key: string, items: string[]): string {\n const line = `${indent}\"${key}\": [${items.join(', ')}],`\n if (line.length <= 100) return line\n return `${indent}\"${key}\": [\\n${items.map((i) => `${indent} ${i},`).join('\\n')}\\n${indent}],`\n}\n\nfunction wranglerJsonc(opts: ScaffoldOptions): string {\n const publish = hasExample(opts, 'publish')\n const rooms = usesRooms(opts)\n const agent = hasExample(opts, 'agent')\n const ai = usesAgents(opts)\n // One Durable Object class per example; a fresh app declares them all in one migration.\n const objects = [\n ...(rooms ? [{ name: 'ROOMS', className: 'SyncRoom' }] : []),\n ...(agent ? [{ name: 'Assistant', className: 'Assistant' }] : []),\n ...(hasExample(opts, 'live') ? [{ name: 'LIVE', className: 'LiveRoom' }] : []),\n ...(hasExample(opts, 'voice') ? [{ name: 'Voice', className: 'Voice' }] : []),\n ]\n const comments = [\n ...(rooms ? ['// ROOMS: one SyncRoom per room (@cascivo/app/sync-server).'] : []),\n ...(agent\n ? ['// Assistant: one AIChatAgent per conversation; it stores the messages in SQLite.']\n : []),\n ...(hasExample(opts, 'live')\n ? [\"// LIVE: the /ops dashboard's per-second totals (@cascivo/app/live-server).\"]\n : []),\n ...(hasExample(opts, 'voice')\n ? ['// Voice: one voice agent per conversation (agents/voice); it stores the transcript.']\n : []),\n ]\n return `// Cloudflare deploy config. \\`${runExplicitCommand(opts.pm ?? 'npm', 'deploy')}\\` builds and ships the SPA and\n// the Worker together. https://developers.cloudflare.com/workers/wrangler/configuration/\n{\n \"name\": \"${packageName(opts.name)}\",\n \"main\": \"./worker/index.ts\",\n \"compatibility_date\": \"${COMPATIBILITY_DATE}\",\n \"assets\": {\n // Client-side app: an unknown path serves index.html, and the client renders it.\n \"not_found_handling\": \"single-page-application\",\n // ${\n publish\n ? 'Only the API and published pages reach the Worker; static assets are served\\n // without invoking it.'\n : 'Only the API reaches the Worker; static assets are served without invoking it.'\n }\n \"run_worker_first\": [${['\"/api/*\"', ...(ai ? ['\"/agents/*\"'] : []), ...(publish ? ['\"/p/*\"'] : [])].join(', ')}],${\n publish\n ? `\n // The Worker reads index.html through this to put a published page into it.\n \"binding\": \"ASSETS\",`\n : ''\n }\n },\n \"observability\": { \"enabled\": true },${\n objects.length > 0\n ? `\n ${comments.join('\\n ')}\n \"durable_objects\": {\n${jsoncArray(\n ' ',\n 'bindings',\n objects.map((o) => `{ \"name\": \"${o.name}\", \"class_name\": \"${o.className}\" }`),\n)}\n },\n${jsoncArray(' ', 'migrations', [`{ \"tag\": \"v1\", \"new_sqlite_classes\": [${objects.map((o) => `\"${o.className}\"`).join(', ')}] }`])}`\n : ''\n }${\n hasExample(opts, 'files') || hasExample(opts, 'social')\n ? `\n // ${[\n hasExample(opts, 'files')\n ? 'Uploaded files, and Cloudflare Images for their resized previews'\n : '',\n hasExample(opts, 'social') ? 'Images attached to social posts (worker/social.ts)' : '',\n ]\n .filter(Boolean)\n .join('; ')}.\n${jsoncArray(' ', 'r2_buckets', [\n ...(hasExample(opts, 'files')\n ? [`{ \"binding\": \"FILES\", \"bucket_name\": \"${packageName(opts.name)}-files\" }`]\n : []),\n ...(hasExample(opts, 'social')\n ? [`{ \"binding\": \"SOCIAL_MEDIA\", \"bucket_name\": \"${packageName(opts.name)}-social-media\" }`]\n : []),\n])}${hasExample(opts, 'files') ? '\\n \"images\": { \"binding\": \"IMAGES\" },' : ''}`\n : ''\n }${\n usesLimiter(opts)\n ? `\n // ${[\n hasExample(opts, 'files') ? 'Uploads' : '',\n hasExample(opts, 'export') ? 'exports' : '',\n hasExample(opts, 'publish') ? 'published pages' : '',\n hasExample(opts, 'digest') ? 'digests sent now' : '',\n hasExample(opts, 'search') ? 'search indexing' : '',\n hasExample(opts, 'checkout') ? 'checkouts started' : '',\n hasExample(opts, 'newsletter') ? 'newsletter sign-ups and composer requests' : '',\n hasExample(opts, 'social') ? 'connecting accounts, posts and image uploads' : '',\n emailSignIn(opts) ? 'sign-in emails' : '',\n ]\n .filter(Boolean)\n .join(', ')\n .replace(/^./, (c) => c.toUpperCase())} per caller: 20 a minute.\n // namespace_id is any number unique within your account:\n // https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/\n${jsoncArray(' ', 'ratelimits', ['{ \"name\": \"LIMITER\", \"namespace_id\": \"1001\", \"simple\": { \"limit\": 20, \"period\": 60 } }'])}`\n : ''\n }${wranglerVars(opts)}${\n hasExample(opts, 'digest') || hasExample(opts, 'social')\n ? `\n // ${[\n hasExample(opts, 'digest') ? 'The weekly digest: Mondays at 08:00 UTC (worker/digest.ts)' : '',\n hasExample(opts, 'social')\n ? 'Threads tokens renewed and reconnect reminders sent, daily at 04:17 UTC (worker/social.ts)'\n : '',\n ]\n .filter(Boolean)\n .join('; ')}.\n \"triggers\": { \"crons\": [${[\n hasExample(opts, 'digest') ? '\"0 8 * * 1\"' : '',\n hasExample(opts, 'social') ? `\"${SOCIAL_CRON}\"` : '',\n ]\n .filter(Boolean)\n .join(', ')}] },`\n : ''\n }${\n usesD1(opts)\n ? `\n // Tables for ${[\n hasExample(opts, 'crud') ? 'customers' : '',\n hasExample(opts, 'publish') ? 'published pages' : '',\n hasExample(opts, 'webhooks') ? 'webhook deliveries' : '',\n hasExample(opts, 'digest') ? 'digest runs' : '',\n hasExample(opts, 'search') ? 'help articles' : '',\n hasExample(opts, 'checkout') ? 'orders' : '',\n hasExample(opts, 'newsletter') ? 'newsletter subscribers and issues' : '',\n hasAccounts(opts) ? 'accounts' : '',\n ]\n .filter(Boolean)\n .join(', ')}.\n // No database_id: wrangler creates the database on first deploy, and the Worker applies its\n // own schema on its first query (\\`migrate\\`).\n${jsoncArray(' ', 'd1_databases', [`{ \"binding\": \"DB\", \"database_name\": \"${packageName(opts.name)}-db\" }`])}`\n : ''\n }${\n hasExample(opts, 'usage')\n ? `\n // Every API request is recorded here (worker/index.ts); /usage reads it back.\n \"analytics_engine_datasets\": [{ \"binding\": \"USAGE\", \"dataset\": \"${usageDataset(opts)}\" }],`\n : ''\n }${wranglerQueues(opts)}${\n hasExample(opts, 'import') || hasExample(opts, 'social')\n ? `\n // ${[\n hasExample(opts, 'import')\n ? 'The CSV import runs as a Workflow (worker/import-job.ts); its progress is a room.'\n : '',\n hasExample(opts, 'social')\n ? 'Each scheduled post is a Workflow that waits until it is due (worker/social-post.ts).'\n : '',\n ]\n .filter(Boolean)\n .join('\\n // ')}\n${jsoncArray(' ', 'workflows', [\n ...(hasExample(opts, 'import')\n ? ['{ \"name\": \"import-job\", \"binding\": \"IMPORT_JOB\", \"class_name\": \"ImportJob\" }']\n : []),\n ...(hasExample(opts, 'social')\n ? [\n `{ \"name\": \"${packageName(opts.name)}-social-post\", \"binding\": \"SOCIAL_POST\", \"class_name\": \"SocialPost\" }`,\n ]\n : []),\n])}`\n : ''\n }${\n usesWorkersAi(opts)\n ? `\n // Workers AI, which the ${[agent ? 'assistant' : '', hasExample(opts, 'voice') ? 'voice agent' : '', hasExample(opts, 'search') ? 'search page' : ''].filter(Boolean).join(' and ')} call${[agent, hasExample(opts, 'voice'), hasExample(opts, 'search')].filter(Boolean).length > 1 ? '' : 's'}.\n \"ai\": { \"binding\": \"AI\" },`\n : ''\n }${\n hasExample(opts, 'search')\n ? `\n // The help articles' embeddings (worker/search.ts). Create it once before deploying (README).\n${jsoncArray(' ', 'vectorize', [`{ \"binding\": \"ARTICLES_INDEX\", \"index_name\": \"${packageName(opts.name)}-articles\" }`])}`\n : ''\n }${\n hasExample(opts, 'export')\n ? `\n // Browser Run: renders pages to PDF/PNG for /api/export.\n \"browser\": { \"binding\": \"BROWSER\" },`\n : ''\n }${\n ai || hasExample(opts, 'export')\n ? `\n // ${[ai ? 'The Agents SDK' : '', hasExample(opts, 'export') ? \"Cloudflare's puppeteer\" : ''].filter(Boolean).join(' and ')} use${ai && hasExample(opts, 'export') ? '' : 's'} Node.js APIs.\n \"compatibility_flags\": [\"nodejs_compat\"],`\n : ''\n }\n // Add bindings here (KV, D1, R2, Durable Objects, Workers AI) and read them from the\n // \\`env\\` argument of the Worker's fetch handler.\n}\n`\n}\n\n/**\n * wrangler.jsonc's plain-text settings, in one `vars` object (a second one would replace the\n * first), and the Email Service binding when anything sends mail.\n */\nfunction wranglerVars(opts: ScaffoldOptions): string {\n const digest = hasExample(opts, 'digest')\n const social = hasExample(opts, 'social')\n const checkout = hasExample(opts, 'checkout')\n const newsletter = hasExample(opts, 'newsletter')\n const emailAuth = emailSignIn(opts)\n const comments = [\n ...(opts.auth === 'access'\n ? ['Cloudflare Access (README). Until both are set, the Worker refuses every request.']\n : []),\n ...(emailAuth\n ? [\n 'Sign-in links go out through Email Service (README). AUTH_FROM must be an address on a',\n 'domain you have onboarded; until it is set, sign-in fails with a clear error.',\n ]\n : []),\n ...(digest\n ? [\n \"The weekly digest (README): who gets it, who sends it, and the deployed app's URL,\",\n 'which the browser opens. Until they are set, each run is recorded as skipped.',\n ]\n : []),\n ...(social\n ? [\n 'Reminders to connect LinkedIn again go out through Email Service from REMINDER_FROM, an',\n `address on a domain you have onboarded, with links to APP_URL (README). Until both are`,\n 'set, no reminder is sent.',\n ]\n : []),\n ...(checkout\n ? [\n 'Receipts for paid orders go out through Email Service from RECEIPT_FROM, an address',\n 'on a domain you have onboarded (README). Until it is set, no receipt is sent.',\n ]\n : []),\n ...(newsletter\n ? [\n 'The newsletter sends through Amazon SES (README): the region your sending identity is',\n 'verified in, its From address, and the SNS topic SES reports bounces and complaints to.',\n ]\n : []),\n ]\n const names = [\n ...(opts.auth === 'access' ? ['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] : []),\n ...(emailAuth ? ['AUTH_FROM'] : []),\n ...(digest ? ['DIGEST_TO', 'DIGEST_FROM', 'APP_URL'] : []),\n ...(social ? ['REMINDER_FROM', ...(digest ? [] : ['APP_URL'])] : []),\n ...(checkout ? ['RECEIPT_FROM'] : []),\n ...(newsletter ? ['AWS_REGION', 'NEWSLETTER_FROM', 'SNS_TOPIC_ARN'] : []),\n ]\n if (names.length === 0) return ''\n const entries = names.map((name) => `\"${name}\": \"\"`)\n const line = ` \"vars\": { ${entries.join(', ')} },`\n const vars =\n line.length <= 100\n ? line\n : ` \"vars\": {\\n${entries.map((entry) => ` ${entry},`).join('\\n')}\\n },`\n return `\n${comments.map((comment) => ` // ${comment}`).join('\\n')}${emailAuth || digest || checkout || social ? '\\n \"send_email\": [{ \"name\": \"EMAIL\" }],' : ''}\n${vars}`\n}\n\n/** One `queues` object for every example that has a queue (a second one would replace the first). */\nfunction wranglerQueues(opts: ScaffoldOptions): string {\n const queues = [\n ...(hasExample(opts, 'live')\n ? [\n {\n comment: [\n \"Events for /ops: POST /api/events sends them, and the Worker's queue handler takes them in\",\n 'batches of up to 100, or whatever arrived within a second.',\n ],\n binding: 'EVENTS',\n queue: `${packageName(opts.name)}-events`,\n consumer: ['\"max_batch_size\": 100', '\"max_batch_timeout\": 1'],\n },\n ]\n : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n {\n comment: [\n 'Newsletter sends (worker/newsletter.ts): one message of 25 readers at a time, so SES',\n 'is called at a steady pace. A throttled message is retried after 30 seconds.',\n ],\n binding: 'NEWSLETTER',\n queue: newsletterQueue(opts),\n consumer: [\n '\"max_batch_size\": 1',\n '\"max_concurrency\": 1',\n '\"max_retries\": 10',\n '\"retry_delay\": 30',\n ],\n },\n ]\n : []),\n ]\n if (queues.length === 0) return ''\n return `\n${queues.flatMap((q) => q.comment.map((line) => ` // ${line}`)).join('\\n')}\n \"queues\": {\n${jsoncArray(\n ' ',\n 'producers',\n queues.map((q) => `{ \"binding\": \"${q.binding}\", \"queue\": \"${q.queue}\" }`),\n)}\n \"consumers\": [\n${queues\n .map(\n (q) => ` {\n \"queue\": \"${q.queue}\",\n${q.consumer.map((line) => ` ${line},`).join('\\n')}\n },`,\n )\n .join('\\n')}\n ],\n },`\n}\n\n/** A subscription plan needs an account to belong to: checkout with accounts bills one. */\nfunction usesBilling(opts: ScaffoldOptions): boolean {\n return hasExample(opts, 'checkout') && hasAccounts(opts)\n}\n\n/** Accounts of any kind: users, sessions, and API writes that need a signed-in user. */\nfunction hasAccounts(opts: ScaffoldOptions): boolean {\n return emailSignIn(opts) || oauthSignIn(opts)\n}\n\n/** Sign-in by an emailed one-time link (`handleAuth`). */\nfunction emailSignIn(opts: ScaffoldOptions): boolean {\n return opts.auth === 'email' || opts.auth === 'email,oauth'\n}\n\n/**\n * `--auth`: `access`, `email`, `oauth`, or `email,oauth` (either order); `null` when absent,\n * `'invalid'` for anything else, including `access` combined with a sign-in method.\n */\nexport function parseAuth(raw: string | undefined): Auth | null | 'invalid' {\n const parts = [\n ...new Set(\n (raw ?? '')\n .toLowerCase()\n .split(',')\n .map((p) => p.trim()),\n ),\n ].filter(Boolean)\n if (parts.length === 0) return null\n const key = parts.sort().join(',')\n return key === 'access' || key === 'email' || key === 'oauth' || key === 'email,oauth'\n ? key\n : 'invalid'\n}\n\n/** Sign-in with GitHub, Google and LinkedIn (`handleOAuth`). */\nfunction oauthSignIn(opts: ScaffoldOptions): boolean {\n return opts.auth === 'oauth' || opts.auth === 'email,oauth'\n}\n\n/** The newsletter's queue, named after the app like every other resource. */\nfunction newsletterQueue(opts: ScaffoldOptions): string {\n return `${packageName(opts.name)}-newsletter`\n}\n\nfunction hasExample(opts: ScaffoldOptions, example: Example): boolean {\n return opts.examples?.includes(example) ?? false\n}\n\n/**\n * Examples that live in `SyncRoom`s: the board and the notes (at /api/rooms/:name), and the\n * import's job progress (a read-only room at /api/jobs/:id).\n */\n/** The Analytics Engine dataset for `--example usage`: one per app, named after it. */\nfunction usageDataset(opts: ScaffoldOptions): string {\n return `${packageName(opts.name)\n .replace(/[^a-z0-9_]/g, '_')\n .replace(/^[^a-z_]/, '_$&')}_usage`\n}\n\n/**\n * Examples with requests that cost money (storage, a browser) or put content on the app's\n * origin (a published page): rate-limited per caller.\n */\nfunction usesLimiter(opts: ScaffoldOptions): boolean {\n return (\n hasExample(opts, 'files') ||\n hasExample(opts, 'export') ||\n hasExample(opts, 'publish') ||\n hasExample(opts, 'digest') ||\n hasExample(opts, 'search') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter') ||\n hasExample(opts, 'social') ||\n emailSignIn(opts)\n )\n}\n\n/** Examples that keep tables in D1, bound as DB. */\nfunction usesD1(opts: ScaffoldOptions): boolean {\n return (\n hasExample(opts, 'crud') ||\n hasExample(opts, 'publish') ||\n hasExample(opts, 'webhooks') ||\n hasExample(opts, 'digest') ||\n hasExample(opts, 'search') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter') ||\n hasAccounts(opts)\n )\n}\n\n/** Examples that call Workers AI: the Agents SDK ones, and search's embeddings. */\nfunction usesWorkersAi(opts: ScaffoldOptions): boolean {\n return usesAgents(opts) || hasExample(opts, 'search')\n}\n\n/** Examples on Cloudflare's Agents SDK: routed under /agents/*, calling Workers AI. */\nfunction usesAgents(opts: ScaffoldOptions): boolean {\n return hasExample(opts, 'agent') || hasExample(opts, 'voice')\n}\n\nfunction usesRooms(opts: ScaffoldOptions): boolean {\n return (\n hasExample(opts, 'board') ||\n hasExample(opts, 'notes') ||\n hasExample(opts, 'import') ||\n hasExample(opts, 'webhooks') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter')\n )\n}\n\n/**\n * Worker code that extends a runtime class (the Agents SDK's Durable Object, a Workflow)\n * needs Cloudflare's runtime types, which clash with the DOM's: those apps type-check\n * worker/ on its own (tsconfig.worker.json).\n */\nfunction needsWorkerTypes(opts: ScaffoldOptions): boolean {\n return usesAgents(opts) || hasExample(opts, 'import') || hasExample(opts, 'social')\n}\n\n/**\n * The client runtime. Preact by default; React with the `agent` example, because the Agents\n * SDK's hooks (`useAgent`, `useAgentChat`) call React 19's `use()`, which preact/compat\n * does not implement — the build fails on the missing export.\n */\nfunction runtimeOf(opts: ScaffoldOptions): Runtime {\n return opts.runtime ?? (hasExample(opts, 'agent') ? 'react' : 'preact')\n}\n\nfunction cfApiTs(opts: ScaffoldOptions): string {\n const imports = hasExample(opts, 'import')\n const files = hasExample(opts, 'files')\n const usage = hasExample(opts, 'usage')\n const crud = hasExample(opts, 'crud')\n const live = hasExample(opts, 'live')\n const publish = hasExample(opts, 'publish')\n const webhooks = hasExample(opts, 'webhooks')\n const digest = hasExample(opts, 'digest')\n const search = hasExample(opts, 'search')\n const checkout = hasExample(opts, 'checkout')\n const newsletter = hasExample(opts, 'newsletter')\n const billing = usesBilling(opts)\n const social = hasExample(opts, 'social')\n return `import { defineApi, ${imports || files || usage || crud || live || publish || webhooks || digest || search || checkout || newsletter || social ? 'endpoint, ' : ''}stream } from '@cascivo/app/api'\n${crud ? `import { parseTablePage, parseTableQuery } from '@cascivo/app/db'\\n` : ''}${files ? `import { parseStoredFile } from '@cascivo/app/uploads'\\n` : ''}${imports ? `import { parseImportRequest, parseStarted } from './import-job'\\n` : ''}${usage ? `import { parseUsageReport } from './usage'\\n` : ''}${crud ? `import { parseCustomer, parseCustomerInput, parseDeleted } from './customers'\\n` : ''}${live ? `import { ops, parseAccepted } from './ops'\\n` : ''}${publish ? `import { parsePage, parsePageInput, parsePageSummary } from './pages'\\n` : ''}${webhooks ? `import { parseDeliveries, parseTestResult } from './webhooks'\\n` : ''}${digest ? `import { parseDigestRun, parseDigestRuns } from './digest'\\n` : ''}${search ? `import { parseIndexed, parseSearchQuery, parseSearchResult } from './search'\\n` : ''}${billing ? `import { parseBilling, parseRedirect, parseSyncInput } from './billing'\\n` : ''}${checkout ? `import { parseCheckoutStarted, parseOrder } from './checkout'\\n` : ''}${\n newsletter\n ? `import {\n parseConfirmed,\n parseEmailInput,\n parseIssue,\n parseIssueInput,\n parseKeyInput,\n parseOverview,\n parsePreview,\n parseSubscribed,\n parseTokenInput,\n} from './newsletter'\\n`\n : ''\n }${social ? `import { parsePostInput, parseScheduledPost, parseSocial } from './social'\\n` : ''}\n/**\n * The contract between the browser and the Worker. Both import this file: the Worker serves\n * it with \\`createHandler\\`, the app calls it with \\`createClient\\`, and a change that breaks\n * either side is a type error. Add an endpoint here, then its handler in worker/index.ts.\n */\n\n/** How many ticks one stream sends before it ends. */\nexport const TICKS_PER_STREAM = 30\n\nexport interface Tick {\n n: number\n /** ISO timestamp, set by the Worker. */\n at: string\n}\n\n/**\n * Parses one streamed tick. It crosses the network, so it is checked rather than cast —\n * \\`JSON.parse\\` returns \\`any\\`, and \\`as Tick\\` would prove nothing.\n */\nexport function parseTick(raw: unknown): Tick {\n if (typeof raw === 'object' && raw !== null) {\n const { n, at } = raw as Record<string, unknown>\n if (typeof n === 'number' && typeof at === 'string') return { n, at }\n }\n throw new Error('Malformed tick')\n}\n\nexport const api = defineApi({\n ticks: stream({ method: 'GET', path: '/api/ticks', event: parseTick }),${\n imports\n ? `\n // Starts a CSV import; returns the job id to watch (src/import-page.ts).\n startImport: endpoint({\n method: 'POST',\n path: '/api/import',\n input: parseImportRequest,\n output: parseStarted,\n }),`\n : ''\n }${\n files\n ? `\n // The stored files (the uploads themselves are \\`handleUploads\\` in the Worker).\n listFiles: endpoint({\n method: 'GET',\n path: '/api/files',\n output: (raw) => {\n if (!Array.isArray(raw)) throw new Error('Expected a list of files')\n return raw.map(parseStoredFile)\n },\n }),`\n : ''\n }${\n usage\n ? `\n // The last 24 hours of API usage, read from Analytics Engine by the Worker.\n usage: endpoint({ method: 'GET', path: '/api/usage', output: parseUsageReport }),`\n : ''\n }${\n live\n ? `\n // Events for the /ops dashboard, at most 100 at a time, onto the EVENTS queue.\n sendEvents: endpoint({\n method: 'POST',\n path: '/api/events',\n input: ops.parseEvents,\n output: parseAccepted,\n }),`\n : ''\n }${\n publish\n ? `\n // Publishes a view as a page (checked against the manifests), and reads one back.\n publishPage: endpoint({\n method: 'POST',\n path: '/api/pages',\n input: parsePageInput,\n output: parsePageSummary,\n }),\n getPage: endpoint({ method: 'GET', path: '/api/pages/:slug', output: parsePage }),`\n : ''\n }${\n webhooks\n ? `\n // The last 50 webhook deliveries (they arrive at /api/webhooks/github, signed).\n listDeliveries: endpoint({ method: 'GET', path: '/api/webhooks', output: parseDeliveries }),\n // Signs a sample delivery with WEBHOOK_SECRET and runs it through the same path.\n sendTestDelivery: endpoint({\n method: 'POST',\n path: '/api/webhook-test',\n output: parseTestResult,\n }),`\n : ''\n }${\n digest\n ? `\n // The weekly digest's recent runs, and one run now (the Cron Trigger runs it on Mondays).\n digestRuns: endpoint({ method: 'GET', path: '/api/digest/runs', output: parseDigestRuns }),\n runDigest: endpoint({ method: 'POST', path: '/api/digest/run', output: parseDigestRun }),`\n : ''\n }${\n search\n ? `\n // Help articles by meaning (Vectorize), or by keyword in vite dev.\n search: endpoint({\n method: 'POST',\n path: '/api/search',\n input: parseSearchQuery,\n output: parseSearchResult,\n }),\n // Embeds every article into the Vectorize index.\n indexArticles: endpoint({ method: 'POST', path: '/api/search/index', output: parseIndexed }),`\n : ''\n }${\n checkout\n ? `\n // Opens a Stripe Checkout page for the product; the browser goes to its url.\n startCheckout: endpoint({ method: 'POST', path: '/api/checkout', output: parseCheckoutStarted }),\n // An order, checked with Stripe while it is pending (Stripe's webhook settles it too).\n getOrder: endpoint({ method: 'GET', path: '/api/orders/:id', output: parseOrder }),`\n : ''\n }${\n billing\n ? `\n // The signed-in user's subscription (worker/billing.ts), and the ways to change it.\n getBilling: endpoint({ method: 'GET', path: '/api/billing', output: parseBilling }),\n startSubscription: endpoint({\n method: 'POST',\n path: '/api/billing/subscribe',\n output: parseRedirect,\n }),\n syncBilling: endpoint({\n method: 'POST',\n path: '/api/billing/sync',\n input: parseSyncInput,\n output: parseBilling,\n }),\n openBillingPortal: endpoint({\n method: 'POST',\n path: '/api/billing/portal',\n output: parseRedirect,\n }),`\n : ''\n }${\n newsletter\n ? `\n // Signing up sends a confirmation link; only confirmed readers get issues.\n subscribe: endpoint({\n method: 'POST',\n path: '/api/newsletter/subscribe',\n input: parseEmailInput,\n output: parseSubscribed,\n }),\n confirmSubscription: endpoint({\n method: 'POST',\n path: '/api/newsletter/confirm',\n input: parseTokenInput,\n output: parseConfirmed,\n }),\n // The composer's calls, each carrying NEWSLETTER_KEY.\n newsletterOverview: endpoint({\n method: 'POST',\n path: '/api/newsletter/overview',\n input: parseKeyInput,\n output: parseOverview,\n }),\n previewIssue: endpoint({\n method: 'POST',\n path: '/api/newsletter/preview',\n input: parseIssueInput,\n output: parsePreview,\n }),\n sendIssue: endpoint({\n method: 'POST',\n path: '/api/newsletter/issues',\n input: parseIssueInput,\n output: parseIssue,\n }),`\n : ''\n }${\n crud\n ? `\n // One page of customers for DataTable's query (sort, search, filters, page).\n customers: endpoint({\n method: 'POST',\n path: '/api/customers/query',\n input: parseTableQuery,\n output: (raw) => parseTablePage(raw, parseCustomer),\n }),\n createCustomer: endpoint({\n method: 'POST',\n path: '/api/customers',\n input: parseCustomerInput,\n output: parseCustomer,\n }),\n updateCustomer: endpoint({\n method: 'PUT',\n path: '/api/customers/:id',\n input: parseCustomerInput,\n output: parseCustomer,\n }),\n deleteCustomer: endpoint({ method: 'DELETE', path: '/api/customers/:id', output: parseDeleted }),`\n : ''\n }${\n social\n ? `\n // The signed-in user's connected accounts and posts (worker/social.ts).\n getSocial: endpoint({ method: 'GET', path: '/api/social', output: parseSocial }),\n schedulePost: endpoint({\n method: 'POST',\n path: '/api/social/posts',\n input: parsePostInput,\n output: parseScheduledPost,\n }),\n cancelPost: endpoint({\n method: 'POST',\n path: '/api/social/posts/:id/cancel',\n output: parseScheduledPost,\n }),`\n : ''\n }\n})\n`\n}\n\nfunction cfWorkerTs(opts: ScaffoldOptions): string {\n const rooms = usesRooms(opts)\n const agent = hasExample(opts, 'agent')\n const imports = hasExample(opts, 'import')\n const files = hasExample(opts, 'files')\n const exports = hasExample(opts, 'export')\n const usage = hasExample(opts, 'usage')\n const crud = hasExample(opts, 'crud')\n const live = hasExample(opts, 'live')\n const voice = hasExample(opts, 'voice')\n const publish = hasExample(opts, 'publish')\n const webhooks = hasExample(opts, 'webhooks')\n const digest = hasExample(opts, 'digest')\n const search = hasExample(opts, 'search')\n const checkout = hasExample(opts, 'checkout')\n const newsletter = hasExample(opts, 'newsletter')\n const billing = usesBilling(opts)\n const d1 = usesD1(opts)\n const ai = usesAgents(opts)\n const limiter = usesLimiter(opts)\n const access = opts.auth === 'access'\n const emailAuth = emailSignIn(opts)\n const oauth = oauthSignIn(opts)\n const accounts = hasAccounts(opts)\n const social = hasExample(opts, 'social')\n const guards = [\n ...(access ? ['requireAccess'] : []),\n ...(limiter ? ['clientIp', 'rateLimit'] : []),\n ]\n const custom = rooms || ai || files || exports || access || live || limiter || publish || accounts\n const isAsync =\n ai ||\n files ||\n exports ||\n access ||\n limiter ||\n webhooks ||\n publish ||\n checkout ||\n newsletter ||\n accounts\n // Rooms the server writes: never opened through /api/rooms/:name, where clients may write.\n const serverRooms = [\n ...(imports ? ['job-'] : []),\n ...(webhooks ? ['webhooks$'] : []),\n ...(checkout ? ['order-'] : []),\n ...(newsletter ? ['issue-'] : []),\n ]\n // Writes that need no signed-in user even with accounts: webhooks carry a signature instead of a\n // session, and a newsletter's readers have no account.\n const signedPaths = [\n ...(webhooks ? ['/api/webhooks/'] : []),\n ...(checkout ? ['/api/stripe/'] : []),\n ...(newsletter ? ['/api/sns/'] : []),\n ]\n const readerPaths = newsletter\n ? ['/api/newsletter/subscribe', '/api/newsletter/confirm', '/api/newsletter/unsubscribe']\n : []\n const openPaths = [...signedPaths, ...readerPaths]\n return `${usage ? `import type { AnalyticsDataset } from '@cascivo/app/analytics'\\n` : ''}${d1 ? `import type { Database } from '@cascivo/app/db'\\n` : ''}${exports ? `import { handleExport } from '@cascivo/app/export'\\n` : ''}import { createHandler${publish ? ', HttpError' : ''} } from '@cascivo/app/api'\n${accounts ? `import { ${emailAuth ? 'handleAuth, ' : ''}requireUser } from '@cascivo/app/auth-server'\\n` : ''}${oauth ? `import { github, google, linkedin } from '@cascivo/app/oauth'\\nimport type { OAuthProvider } from '@cascivo/app/oauth'\\nimport { handleOAuth } from '@cascivo/app/oauth-server'\\n` : ''}${guards.length > 0 || webhooks || accounts ? `import { ${[...guards, 'guardResponse'].sort().join(', ')} } from '@cascivo/app/guard'\\n${limiter ? `import type { RateLimiter } from '@cascivo/app/guard'\\n` : ''}` : ''}${imports ? `import { jobReporter } from '@cascivo/app/jobs-server'\\n` : ''}${\n files\n ? `import { handleUploads, listUploads } from '@cascivo/app/uploads-server'\nimport type { ImageResizer, UploadBucket } from '@cascivo/app/uploads-server'\n`\n : ''\n }${\n live\n ? `import { recordLive } from '@cascivo/app/live-server'\nimport type { LiveBatch, LiveQueue } from '@cascivo/app/live-server'\n`\n : ''\n }${\n rooms || live\n ? `import { roomResponse } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\n`\n : ''\n }${\n exports\n ? `import puppeteer from '@cloudflare/puppeteer'\nimport type { BrowserWorker } from '@cloudflare/puppeteer'\n`\n : ''\n }${ai ? `import { routeAgentRequest } from 'agents'\\n` : ''}import { api, TICKS_PER_STREAM } from '../src/api'\nimport type { Tick } from '../src/api'\n${imports ? `import { importJob } from '../src/import-job'\\n` : ''}${files ? `import { uploads } from '../src/upload-policy'\\n` : ''}${usage ? `import { usageMetrics } from '../src/usage'\\nimport { usageReport } from './usage'\\n` : ''}${live ? `import { OPS_ROOM, ops } from '../src/ops'\\n` : ''}${emailAuth ? `import { sendSignInLink } from './auth'\\nimport type { SignInSender } from './auth'\\n` : ''}${crud ? `import * as customerStore from './customers'\\n` : ''}${publish ? `import * as pageStore from './pages'\\nimport { renderPageHtml } from './page-html'\\nimport type { Assets } from './page-html'\\n${exports ? `import { pagePreview } from './page-preview'\\n` : ''}` : ''}${webhooks ? `import * as webhookStore from './webhooks'\\nimport { DELIVERIES_ROOM } from '../src/webhooks'\\n` : ''}${digest ? `import * as digestJob from './digest'\\nimport type { DigestSender } from './digest'\\n` : ''}${search ? `import * as articleSearch from './search'\\nimport type { ${ai ? '' : 'Embedder, '}VectorIndex } from './search'\\n` : ''}${billing ? `import * as billingStore from './billing'\\n` : ''}${social ? `import * as socialStore from './social'\\nimport type { ReminderSender, SocialMediaBucket, SocialPostParams } from './social'\\n` : ''}${checkout ? `import * as orderStore from './checkout'\\nimport type { ReceiptSender } from './checkout'\\nimport { ORDER_ID, orderRoom } from '../src/checkout'\\n` : ''}${newsletter ? `import * as newsletterStore from './newsletter'\\nimport type { NewsletterBatch, NewsletterQueue } from './newsletter'\\nimport { ISSUE_ID, issueRoom } from '../src/newsletter'\\n` : ''}${\n rooms\n ? `\n// The Durable Object class behind every room. wrangler.jsonc binds it as ROOMS, and it must be\n// exported from the Worker's main module.\nexport { SyncRoom } from '@cascivo/app/sync-server'\n`\n : ''\n }${\n agent\n ? `\n// The Durable Object behind /assistant: one per conversation (worker/assistant.ts).\nexport { Assistant } from './assistant'\n`\n : ''\n }${\n voice\n ? `\n// The Durable Object behind /voice: one per conversation (worker/voice.ts).\nexport { Voice } from './voice'\n`\n : ''\n }${\n live\n ? `\n// The Durable Object behind /ops: the last two minutes, per second (src/ops.ts).\nexport { LiveRoom } from '@cascivo/app/live-server'\n`\n : ''\n }${\n imports\n ? `\n// The Workflow behind /import (worker/import-job.ts), bound as IMPORT_JOB.\nexport { ImportJob } from './import-job'\n`\n : ''\n }${\n social\n ? `\n// The Workflow behind each scheduled post (worker/social-post.ts), bound as SOCIAL_POST.\nexport { SocialPost } from './social-post'\n`\n : ''\n }\n/**\n * Add bindings (KV, D1, R2, Durable Objects, Workers AI) in wrangler.jsonc and type them\n * here; every handler receives them as \\`env\\`.\n */\n${\n rooms ||\n ai ||\n files ||\n exports ||\n usage ||\n d1 ||\n access ||\n live ||\n accounts ||\n social ||\n webhooks ||\n digest ||\n search ||\n checkout ||\n newsletter\n ? `export interface Env {${ai ? '\\n /** Workers AI, bound in wrangler.jsonc. */\\n AI: Ai' : search ? '\\n /** Workers AI, bound in wrangler.jsonc. */\\n AI: Embedder' : ''}${search ? '\\n ARTICLES_INDEX: VectorIndex' : ''}${rooms ? '\\n ROOMS: RoomNamespace<unknown>' : ''}${imports ? '\\n IMPORT_JOB: Workflow<{ csv: string }>' : ''}${files ? '\\n FILES: UploadBucket\\n IMAGES: ImageResizer' : ''}${exports ? '\\n BROWSER: BrowserWorker' : ''}${usage ? '\\n USAGE: AnalyticsDataset\\n /** Secrets for reading Analytics Engine back (see README). */\\n CF_ACCOUNT_ID?: string\\n CF_API_TOKEN?: string' : ''}${d1 ? '\\n DB: Database' : ''}${publish ? '\\n ASSETS: Assets' : ''}${live ? '\\n LIVE: RoomNamespace<unknown>\\n EVENTS: LiveQueue' : ''}${limiter ? '\\n LIMITER: RateLimiter' : ''}${access ? '\\n /** Set in wrangler.jsonc (see README). */\\n ACCESS_TEAM_DOMAIN: string\\n ACCESS_AUD: string' : ''}${webhooks ? '\\n /** The webhook signing secret: `wrangler secret put WEBHOOK_SECRET` (.dev.vars locally). */\\n WEBHOOK_SECRET: string' : ''}${emailAuth || digest || checkout || social ? `\\n EMAIL: ${[emailAuth ? 'SignInSender' : '', digest ? 'DigestSender' : '', checkout ? 'ReceiptSender' : '', social ? 'ReminderSender' : ''].filter(Boolean).join(' & ')}` : ''}${emailAuth ? '\\n /** The From address of sign-in emails, set in wrangler.jsonc. */\\n AUTH_FROM: string' : ''}${oauth ? '\\n /** Sign-in with GitHub, Google and LinkedIn: `wrangler secret put` (.dev.vars locally). A provider is\\n * offered once both its id and secret are set; AUTH_SECRET seals the sign-in state. */\\n AUTH_SECRET?: string\\n GITHUB_CLIENT_ID?: string\\n GITHUB_CLIENT_SECRET?: string\\n GOOGLE_CLIENT_ID?: string\\n GOOGLE_CLIENT_SECRET?: string\\n LINKEDIN_CLIENT_ID?: string\\n LINKEDIN_CLIENT_SECRET?: string' : social ? \"\\n /** Seals connected accounts' tokens, and LinkedIn's app: `wrangler secret put` (.dev.vars\\n * locally). LinkedIn is offered once both its values are set. */\\n AUTH_SECRET?: string\\n LINKEDIN_CLIENT_ID?: string\\n LINKEDIN_CLIENT_SECRET?: string\" : ''}${social ? '\\n /** Bluesky: an ES256 private JWK (README); unset, the app is a public client. */\\n BLUESKY_PRIVATE_JWK?: string\\n /** A Buffer app client (README); Buffer is offered once its id is set. */\\n BUFFER_CLIENT_ID?: string\\n BUFFER_CLIENT_SECRET?: string\\n /** A Meta app with the Threads use case (README); Threads is offered once both are set. */\\n THREADS_APP_ID?: string\\n THREADS_APP_SECRET?: string\\n SOCIAL_POST: Workflow<SocialPostParams>\\n SOCIAL_MEDIA: SocialMediaBucket\\n /** Reconnect reminders (worker/social.ts), set in wrangler.jsonc. */\\n REMINDER_FROM: string' + (digest ? '' : '\\n APP_URL: string') : ''}${digest ? '\\n /** The weekly digest (worker/digest.ts), set in wrangler.jsonc. */\\n DIGEST_TO: string\\n DIGEST_FROM: string\\n APP_URL: string' : ''}${checkout ? '\\n /** The From address of receipts, set in wrangler.jsonc. */\\n RECEIPT_FROM: string\\n /** Stripe secrets: `wrangler secret put` (.dev.vars locally). Unset until you add them. */\\n STRIPE_SECRET_KEY?: string\\n STRIPE_WEBHOOK_SECRET?: string' : ''}${newsletter ? '\\n NEWSLETTER: NewsletterQueue\\n /** The newsletter (worker/newsletter.ts), set in wrangler.jsonc. */\\n AWS_REGION: string\\n NEWSLETTER_FROM: string\\n SNS_TOPIC_ARN: string\\n /** Secrets: `wrangler secret put` (.dev.vars locally). Unset until you add them. */\\n AWS_ACCESS_KEY_ID?: string\\n AWS_SECRET_ACCESS_KEY?: string\\n NEWSLETTER_KEY?: string' : ''}\n}`\n : `// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- bindings are added as members\nexport interface Env {}`\n}\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n${\n oauth\n ? `\n/** The sign-in providers with both an id and a secret set; the others are not offered. */\nfunction oauthProviders(env: Env): OAuthProvider[] {\n const providers: OAuthProvider[] = []\n if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) {\n providers.push(\n github({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }),\n )\n }\n if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {\n providers.push(\n google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),\n )\n }\n if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) {\n providers.push(\n linkedin({ clientId: env.LINKEDIN_CLIENT_ID, clientSecret: env.LINKEDIN_CLIENT_SECRET }),\n )\n }\n return providers\n}\n`\n : ''\n}${\n limiter\n ? `\n/**\n * Requests that each count against LIMITER:\n${[\n emailAuth ? 'a sign-in email' : '',\n digest ? 'a digest sent now (it starts a browser)' : '',\n search ? 'indexing the articles (it embeds every one)' : '',\n checkout ? 'a checkout started (it creates a Stripe session and an order)' : '',\n billing ? 'a subscription checkout or billing portal opened' : '',\n newsletter ? 'a newsletter sign-up (it sends an email), and each use of the newsletter key' : '',\n files ? 'a new upload (not each part of one)' : '',\n exports ? 'an export' : '',\n publish ? 'a published page' : '',\n social ? 'connecting an account (Mastodon registers the app with the server), or a post' : '',\n]\n .filter(Boolean)\n .map((item) => ` * - ${item}`)\n .join('\\n')}\n */\nfunction countsAgainstLimit(request: Request): boolean {\n const url = new URL(request.url)${\n search\n ? `\n if (url.pathname === '/api/search/index') return request.method === 'POST'`\n : ''\n }${\n digest\n ? `\n if (url.pathname === '/api/digest/run') return request.method === 'POST'`\n : ''\n }${\n checkout\n ? `\n if (url.pathname === '/api/checkout') return request.method === 'POST'`\n : ''\n }${\n billing\n ? `\n if (url.pathname === '/api/billing/subscribe' || url.pathname === '/api/billing/portal') {\n return request.method === 'POST'\n }`\n : ''\n }${\n newsletter\n ? `\n if (/^\\\\/api\\\\/newsletter\\\\/(subscribe|overview|preview|issues)$/.test(url.pathname)) {\n return request.method === 'POST'\n }`\n : ''\n }${\n emailAuth\n ? `\n if (url.pathname === '/api/auth/start') return request.method === 'POST'`\n : ''\n }${\n social\n ? `\n if (/^\\\\/api\\\\/connections\\\\/[a-z-]+$/.test(url.pathname)) return request.method === 'GET'\n if (url.pathname === '/api/social/posts') return request.method === 'POST'\n if (url.pathname.startsWith('/api/social/images')) return request.method !== 'GET'`\n : ''\n }${\n publish\n ? `\n if (url.pathname === '/api/pages') return request.method === 'POST'`\n : ''\n }${\n files\n ? `\n if (url.pathname === uploads.path) {\n const step = url.searchParams.get('multipart')\n return (request.method === 'PUT' && step === null) || step === 'start'\n }`\n : ''\n }\n return ${exports ? \"url.pathname === '/api/export'\" : 'false'}\n}\n`\n : ''\n }\n/**\n * One handler per endpoint in src/api.ts, typed from it. A stream handler is an async\n * generator: each \\`yield\\` is one server-sent event, and returning ends the stream. When the\n * client disconnects, \\`signal\\` aborts.\n */\nconst handleApi = createHandler<typeof api, Env>(api, {\n ticks: async function* ({ signal }) {\n for (let n = 1; n <= TICKS_PER_STREAM && !signal.aborted; n++) {\n const tick: Tick = { n, at: new Date().toISOString() }\n yield tick\n await sleep(1000)\n }\n },${\n imports\n ? `\n // The job's room exists (as \"queued\") before the Workflow starts, so the page has\n // something to show at once.\n startImport: async ({ body, env }) => {\n const id = crypto.randomUUID()\n await jobReporter(importJob, env.ROOMS, id).queued()\n await env.IMPORT_JOB.create({ id, params: { csv: body.csv } })\n return { id }\n },`\n : ''\n }${files ? `\\n listFiles: ({ env }) => listUploads(env.FILES),` : ''}${\n usage\n ? `\n usage: ({ env }) =>\n usageReport(\n env.CF_ACCOUNT_ID && env.CF_API_TOKEN\n ? { accountId: env.CF_ACCOUNT_ID, apiToken: env.CF_API_TOKEN }\n : null,\n ),`\n : ''\n }${\n crud\n ? `\n customers: ({ body, env }) => customerStore.listCustomers(env.DB, body),\n createCustomer: ({ body, env }) => customerStore.createCustomer(env.DB, body),\n updateCustomer: ({ params, body, env }) => customerStore.updateCustomer(env.DB, params.id, body),\n deleteCustomer: ({ params, env }) => customerStore.deleteCustomer(env.DB, params.id),`\n : ''\n }${\n publish\n ? `\n publishPage: ({ body, env }) => pageStore.publishPage(env.DB, body),\n getPage: ({ params, env }) => pageStore.getPage(env.DB, params.slug),`\n : ''\n }${\n webhooks\n ? `\n listDeliveries: ({ env }) => webhookStore.listDeliveries(env.DB),\n sendTestDelivery: ({ request, env }) => webhookStore.sendTestDelivery(request.url, env),`\n : ''\n }${\n search\n ? `\n search: ({ body, env }) => articleSearch.search(env, body.q),\n indexArticles: ({ env }) => articleSearch.indexArticles(env),`\n : ''\n }${\n checkout\n ? `\n startCheckout: ({ request, env }) => orderStore.startCheckout(env, new URL(request.url).origin),\n getOrder: ({ params, request, env }) =>\n orderStore.getOrder(env, params.id, new URL(request.url).origin),`\n : ''\n }${\n billing\n ? `\n getBilling: ({ request, env }) => billingStore.getBilling(env, request),\n startSubscription: ({ request, env }) =>\n billingStore.startSubscription(env, request, new URL(request.url).origin),\n syncBilling: ({ body, request, env }) => billingStore.syncBilling(env, request, body.sessionId),\n openBillingPortal: ({ request, env }) =>\n billingStore.openPortal(env, request, new URL(request.url).origin),`\n : ''\n }${\n newsletter\n ? `\n subscribe: ({ body, request, env }) =>\n newsletterStore.subscribe(env, body.email, new URL(request.url).origin),\n confirmSubscription: ({ body, env }) => newsletterStore.confirm(env, body.token),\n newsletterOverview: ({ body, env }) => newsletterStore.overview(env, body.key),\n previewIssue: ({ body, request, env }) =>\n newsletterStore.preview(env, body, new URL(request.url).origin),\n sendIssue: ({ body, request, env }) =>\n newsletterStore.sendIssue(env, body, new URL(request.url).origin),`\n : ''\n }${\n social\n ? `\n getSocial: ({ request, env }) => socialStore.getSocial(env, request),\n schedulePost: ({ body, request, env }) => socialStore.schedulePost(env, request, body),\n cancelPost: ({ params, request, env }) => socialStore.cancelPost(env, request, params.id),`\n : ''\n }${\n digest\n ? `\n digestRuns: ({ env }) => digestJob.listRuns(env.DB),\n runDigest: ({ request, env }) =>\n digestJob.runDigest(\n env,\n () => puppeteer.launch(env.BROWSER),\n 'manual',\n new URL(request.url).origin,\n ),`\n : ''\n }${\n live\n ? `\n // Each event is placed by the Worker's clock: a browser's is not trusted to say when.\n sendEvents: async ({ body, env }) => {\n const at = Date.now()\n if (body.length > 0) {\n await env.EVENTS.sendBatch(body.map((event) => ({ body: { at, values: event.values } })))\n }\n return { accepted: body.length }\n },`\n : ''\n }\n})\n${\n usage\n ? `\n/** Every API request, recorded in Analytics Engine; src/usage.ts names the columns. */\nasync function handleAndRecord(request: Request, env: Env): Promise<Response> {\n const started = Date.now()\n const response = await handleApi(request, env)\n usageMetrics.write(env.USAGE, {\n path: new URL(request.url).pathname,\n method: request.method,\n status: response.status,\n duration_ms: Date.now() - started,\n })\n return response\n}\n`\n : ''\n}\n// wrangler.jsonc routes only ${ai ? '/api/* and /agents/*' : '/api/*'} here; everything else is a static asset or index.html.\nexport default {\n${\n custom\n ? ` ${isAsync ? 'async ' : ''}fetch(request: Request, env: Env): Promise<Response>${isAsync ? '' : ' | Response'} {${\n access\n ? `\n // Cloudflare Access is in front of the app (README); this refuses a request that came\n // around it (straight to *.workers.dev, say). \\`vite dev\\` has no Access, so not there.\n if (!import.meta.env.DEV) {\n try {\n await requireAccess(request, {\n teamDomain: env.ACCESS_TEAM_DOMAIN,\n audience: env.ACCESS_AUD,\n })\n } catch (error) {\n return guardResponse(error)\n }\n }`\n : ''\n }${\n limiter\n ? `\n if (countsAgainstLimit(request)) {\n try {\n await rateLimit(env.LIMITER, clientIp(request))\n } catch (error) {\n return guardResponse(error)\n }\n }`\n : ''\n }${\n accounts\n ? `${\n emailAuth\n ? `\n // Sign-in links and sessions: /api/auth/* is answered here (worker/auth.ts sends mail).\n const signIn = await handleAuth(env.DB, {\n sendLink: (email, url) => sendSignInLink(env.EMAIL, env.AUTH_FROM, email, url),\n exposeLink: import.meta.env.DEV,\n })(request)\n if (signIn) return signIn`\n : ''\n }${\n oauth\n ? `\n // Sign-in with GitHub, Google and LinkedIn: /api/auth/oauth/* (and /me, /signout). A failed\n // sign-in lands on /account with ?error=.\n const signInWith = await handleOAuth(env.DB, {\n secret: env.AUTH_SECRET ?? '',\n providers: oauthProviders(env),\n errorPath: '/account',\n })(request)\n if (signInWith) return signInWith`\n : ''\n }\n // Every other API write needs a signed-in user; reads stay public.${signedPaths.length > 0 ? '\\n // Webhooks carry a signature instead of a session, and are checked by it.' : ''}${readerPaths.length > 0 ? '\\n // Newsletter readers sign up, confirm and unsubscribe without an account.' : ''}\n if (${\n openPaths.length > 0\n ? `\n request.method !== 'GET' &&\n request.method !== 'HEAD' &&${openPaths\n .map((prefix) => `\\n !new URL(request.url).pathname.startsWith('${prefix}')`)\n .join(' &&')}\n `\n : \"request.method !== 'GET' && request.method !== 'HEAD'\"\n }) {\n try {\n await requireUser(env.DB, request)\n } catch (error) {\n return guardResponse(error)\n }\n }${\n social\n ? `\n // The accounts a user connects to post with (worker/social.ts): /api/connections/*, and\n // the client metadata Bluesky's servers read.\n const blueskyClient = socialStore.blueskyClient(env, request)\n if (blueskyClient) return blueskyClient\n const connected = await socialStore.connections(env)(request)\n if (connected) return connected\n // Images for posts: the user's own uploads, and the signed links Threads and Buffer fetch.\n const image =\n (await socialStore.images(env, request)) ?? (await socialStore.media(env, request))\n if (image) return image`\n : ''\n }`\n : ''\n }${\n ai\n ? `\n // /agents/<agent>/<conversation>: the WebSocket ${[agent ? 'useAgent()' : '', voice ? 'VoiceClient' : ''].filter(Boolean).join(' or ')} opens.\n const agent = await routeAgentRequest(request, env)\n if (agent) return agent`\n : ''\n }${\n files\n ? `\n // Uploads into R2 and the files they stored.${access ? '' : ' Anyone can upload: see README.'}\n const upload = await handleUploads(uploads, env.FILES, { images: env.IMAGES })(request)\n if (upload) return upload`\n : ''\n }${\n exports\n ? `\n // /api/export?page=/report&format=pdf — each export starts a browser.\n const exported = await handleExport(request, { launch: () => puppeteer.launch(env.BROWSER) })\n if (exported) return exported`\n : ''\n }${\n live\n ? `\n // The /ops dashboard's room: browsers watch it, and only the queue handler writes to it.\n if (new URL(request.url).pathname === '/api/live') {\n return roomResponse(request, env.LIVE, OPS_ROOM, { readOnly: true })\n }`\n : ''\n }${\n publish\n ? `\n // A published page: its HTML carries the page's title, description and content, for link\n // previews and readers without JavaScript (worker/page-html.ts). An unknown slug gets the\n // app itself, which says so.\n const pagePath = new URL(request.url).pathname\n const published = /^\\\\/p\\\\/([a-z0-9]{10})$/.exec(pagePath)\n if (published && request.method === 'GET') {\n try {\n const page = await pageStore.getPage(env.DB, published[1]!)${\n exports\n ? `\n const image = new URL(\\`/api/pages/\\${page.slug}/preview.png\\`, request.url).href\n return await renderPageHtml(request, page, env.ASSETS, image)`\n : `\n return await renderPageHtml(request, page, env.ASSETS, null)`\n }\n } catch (error) {\n if (!(error instanceof HttpError)) throw error\n return env.ASSETS.fetch(request)\n }\n }${\n exports\n ? `\n // Its link-preview image, rendered once by Browser Run (worker/page-preview.ts).\n const preview = /^\\\\/api\\\\/pages\\\\/([a-z0-9]{10})\\\\/preview\\\\.png$/.exec(pagePath)\n if (preview && request.method === 'GET') {\n try {\n return await pagePreview(env.DB, preview[1]!, new URL(request.url).origin, () =>\n puppeteer.launch(env.BROWSER),\n )\n } catch (error) {\n return guardResponse(error)\n }\n }`\n : ''\n }`\n : ''\n }${\n webhooks\n ? `\n const path = new URL(request.url).pathname\n // Signed deliveries from GitHub (worker/webhooks.ts); a bad signature is a 401.\n if (path === webhookStore.GITHUB_WEBHOOK_PATH && request.method === 'POST') {\n try {\n return await webhookStore.receiveGithub(request, env)\n } catch (error) {\n return guardResponse(error)\n }\n }\n // New deliveries, pushed to the /webhooks page: it may watch, never write.\n if (path === '/api/webhooks/live') {\n return roomResponse(request, env.ROOMS, DELIVERIES_ROOM, { readOnly: true })\n }`\n : ''\n }${\n checkout\n ? `\n const checkoutPath = new URL(request.url).pathname\n // Stripe's webhook (worker/checkout.ts); a bad signature is a 401.\n if (checkoutPath === orderStore.STRIPE_WEBHOOK_PATH && request.method === 'POST') {\n try {\n return await orderStore.receiveStripe(request, env${\n billing ? ', billingStore.billingHooks(env)' : ''\n })\n } catch (error) {\n return guardResponse(error)\n }\n }\n // An order's page watches its room for what Stripe reports: it may watch, never write.\n const orderLive = /^\\\\/api\\\\/orders\\\\/([^/]+)\\\\/live$/.exec(checkoutPath)\n if (orderLive && ORDER_ID.test(orderLive[1]!)) {\n return roomResponse(request, env.ROOMS, orderRoom(orderLive[1]!), { readOnly: true })\n }`\n : ''\n }${\n newsletter\n ? `\n const newsletterPath = new URL(request.url).pathname\n // One-click unsubscribe (RFC 8058) and the unsubscribe page's button.\n if (newsletterPath === '/api/newsletter/unsubscribe' && request.method === 'POST') {\n return newsletterStore.unsubscribe(request, env)\n }\n // SES bounces and complaints, delivered by SNS (worker/newsletter.ts); verified by signature.\n if (newsletterPath === '/api/sns/ses' && request.method === 'POST') {\n try {\n return await newsletterStore.receiveFeedback(request, env)\n } catch (error) {\n return guardResponse(error)\n }\n }\n // An issue's sending progress, for the composer: it may watch, never write.\n const issueLive = /^\\\\/api\\\\/newsletter\\\\/issues\\\\/([^/]+)\\\\/live$/.exec(newsletterPath)\n if (issueLive && ISSUE_ID.test(issueLive[1]!)) {\n return roomResponse(request, env.ROOMS, issueRoom(issueLive[1]!), { readOnly: true })\n }`\n : ''\n }${\n hasExample(opts, 'board') || hasExample(opts, 'notes')\n ? `\n const room = /^\\\\/api\\\\/rooms\\\\/([^/]+)$/.exec(new URL(request.url).pathname)${\n serverRooms.length > 0\n ? `\n // Rooms only the server writes are watched read-only at their own routes, never opened\n // here: ${[imports ? \"a job's progress\" : '', webhooks ? 'webhook deliveries' : '', checkout ? 'orders' : '', newsletter ? 'newsletter issues' : ''].filter(Boolean).join(', ')}.\n if (room && !/^(${serverRooms.join('|')})/.test(room[1]!)) {\n return roomResponse(request, env.ROOMS, room[1]!)\n }`\n : `\n if (room) return roomResponse(request, env.ROOMS, room[1]!)`\n }`\n : ''\n }${\n imports\n ? `\n // A job's progress: the browser may watch its room, never write to it.\n const job = /^\\\\/api\\\\/jobs\\\\/([\\\\w-]{1,60})$/.exec(new URL(request.url).pathname)\n if (job) {\n return roomResponse(request, env.ROOMS, importJob.roomName(job[1]!), { readOnly: true })\n }`\n : ''\n }\n return ${usage ? 'handleAndRecord' : 'handleApi'}(request, env)\n },`\n : ` fetch: ${usage ? 'handleAndRecord' : 'handleApi'},`\n}${\n digest && social\n ? `\n // The Cron Triggers in wrangler.jsonc, told apart by their schedule.\n async scheduled(event: { cron: string }, env: Env): Promise<void> {\n // Threads tokens renewed, and reminders for those that cannot be (worker/social.ts).\n if (event.cron === '${SOCIAL_CRON}') {\n await socialStore.renewConnections(env)\n return socialStore.remindExpiring(env)\n }\n // The weekly digest, recorded whatever happens.\n await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')\n },`\n : digest\n ? `\n // The Cron Trigger in wrangler.jsonc: the weekly digest, recorded whatever happens.\n async scheduled(_event: unknown, env: Env): Promise<void> {\n await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')\n },`\n : social\n ? `\n // The Cron Trigger in wrangler.jsonc: Threads tokens renewed, then reminders for the ones\n // that cannot be (LinkedIn).\n async scheduled(_event: unknown, env: Env): Promise<void> {\n await socialStore.renewConnections(env)\n await socialStore.remindExpiring(env)\n },`\n : ''\n }${\n live && newsletter\n ? `\n // Two queues, one handler: each batch says which queue it came from. A throw retries it.\n async queue(batch: LiveBatch & NewsletterBatch & { queue: string }, env: Env): Promise<void> {\n // NEWSLETTER: one message of readers at a time, through SES (worker/newsletter.ts).\n if (batch.queue === '${newsletterQueue(opts)}') return newsletterStore.deliver(env, batch)\n // EVENTS: into the dashboard's room.\n await recordLive(\n ops,\n env.LIVE,\n OPS_ROOM,\n batch.messages.map((message) => message.body),\n )\n },`\n : live\n ? `\n // The EVENTS queue, a batch at a time, into the dashboard's room. A throw retries the batch.\n async queue(batch: LiveBatch, env: Env): Promise<void> {\n await recordLive(\n ops,\n env.LIVE,\n OPS_ROOM,\n batch.messages.map((message) => message.body),\n )\n },`\n : newsletter\n ? `\n // The NEWSLETTER queue: one message of readers at a time, through SES\n // (worker/newsletter.ts). A throw retries the message; readers already sent are skipped.\n async queue(batch: NewsletterBatch, env: Env): Promise<void> {\n await newsletterStore.deliver(env, batch)\n },`\n : ''\n }\n}\n`\n}\n\nfunction cfLiveTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport type { Tick } from './api'\n\nconst client = createClient(api)\n\n// Module-level signals: any component that reads them re-renders when they change, and\n// writing them from plain functions needs no hooks.\nexport const ticks = signal<Tick[]>([])\nexport const status = signal<'idle' | 'live' | 'error'>('idle')\nexport const error = signal<string | null>(null)\n\nlet controller: AbortController | null = null\n\n/** Opens the Worker's event stream and appends each tick until it ends or is stopped. */\nexport async function connect(): Promise<void> {\n if (controller) return\n const abort = new AbortController()\n controller = abort\n ticks.value = []\n error.value = null\n status.value = 'live'\n try {\n for await (const tick of client.ticks({ signal: abort.signal })) {\n ticks.value = [...ticks.value, tick]\n }\n status.value = 'idle'\n } catch (cause) {\n if (abort.signal.aborted) {\n status.value = 'idle'\n } else {\n status.value = 'error'\n error.value = cause instanceof Error ? cause.message : String(cause)\n }\n } finally {\n if (controller === abort) controller = null\n }\n}\n\nexport function disconnect(): void {\n controller?.abort()\n}\n`\n}\n\nfunction cfLiveCardTsx(): string {\n return `import {\n Badge,\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n Flex,\n Text,\n useSignals,\n} from '@cascivo/react'\nimport { TICKS_PER_STREAM } from './api'\nimport { connect, disconnect, error, status, ticks } from './live'\n\n/** Streams server-sent events from worker/index.ts into signals via src/live.ts. */\nexport function LiveCard() {\n useSignals()\n const live = status.value === 'live'\n const latest = ticks.value.at(-1)\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>Live from your Worker</CardTitle>\n </CardHeader>\n <CardContent>\n <Flex gap={3}>\n <Text muted>\n <code>worker/index.ts</code> streams events; <code>src/live.ts</code> reads them through\n the typed client for <code>src/api.ts</code>.\n </Text>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Badge variant={live ? 'success' : status.value === 'error' ? 'danger' : 'neutral'}>\n {live ? 'Live' : status.value === 'error' ? 'Error' : 'Idle'}\n </Badge>\n <Text>\n {latest\n ? \\`Tick \\${latest.n} of \\${TICKS_PER_STREAM} at \\${new Date(latest.at).toLocaleTimeString()}\\`\n : 'No events yet'}\n </Text>\n </Flex>\n {error.value !== null && <Text muted>{error.value}</Text>}\n <div>\n <Button\n variant={live ? 'secondary' : 'primary'}\n onClick={live ? disconnect : () => void connect()}\n >\n {live ? 'Disconnect' : 'Connect'}\n </Button>\n </div>\n </Flex>\n </CardContent>\n </Card>\n )\n}\n`\n}\n\n/** Route pattern for a section: the first is `/`, the rest by key. */\nfunction cfSectionPath(section: Section, index: number): string {\n return index === 0 ? '/' : `/${section.key}`\n}\n\n/** Route file for a section, relative to `src/routes/`. */\nfunction cfSectionFile(section: Section, index: number): string {\n return index === 0 ? 'index.tsx' : `${section.key}.tsx`\n}\n\nfunction cfRouterTs(): string {\n return `import { createRouter } from '@cascivo/app'\nimport { notFound, routes } from './routes.gen'\n\n// \\`routes.gen.ts\\` is written by \\`cascivoRoutes()\\` (vite.config.ts) from \\`src/routes/\\`:\n// add, rename or delete a file there and the route table follows.\nexport const router = createRouter({ routes, notFound })\n`\n}\n\nfunction cfAppTsx(sections: Section[], opts: ScaffoldOptions): string {\n const items = sections.map((s, i) => ({ label: s.label, href: cfSectionPath(s, i) }))\n if (hasExample(opts, 'agent')) items.push({ label: 'Assistant', href: '/assistant' })\n if (hasExample(opts, 'board')) items.push({ label: 'Board', href: '/board' })\n if (hasExample(opts, 'notes')) items.push({ label: 'Notes', href: '/notes' })\n if (hasExample(opts, 'import')) items.push({ label: 'Import', href: '/import' })\n if (hasExample(opts, 'files')) items.push({ label: 'Files', href: '/files' })\n if (hasExample(opts, 'export')) items.push({ label: 'Report', href: '/report' })\n if (hasExample(opts, 'usage')) items.push({ label: 'Usage', href: '/usage' })\n if (hasExample(opts, 'crud')) items.push({ label: 'Customers', href: '/customers' })\n if (hasExample(opts, 'live')) items.push({ label: 'Ops', href: '/ops' })\n if (hasExample(opts, 'voice')) items.push({ label: 'Voice', href: '/voice' })\n if (hasExample(opts, 'publish')) items.push({ label: 'Publish', href: '/publish' })\n if (hasExample(opts, 'webhooks')) items.push({ label: 'Webhooks', href: '/webhooks' })\n if (hasExample(opts, 'digest')) items.push({ label: 'Digest', href: '/digest' })\n if (hasExample(opts, 'search')) items.push({ label: 'Search', href: '/search' })\n if (hasExample(opts, 'checkout')) items.push({ label: 'Checkout', href: '/checkout' })\n if (usesBilling(opts)) items.push({ label: 'Billing', href: '/billing' })\n if (hasExample(opts, 'newsletter')) {\n items.push({ label: 'Newsletter', href: '/newsletter' })\n items.push({ label: 'Send newsletter', href: '/newsletter/send' })\n }\n if (hasExample(opts, 'social')) items.push({ label: 'Social', href: '/social' })\n if (hasAccounts(opts)) items.push({ label: 'Account', href: '/account' })\n const navItems = items\n .map(\n (item) => ` {\n label: '${item.label.replace(/'/g, \"\\\\'\")}',\n href: '${item.href}',\n active: path === '${item.href}',\n },`,\n )\n .join('\\n')\n const exports = hasExample(opts, 'export')\n return `import { RouterView } from '@cascivo/app'\n${exports ? `import { isExporting } from '@cascivo/app/export'\\n` : ''}import { Spinner, useSignals, type SideNavItem } from '@cascivo/react'\nimport { router } from './router'\nimport { Shell } from './Shell'\n\nexport default function App() {\n useSignals()\n const path = router.pathname.value\n\n // \\`href\\`s, not click handlers: main.tsx registers the router's Link, so these navigate\n // client-side while middle-click and \"open in new tab\" still work.\n const navItems: SideNavItem[] = [\n${navItems}\n ]\n${\n exports\n ? `\n // Rendered by /api/export: the page alone, without the shell (which gave it its padding).\n if (isExporting()) {\n return (\n <div style={{ padding: 'var(--cascivo-space-8)' }}>\n <RouterView router={router} fallback={<Spinner label=\"Loading\" />} />\n </div>\n )\n }\n`\n : ''\n}\n return (\n <Shell navItems={navItems}>\n <RouterView router={router} fallback={<Spinner label=\"Loading\" />} />\n </Shell>\n )\n}\n`\n}\n\nfunction cfMainTsx(): string {\n return `import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport { setLinkComponent } from '@cascivo/react'\nimport App from './App'\nimport { router } from './router'\n\n// SideNav, ShellHeader and Breadcrumb render their links through the router from here on.\nsetLinkComponent(router.Link)\n\nconst root = document.getElementById('root')\nif (root) {\n ReactDOM.createRoot(root).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>,\n )\n}\n`\n}\n\nfunction cfFirstRouteTsx(section: Section): string {\n return `import { Flex, Heading, Text } from '@cascivo/react'\nimport { LiveCard } from '../LiveCard'\n\nexport default function ${section.component}() {\n return (\n <Flex gap={6}>\n <Flex gap={2}>\n <Heading level={1}>${section.label}</Heading>\n <Text muted>\n Edit <code>src/routes/index.tsx</code> to build out this page.\n </Text>\n </Flex>\n <LiveCard />\n </Flex>\n )\n}\n`\n}\n\nfunction cfRouteTsx(section: Section, index: number): string {\n // The Vite scaffold's section, as a route file: a default export, at its own URL.\n return sectionTsx(section)\n .replace(\n `export function ${section.component}()`,\n `export default function ${section.component}()`,\n )\n .replace(`src/sections/${section.component}.tsx`, `src/routes/${cfSectionFile(section, index)}`)\n}\n\nfunction cf404Tsx(): string {\n return `import { EmptyState } from '@cascivo/react'\n\nexport default function NotFound() {\n return <EmptyState title=\"Page not found\" description=\"There is nothing at this address.\" />\n}\n`\n}\n\n/* --- `--example board`: a multiplayer board on @cascivo/app/sync + a SyncRoom DO --- */\n\nfunction cfBoardTs(): string {\n return `import { connectRoom } from '@cascivo/app/sync'\n\nexport interface Note {\n text: string\n x: number\n y: number\n}\n\nexport interface Cursor {\n x: number\n y: number\n}\n\n/** \\`/board?room=team\\` is its own board; the room name is what the Worker routes on. */\nexport const roomName =\n (new URLSearchParams(location.search).get('room') ?? '').replace(/[^\\\\w-]/g, '').slice(0, 64) ||\n 'lobby'\n\n// One WebSocket to the room's Durable Object. It connects when this module first loads (the\n// /board route is its own chunk) and reconnects on its own.\nexport const room = connectRoom(\\`/api/rooms/\\${roomName}\\`)\n\n/**\n * Values in a room come from other people, so they are parsed, not cast. A note that fails\n * this is ignored rather than breaking the board.\n */\nexport function parseNote(raw: unknown): Note {\n if (typeof raw === 'object' && raw !== null) {\n const { text, x, y } = raw as Record<string, unknown>\n if (typeof text === 'string' && typeof x === 'number' && typeof y === 'number') {\n return { text, x, y }\n }\n }\n throw new Error('Malformed note')\n}\n\nexport function parseCursor(raw: unknown): Cursor | null {\n if (typeof raw === 'object' && raw !== null) {\n const { x, y } = raw as Record<string, unknown>\n if (typeof x === 'number' && typeof y === 'number') return { x, y }\n }\n return null\n}\n\n/** Notes by id. Each note is its own path, so two people editing two notes never collide. */\nexport const notes = room.map('notes', parseNote)\n\nexport function addNote(): void {\n const offset = Object.keys(notes.value).length * 24\n notes.set(crypto.randomUUID(), { text: '', x: 24 + (offset % 240), y: 24 + (offset % 160) })\n}\n`\n}\n\nfunction cfBoardRouteTsx(): string {\n return `import type { PointerEvent } from 'react'\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Text,\n Textarea,\n useSignals,\n} from '@cascivo/react'\nimport { addNote, notes, parseCursor, room, roomName } from '../board'\nimport type { Note } from '../board'\nimport styles from '../board.module.css'\n\nlet frame = 0\n\n/** Shares this pointer's position on the board, at most once per frame. */\nfunction trackCursor(event: PointerEvent<HTMLDivElement>) {\n const rect = event.currentTarget.getBoundingClientRect()\n const cursor = {\n x: Math.round(event.clientX - rect.left),\n y: Math.round(event.clientY - rect.top),\n }\n cancelAnimationFrame(frame)\n frame = requestAnimationFrame(() => room.setPresence(cursor))\n}\n\n/** Drags a note by its handle; every move is a write the whole room sees. */\nfunction startDrag(event: PointerEvent<HTMLDivElement>, id: string, note: Note) {\n if ((event.target as Element).closest('button')) return\n const handle = event.currentTarget\n handle.setPointerCapture(event.pointerId)\n const dx = event.clientX - note.x\n const dy = event.clientY - note.y\n const move = (e: globalThis.PointerEvent) => {\n const current = notes.value[id]\n if (current)\n notes.set(id, { ...current, x: Math.max(0, e.clientX - dx), y: Math.max(0, e.clientY - dy) })\n }\n const up = () => {\n handle.removeEventListener('pointermove', move)\n handle.removeEventListener('pointerup', up)\n }\n handle.addEventListener('pointermove', move)\n handle.addEventListener('pointerup', up)\n}\n\nfunction NoteCard({ id, note }: { id: string; note: Note }) {\n return (\n <div className={styles['note']} style={{ transform: \\`translate(\\${note.x}px, \\${note.y}px)\\` }}>\n <Card>\n <div className={styles['handle']} onPointerDown={(event) => startDrag(event, id, note)}>\n <Text size=\"sm\" muted>\n Drag\n </Text>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-label=\"Delete note\"\n onClick={() => notes.delete(id)}\n >\n ×\n </Button>\n </div>\n <CardContent>\n <Textarea\n aria-label=\"Note text\"\n rows={3}\n value={note.text}\n onChange={(event) => notes.set(id, { ...note, text: event.target.value })}\n />\n </CardContent>\n </Card>\n </div>\n )\n}\n\nexport default function Board() {\n useSignals()\n const connected = room.status.value === 'open'\n const others = Object.entries(room.presence.value).flatMap(([id, raw]) => {\n const cursor = parseCursor(raw)\n return cursor ? [{ id, ...cursor }] : []\n })\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Board</Heading>\n <Text muted>\n Room <code>{roomName}</code>. Open this page in a second window: notes, edits and\n cursors sync live through a Durable Object.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Badge variant={connected ? 'success' : 'neutral'}>\n {connected ? \\`\\${Object.keys(room.presence.value).length + 1} here\\` : 'Connecting…'}\n </Badge>\n <Button onClick={addNote}>Add note</Button>\n </Flex>\n </Flex>\n <div\n className={styles['board']}\n onPointerMove={trackCursor}\n onPointerLeave={() => room.setPresence(null)}\n >\n {Object.entries(notes.value).map(([id, note]) => (\n <NoteCard key={id} id={id} note={note} />\n ))}\n {others.map((cursor) => (\n <span\n key={cursor.id}\n className={styles['cursor']}\n style={{ transform: \\`translate(\\${cursor.x}px, \\${cursor.y}px)\\` }}\n aria-hidden=\"true\"\n />\n ))}\n </div>\n </Flex>\n )\n}\n`\n}\n\nfunction cfBoardCss(): string {\n return `/* Your app's own styles live in the cascivo.example layer (declared in index.html). */\n@layer cascivo.example {\n .board {\n position: relative;\n min-block-size: 32rem;\n overflow: hidden;\n border: 1px dashed var(--cascivo-border-default);\n border-radius: var(--cascivo-radius-surface);\n background: var(--cascivo-color-surface);\n }\n\n /* Positions are shared by everyone in the room, so they are physical (left/top), not\n logical: an RTL visitor must see a note where an LTR one put it. */\n .note {\n position: absolute;\n top: 0;\n left: 0;\n inline-size: 14rem;\n }\n\n .handle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--cascivo-space-1) var(--cascivo-space-2);\n cursor: grab;\n touch-action: none;\n }\n\n .cursor {\n position: absolute;\n top: 0;\n left: 0;\n inline-size: 0.75rem;\n block-size: 0.75rem;\n border-radius: 50%;\n background: var(--cascivo-color-accent);\n pointer-events: none;\n }\n}\n`\n}\n\n/* --- `--example agent`: generative UI on Cloudflare's Agents SDK + @cascivo/render --- */\n\nfunction cfAssistantTs(): string {\n return `import type { ViewConfig } from '@cascivo/render'\nimport { validateView } from '@cascivo/render/validate'\n\n/**\n * Shared by the Worker (worker/assistant.ts) and the page (src/routes/assistant.tsx): the\n * name the agent is routed under, and the one check both sides run on a generated view.\n */\n\n/** The Durable Object class, its wrangler binding and the \\`useAgent({ agent })\\` name. */\nexport const AGENT = 'Assistant'\n\nexport type CheckedView = { title: string; view: ViewConfig } | { errors: string[] }\n\n/**\n * Validates a view the model produced against the component manifests — unknown components,\n * invented props and out-of-range values all come back as errors the model can fix. It runs\n * in the Worker before the result is stored, and again in the browser on the stored message,\n * which crossed the network and is checked rather than trusted.\n */\nexport function checkView(title: unknown, view: unknown): CheckedView {\n if (typeof title !== 'string' || title.length === 0) return { errors: ['title: expected text'] }\n const result = validateView(view)\n if (!result.valid) return { errors: result.errors.map((e) => \\`\\${e.path}: \\${e.message}\\`) }\n // validateView has checked the whole shape, so the cast states a proven fact.\n return { title, view: view as ViewConfig }\n}\n`\n}\n\nfunction cfAssistantWorkerTs(): string {\n return `import { AIChatAgent } from '@cloudflare/ai-chat'\nimport { convertToModelMessages, stepCountIs, streamText, tool } from 'ai'\nimport type { LanguageModel } from 'ai'\nimport { createWorkersAI } from 'workers-ai-provider'\nimport { z } from 'zod'\nimport { checkView } from '../src/assistant'\nimport type { Env } from './index'\nimport { scriptedModel } from './scripted-model'\n\n/** Any Workers AI model with tool calling. */\nconst MODEL = '@cf/moonshotai/kimi-k2.7-code'\n\nconst SYSTEM = \\`You are an assistant inside a web app. When an answer is best shown as UI — a\nsummary, a status overview, a list — call show_view instead of describing it in text.\nA view is { \"view\": { \"regions\": { \"main\": [nodes] } } }.\nA node is { \"component\": Name, \"props\": { ... }, \"children\": [nodes] or \"text\" }.\nComponents: Flex (direction: \"vertical\" | \"horizontal\", gap: 1-8, wrap), Grid (cols: 1-4, gap),\nCard (padding: \"sm\" | \"md\" | \"lg\"; content as children), Badge (variant: \"default\" | \"success\" |\n\"warning\" | \"destructive\"; text as children), Alert (variant: \"info\" | \"success\" | \"warning\" |\n\"destructive\", title; text as children), ProgressBar (value, max, label), Separator,\nEmptyState (title, description).\nIf show_view returns errors, fix exactly those and call it again. Keep text replies short.\\`\n\nconst showView = tool({\n description: 'Show the user a view built from cascivo components. Returns errors to fix, or ok.',\n inputSchema: z.object({\n title: z.string().describe('A short title for the view'),\n view: z.object({ view: z.object({ regions: z.record(z.string(), z.array(z.unknown())) }) }),\n }),\n execute: async ({ title, view }) => checkView(title, view),\n})\n\n/**\n * One Durable Object per conversation: it stores the messages in its SQLite database and\n * streams replies to every open tab over a WebSocket, resuming a stream after a reconnect.\n */\nexport class Assistant extends AIChatAgent<Env> {\n async onChatMessage(_onFinish: unknown, options?: { abortSignal?: AbortSignal }) {\n // \\`vite dev\\` answers from a scripted model, so the page works offline and without an\n // account. A deployed Worker calls Workers AI, and so does \\`VITE_REAL_AI=1 vite dev\\`.\n const scripted = import.meta.env.DEV && import.meta.env['VITE_REAL_AI'] !== '1'\n const model: LanguageModel = scripted\n ? scriptedModel()\n : createWorkersAI({ binding: this.env.AI })(MODEL)\n const result = streamText({\n model,\n system: SYSTEM,\n messages: await convertToModelMessages(this.messages),\n tools: { show_view: showView },\n stopWhen: stepCountIs(4),\n ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}),\n })\n return result.toUIMessageStreamResponse()\n }\n}\n`\n}\n\nfunction cfScriptedModelTs(): string {\n return `import { simulateReadableStream } from 'ai'\nimport { MockLanguageModelV4 } from 'ai/test'\n\n/**\n * A stand-in for Workers AI during \\`vite dev\\`: it answers every message by calling\n * show_view with the view below, then replies with one line of text. It goes through the\n * same tool, validation and streaming as the real model, so the page can be built offline.\n * Production never imports it — worker/assistant.ts reaches it behind \\`import.meta.env.DEV\\`.\n */\nconst VIEW = {\n view: {\n regions: {\n main: [\n {\n component: 'Flex',\n props: { direction: 'vertical', gap: 3 },\n children: [\n {\n component: 'Alert',\n props: { variant: 'info', title: 'Scripted reply' },\n children:\n 'vite dev answers from worker/scripted-model.ts. Deploy to talk to Workers AI.',\n },\n {\n component: 'Grid',\n props: { cols: 3, gap: 3 },\n children: [\n {\n component: 'Card',\n props: { padding: 'md' },\n children: [\n { component: 'Badge', props: { variant: 'success' }, children: 'API healthy' },\n ],\n },\n {\n component: 'Card',\n props: { padding: 'md' },\n children: [\n {\n component: 'Badge',\n props: { variant: 'warning' },\n children: '2 jobs queued',\n },\n ],\n },\n {\n component: 'Card',\n props: { padding: 'md' },\n children: [{ component: 'Badge', children: '14 users online' }],\n },\n ],\n },\n { component: 'ProgressBar', props: { value: 72, label: 'Sprint progress' } },\n ],\n },\n ],\n },\n },\n}\n\nconst usage = {\n inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },\n outputTokens: { total: 0, text: 0, reasoning: 0 },\n}\n\nexport function scriptedModel(): MockLanguageModelV4 {\n return new MockLanguageModelV4({\n doStream: async ({ prompt }) => {\n // After the tool has run, the last prompt message is its result: reply in text.\n if (prompt.at(-1)?.role === 'tool') {\n return {\n stream: simulateReadableStream({\n chunkDelayInMs: 40,\n chunks: [\n { type: 'stream-start', warnings: [] },\n { type: 'text-start', id: 'reply' },\n { type: 'text-delta', id: 'reply', delta: 'Here is the overview ' },\n { type: 'text-delta', id: 'reply', delta: 'you asked for.' },\n { type: 'text-end', id: 'reply' },\n { type: 'finish', usage, finishReason: { unified: 'stop', raw: 'stop' } },\n ],\n }),\n }\n }\n return {\n stream: simulateReadableStream({\n chunkDelayInMs: 40,\n chunks: [\n { type: 'stream-start', warnings: [] },\n {\n type: 'tool-call',\n toolCallId: \\`call-\\${prompt.length}\\`,\n toolName: 'show_view',\n input: JSON.stringify({ title: 'Team overview', view: VIEW }),\n },\n { type: 'finish', usage, finishReason: { unified: 'tool-calls', raw: 'tool_calls' } },\n ],\n }),\n }\n },\n })\n}\n`\n}\n\nfunction cfAssistantRouteTsx(): string {\n return `import type { FormEvent } from 'react'\nimport { CascivoView } from '@cascivo/render'\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Text,\n Textarea,\n useSignalState,\n} from '@cascivo/react'\nimport { useAgentChat } from '@cloudflare/ai-chat/react'\nimport { useAgent } from 'agents/react'\nimport type { UIMessage } from 'ai'\nimport { AGENT, checkView } from '../assistant'\nimport styles from '../assistant.module.css'\n\nconst STORAGE_KEY = 'assistant-conversation'\n\n/**\n * The conversation this browser continues: its Durable Object's name. Kept in localStorage\n * so a reload picks the conversation back up; a new one each visit where storage is blocked.\n */\nfunction conversationName(): string {\n try {\n const saved = localStorage.getItem(STORAGE_KEY)\n if (saved) return saved\n const created = crypto.randomUUID()\n localStorage.setItem(STORAGE_KEY, created)\n return created\n } catch {\n return crypto.randomUUID()\n }\n}\n\nconst conversation = conversationName()\n\nfunction MessagePart({ part }: { part: UIMessage['parts'][number] }) {\n if (part.type === 'text') return <Text>{part.text}</Text>\n if (part.type !== 'tool-show_view') return null\n if (part.state !== 'output-available') {\n return (\n <Badge variant=\"neutral\">\n {part.state === 'output-error' ? 'View failed' : 'Building a view…'}\n </Badge>\n )\n }\n // The stored message came over the network: check it again rather than trusting it.\n const output: unknown = part.output\n const shown =\n typeof output === 'object' && output !== null && 'title' in output && 'view' in output\n ? checkView(output.title, output.view)\n : null\n if (!shown || 'errors' in shown) {\n // The model saw these errors as the tool result and tries again in the next step.\n return (\n <Badge variant=\"warning\">Rejected a view with {shown?.errors.length ?? 1} problems</Badge>\n )\n }\n return (\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Heading level={3}>{shown.title}</Heading>\n <CascivoView config={shown.view} onInvalid=\"render\" />\n </Flex>\n </CardContent>\n </Card>\n )\n}\n\nexport default function Assistant() {\n const agent = useAgent({ agent: AGENT, name: conversation })\n const { messages, sendMessage, status, clearHistory } = useAgentChat({ agent })\n const [draft, setDraft] = useSignalState('')\n const busy = status === 'submitted' || status === 'streaming'\n\n const send = (event: FormEvent) => {\n event.preventDefault()\n const text = draft.value.trim()\n if (!text || busy) return\n void sendMessage({ role: 'user', parts: [{ type: 'text', text }] })\n setDraft('')\n }\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Assistant</Heading>\n <Text muted>\n Ask for an overview, a status page or a list: the agent answers with real components,\n checked against their manifests before they reach you.\n </Text>\n </Flex>\n <Button variant=\"secondary\" onClick={clearHistory} disabled={messages.length === 0}>\n New conversation\n </Button>\n </Flex>\n <ol className={styles['messages']} aria-live=\"polite\">\n {messages.map((message) => (\n <li key={message.id} className={styles[message.role === 'user' ? 'user' : 'assistant']}>\n <Flex gap={2}>\n {message.parts.map((part, index) => (\n <MessagePart key={index} part={part} />\n ))}\n </Flex>\n </li>\n ))}\n </ol>\n <form className={styles['composer']} onSubmit={send}>\n <Textarea\n aria-label=\"Message\"\n rows={2}\n value={draft.value}\n placeholder=\"Show me the team's status\"\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && !event.shiftKey) send(event)\n }}\n />\n <Button type=\"submit\" loading={busy}>\n Send\n </Button>\n </form>\n </Flex>\n )\n}\n`\n}\n\nfunction cfAssistantCss(): string {\n return `/* Your app's own styles live in the cascivo.example layer (declared in index.html). */\n@layer cascivo.example {\n .messages {\n display: flex;\n flex-direction: column;\n gap: var(--cascivo-space-3);\n margin: 0;\n padding: 0;\n list-style: none;\n }\n\n .user {\n align-self: end;\n max-inline-size: 80%;\n padding: var(--cascivo-space-2) var(--cascivo-space-3);\n border-radius: var(--cascivo-radius-md);\n background: var(--cascivo-color-bg-subtle);\n }\n\n .assistant {\n max-inline-size: 100%;\n }\n\n .composer {\n display: flex;\n gap: var(--cascivo-space-2);\n align-items: end;\n }\n\n .composer > :first-child {\n flex: 1;\n }\n}\n`\n}\n\n/* --- `--example notes`: a local-first page on @cascivo/app/sync + IndexedDB --- */\n\nfunction cfNotesTs(): string {\n return `import { connectRoom } from '@cascivo/app/sync'\nimport { indexedDBDriver } from '@cascivo/storage'\n\nexport interface Note {\n title: string\n body: string\n /** Epoch ms of the last edit, for ordering. */\n updatedAt: number\n}\n\nconst LIST_KEY = 'notes-list'\n\n/**\n * Which list this browser shows: \\`?list=\\` when the URL names one, otherwise one made up on\n * first visit and remembered. Open the same \\`?list=\\` on another device to sync with it.\n */\nfunction resolveList(): string {\n const fromUrl = (new URLSearchParams(location.search).get('list') ?? '')\n .replace(/[^\\\\w-]/g, '')\n .slice(0, 48)\n if (fromUrl) return fromUrl\n try {\n const saved = localStorage.getItem(LIST_KEY)\n if (saved) return saved\n const created = crypto.randomUUID().slice(0, 8)\n localStorage.setItem(LIST_KEY, created)\n return created\n } catch {\n return crypto.randomUUID().slice(0, 8)\n }\n}\n\nexport const listName = resolveList()\n\n// \\`storage\\` makes the list local-first: the room's last state and every unconfirmed edit are\n// kept in IndexedDB, so edits made while the connection is down survive a reload or a closed\n// tab, and go to the room when it is reachable again.\nexport const room = connectRoom(\\`/api/rooms/notes-\\${listName}\\`, {\n storage: indexedDBDriver('cascivo-notes'),\n})\n\n/** Notes come from other devices, so they are parsed, not cast. */\nexport function parseNote(raw: unknown): Note {\n if (typeof raw === 'object' && raw !== null) {\n const { title, body, updatedAt } = raw as Record<string, unknown>\n if (typeof title === 'string' && typeof body === 'string' && typeof updatedAt === 'number') {\n return { title, body, updatedAt }\n }\n }\n throw new Error('Malformed note')\n}\n\nexport const notes = room.map('notes', parseNote)\n\nexport function addNote(): void {\n notes.set(crypto.randomUUID(), { title: '', body: '', updatedAt: Date.now() })\n}\n\nexport function editNote(id: string, note: Note, change: Partial<Note>): void {\n notes.set(id, { ...note, ...change, updatedAt: Date.now() })\n}\n`\n}\n\nfunction cfNotesRouteTsx(): string {\n return `import {\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Input,\n Text,\n Textarea,\n useSignals,\n} from '@cascivo/react'\nimport { addNote, editNote, listName, notes, room } from '../notes'\n\n/** Where the list stands: offline edits are counted until the room confirms them. */\nfunction SyncBadge() {\n useSignals()\n const waiting = room.unsynced.value\n if (room.status.value === 'open') {\n return (\n <Badge variant={waiting > 0 ? 'warning' : 'success'}>\n {waiting > 0 ? 'Syncing…' : 'Synced'}\n </Badge>\n )\n }\n return <Badge variant=\"neutral\">{waiting > 0 ? \\`Offline · \\${waiting} waiting\\` : 'Offline'}</Badge>\n}\n\nexport default function Notes() {\n useSignals()\n const sorted = Object.entries(notes.value).sort(([, a], [, b]) => b.updatedAt - a.updatedAt)\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Notes</Heading>\n <Text muted>\n Keeps working offline: edits are saved on this device and sync when you are back. Open{' '}\n <code>/notes?list={listName}</code> on another device to share this list.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <SyncBadge />\n <Button onClick={addNote}>New note</Button>\n </Flex>\n </Flex>\n {sorted.length === 0 ? <Text muted>No notes yet.</Text> : null}\n {sorted.map(([id, note]) => (\n <Card key={id}>\n <CardContent>\n <Flex gap={2}>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Input\n aria-label=\"Title\"\n placeholder=\"Title\"\n value={note.title}\n onChange={(event) => editNote(id, note, { title: event.target.value })}\n />\n <Button variant=\"ghost\" aria-label=\"Delete note\" onClick={() => notes.delete(id)}>\n ×\n </Button>\n </Flex>\n <Textarea\n aria-label=\"Note\"\n rows={3}\n value={note.body}\n onChange={(event) => editNote(id, note, { body: event.target.value })}\n />\n </Flex>\n </CardContent>\n </Card>\n ))}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example import`: a Workflow whose progress streams through @cascivo/app/jobs --- */\n\nfunction cfImportJobTs(): string {\n return `import { defineJob } from '@cascivo/app/jobs'\n\n/**\n * The CSV import job, shared by the Worker (which runs it) and the page (which watches it).\n * Its output crosses the network, so it is parsed on the way in, never cast.\n */\n\n/** Largest CSV the Worker accepts. A Workflow's params are limited in size, too. */\nexport const MAX_CSV_LENGTH = 200_000\n\nexport interface Rejected {\n /** 1-based line in the CSV. */\n line: number\n reason: string\n}\n\nexport interface ImportSummary {\n imported: number\n rejected: Rejected[]\n}\n\nexport function parseImportSummary(raw: unknown): ImportSummary {\n if (typeof raw === 'object' && raw !== null) {\n const { imported, rejected } = raw as Record<string, unknown>\n if (typeof imported === 'number' && Array.isArray(rejected)) {\n return {\n imported,\n rejected: rejected.map((r: unknown) => {\n if (typeof r === 'object' && r !== null) {\n const { line, reason } = r as Record<string, unknown>\n if (typeof line === 'number' && typeof reason === 'string') return { line, reason }\n }\n throw new Error('Malformed rejected row')\n }),\n }\n }\n }\n throw new Error('Malformed import summary')\n}\n\nexport const importJob = defineJob({\n steps: ['Read', 'Check', 'Import'],\n output: parseImportSummary,\n})\n\nexport function parseImportRequest(raw: unknown): { csv: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { csv } = raw as Record<string, unknown>\n if (typeof csv === 'string' && csv.length > 0 && csv.length <= MAX_CSV_LENGTH) return { csv }\n }\n throw new Error(\\`Send { csv } with 1 to \\${MAX_CSV_LENGTH} characters\\`)\n}\n\nexport function parseStarted(raw: unknown): { id: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { id } = raw as Record<string, unknown>\n if (typeof id === 'string' && /^[\\\\w-]{1,60}$/.test(id)) return { id }\n }\n throw new Error('Malformed job id')\n}\n`\n}\n\nfunction cfImportPageTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { watchJob } from '@cascivo/app/jobs'\nimport type { WatchedJob } from '@cascivo/app/jobs'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport { importJob } from './import-job'\nimport type { ImportSummary } from './import-job'\n\nconst client = createClient(api)\n\n/** The job this page shows. Kept in the URL (\\`?job=\\`), so a reload picks it back up. */\nexport const current = signal<WatchedJob<ImportSummary> | null>(null)\nexport const starting = signal(false)\nexport const startError = signal<string | null>(null)\n\nfunction watch(id: string): void {\n current.value?.close()\n current.value = watchJob(importJob, \\`/api/jobs/\\${id}\\`)\n}\n\nconst fromUrl = new URLSearchParams(location.search).get('job')\nif (fromUrl && /^[\\\\w-]{1,60}$/.test(fromUrl)) watch(fromUrl)\n\nexport async function startImport(csv: string): Promise<void> {\n starting.value = true\n startError.value = null\n try {\n const { id } = await client.startImport({ body: { csv } })\n history.replaceState(null, '', \\`/import?job=\\${id}\\`)\n watch(id)\n } catch (error) {\n startError.value = error instanceof Error ? error.message : 'The import could not start'\n } finally {\n starting.value = false\n }\n}\n`\n}\n\nfunction cfImportWorkflowTs(): string {\n return `import { jobReporter } from '@cascivo/app/jobs-server'\nimport { WorkflowEntrypoint } from 'cloudflare:workers'\nimport type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'\nimport { importJob } from '../src/import-job'\nimport type { ImportSummary, Rejected } from '../src/import-job'\nimport type { Env } from './index'\n\ninterface Contact {\n line: number\n name: string\n email: string\n}\n\nconst EMAIL = /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/\nconst CHUNK = 25\n\n/** \\`name,email\\` per line, after a header line. Quoted fields are not supported. */\nfunction readCsv(csv: string): Contact[] {\n const lines = csv.split(/\\\\r?\\\\n/)\n const rows: Contact[] = []\n lines.forEach((text, i) => {\n if (i === 0 || text.trim() === '') return\n const [name = '', email = ''] = text.split(',').map((cell) => cell.trim())\n rows.push({ line: i + 1, name, email })\n })\n return rows\n}\n\nfunction checkRows(rows: Contact[]): { valid: Contact[]; rejected: Rejected[] } {\n const valid: Contact[] = []\n const rejected: Rejected[] = []\n for (const row of rows) {\n if (!row.name) rejected.push({ line: row.line, reason: 'No name' })\n else if (!EMAIL.test(row.email))\n rejected.push({ line: row.line, reason: 'Not an email address' })\n else valid.push(row)\n }\n return { valid, rejected }\n}\n\n/**\n * Write the contacts where they belong (D1, an API…). Here it only waits, so the progress\n * is visible; each chunk is its own step, so a failure retries that chunk alone.\n */\nasync function importContacts(contacts: Contact[]): Promise<void> {\n console.log(\\`import: \\${contacts.length} contacts\\`)\n await new Promise((resolve) => setTimeout(resolve, 400))\n}\n\n/**\n * The import, as a Workflow: each step is retried on failure and its result is kept, so a\n * crash or a deploy resumes the job where it was. Progress goes to the job's room, where the\n * page watches it (\\`watchJob\\`). Every report is made inside a step: a Workflow replays\n * \\`run()\\` from the top after each step, and a report outside one would run again.\n */\nexport class ImportJob extends WorkflowEntrypoint<Env, { csv: string }> {\n override async run(event: WorkflowEvent<{ csv: string }>, step: WorkflowStep) {\n const report = jobReporter(importJob, this.env.ROOMS, event.instanceId)\n let at = 0\n try {\n const rows = await step.do('read', async () => {\n await report.step(0, 'Reading the file')\n return readCsv(event.payload.csv)\n })\n at = 1\n const checked = await step.do('check', async () => {\n await report.step(1, \\`Checking \\${rows.length} rows\\`)\n return checkRows(rows)\n })\n at = 2\n const total = checked.valid.length\n for (let start = 0; start < total; start += CHUNK) {\n await step.do(\\`import \\${start}\\`, async () => {\n await report.progress(2, start / total, \\`Imported \\${start} of \\${total}\\`)\n await importContacts(checked.valid.slice(start, start + CHUNK))\n })\n }\n const summary: ImportSummary = { imported: total, rejected: checked.rejected }\n await step.do('done', () => report.done(summary))\n return summary\n } catch (error) {\n await report.fail(error, at)\n throw error\n }\n }\n}\n`\n}\n\nfunction cfImportRouteTsx(): string {\n return `import type { Step } from '@cascivo/react'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n ProgressBar,\n Steps,\n Text,\n Textarea,\n useSignals,\n useSignalState,\n} from '@cascivo/react'\nimport { importJob } from '../import-job'\nimport type { JobState } from '@cascivo/app/jobs'\nimport type { ImportSummary } from '../import-job'\nimport { current, startError, startImport, starting } from '../import-page'\n\nconst SAMPLE = [\n 'name,email',\n ...Array.from({ length: 120 }, (_, i) => \\`Person \\${i + 1},person\\${i + 1}@example.com\\`),\n 'No Email,',\n ',nameless@example.com',\n].join('\\\\n')\n\n/** One Steps entry per job step, from where the job is. */\nfunction stepsOf(state: JobState<ImportSummary>): Step[] {\n return importJob.steps.map((label, i) => ({\n id: label,\n label,\n state:\n state.status === 'done' || i < state.step\n ? 'complete'\n : i > state.step || state.status === 'queued'\n ? 'pending'\n : state.status === 'failed'\n ? 'error'\n : 'active',\n }))\n}\n\nfunction JobView() {\n useSignals()\n const job = current.value\n if (!job) return null\n const state = job.state.value\n return (\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Steps steps={stepsOf(state)} activeStep={state.step} ariaLabel=\"Import progress\" />\n {state.status === 'running' && state.progress !== null ? (\n <ProgressBar\n value={Math.round(state.progress * 100)}\n label={state.message ?? 'Importing'}\n />\n ) : (\n <Text muted>\n {state.status === 'queued' ? 'Waiting to start…' : (state.message ?? '')}\n </Text>\n )}\n {state.status === 'done' && state.output ? (\n <Alert variant=\"success\" title={\\`Imported \\${state.output.imported} contacts\\`}>\n {state.output.rejected.length === 0\n ? 'Every row was valid.'\n : \\`Rejected: \\${state.output.rejected.map((r) => \\`line \\${r.line} (\\${r.reason})\\`).join(', ')}\\`}\n </Alert>\n ) : null}\n {state.status === 'failed' ? (\n <Alert variant=\"destructive\" title=\"The import failed\">\n {state.error}\n </Alert>\n ) : null}\n </Flex>\n </CardContent>\n </Card>\n )\n}\n\nexport default function Import() {\n useSignals()\n const [csv, setCsv] = useSignalState(SAMPLE)\n const running = current.value?.state.value.status\n const busy = starting.value || running === 'queued' || running === 'running'\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Import</Heading>\n <Text muted>\n A CSV import running as a Cloudflare Workflow. Each step retries on its own and the job\n survives a deploy; progress streams here, and a reload picks the job back up.\n </Text>\n </Flex>\n <Textarea\n aria-label=\"CSV to import\"\n rows={8}\n value={csv.value}\n onChange={(event) => setCsv(event.target.value)}\n />\n <Flex direction=\"horizontal\" gap={2} align=\"center\">\n <Button onClick={() => void startImport(csv.value)} loading={busy} disabled={busy}>\n Import\n </Button>\n {startError.value ? <Text muted>{startError.value}</Text> : null}\n </Flex>\n <JobView />\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example files`: uploads into R2 through @cascivo/app/uploads, Images previews --- */\n\nfunction cfUploadPolicyTs(): string {\n return `import { defineUploads } from '@cascivo/app/uploads'\n\n/**\n * What the app accepts, shared by the page (which checks first) and the Worker (which\n * enforces). SVG and HTML are not on the list, and cannot be: served from this origin they\n * would run script.\n */\nexport const uploads = defineUploads({\n path: '/api/uploads',\n maxBytes: 50 * 1024 * 1024,\n types: ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf'],\n})\n`\n}\n\nfunction cfFilesTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { startUpload } from '@cascivo/app/uploads'\nimport type { StoredFile, Upload } from '@cascivo/app/uploads'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport { uploads } from './upload-policy'\n\nconst client = createClient(api)\n\n/** Files being uploaded now, newest first. */\nexport const inFlight = signal<Upload[]>([])\n/** Files already stored (see listUploads for their order). */\nexport const stored = signal<StoredFile[]>([])\n\nexport async function refresh(): Promise<void> {\n stored.value = await client.listFiles()\n}\n\nexport function addFiles(files: File[]): void {\n for (const file of files) {\n const upload = startUpload(uploads, file)\n inFlight.value = [upload, ...inFlight.value]\n // Once stored, the file moves from the upload list to the file list.\n const stop = upload.status.subscribe((status) => {\n if (status !== 'done') return\n stop()\n inFlight.value = inFlight.value.filter((u) => u !== upload)\n void refresh()\n })\n }\n}\n\nexport function removeUpload(id: string): void {\n const upload = inFlight.value.find((u) => u.id === id)\n upload?.abort()\n inFlight.value = inFlight.value.filter((u) => u.id !== id)\n}\n`\n}\n\nfunction cfFilesRouteTsx(): string {\n return `import type { UploaderFile } from '@cascivo/react'\nimport {\n Card,\n CardContent,\n FileUploader,\n Flex,\n Heading,\n ProgressBar,\n Text,\n useSignals,\n} from '@cascivo/react'\nimport { formatBytes } from '@cascivo/app/uploads'\nimport { addFiles, inFlight, refresh, removeUpload, stored } from '../files'\nimport { uploads } from '../upload-policy'\nimport styles from '../files.module.css'\n\nvoid refresh()\n\nexport default function Files() {\n useSignals()\n const files: UploaderFile[] = inFlight.value.map((upload) => ({\n id: upload.id,\n name: upload.name,\n size: upload.size,\n status: upload.status.value === 'done' ? 'complete' : upload.status.value,\n ...(upload.error.value ? { errorMessage: upload.error.value } : {}),\n }))\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Files</Heading>\n <Text muted>\n Uploads go through the Worker into R2: up to {formatBytes(uploads.maxBytes)} each, large\n files in parts. Images get resized previews from Cloudflare Images.\n </Text>\n </Flex>\n <FileUploader\n multiple\n accept={uploads.types.join(',')}\n maxSize={uploads.maxBytes}\n files={files}\n onFilesAdded={addFiles}\n onRemove={removeUpload}\n />\n {inFlight.value\n .filter((upload) => upload.status.value === 'uploading')\n .map((upload) => (\n <ProgressBar\n key={upload.id}\n value={Math.round(upload.progress.value * 100)}\n label={upload.name}\n />\n ))}\n <div className={styles['grid']}>\n {stored.value.map((file) => (\n <Card key={file.key}>\n <CardContent>\n <Flex gap={2}>\n {file.type.startsWith('image/') ? (\n <img\n className={styles['preview']}\n src={\\`\\${uploads.path}/\\${file.key}?w=320\\`}\n alt={file.name}\n loading=\"lazy\"\n />\n ) : null}\n <a href={\\`\\${uploads.path}/\\${file.key}\\`} target=\"_blank\" rel=\"noreferrer\">\n {file.name}\n </a>\n <Text size=\"sm\" muted>\n {formatBytes(file.size)}\n </Text>\n </Flex>\n </CardContent>\n </Card>\n ))}\n </div>\n </Flex>\n )\n}\n`\n}\n\nfunction cfFilesCss(): string {\n return `/* Your app's own styles live in the cascivo.example layer (declared in index.html). */\n@layer cascivo.example {\n .grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));\n gap: var(--cascivo-space-3);\n }\n\n .preview {\n inline-size: 100%;\n aspect-ratio: 4 / 3;\n object-fit: cover;\n border-radius: var(--cascivo-radius-md);\n background: var(--cascivo-color-bg-subtle);\n }\n}\n`\n}\n\n/* --- `--example export`: a page exported to PDF/PNG by Browser Run (@cascivo/app/export) --- */\n\nfunction cfReportRouteTsx(): string {\n return `import { exportUrl, isExporting } from '@cascivo/app/export'\nimport type { Column } from '@cascivo/react'\nimport { Badge, Button, Card, CardContent, DataTable, Flex, Heading, Text } from '@cascivo/react'\n\ninterface Month {\n month: string\n revenue: number\n customers: number\n change: number\n}\n\nconst ROWS: Month[] = [\n { month: 'July', revenue: 48_200, customers: 312, change: 4.1 },\n { month: 'August', revenue: 51_900, customers: 334, change: 7.7 },\n { month: 'September', revenue: 50_300, customers: 341, change: -3.1 },\n]\n\nconst money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\n\nconst COLUMNS: Column<Month>[] = [\n { key: 'month', header: 'Month' },\n { key: 'revenue', header: 'Revenue', align: 'end', render: (row) => money.format(row.revenue) },\n { key: 'customers', header: 'Customers', align: 'end' },\n {\n key: 'change',\n header: 'Change',\n align: 'end',\n render: (row) => (\n <Badge variant={row.change >= 0 ? 'success' : 'warning'}>\n {row.change >= 0 ? '+' : ''}\n {row.change}%\n </Badge>\n ),\n },\n]\n\n/**\n * A page worth exporting. The buttons download it as a file, rendered by the Worker in\n * Cloudflare's Browser Run; inside that browser (\\`isExporting()\\`) they are left out, and\n * App.tsx drops the shell, so the file holds the report and nothing else.\n */\nexport default function Report() {\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Quarterly report</Heading>\n <Text muted>Q3 revenue and customers, by month.</Text>\n </Flex>\n {isExporting() ? null : (\n <Flex direction=\"horizontal\" gap={2}>\n <Button asChild variant=\"secondary\">\n <a href={exportUrl('/report', 'png')}>Download PNG</a>\n </Button>\n <Button asChild>\n <a href={exportUrl('/report', 'pdf')}>Download PDF</a>\n </Button>\n </Flex>\n )}\n </Flex>\n <Card>\n <CardContent>\n <DataTable columns={COLUMNS} rows={ROWS} getRowId={(row) => row.month} />\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example usage`: API usage in Workers Analytics Engine, charted (@cascivo/app/analytics) --- */\n\nfunction cfUsageTs(): string {\n return `import { defineMetrics } from '@cascivo/app/analytics'\n\n/**\n * What the Worker records for every API request, in Analytics Engine (worker/index.ts writes\n * it). The names are the columns: a query says {path}, never blob1.\n */\nexport const usageMetrics = defineMetrics({\n dataset: 'app_usage',\n blobs: ['path', 'method'],\n doubles: ['status', 'duration_ms'],\n index: 'path',\n})\n\nexport interface UsageReport {\n /** False until the Worker has the secrets it needs to read Analytics Engine. */\n configured: boolean\n hourly: { hour: string; requests: number }[]\n routes: { path: string; requests: number }[]\n totals: { requests: number; errors: number; avgMs: number }\n}\n\nfunction num(raw: unknown): number {\n if (typeof raw !== 'number' || !Number.isFinite(raw)) throw new Error('Expected a number')\n return raw\n}\n\nfunction str(raw: unknown): string {\n if (typeof raw !== 'string') throw new Error('Expected a string')\n return raw\n}\n\nfunction list<T>(raw: unknown, item: (row: Record<string, unknown>) => T): T[] {\n if (!Array.isArray(raw)) throw new Error('Expected a list')\n return raw.map((row: unknown) => {\n if (typeof row !== 'object' || row === null) throw new Error('Expected an object')\n return item(row as Record<string, unknown>)\n })\n}\n\n/** The report crosses the network, so the page parses it. */\nexport function parseUsageReport(raw: unknown): UsageReport {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed usage report')\n const r = raw as Record<string, unknown>\n const totals = r['totals']\n if (typeof r['configured'] !== 'boolean' || typeof totals !== 'object' || totals === null) {\n throw new Error('Malformed usage report')\n }\n const t = totals as Record<string, unknown>\n return {\n configured: r['configured'],\n hourly: list(r['hourly'], (row) => ({\n hour: str(row['hour']),\n requests: num(row['requests']),\n })),\n routes: list(r['routes'], (row) => ({\n path: str(row['path']),\n requests: num(row['requests']),\n })),\n totals: { requests: num(t['requests']), errors: num(t['errors']), avgMs: num(t['avgMs']) },\n }\n}\n`\n}\n\nfunction cfUsageWorkerTs(): string {\n return `import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'\nimport type { AnalyticsCredentials } from '@cascivo/app/analytics'\nimport { usageMetrics } from '../src/usage'\nimport type { UsageReport } from '../src/usage'\n\nconst LAST_DAY = \"timestamp > NOW() - INTERVAL '1' DAY\"\n\n/** A number, or 0 when the query had nothing to sum (an empty day). */\nconst orZero = (row: unknown, name: string): number => {\n try {\n return numberField(row, name)\n } catch {\n return 0\n }\n}\n\n/**\n * The last 24 hours of API usage, from Analytics Engine's SQL API. Counts are\n * \\`SUM(_sample_interval)\\`, never \\`COUNT()\\`: Analytics Engine samples at high volume, and each\n * row stands for \\`_sample_interval\\` requests.\n */\nexport async function usageReport(credentials: AnalyticsCredentials | null): Promise<UsageReport> {\n if (!credentials) {\n return {\n configured: false,\n hourly: [],\n routes: [],\n totals: { requests: 0, errors: 0, avgMs: 0 },\n }\n }\n const [hourly, routes, totals, errors] = await Promise.all([\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT toStartOfInterval(timestamp, INTERVAL '1' HOUR) AS hour, SUM(_sample_interval) AS requests\n FROM {dataset} WHERE \\${LAST_DAY} GROUP BY hour ORDER BY hour\\`,\n ),\n (row) => ({ hour: stringField(row, 'hour'), requests: numberField(row, 'requests') }),\n ),\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT {path} AS path, SUM(_sample_interval) AS requests\n FROM {dataset} WHERE \\${LAST_DAY} GROUP BY path ORDER BY requests DESC LIMIT 8\\`,\n ),\n (row) => ({ path: stringField(row, 'path'), requests: numberField(row, 'requests') }),\n ),\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT SUM(_sample_interval) AS requests,\n SUM(_sample_interval * {duration_ms}) / SUM(_sample_interval) AS avg_ms\n FROM {dataset} WHERE \\${LAST_DAY}\\`,\n ),\n (row) => ({ requests: orZero(row, 'requests'), avgMs: orZero(row, 'avg_ms') }),\n ),\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT SUM(_sample_interval) AS errors FROM {dataset} WHERE \\${LAST_DAY} AND {status} >= 500\\`,\n ),\n (row) => orZero(row, 'errors'),\n ),\n ])\n return {\n configured: true,\n hourly,\n routes,\n totals: {\n requests: totals[0]?.requests ?? 0,\n errors: errors[0] ?? 0,\n avgMs: Math.round(totals[0]?.avgMs ?? 0),\n },\n }\n}\n`\n}\n\nfunction cfUsageRouteTsx(): string {\n return `import { BarChart, Kpi, LineChart } from '@cascivo/charts'\nimport { createClient } from '@cascivo/app/api'\nimport {\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Grid,\n Heading,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport type { UsageReport } from '../usage'\n\nconst client = createClient(api)\nconst report = signal<UsageReport | null>(null)\nconst failed = signal<string | null>(null)\n\nasync function load(): Promise<void> {\n failed.value = null\n try {\n report.value = await client.usage()\n } catch (error) {\n failed.value = error instanceof Error ? error.message : 'Could not load usage'\n }\n}\nvoid load()\n\n/** Analytics Engine returns hours as \"2026-09-30 14:00:00\", in UTC. */\nconst toDate = (hour: string) => new Date(\\`\\${hour.replace(' ', 'T')}Z\\`)\n\nexport default function Usage() {\n useSignals()\n const data = report.value\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Usage</Heading>\n <Text muted>Every API request, recorded by the Worker in Workers Analytics Engine.</Text>\n </Flex>\n <Button variant=\"secondary\" onClick={() => void load()}>\n Refresh\n </Button>\n </Flex>\n {failed.value ? <Text muted>{failed.value}</Text> : null}\n {data && !data.configured ? (\n <EmptyState\n title=\"Connect Analytics Engine to read usage\"\n description=\"The Worker already records every request. To read them back it needs your account id and an API token with Account Analytics: Read, as secrets: npx wrangler secret put CF_ACCOUNT_ID, then npx wrangler secret put CF_API_TOKEN. For vite dev, put both in .dev.vars.\"\n />\n ) : null}\n {data && data.configured ? (\n <>\n <Grid cols={3} gap={3}>\n <Kpi label=\"Requests, 24 h\" value={data.totals.requests.toLocaleString()} />\n <Kpi label=\"Server errors\" value={data.totals.errors.toLocaleString()} />\n <Kpi label=\"Average latency\" value={\\`\\${data.totals.avgMs} ms\\`} />\n </Grid>\n <Card>\n <CardContent>\n <LineChart\n title=\"Requests per hour\"\n series={[{ id: 'requests', label: 'Requests', data: data.hourly }]}\n x={(d) => toDate(d.hour)}\n y={(d) => d.requests}\n />\n </CardContent>\n </Card>\n <Card>\n <CardContent>\n <BarChart\n title=\"Busiest routes\"\n series={[{ id: 'routes', label: 'Requests', data: data.routes }]}\n x={(d) => d.path}\n y={(d) => d.requests}\n />\n </CardContent>\n </Card>\n </>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example search`: help articles by meaning (Vectorize + Workers AI) --- */\n\nfunction cfSearchTs(): string {\n return `/**\n * /search, shared by the Worker (which searches) and the page (which asks). \\`semantic\\` is\n * Vectorize over Workers AI embeddings; \\`keyword\\` is the \\`vite dev\\` stand-in, SQLite's\n * full-text search, because neither runs locally.\n */\nexport interface SearchHit {\n id: string\n title: string\n body: string\n /** Higher is closer. Cosine similarity when semantic; a rank when keyword. */\n score: number\n}\n\nexport interface SearchResult {\n mode: 'semantic' | 'keyword'\n hits: SearchHit[]\n /** Articles in the vector index, when semantic: 0 until \"Index articles\" has run. */\n indexed: number | null\n}\n\nexport function parseSearchQuery(raw: unknown): { q: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { q } = raw as Record<string, unknown>\n if (typeof q === 'string' && q.trim() !== '' && q.length <= 200) return { q: q.trim() }\n }\n throw new Error('Expected { q }: 1–200 characters')\n}\n\nfunction parseHit(raw: unknown): SearchHit {\n if (typeof raw === 'object' && raw !== null) {\n const { id, title, body, score } = raw as Record<string, unknown>\n if (\n typeof id === 'string' &&\n typeof title === 'string' &&\n typeof body === 'string' &&\n typeof score === 'number'\n ) {\n return { id, title, body, score }\n }\n }\n throw new Error('Malformed search hit')\n}\n\nexport function parseSearchResult(raw: unknown): SearchResult {\n if (typeof raw === 'object' && raw !== null) {\n const { mode, hits, indexed } = raw as Record<string, unknown>\n if (\n (mode === 'semantic' || mode === 'keyword') &&\n Array.isArray(hits) &&\n (indexed === null || typeof indexed === 'number')\n ) {\n return { mode, hits: hits.map(parseHit), indexed }\n }\n }\n throw new Error('Malformed search result')\n}\n\nexport function parseIndexed(raw: unknown): { indexed: number } {\n if (typeof raw === 'object' && raw !== null) {\n const { indexed } = raw as Record<string, unknown>\n if (typeof indexed === 'number') return { indexed }\n }\n throw new Error('Malformed reply')\n}\n`\n}\n\nfunction cfSearchWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport type { SearchHit, SearchResult } from '../src/search'\nimport { articles } from './articles'\n\n/** A Workers AI embedding model; its dimensions (768) are the Vectorize index's. */\nconst EMBEDDING_MODEL = '@cf/baai/bge-base-en-v1.5'\n\n// \\`vite dev\\` has neither Workers AI nor Vectorize, so it searches with SQLite's full-text\n// index instead: by words, not meaning. \\`VITE_REAL_AI=1 vite dev\\` uses the real ones.\nconst keywordOnly = import.meta.env.DEV && import.meta.env['VITE_REAL_AI'] !== '1'\n\n/** What search needs of the Vectorize binding. */\nexport interface VectorIndex {\n query(\n vector: number[],\n options: { topK: number },\n ): Promise<{ matches: { id: string; score: number }[] }>\n upsert(vectors: { id: string; values: number[] }[]): Promise<unknown>\n describe(): Promise<{ vectorCount: number }>\n}\n\n/** What search needs of the Workers AI binding. */\nexport interface Embedder {\n run(model: typeof EMBEDDING_MODEL, input: { text: string[] }): Promise<unknown>\n}\n\nexport interface SearchEnv {\n DB: Database\n AI: Embedder\n ARTICLES_INDEX: VectorIndex\n}\n\nconst migrations = [\n {\n id: '0001_articles',\n statements: [\n 'CREATE TABLE articles (id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL)',\n \"CREATE VIRTUAL TABLE articles_fts USING fts5(title, body, content='articles', content_rowid='rowid')\",\n ],\n },\n]\n\n/** The schema, then the articles (once: a second isolate's inserts are ignored). */\nasync function ready(db: Database): Promise<void> {\n await migrate(db, migrations)\n const [seeded] = await queryRows(db, 'SELECT COUNT(*) AS n FROM articles', [], (row) =>\n typeof row === 'object' && row !== null ? Number((row as Record<string, unknown>)['n']) : 0,\n )\n if (seeded) return\n await db.batch([\n ...articles.map((a) =>\n db\n .prepare('INSERT OR IGNORE INTO articles (id, title, body) VALUES (?, ?, ?)')\n .bind(a.id, a.title, a.body),\n ),\n // The full-text index reads the articles table; rebuild it from what is there now.\n db.prepare(\"INSERT INTO articles_fts (articles_fts) VALUES ('rebuild')\"),\n ])\n}\n\nfunction parseRow(raw: unknown): SearchHit {\n if (typeof raw === 'object' && raw !== null) {\n const { id, title, body, score } = raw as Record<string, unknown>\n if (typeof id === 'string' && typeof title === 'string' && typeof body === 'string') {\n return { id, title, body, score: typeof score === 'number' ? score : 0 }\n }\n }\n throw new Error('Malformed article row')\n}\n\n/** Embeddings from Workers AI, checked: a model's reply is data like any other. */\nasync function embed(ai: Embedder, texts: string[]): Promise<number[][]> {\n const reply = await ai.run(EMBEDDING_MODEL, { text: texts })\n const data =\n typeof reply === 'object' && reply !== null ? (reply as Record<string, unknown>)['data'] : null\n if (\n !Array.isArray(data) ||\n data.length !== texts.length ||\n !data.every((v) => Array.isArray(v) && v.every((n) => typeof n === 'number'))\n ) {\n throw new Error('Workers AI returned no embeddings')\n }\n return data as number[][]\n}\n\n/** Every word as a quoted FTS5 term, OR-ed: user input never reaches FTS5's query syntax. */\nfunction keywordQuery(q: string): string | null {\n const words = q.toLowerCase().match(/[\\\\p{L}\\\\p{N}]+/gu) ?? []\n return words.length > 0 ? words.map((w) => \\`\"\\${w}\"\\`).join(' OR ') : null\n}\n\nexport async function search(env: SearchEnv, q: string): Promise<SearchResult> {\n await ready(env.DB)\n if (keywordOnly) {\n const match = keywordQuery(q)\n const hits = match\n ? await queryRows(\n env.DB,\n \\`SELECT a.id, a.title, a.body, -bm25(articles_fts) AS score\n FROM articles_fts JOIN articles a ON a.rowid = articles_fts.rowid\n WHERE articles_fts MATCH ? ORDER BY score DESC LIMIT 5\\`,\n [match],\n parseRow,\n )\n : []\n return { mode: 'keyword', hits, indexed: null }\n }\n const { vectorCount } = await env.ARTICLES_INDEX.describe()\n const [vector] = await embed(env.AI, [q])\n const { matches } = await env.ARTICLES_INDEX.query(vector!, { topK: 5 })\n if (matches.length === 0) return { mode: 'semantic', hits: [], indexed: vectorCount }\n const rows = await queryRows(\n env.DB,\n \\`SELECT id, title, body FROM articles WHERE id IN (\\${matches.map(() => '?').join(', ')})\\`,\n matches.map((m) => m.id),\n parseRow,\n )\n const byId = new Map(rows.map((row) => [row.id, row]))\n const hits = matches.flatMap((m) => {\n const row = byId.get(m.id)\n return row ? [{ ...row, score: m.score }] : []\n })\n return { mode: 'semantic', hits, indexed: vectorCount }\n}\n\n/**\n * Embeds every article and upserts it into the Vectorize index. Vectorize applies writes a few\n * seconds later, so \\`indexed\\` counts what it had already applied when this finished.\n */\nexport async function indexArticles(env: SearchEnv): Promise<{ indexed: number }> {\n if (keywordOnly)\n throw new HttpError(400, 'vite dev searches by keyword: there is nothing to index')\n await ready(env.DB)\n const rows = await queryRows(env.DB, 'SELECT id, title, body FROM articles', [], parseRow)\n for (let i = 0; i < rows.length; i += 50) {\n const batch = rows.slice(i, i + 50)\n const vectors = await embed(\n env.AI,\n batch.map((row) => \\`\\${row.title}\\\\n\\${row.body}\\`),\n )\n await env.ARTICLES_INDEX.upsert(batch.map((row, j) => ({ id: row.id, values: vectors[j]! })))\n }\n return { indexed: (await env.ARTICLES_INDEX.describe()).vectorCount }\n}\n`\n}\n\nfunction cfArticlesTs(): string {\n return `/**\n * The help articles /search looks through, seeded into D1 on the first query. Replace them with\n * your own content: anything with an id, a title and a body can be indexed the same way.\n */\nexport const articles: { id: string; title: string; body: string }[] = [\n {\n id: 'refunds',\n title: 'Refunds',\n body: 'We refund any charge within 30 days of the invoice. Open Billing, pick the invoice and choose Request refund; the amount returns to the card that paid it within five business days.',\n },\n {\n id: 'cancel',\n title: 'Cancelling a subscription',\n body: 'Cancel from Billing at any time. The plan stays active until the end of the period you paid for, and nothing is charged after that.',\n },\n {\n id: 'invoices',\n title: 'Invoices and receipts',\n body: 'Every charge produces an invoice in Billing. Add a tax number and a billing address there, and they appear on every invoice from then on.',\n },\n {\n id: 'change-plan',\n title: 'Changing plans',\n body: 'Upgrade or downgrade from Billing. An upgrade is prorated and charged at once; a downgrade takes effect at the next renewal.',\n },\n {\n id: 'payment-failed',\n title: 'When a payment fails',\n body: 'If a card is declined we retry three times over a week and email the account owner each time. Update the card in Billing to settle the balance.',\n },\n {\n id: 'sso',\n title: 'Single sign-on with SAML',\n body: 'Enterprise workspaces can require sign-in through an identity provider such as Okta or Entra ID. Upload the provider metadata under Security, then test with one account before enforcing it.',\n },\n {\n id: 'two-factor',\n title: 'Two-step verification',\n body: 'Turn on an authenticator app under Profile, Security. Keep the recovery codes somewhere safe: they are the only way back in if the phone is lost.',\n },\n {\n id: 'password-reset',\n title: 'Resetting a password',\n body: 'Choose Forgot password on the sign-in page. The link we email works once and expires after an hour.',\n },\n {\n id: 'invite',\n title: 'Inviting teammates',\n body: 'Admins invite people from Members by email. Each invitation expires after seven days and can be resent.',\n },\n {\n id: 'roles',\n title: 'Roles and permissions',\n body: 'Owners manage billing and security, admins manage members and projects, and members work inside the projects they are added to.',\n },\n {\n id: 'remove-member',\n title: 'Removing someone from the workspace',\n body: 'An admin removes a member from Members. Their projects stay, reassigned to the admin, and their access ends immediately.',\n },\n {\n id: 'export-data',\n title: 'Exporting your data',\n body: 'Download every project as CSV or JSON from Settings, Data. Large workspaces receive an email with a download link when the archive is ready.',\n },\n {\n id: 'delete-account',\n title: 'Deleting a workspace',\n body: 'The owner deletes the workspace under Settings. Data is kept for 30 days in case of a mistake, then erased permanently.',\n },\n {\n id: 'api-keys',\n title: 'API keys',\n body: 'Create keys under Settings, API. A key is shown once; store it as a secret, and rotate it by creating a new key before revoking the old one.',\n },\n {\n id: 'rate-limits',\n title: 'API rate limits',\n body: 'Each key may make 600 requests a minute. A request over the limit receives status 429 with a Retry-After header saying when to try again.',\n },\n {\n id: 'webhooks',\n title: 'Webhooks',\n body: 'Subscribe a URL to events under Settings, Webhooks. Each delivery is signed with your secret; verify the signature before trusting the body.',\n },\n {\n id: 'uptime',\n title: 'Status and incidents',\n body: 'Live service status and past incidents are on the status page. Subscribe there to hear about outages and maintenance by email.',\n },\n {\n id: 'data-region',\n title: 'Where data is stored',\n body: 'Choose the EU or US region when creating a workspace. Data at rest stays in that region, and it cannot be moved later.',\n },\n {\n id: 'gdpr',\n title: 'Privacy and GDPR requests',\n body: 'Request a copy of personal data, or its deletion, from Profile, Privacy. We answer within 30 days, as the regulation requires.',\n },\n {\n id: 'dark-mode',\n title: 'Dark mode and themes',\n body: 'Switch between light and dark themes under Profile, Appearance, or follow the operating system setting automatically.',\n },\n {\n id: 'keyboard',\n title: 'Keyboard shortcuts',\n body: 'Press the question mark anywhere to list shortcuts. Command K opens the command menu to jump to any page or action.',\n },\n {\n id: 'notifications',\n title: 'Email notifications',\n body: 'Choose which updates arrive by email under Profile, Notifications, or pause them all for a while.',\n },\n {\n id: 'mobile',\n title: 'Using the mobile app',\n body: 'The iOS and Android apps sign in with the same account and receive push notifications for mentions and assignments.',\n },\n {\n id: 'import',\n title: 'Importing from a spreadsheet',\n body: 'Upload a CSV under Settings, Data, Import. Map each column to a field, preview the first rows, then run the import; errors are listed per row.',\n },\n]\n`\n}\n\nfunction cfSearchRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Search,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport type { SearchResult } from '../search'\n\nconst client = createClient(api)\nconst result = signal<SearchResult | null>(null)\nconst failure = signal<string | null>(null)\nconst indexing = signal(false)\n\nlet latest = 0\n\n/** Runs a search (Search debounces typing); an older answer never replaces a newer one. */\nfunction onQuery(q: string): void {\n const ticket = ++latest\n if (q.trim() === '') {\n result.value = null\n return\n }\n client\n .search({ body: { q } })\n .then((answer) => {\n if (ticket === latest) {\n result.value = answer\n failure.value = null\n }\n })\n .catch((error: unknown) => {\n if (ticket === latest)\n failure.value = error instanceof Error ? error.message : 'Search failed'\n })\n}\n\nasync function indexArticles(): Promise<void> {\n indexing.value = true\n failure.value = null\n try {\n const { indexed } = await client.indexArticles()\n result.value = result.value ? { ...result.value, indexed } : null\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Indexing failed'\n } finally {\n indexing.value = false\n }\n}\n\nexport default function SearchPage() {\n useSignals()\n const current = result.value\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Search</Heading>\n <Text muted>\n Help articles, searched by meaning once deployed: ask “how do I get my money back” and\n Refunds comes first, though it shares no words with the question. vite dev searches by\n keyword instead.\n </Text>\n </Flex>\n <Search label=\"Search the help articles\" placeholder=\"Ask a question\" onSearch={onQuery} />\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Search failed\">\n {failure.value}\n </Alert>\n ) : null}\n {current ? (\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={current.mode === 'semantic' ? 'success' : 'warning'}>\n {current.mode === 'semantic'\n ? 'By meaning (Vectorize)'\n : 'By keyword (vite dev stand-in)'}\n </Badge>\n {current.mode === 'semantic' ? (\n <>\n <Text size=\"sm\" muted>\n {current.indexed ?? 0} articles indexed\n </Text>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n loading={indexing.value}\n onClick={() => void indexArticles()}\n >\n Index articles\n </Button>\n </>\n ) : null}\n </Flex>\n ) : null}\n {current && current.hits.length === 0 ? (\n <EmptyState\n title=\"Nothing found\"\n description={\n current.mode === 'semantic' && !current.indexed\n ? 'The index is empty: press Index articles, then search again in a few seconds.'\n : 'Try other words.'\n }\n />\n ) : null}\n {current?.hits.map((hit) => (\n <Card key={hit.id}>\n <CardContent>\n <Flex gap={1}>\n <Text weight=\"semibold\">{hit.title}</Text>\n <Text size=\"sm\" muted>\n {hit.body}\n </Text>\n </Flex>\n </CardContent>\n </Card>\n ))}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example digest`: the report, rendered and emailed on a Cron Trigger --- */\n\nfunction cfDigestTs(): string {\n return `/**\n * The weekly digest's runs, shared by the Worker (which records each one) and the /digest page\n * (which lists them). A run happens on the Cron Trigger in wrangler.jsonc, or from \"Send now\".\n */\nexport interface DigestRun {\n id: string\n startedAt: string\n /** \\`sent\\`, \\`skipped\\` (not configured yet: see \\`detail\\`) or \\`failed\\`. */\n status: 'sent' | 'skipped' | 'failed'\n /** Who it went to, why it was skipped, or what failed. */\n detail: string\n /** \\`cron\\` or \\`manual\\`. */\n trigger: 'cron' | 'manual'\n}\n\nconst STATUSES = ['sent', 'skipped', 'failed']\nconst TRIGGERS = ['cron', 'manual']\n\nexport function parseDigestRun(raw: unknown): DigestRun {\n if (typeof raw === 'object' && raw !== null) {\n const { id, startedAt, status, detail, trigger } = raw as Record<string, unknown>\n if (\n typeof id === 'string' &&\n typeof startedAt === 'string' &&\n typeof status === 'string' &&\n STATUSES.includes(status) &&\n typeof detail === 'string' &&\n typeof trigger === 'string' &&\n TRIGGERS.includes(trigger)\n ) {\n // Both checked against their lists just above.\n return {\n id,\n startedAt,\n status: status as DigestRun['status'],\n detail,\n trigger: trigger as DigestRun['trigger'],\n }\n }\n }\n throw new Error('Malformed digest run')\n}\n\nexport function parseDigestRuns(raw: unknown): DigestRun[] {\n if (!Array.isArray(raw)) throw new Error('Expected a list of runs')\n return raw.map(parseDigestRun)\n}\n`\n}\n\nfunction cfDigestWorkerTs(): string {\n return `import { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { exportPage } from '@cascivo/app/export'\nimport type { ExportBrowser } from '@cascivo/app/export'\nimport { parseDigestRun } from '../src/digest'\nimport type { DigestRun } from '../src/digest'\n\nconst migrations = [\n {\n id: '0001_digest_runs',\n statements: [\n \\`CREATE TABLE digest_runs (\n id TEXT PRIMARY KEY,\n started_at TEXT NOT NULL,\n status TEXT NOT NULL,\n detail TEXT NOT NULL,\n trigger TEXT NOT NULL\n )\\`,\n ],\n },\n]\n\n/** What sending the digest needs of the Email Service binding (\\`send_email\\`). */\nexport interface DigestSender {\n send(message: {\n from: string\n to: string[]\n subject: string\n text: string\n html: string\n attachments: {\n filename: string\n type: string\n content: Uint8Array\n disposition: 'attachment'\n }[]\n }): Promise<unknown>\n}\n\nexport interface DigestEnv {\n DB: Database\n EMAIL: DigestSender\n /** Comma-separated recipients, set in wrangler.jsonc. */\n DIGEST_TO: string\n DIGEST_FROM: string\n /** The deployed app's origin, which the browser opens: a cron has no request to read it from. */\n APP_URL: string\n}\n\n/** Why the digest cannot be sent yet, or \\`null\\` when it can. */\nfunction missing(env: DigestEnv, origin: string): string | null {\n const unset = [\n ...(env.DIGEST_TO.trim() ? [] : ['DIGEST_TO']),\n ...(env.DIGEST_FROM.trim() ? [] : ['DIGEST_FROM']),\n ...(origin ? [] : ['APP_URL']),\n ]\n return unset.length > 0 ? \\`Set \\${unset.join(', ')} in wrangler.jsonc\\` : null\n}\n\n/**\n * Renders /report to a PDF with Browser Run and emails it to DIGEST_TO. Every run is recorded,\n * whatever happens, so the /digest page shows why a Monday's digest did not arrive.\n */\nexport async function runDigest(\n env: DigestEnv,\n launch: () => Promise<ExportBrowser>,\n trigger: DigestRun['trigger'],\n origin = env.APP_URL,\n): Promise<DigestRun> {\n const run = { id: crypto.randomUUID(), startedAt: new Date().toISOString(), trigger }\n let result: DigestRun\n const skip = missing(env, origin)\n if (skip) {\n result = { ...run, status: 'skipped', detail: skip }\n } else {\n try {\n const pdf = await exportPage(launch, new URL('/report', origin).href, { format: 'pdf' })\n const to = env.DIGEST_TO.split(',')\n .map((address) => address.trim())\n .filter(Boolean)\n const week = run.startedAt.slice(0, 10)\n await env.EMAIL.send({\n from: env.DIGEST_FROM,\n to,\n subject: \\`Weekly report, \\${week}\\`,\n text: 'This week’s report is attached as a PDF.',\n html: '<p>This week’s report is attached as a PDF.</p>',\n attachments: [\n {\n filename: \\`report-\\${week}.pdf\\`,\n type: 'application/pdf',\n content: pdf,\n disposition: 'attachment',\n },\n ],\n })\n result = {\n ...run,\n status: 'sent',\n detail: \\`\\${to.join(', ')} (\\${Math.round(pdf.byteLength / 1024)} KB)\\`,\n }\n } catch (error) {\n console.error('[digest] failed:', error)\n result = {\n ...run,\n status: 'failed',\n detail: error instanceof Error ? error.message : String(error),\n }\n }\n }\n await migrate(env.DB, migrations)\n await env.DB.prepare(\n 'INSERT INTO digest_runs (id, started_at, status, detail, trigger) VALUES (?, ?, ?, ?, ?)',\n )\n .bind(result.id, result.startedAt, result.status, result.detail.slice(0, 500), result.trigger)\n .run()\n return result\n}\n\nexport async function listRuns(db: Database): Promise<DigestRun[]> {\n await migrate(db, migrations)\n return queryRows(\n db,\n \\`SELECT id, started_at AS startedAt, status, detail, trigger FROM digest_runs\n ORDER BY started_at DESC LIMIT 20\\`,\n [],\n parseDigestRun,\n )\n}\n`\n}\n\nfunction cfDigestRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Badge,\n Button,\n EmptyState,\n Flex,\n Heading,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport type { DigestRun } from '../digest'\n\nconst client = createClient(api)\nconst runs = signal<DigestRun[]>([])\nconst sending = signal(false)\nconst failure = signal<string | null>(null)\n\nasync function load(): Promise<void> {\n runs.value = await client.digestRuns().catch(() => runs.peek())\n}\nvoid load()\n\nasync function sendNow(): Promise<void> {\n sending.value = true\n failure.value = null\n try {\n await client.runDigest()\n await load()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not send'\n } finally {\n sending.value = false\n }\n}\n\nconst VARIANT = { sent: 'success', skipped: 'warning', failed: 'destructive' } as const\n\nexport default function Digest() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Weekly digest</Heading>\n <Text muted>\n Every Monday at 08:00 UTC, the Worker renders /report to a PDF and emails it. Each run is\n listed here, including the ones that could not send.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\">\n <Button loading={sending.value} onClick={() => void sendNow()}>\n Send now\n </Button>\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not sent\">\n {failure.value}\n </Alert>\n ) : null}\n {runs.value.length === 0 ? (\n <EmptyState\n title=\"No runs yet\"\n description=\"The first runs on Monday, or press Send now.\"\n />\n ) : (\n <Flex gap={2}>\n {runs.value.map((run) => (\n <Flex key={run.id} direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={VARIANT[run.status]}>{run.status}</Badge>\n <Text>{run.detail}</Text>\n <Text size=\"sm\" muted>\n {new Date(run.startedAt).toLocaleString()} · {run.trigger}\n </Text>\n </Flex>\n ))}\n </Flex>\n )}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example webhooks`: signed deliveries, stored once, pushed live --- */\n\nfunction cfWebhooksTs(): string {\n return `/**\n * Webhook deliveries, shared by the Worker (which verifies and stores them) and the\n * /webhooks page (which lists them as they arrive).\n */\nexport interface Delivery {\n id: string\n /** The sender's event name, e.g. GitHub's \\`push\\` or \\`issues\\`. */\n event: string\n /** One line about what happened, taken from the payload. */\n summary: string\n receivedAt: string\n}\n\n/** The live room the page watches; the Worker writes each new delivery to \\`latest\\` in it. */\nexport const DELIVERIES_ROOM = 'webhooks'\n\nexport function parseDelivery(raw: unknown): Delivery {\n if (typeof raw === 'object' && raw !== null) {\n const { id, event, summary, receivedAt } = raw as Record<string, unknown>\n if (\n typeof id === 'string' &&\n typeof event === 'string' &&\n typeof summary === 'string' &&\n typeof receivedAt === 'string'\n ) {\n return { id, event, summary, receivedAt }\n }\n }\n throw new Error('Malformed delivery')\n}\n\nexport function parseDeliveries(raw: unknown): Delivery[] {\n if (!Array.isArray(raw)) throw new Error('Expected a list of deliveries')\n return raw.map(parseDelivery)\n}\n\nexport function parseTestResult(raw: unknown): { status: number } {\n if (typeof raw === 'object' && raw !== null) {\n const { status } = raw as Record<string, unknown>\n if (typeof status === 'number') return { status }\n }\n throw new Error('Malformed reply')\n}\n`\n}\n\nfunction cfWebhooksWorkerTs(): string {\n return `import { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { verifyWebhook } from '@cascivo/app/guard'\nimport { writeRoom } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\nimport { DELIVERIES_ROOM, parseDelivery } from '../src/webhooks'\nimport type { Delivery } from '../src/webhooks'\n\nconst migrations = [\n {\n id: '0001_webhook_deliveries',\n statements: [\n \\`CREATE TABLE webhook_deliveries (\n id TEXT PRIMARY KEY,\n event TEXT NOT NULL,\n summary TEXT NOT NULL,\n received_at TEXT NOT NULL\n )\\`,\n ],\n },\n]\n\n/** Where GitHub posts; set it as the Payload URL of the repository's webhook. */\nexport const GITHUB_WEBHOOK_PATH = '/api/webhooks/github'\n\n/** A field of an object, or \\`undefined\\` for anything else. */\nconst field = (value: unknown, key: string): unknown =>\n typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined\n\n/**\n * One line about a GitHub event. The payload is the sender's JSON, read field by field: a\n * signature proves who sent it, not what shape it has.\n */\nfunction summarize(event: string, payload: unknown): string {\n const repo = field(field(payload, 'repository'), 'full_name')\n const action = field(payload, 'action')\n const where = typeof repo === 'string' ? repo : 'a repository'\n return \\`\\${event}\\${typeof action === 'string' ? \\` \\${action}\\` : ''} in \\${where}\\`.slice(0, 200)\n}\n\n/**\n * Verifies a GitHub delivery, stores it once (a retry has the same X-GitHub-Delivery id and is\n * ignored), and pushes it to the /webhooks page. Answers 202 quickly, as GitHub expects.\n */\nexport async function receiveGithub(\n request: Request,\n env: { DB: Database; ROOMS: RoomNamespace<unknown>; WEBHOOK_SECRET: string },\n): Promise<Response> {\n const { body, id } = await verifyWebhook(request, {\n scheme: 'github',\n secret: env.WEBHOOK_SECRET,\n })\n const event = request.headers.get('x-github-event') ?? 'unknown'\n let payload: unknown\n try {\n payload = JSON.parse(body)\n } catch {\n return Response.json({ error: 'Expected a JSON payload' }, { status: 400 })\n }\n const delivery: Delivery = {\n id: id ?? crypto.randomUUID(),\n event: event.slice(0, 60),\n summary: summarize(event, payload),\n receivedAt: new Date().toISOString(),\n }\n await migrate(env.DB, migrations)\n const stored = await queryRows(\n env.DB,\n \\`INSERT INTO webhook_deliveries (id, event, summary, received_at) VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO NOTHING RETURNING id\\`,\n [delivery.id, delivery.event, delivery.summary, delivery.receivedAt],\n (row) => row,\n )\n // Only a first delivery reaches the page; a retry was stored already.\n if (stored.length > 0) await writeRoom(env.ROOMS, DELIVERIES_ROOM, 'latest', { ...delivery })\n return Response.json({ received: true }, { status: 202 })\n}\n\nexport async function listDeliveries(db: Database): Promise<Delivery[]> {\n await migrate(db, migrations)\n return queryRows(\n db,\n \\`SELECT id, event, summary, received_at AS receivedAt FROM webhook_deliveries\n ORDER BY received_at DESC LIMIT 50\\`,\n [],\n parseDelivery,\n )\n}\n\n/** A signed test delivery, run through the same path GitHub's would take. */\nexport async function sendTestDelivery(\n origin: string,\n env: { DB: Database; ROOMS: RoomNamespace<unknown>; WEBHOOK_SECRET: string },\n): Promise<{ status: number }> {\n const body = JSON.stringify({ zen: 'Keep it logically awesome.', hook_id: 1 })\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(env.WEBHOOK_SECRET),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)))\n const hex = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('')\n const response = await receiveGithub(\n new Request(new URL(GITHUB_WEBHOOK_PATH, origin), {\n method: 'POST',\n headers: {\n 'x-github-event': 'ping',\n 'x-github-delivery': crypto.randomUUID(),\n 'x-hub-signature-256': \\`sha256=\\${hex}\\`,\n },\n body,\n }),\n env,\n )\n return { status: response.status }\n}\n`\n}\n\nfunction cfWebhooksRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { connectRoom } from '@cascivo/app/sync'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Kbd,\n Text,\n computed,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { parseDelivery } from '../webhooks'\nimport type { Delivery } from '../webhooks'\n\nconst client = createClient(api)\nconst stored = signal<Delivery[]>([])\nconst failure = signal<string | null>(null)\n\n// The Worker writes each new delivery here; the browser may only watch.\nconst room = connectRoom('/api/webhooks/live')\nconst latest = room.signal<Delivery | null>('latest', null, (raw) =>\n raw === null ? null : parseDelivery(raw),\n)\n/** Stored deliveries, with the newest pushed one on top when it is not in the list yet. */\nconst deliveries = computed(() => {\n const pushed = latest.value\n return pushed && !stored.value.some((d) => d.id === pushed.id)\n ? [pushed, ...stored.value]\n : stored.value\n})\n\nasync function load(): Promise<void> {\n stored.value = await client.listDeliveries().catch(() => stored.peek())\n}\nvoid load()\n\nasync function sendTest(): Promise<void> {\n failure.value = null\n try {\n await client.sendTestDelivery()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'The test delivery failed'\n }\n}\n\nexport default function Webhooks() {\n useSignals()\n const url = \\`\\${location.origin}/api/webhooks/github\\`\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Webhooks</Heading>\n <Text muted>\n GitHub deliveries, checked against their signature, stored once each, and shown here as\n they arrive.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={2}>\n <Text>\n In the repository's settings, add a webhook with the payload URL <Kbd>{url}</Kbd>,\n content type application/json, and the secret you set as WEBHOOK_SECRET.\n </Text>\n <Flex direction=\"horizontal\">\n <Button variant=\"secondary\" onClick={() => void sendTest()}>\n Send a test delivery\n </Button>\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not delivered\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n </CardContent>\n </Card>\n {deliveries.value.length === 0 ? (\n <EmptyState\n title=\"No deliveries yet\"\n description=\"Send a test delivery, or push to the repository.\"\n />\n ) : (\n <Flex gap={2} role=\"log\" aria-label=\"Deliveries\">\n {deliveries.value.map((delivery) => (\n <Flex key={delivery.id} direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant=\"secondary\">{delivery.event}</Badge>\n <Text>{delivery.summary}</Text>\n <Text size=\"sm\" muted>\n {new Date(delivery.receivedAt).toLocaleTimeString()}\n </Text>\n </Flex>\n ))}\n </Flex>\n )}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example checkout`: Stripe Checkout, its webhook, an order pushed live, a receipt --- */\n\nfunction cfCheckoutTs(opts: ScaffoldOptions): string {\n return `/**\n * What /checkout sells and the orders it makes, shared by the Worker (which creates them and\n * hears from Stripe) and the pages (which show them).\n */\n\n/** The seller, as the receipt names it. */\nexport const SHOP_NAME = '${brandName(opts.name).replace(/'/g, \"\\\\'\")}'\n\n/**\n * What you sell. The Worker sends this to Stripe, so a browser cannot change the price; the\n * page only displays it.\n */\nexport const PRODUCT = {\n name: 'Sticker pack',\n description: 'Twelve vinyl stickers of your favourite components, shipped worldwide.',\n /** In the currency's smallest unit: 900 is €9.00. */\n amount: 900,\n /** Three-letter ISO code, lowercase. */\n currency: 'eur',\n}\n\n/**\n * \\`pending\\` until Stripe confirms the payment. \\`failed\\` and \\`expired\\` are final. A paid\n * order becomes \\`refunded\\` when all of it is refunded, and \\`disputed\\` while a chargeback is\n * open (back to \\`paid\\` if it is won).\n */\nexport type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'disputed'\n\nexport interface Order {\n id: string\n status: OrderStatus\n /** What Stripe charged, in the currency's smallest unit. */\n amount: number\n currency: string\n /** Refunded so far, in the same unit: part of a paid order, or all of a refunded one. */\n refundedAmount: number\n createdAt: string\n paidAt: string | null\n}\n\n/** An order id: a UUID the Worker made. */\nexport const ORDER_ID = /^[0-9a-f-]{36}$/\n\n/** The room the Worker pushes an order's changes to; its page watches it. */\nexport const orderRoom = (id: string) => \\`order-\\${id}\\`\n\n/** A price in the currency's smallest unit, for people: 900 eur is €9.00, 900 jpy is ¥900. */\nexport function formatPrice(amount: number, currency: string): string {\n const format = new Intl.NumberFormat(undefined, { style: 'currency', currency })\n const digits = format.resolvedOptions().maximumFractionDigits ?? 2\n return format.format(amount / 10 ** digits)\n}\n\nconst STATUSES = ['pending', 'paid', 'failed', 'expired', 'refunded', 'disputed']\n\nexport function parseOrder(raw: unknown): Order {\n if (typeof raw === 'object' && raw !== null) {\n const { id, status, amount, currency, refundedAmount, createdAt, paidAt } = raw as Record<\n string,\n unknown\n >\n if (\n typeof id === 'string' &&\n typeof status === 'string' &&\n STATUSES.includes(status) &&\n typeof amount === 'number' &&\n typeof currency === 'string' &&\n typeof refundedAmount === 'number' &&\n typeof createdAt === 'string' &&\n (paidAt === null || typeof paidAt === 'string')\n ) {\n return {\n id,\n // Checked against STATUSES just above.\n status: status as OrderStatus,\n amount,\n currency,\n refundedAmount,\n createdAt,\n paidAt,\n }\n }\n }\n throw new Error('Malformed order')\n}\n\nexport function parseCheckoutStarted(raw: unknown): { url: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { url } = raw as Record<string, unknown>\n if (typeof url === 'string' && url.startsWith('https://')) return { url }\n }\n throw new Error('Malformed checkout')\n}\n`\n}\n\nfunction cfCheckoutWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { verifyWebhook } from '@cascivo/app/guard'\nimport { StripeError, createStripe, parseStripeEvent } from '@cascivo/app/stripe'\nimport type {\n Charge,\n CheckoutEventType,\n CheckoutSession,\n Dispute,\n DisputeEventType,\n Invoice,\n Stripe,\n} from '@cascivo/app/stripe'\nimport { writeRoom } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\nimport { Receipt, receiptSubject, renderEmail } from '@cascivo/email'\nimport { createElement } from 'react'\nimport { ORDER_ID, PRODUCT, SHOP_NAME, formatPrice, orderRoom, parseOrder } from '../src/checkout'\nimport type { Order, OrderStatus } from '../src/checkout'\n\nconst migrations = [\n {\n id: '0001_orders',\n statements: [\n \\`CREATE TABLE orders (\n id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL UNIQUE,\n status TEXT NOT NULL,\n amount INTEGER NOT NULL,\n currency TEXT NOT NULL,\n email TEXT,\n created_at TEXT NOT NULL,\n paid_at TEXT,\n payment_intent_id TEXT,\n refunded_amount INTEGER NOT NULL DEFAULT 0,\n dispute_status TEXT\n )\\`,\n // Refund and dispute events name the payment, not the session.\n 'CREATE INDEX orders_payment_intent ON orders (payment_intent_id)',\n ],\n },\n]\n\n/** Where Stripe posts; add it as an endpoint in the Stripe dashboard (README). */\nexport const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'\n\n/** What sending a receipt needs of the Email Service binding (\\`send_email\\`). */\nexport interface ReceiptSender {\n send(message: {\n from: string\n to: string\n subject: string\n text: string\n html: string\n }): Promise<unknown>\n}\n\nexport interface CheckoutEnv {\n DB: Database\n ROOMS: RoomNamespace<unknown>\n EMAIL: ReceiptSender\n RECEIPT_FROM: string\n STRIPE_SECRET_KEY?: string\n STRIPE_WEBHOOK_SECRET?: string\n}\n\nconst COLUMNS =\n 'id, status, amount, currency, refunded_amount AS refundedAmount, created_at AS createdAt, paid_at AS paidAt'\n\nexport function stripeOf(env: { STRIPE_SECRET_KEY?: string }): Stripe {\n if (!env.STRIPE_SECRET_KEY) {\n throw new HttpError(\n 503,\n 'Set STRIPE_SECRET_KEY to a test key from the Stripe dashboard (README)',\n )\n }\n return createStripe(env.STRIPE_SECRET_KEY)\n}\n\n/** Stripe's refusal, for the page: its message in vite dev, a pointer to the log deployed. */\nexport function refused(error: unknown): never {\n if (!(error instanceof StripeError)) throw error\n console.error('[checkout] Stripe refused:', error.status, error.code, error.message)\n throw new HttpError(\n 502,\n import.meta.env.DEV\n ? \\`Stripe: \\${error.message}\\`\n : 'The payment provider refused the request (see the Worker log)',\n )\n}\n\n/**\n * Creates a Stripe Checkout Session for PRODUCT and records the order as pending. The order id\n * is also the idempotency key, so a retried request cannot open a second session.\n */\nexport async function startCheckout(env: CheckoutEnv, origin: string): Promise<{ url: string }> {\n const stripe = stripeOf(env)\n const id = crypto.randomUUID()\n let session: CheckoutSession\n try {\n session = await stripe.createCheckoutSession(\n {\n mode: 'payment',\n lineItems: [{ ...PRODUCT, quantity: 1 }],\n successUrl: \\`\\${origin}/checkout/\\${id}\\`,\n cancelUrl: \\`\\${origin}/checkout\\`,\n clientReferenceId: id,\n },\n { idempotencyKey: id },\n )\n } catch (error) {\n refused(error)\n }\n if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')\n await migrate(env.DB, migrations)\n await queryRows(\n env.DB,\n \\`INSERT INTO orders (id, session_id, status, amount, currency, created_at)\n VALUES (?, ?, 'pending', ?, ?, ?)\\`,\n [id, session.id, PRODUCT.amount, PRODUCT.currency, new Date().toISOString()],\n (row) => row,\n )\n return { url: session.url }\n}\n\n/** Where an event moves the order, or \\`null\\` when it does not move it. */\nfunction statusAfter(type: CheckoutEventType, session: CheckoutSession): OrderStatus | null {\n switch (type) {\n case 'checkout.session.completed':\n // \\`unpaid\\` here is a bank debit that has not settled: the async events decide it.\n return session.paymentStatus === 'unpaid' ? null : 'paid'\n case 'checkout.session.async_payment_succeeded':\n return 'paid'\n case 'checkout.session.async_payment_failed':\n return 'failed'\n case 'checkout.session.expired':\n return 'expired'\n }\n}\n\n/** The same decision from a session read back from Stripe, with no event to go by. */\nfunction statusOf(session: CheckoutSession): OrderStatus | null {\n if (session.status === 'complete' && session.paymentStatus !== 'unpaid') return 'paid'\n return session.status === 'expired' ? 'expired' : null\n}\n\n/**\n * Moves a pending order to its final status, once: a retried event, or the order page reading\n * the session before the webhook arrived, finds it settled and changes nothing. The order is\n * found by Stripe's session id, never by \\`client_reference_id\\`, which a buyer can set on a\n * Payment Link. Then the order's page hears about it, and a paid order gets its receipt.\n */\nasync function settle(\n env: CheckoutEnv,\n session: CheckoutSession,\n status: OrderStatus,\n origin: string,\n): Promise<void> {\n await migrate(env.DB, migrations)\n const [order] = await queryRows(\n env.DB,\n \\`UPDATE orders SET status = ?, paid_at = ?, amount = COALESCE(?, amount),\n currency = COALESCE(?, currency), email = ?, payment_intent_id = ?\n WHERE session_id = ? AND status = 'pending' RETURNING \\${COLUMNS}\\`,\n [\n status,\n status === 'paid' ? new Date().toISOString() : null,\n session.amountTotal,\n session.currency,\n session.customerEmail,\n session.paymentIntentId,\n session.id,\n ],\n parseOrder,\n )\n if (!order) return\n await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })\n if (order.status === 'paid' && session.customerEmail) {\n await sendReceipt(env, order, session.customerEmail, origin)\n }\n}\n\n/**\n * Emails a receipt rendered with @cascivo/email. \\`vite dev\\` renders it and logs it instead.\n * A receipt that cannot be sent is logged, not thrown: the payment is recorded either way, and\n * Stripe retrying the event would not send it again.\n */\nasync function sendReceipt(env: CheckoutEnv, order: Order, to: string, origin: string) {\n const total = formatPrice(order.amount, order.currency)\n const props = {\n productName: SHOP_NAME,\n orderId: order.id.slice(0, 8).toUpperCase(),\n items: [{ description: PRODUCT.name, amount: total }],\n total,\n invoiceHref: \\`\\${origin}/checkout/\\${order.id}\\`,\n }\n const message = renderEmail(createElement(Receipt, props), { subject: receiptSubject(props) })\n if (import.meta.env.DEV) {\n console.log(\\`[checkout] receipt for \\${to}: \"\\${message.subject}\" (\\${message.html.length} bytes)\\`)\n return\n }\n if (!env.RECEIPT_FROM) {\n console.warn('[checkout] no receipt sent: set RECEIPT_FROM in wrangler.jsonc')\n return\n }\n try {\n await env.EMAIL.send({\n from: env.RECEIPT_FROM,\n to,\n subject: message.subject,\n text: message.text,\n html: message.html,\n })\n } catch (error) {\n console.error('[checkout] receipt not sent:', error)\n }\n}\n\n/**\n * A refund, made in the Stripe dashboard or with \\`createRefund\\`. \\`charge.refunded\\` carries the\n * running total, so a retried or late event cannot count a refund twice. All of it refunded\n * makes the order \\`refunded\\`; part of it leaves it \\`paid\\`, with the amount shown.\n */\nasync function refundOrder(env: CheckoutEnv, charge: Charge): Promise<void> {\n if (!charge.paymentIntentId) return\n await migrate(env.DB, migrations)\n const [order] = await queryRows(\n env.DB,\n \\`UPDATE orders SET refunded_amount = MAX(refunded_amount, ?),\n status = CASE WHEN ? = 1 THEN 'refunded' ELSE status END\n WHERE payment_intent_id = ? AND status IN ('paid', 'refunded') RETURNING \\${COLUMNS}\\`,\n [charge.amountRefunded, charge.refunded ? 1 : 0, charge.paymentIntentId],\n parseOrder,\n )\n if (order) await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })\n}\n\n/**\n * A chargeback. While it is open the order is \\`disputed\\`: hold back anything not yet\n * delivered, and answer it with evidence in the Stripe dashboard before the deadline shown\n * there. Won, the order is \\`paid\\` again; lost, it stays \\`disputed\\`. The dispute's status is\n * stored, so a \\`created\\` event arriving after \\`closed\\` cannot reopen it.\n */\nasync function disputeOrder(\n env: CheckoutEnv,\n type: DisputeEventType,\n dispute: Dispute,\n): Promise<void> {\n if (!dispute.paymentIntentId) return\n await migrate(env.DB, migrations)\n const [order] =\n type === 'charge.dispute.created'\n ? await queryRows(\n env.DB,\n \\`UPDATE orders SET status = 'disputed', dispute_status = ?\n WHERE payment_intent_id = ? AND status = 'paid' AND dispute_status IS NULL\n RETURNING \\${COLUMNS}\\`,\n [dispute.status, dispute.paymentIntentId],\n parseOrder,\n )\n : await queryRows(\n env.DB,\n \\`UPDATE orders SET dispute_status = ?,\n status = CASE WHEN ? = 'won' THEN 'paid' ELSE 'disputed' END\n WHERE payment_intent_id = ? AND status IN ('paid', 'disputed') RETURNING \\${COLUMNS}\\`,\n [dispute.status, dispute.status, dispute.paymentIntentId],\n parseOrder,\n )\n if (!order) return\n if (type === 'charge.dispute.created') {\n console.warn(\n \\`[checkout] order \\${order.id} is disputed (\\${dispute.reason ?? 'no reason given'})\\`,\n )\n }\n await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })\n}\n\n/** What the webhook hands to worker/billing.ts, when the app bills subscriptions. */\nexport interface BillingHooks {\n /** A subscription changed: store its current state. */\n subscription(subscriptionId: string): Promise<void>\n /** A renewal could not be charged: tell the customer. */\n paymentFailed(invoice: Invoice, origin: string): Promise<void>\n}\n\n/**\n * Stripe's webhook: verified against STRIPE_WEBHOOK_SECRET before anything in it is read,\n * then each Checkout event settles its order, and refunds and disputes update it. Subscription\n * and invoice events, and completed subscription checkouts, go to \\`billing\\` when the app bills\n * subscriptions (worker/billing.ts). Other events are acknowledged, so Stripe stops sending them.\n */\nexport async function receiveStripe(\n request: Request,\n env: CheckoutEnv,\n billing?: BillingHooks,\n): Promise<Response> {\n if (!env.STRIPE_WEBHOOK_SECRET) throw new HttpError(503, 'Set STRIPE_WEBHOOK_SECRET (README)')\n const { body } = await verifyWebhook(request, {\n scheme: 'stripe',\n secret: env.STRIPE_WEBHOOK_SECRET,\n })\n const event = parseStripeEvent(body)\n const origin = new URL(request.url).origin\n if (event.kind === 'subscription') await billing?.subscription(event.subscription.id)\n if (event.kind === 'invoice' && event.type === 'invoice.payment_failed') {\n await billing?.paymentFailed(event.invoice, origin)\n }\n if (event.kind === 'refund') await refundOrder(env, event.charge)\n if (event.kind === 'dispute') await disputeOrder(env, event.type, event.dispute)\n if (event.kind === 'checkout' && event.session.mode === 'subscription') {\n if (event.session.subscriptionId) await billing?.subscription(event.session.subscriptionId)\n } else if (event.kind === 'checkout') {\n const status = statusAfter(event.type, event.session)\n if (status) await settle(env, event.session, status, origin)\n }\n return Response.json({ received: true })\n}\n\nfunction parseStored(raw: unknown): { order: Order; sessionId: string } {\n const sessionId = typeof raw === 'object' && raw !== null ? Reflect.get(raw, 'sessionId') : null\n if (typeof sessionId !== 'string') throw new Error('Malformed order row')\n return { order: parseOrder(raw), sessionId }\n}\n\nasync function readOrder(env: CheckoutEnv, id: string) {\n await migrate(env.DB, migrations)\n const [row] = await queryRows(\n env.DB,\n \\`SELECT \\${COLUMNS}, session_id AS sessionId FROM orders WHERE id = ?\\`,\n [id],\n parseStored,\n )\n if (!row) throw new HttpError(404, 'No such order')\n return row\n}\n\n/**\n * An order, for its page. A pending one is checked with Stripe first, so the page is right\n * even when the webhook is late or not set up yet (as in \\`vite dev\\` without \\`stripe listen\\`).\n */\nexport async function getOrder(env: CheckoutEnv, id: string, origin: string): Promise<Order> {\n if (!ORDER_ID.test(id)) throw new HttpError(404, 'No such order')\n const { order, sessionId } = await readOrder(env, id)\n if (order.status !== 'pending' || !env.STRIPE_SECRET_KEY) return order\n try {\n const session = await stripeOf(env).retrieveCheckoutSession(sessionId)\n const status = statusOf(session)\n if (!status) return order\n await settle(env, session, status, origin)\n } catch (error) {\n // Stripe unreachable: show what is known; the webhook settles the order later.\n console.error('[checkout] could not read the session back:', error)\n return order\n }\n return (await readOrder(env, id)).order\n}\n`\n}\n\nfunction cfCheckoutRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { PRODUCT, formatPrice } from '../checkout'\n\nconst client = createClient(api)\nconst starting = signal(false)\nconst failure = signal<string | null>(null)\n\n/** Asks the Worker for a Stripe Checkout page and goes there: Stripe takes the card, not us. */\nasync function buy(): Promise<void> {\n starting.value = true\n failure.value = null\n try {\n const { url } = await client.startCheckout()\n location.assign(url)\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not start the checkout'\n starting.value = false\n }\n}\n\nexport default function Checkout() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Checkout</Heading>\n <Text muted>\n Paid on Stripe's hosted page. Stripe tells the Worker when the payment succeeds; the\n Worker records the order, updates its page and emails a receipt.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Flex gap={1}>\n <Heading level={2}>{PRODUCT.name}</Heading>\n <Text muted>{PRODUCT.description}</Text>\n </Flex>\n <Text size=\"lg\">{formatPrice(PRODUCT.amount, PRODUCT.currency)}</Text>\n <Flex direction=\"horizontal\">\n <Button loading={starting.value} onClick={() => void buy()}>\n Buy now\n </Button>\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"The checkout did not start\">\n {failure.value}\n </Alert>\n ) : null}\n <Text size=\"sm\" muted>\n In test mode, pay with the card 4242 4242 4242 4242, any future date and any CVC.\n </Text>\n </Flex>\n )\n}\n`\n}\n\nfunction cfOrderRouteTsx(): string {\n return `import type { RouteProps } from '@cascivo/app'\nimport { createClient } from '@cascivo/app/api'\nimport { connectRoom } from '@cascivo/app/sync'\nimport {\n Alert,\n Badge,\n EmptyState,\n Flex,\n Heading,\n Link,\n Spinner,\n Text,\n signal,\n useEffectPropSignal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../../api'\nimport { PRODUCT, formatPrice, parseOrder } from '../../checkout'\nimport type { Order } from '../../checkout'\n\nconst client = createClient(api)\n/** Orders seen, by id; \\`null\\` when the id has none. */\nconst orders = signal<Readonly<Record<string, Order | null>>>({})\n\n/** Keeps the newest word on an order: a final status is never replaced by \\`pending\\`. */\nfunction remember(id: string, order: Order | null): void {\n const known = orders.peek()[id]\n if (known && known.status !== 'pending' && order?.status === 'pending') return\n orders.value = { ...orders.peek(), [id]: order }\n}\n\nasync function load(id: string): Promise<void> {\n try {\n remember(id, await client.getOrder({ params: { id } }))\n } catch {\n remember(id, null)\n }\n}\n\n/** Watches the order's room, where the Worker pushes what Stripe reports. Returns the cleanup. */\nfunction watch(id: string): () => void {\n const room = connectRoom(\\`/api/orders/\\${id}/live\\`)\n const pushed = room.signal<Order | null>('order', null, (raw) =>\n raw === null ? null : parseOrder(raw),\n )\n const stop = pushed.signal.subscribe((order) => {\n if (order) remember(id, order)\n })\n return () => {\n stop()\n room.close()\n }\n}\n\nconst STATUS = {\n pending: { variant: 'warning', label: 'Waiting for Stripe' },\n paid: { variant: 'success', label: 'Paid' },\n failed: { variant: 'destructive', label: 'Payment failed' },\n expired: { variant: 'secondary', label: 'Expired' },\n refunded: { variant: 'secondary', label: 'Refunded' },\n disputed: { variant: 'destructive', label: 'Disputed' },\n} as const\n\n/** \\`/checkout/:order\\` — where Stripe sends the buyer back. It updates when Stripe confirms. */\nexport default function OrderPage({ params }: RouteProps<'/checkout/:order'>) {\n useSignals()\n const id = useEffectPropSignal(params.order)\n useSignalEffect(() => {\n void load(id.value)\n return watch(id.value)\n })\n const order = orders.value[params.order]\n\n if (order === undefined) return <Spinner label=\"Loading\" />\n if (order === null) {\n return <EmptyState title=\"No such order\" description=\"Check the link in your receipt.\" />\n }\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Your order</Heading>\n <Text muted>\n {PRODUCT.name} · {formatPrice(order.amount, order.currency)}\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={STATUS[order.status].variant}>{STATUS[order.status].label}</Badge>\n <Text size=\"sm\" muted>\n Ordered {new Date(order.createdAt).toLocaleString()}\n </Text>\n </Flex>\n {order.status === 'pending' ? (\n <Alert variant=\"info\" title=\"Confirming your payment\">\n This page updates by itself when Stripe confirms. A bank payment can take a few days.\n </Alert>\n ) : null}\n {order.status === 'paid' && order.refundedAmount === 0 ? (\n <Alert variant=\"success\" title=\"Thank you\">\n Your payment went through. A receipt is on its way to your inbox.\n </Alert>\n ) : null}\n {order.status === 'paid' && order.refundedAmount > 0 ? (\n <Alert variant=\"info\" title=\"Partly refunded\">\n {formatPrice(order.refundedAmount, order.currency)} of this order was refunded to the card\n that paid it.\n </Alert>\n ) : null}\n {order.status === 'refunded' ? (\n <Alert variant=\"info\" title=\"Refunded\">\n The full amount went back to the card that paid it. It can take a few days to show.\n </Alert>\n ) : null}\n {order.status === 'disputed' ? (\n <Alert variant=\"warning\" title=\"This payment is disputed\">\n The card's bank is reviewing a chargeback. The order is on hold until it is decided.\n </Alert>\n ) : null}\n {order.status === 'failed' ? (\n <Alert variant=\"destructive\" title=\"The payment failed\">\n Nothing was charged. <Link href=\"/checkout\">Try again</Link>\n </Alert>\n ) : null}\n {order.status === 'expired' ? (\n <Alert variant=\"warning\" title=\"This checkout expired\">\n It was not paid in time. <Link href=\"/checkout\">Start again</Link>\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example newsletter`: double opt-in, sent through Amazon SES on a Queue --- */\n\nfunction cfNewsletterTs(): string {\n return `/**\n * The newsletter's wire types, shared by the Worker (worker/newsletter.ts) and its pages.\n * Every payload is parsed on arrival: the network is not trusted because the types match.\n */\n\n/** Longest subject and body the composer accepts. */\nexport const MAX_SUBJECT = 200\nexport const MAX_BODY = 50_000\n\nexport interface SubscriberCounts {\n /** Signed up, has not clicked the confirmation link yet. */\n pending: number\n subscribed: number\n unsubscribed: number\n /** Bounced for good or complained: never mailed again (worker/newsletter.ts). */\n suppressed: number\n}\n\n/** One sent issue and how far its sending has got. */\nexport interface Issue {\n id: string\n subject: string\n createdAt: string\n /** Subscribers it was queued for. */\n total: number\n sent: number\n failed: number\n}\n\nexport interface Overview {\n subscribers: SubscriberCounts\n issues: Issue[]\n}\n\n/** The room the Worker pushes an issue's progress to; the composer watches it. */\nexport const issueRoom = (id: string) => \\`issue-\\${id}\\`\n\n/** An issue id: a UUID the Worker made. */\nexport const ISSUE_ID = /^[0-9a-f-]{36}$/\n\nconst isRecord = (raw: unknown): raw is Record<string, unknown> =>\n typeof raw === 'object' && raw !== null\n\nfunction text(raw: Record<string, unknown>, key: string, max: number): string {\n const value = raw[key]\n if (typeof value !== 'string' || value.trim() === '' || value.length > max) {\n throw new Error(\\`Expected \\${key}: some text, at most \\${max} characters\\`)\n }\n return value\n}\n\nexport function parseEmailInput(raw: unknown): { email: string } {\n if (!isRecord(raw)) throw new Error('Expected { email }')\n return { email: text(raw, 'email', 254) }\n}\n\nexport function parseTokenInput(raw: unknown): { token: string } {\n if (!isRecord(raw)) throw new Error('Expected { token }')\n return { token: text(raw, 'token', 100) }\n}\n\n/** The composer's key: NEWSLETTER_KEY, which only the sender knows. */\nexport function parseKeyInput(raw: unknown): { key: string } {\n if (!isRecord(raw)) throw new Error('Expected { key }')\n return { key: text(raw, 'key', 200) }\n}\n\nexport interface IssueInput {\n key: string\n subject: string\n /** The body in Markdown, rendered by @cascivo/email's Markdown. */\n body: string\n}\n\nexport function parseIssueInput(raw: unknown): IssueInput {\n if (!isRecord(raw)) throw new Error('Expected { key, subject, body }')\n return {\n key: text(raw, 'key', 200),\n subject: text(raw, 'subject', MAX_SUBJECT),\n body: text(raw, 'body', MAX_BODY),\n }\n}\n\nexport function parseSubscribed(raw: unknown): { devLink: string | null } {\n if (isRecord(raw) && (raw['devLink'] === null || typeof raw['devLink'] === 'string')) {\n return { devLink: raw['devLink'] }\n }\n throw new Error('Malformed reply')\n}\n\nexport function parseConfirmed(raw: unknown): { email: string } {\n if (isRecord(raw) && typeof raw['email'] === 'string') return { email: raw['email'] }\n throw new Error('Malformed reply')\n}\n\nexport function parsePreview(raw: unknown): { html: string; bytes: number } {\n if (isRecord(raw) && typeof raw['html'] === 'string' && typeof raw['bytes'] === 'number') {\n return { html: raw['html'], bytes: raw['bytes'] }\n }\n throw new Error('Malformed preview')\n}\n\nconst count = (raw: Record<string, unknown>, key: string): number => {\n const value = raw[key]\n if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n throw new Error(\\`Expected a count for \\${key}\\`)\n }\n return value\n}\n\nexport function parseIssue(raw: unknown): Issue {\n if (isRecord(raw) && typeof raw['id'] === 'string' && typeof raw['createdAt'] === 'string') {\n return {\n id: raw['id'],\n subject: text(raw, 'subject', MAX_SUBJECT),\n createdAt: raw['createdAt'],\n total: count(raw, 'total'),\n sent: count(raw, 'sent'),\n failed: count(raw, 'failed'),\n }\n }\n throw new Error('Malformed issue')\n}\n\nexport function parseOverview(raw: unknown): Overview {\n if (isRecord(raw) && isRecord(raw['subscribers']) && Array.isArray(raw['issues'])) {\n const s = raw['subscribers']\n return {\n subscribers: {\n pending: count(s, 'pending'),\n subscribed: count(s, 'subscribed'),\n unsubscribed: count(s, 'unsubscribed'),\n suppressed: count(s, 'suppressed'),\n },\n issues: raw['issues'].map(parseIssue),\n }\n }\n throw new Error('Malformed overview')\n}\n`\n}\n\nfunction cfNewsletterEmailTs(opts: ScaffoldOptions): string {\n return `import {\n Body,\n Button,\n Container,\n Footer,\n Head,\n Heading,\n Html,\n Link,\n Markdown,\n Preview,\n Section,\n Text,\n renderEmail,\n} from '@cascivo/email'\nimport type { RenderResult } from '@cascivo/email'\nimport { createElement as h } from 'react'\n\n/** Who the newsletter is from, as its emails say. */\nexport const NEWSLETTER_NAME = '${brandName(opts.name).replace(/'/g, \"\\\\'\")}'\n\n/** Stands in for each reader's unsubscribe token; worker/newsletter.ts swaps it per message. */\nexport const TOKEN_SLOT = '__UNSUBSCRIBE_TOKEN__'\n\n/**\n * An issue, rendered once with @cascivo/email: the body is Markdown, drawn through the email\n * primitives (raw HTML in it stays literal text). The footer carries the unsubscribe link,\n * with TOKEN_SLOT where each reader's token goes.\n */\nexport function renderIssue(subject: string, body: string, origin: string): RenderResult {\n const unsubscribe = \\`\\${origin}/newsletter/unsubscribe?token=\\${TOKEN_SLOT}\\`\n return renderEmail(\n h(\n Html,\n null,\n h(Head, { title: subject }),\n h(\n Body,\n null,\n h(Preview, null, subject),\n h(\n Container,\n null,\n h(Section, { padding: 32 }, h(Heading, { level: 1 }, subject), h(Markdown, null, body)),\n h(\n Footer,\n null,\n \\`You get this because you subscribed to \\${NEWSLETTER_NAME}. \\`,\n h(Link, { href: unsubscribe }, 'Unsubscribe'),\n ),\n ),\n ),\n ),\n { subject },\n )\n}\n\n/** The double opt-in email: nobody is mailed an issue until they open this link. */\nexport function renderConfirmation(confirmUrl: string): RenderResult {\n const subject = \\`Confirm your subscription to \\${NEWSLETTER_NAME}\\`\n return renderEmail(\n h(\n Html,\n null,\n h(Head, { title: subject }),\n h(\n Body,\n null,\n h(Preview, null, 'One click and you are on the list.'),\n h(\n Container,\n null,\n h(\n Section,\n { padding: 32 },\n h(Heading, { level: 1 }, 'Confirm your subscription'),\n h(Text, null, \\`Someone, hopefully you, asked to get \\${NEWSLETTER_NAME} by email.\\`),\n h(Button, { href: confirmUrl }, 'Yes, subscribe me'),\n h(\n Text,\n { variant: 'muted', size: '14px' },\n 'The link works for 24 hours. If you did not ask, ignore this email: you will not hear from us again.',\n ),\n ),\n ),\n ),\n ),\n { subject },\n )\n}\n`\n}\n\nfunction cfNewsletterWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { normalizeEmail } from '@cascivo/app/auth-server'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { SesError, createSes, handleSns, parseSesNotification } from '@cascivo/app/ses'\nimport { writeRoom } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\nimport { assertSendable, sendEmail } from '@cascivo/email'\nimport type { RenderResult } from '@cascivo/email'\nimport { ISSUE_ID, issueRoom, parseIssue } from '../src/newsletter'\nimport type { Issue, IssueInput, Overview, SubscriberCounts } from '../src/newsletter'\nimport { TOKEN_SLOT, renderConfirmation, renderIssue } from './newsletter-email'\n\nconst migrations = [\n {\n id: '0001_newsletter',\n statements: [\n \\`CREATE TABLE subscribers (\n email TEXT PRIMARY KEY,\n status TEXT NOT NULL,\n confirm_hash TEXT,\n confirm_expires INTEGER,\n unsubscribe_token TEXT NOT NULL UNIQUE,\n reason TEXT,\n created_at TEXT NOT NULL,\n confirmed_at TEXT\n )\\`,\n \\`CREATE TABLE issues (\n id TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n body TEXT NOT NULL,\n total INTEGER NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n // One row per reader per issue, written as each send finishes: a retried queue message\n // skips whoever already has one, so nobody gets an issue twice.\n \\`CREATE TABLE deliveries (\n issue_id TEXT NOT NULL,\n email TEXT NOT NULL,\n status TEXT NOT NULL,\n detail TEXT,\n at TEXT NOT NULL,\n PRIMARY KEY (issue_id, email)\n )\\`,\n ],\n },\n]\n\n/** Readers per queue message. Each message is sent in one go, one reader after another. */\nconst CHUNK = 25\n/** A confirmation link works for a day. */\nconst CONFIRM_TTL_MS = 24 * 60 * 60 * 1000\n/** A pending address gets another link at most this often, so the form cannot flood an inbox. */\nconst RESEND_AFTER_MS = 10 * 60 * 1000\n\nexport interface NewsletterMessage {\n issueId: string\n emails: string[]\n /** The app's origin, for the unsubscribe links: a queue consumer has no request. */\n origin: string\n}\n\n/** What sending needs of the NEWSLETTER queue binding. */\nexport interface NewsletterQueue {\n sendBatch(messages: Iterable<{ body: NewsletterMessage }>): Promise<void>\n}\n\n/** The slice of a Queue consumer's batch \\`deliver\\` reads. */\nexport interface NewsletterBatch {\n readonly messages: readonly { readonly body: unknown }[]\n}\n\nexport interface NewsletterEnv {\n DB: Database\n ROOMS: RoomNamespace<unknown>\n NEWSLETTER: NewsletterQueue\n /** Set in wrangler.jsonc: the SES region, the From address, the SNS topic for feedback. */\n AWS_REGION: string\n NEWSLETTER_FROM: string\n SNS_TOPIC_ARN: string\n /** Secrets: \\`wrangler secret put\\` (.dev.vars locally). Unset until you add them. */\n AWS_ACCESS_KEY_ID?: string\n AWS_SECRET_ACCESS_KEY?: string\n NEWSLETTER_KEY?: string\n}\n\nconst encoder = new TextEncoder()\n\nconst cell = (raw: unknown, key: string): unknown =>\n typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined\n\n/** A string column of a D1 row. Rows are read like any payload: checked, not cast. */\nfunction column(raw: unknown, key: string): string {\n const value = cell(raw, key)\n if (typeof value !== 'string') throw new Error(\\`Expected a string in column \\${key}\\`)\n return value\n}\n\nasync function sha256(value: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))\n return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')\n}\n\n/** 32 random bytes, URL-safe: a confirmation or unsubscribe token. */\nfunction token(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32))\n return btoa(String.fromCharCode(...bytes))\n .replace(/\\\\+/g, '-')\n .replace(/\\\\//g, '_')\n .replace(/=+$/, '')\n}\n\n/** Refuses a request without NEWSLETTER_KEY. Compares digests, so timing says nothing. */\nasync function requireKey(env: NewsletterEnv, key: string): Promise<void> {\n if (!env.NEWSLETTER_KEY) throw new HttpError(503, 'Set NEWSLETTER_KEY (README)')\n if ((await sha256(key)) !== (await sha256(env.NEWSLETTER_KEY))) {\n throw new HttpError(403, 'Wrong newsletter key')\n }\n}\n\ntype Send = (\n to: string,\n message: RenderResult,\n headers?: Record<string, string>,\n) => Promise<'sent' | 'logged'>\n\n/**\n * Sends one email through SES, or, in \\`vite dev\\` without AWS credentials, logs it instead.\n * Deployed without them, it refuses with what to set. The SES client is an \\`EmailSender\\`, so\n * \\`sendEmail\\` checks the message (subject, preheader, text part, size) and every header first.\n */\nfunction mailer(env: NewsletterEnv): Send {\n const configured =\n env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_REGION && env.NEWSLETTER_FROM\n if (!configured) {\n if (!import.meta.env.DEV) {\n throw new HttpError(\n 503,\n 'Set AWS_REGION and NEWSLETTER_FROM in wrangler.jsonc, and the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets (README)',\n )\n }\n return async (to, message) => {\n console.log(\\`[newsletter] not sent (no SES credentials) to \\${to}: \"\\${message.subject}\"\\`)\n return 'logged'\n }\n }\n const ses = createSes({\n region: env.AWS_REGION,\n accessKeyId: env.AWS_ACCESS_KEY_ID!,\n secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,\n })\n return async (to, message, headers) => {\n await sendEmail(ses, message, {\n from: env.NEWSLETTER_FROM,\n to,\n ...(headers ? { headers } : {}),\n })\n return 'sent'\n }\n}\n\n/**\n * Signs someone up: a pending subscriber and a confirmation email. The answer is the same\n * whoever asks, so the form does not tell strangers who is on the list. \\`vite dev\\` without\n * SES also returns the confirmation link, to open instead of an email.\n */\nexport async function subscribe(\n env: NewsletterEnv,\n rawEmail: string,\n origin: string,\n): Promise<{ devLink: string | null }> {\n const email = normalizeEmail(rawEmail)\n const send = mailer(env)\n await migrate(env.DB, migrations)\n const confirm = token()\n const now = new Date()\n const expires = now.getTime() + CONFIRM_TTL_MS\n // A new or unsubscribed address gets a link; a pending one gets a fresh link at most every\n // ten minutes. A subscribed or suppressed one is left alone, and nothing is sent.\n const [row] = await queryRows(\n env.DB,\n \\`INSERT INTO subscribers\n (email, status, confirm_hash, confirm_expires, unsubscribe_token, created_at)\n VALUES (?, 'pending', ?, ?, ?, ?)\n ON CONFLICT (email) DO UPDATE SET\n status = 'pending', reason = NULL,\n confirm_hash = excluded.confirm_hash, confirm_expires = excluded.confirm_expires\n WHERE subscribers.status = 'unsubscribed'\n OR (subscribers.status = 'pending' AND subscribers.confirm_expires < ?)\n RETURNING email\\`,\n [email, await sha256(confirm), expires, token(), now.toISOString(), expires - RESEND_AFTER_MS],\n (raw) => column(raw, 'email'),\n )\n if (!row) return { devLink: null }\n const link = \\`\\${origin}/newsletter/confirm?token=\\${confirm}\\`\n const message = renderConfirmation(link)\n let outcome: 'sent' | 'logged'\n try {\n outcome = await send(email, message)\n } catch (error) {\n if (!(error instanceof SesError)) throw error\n console.error('[newsletter] confirmation not sent:', error.code, error.message)\n // Let the reader try again at once rather than wait out the resend interval.\n await queryRows(\n env.DB,\n \"UPDATE subscribers SET confirm_expires = 0 WHERE email = ? AND status = 'pending' RETURNING email\",\n [email],\n (raw) => raw,\n )\n throw new HttpError(502, 'Could not send the confirmation email. Try again in a moment.')\n }\n return { devLink: outcome === 'logged' ? link : null }\n}\n\n/** Opens a confirmation link: the subscriber is on the list from now on. */\nexport async function confirm(env: NewsletterEnv, rawToken: string): Promise<{ email: string }> {\n await migrate(env.DB, migrations)\n const [row] = await queryRows(\n env.DB,\n \\`UPDATE subscribers SET status = 'subscribed', confirm_hash = NULL, confirmed_at = ?\n WHERE confirm_hash = ? AND confirm_expires > ? AND status = 'pending' RETURNING email\\`,\n [new Date().toISOString(), await sha256(rawToken), Date.now()],\n (raw) => ({ email: column(raw, 'email') }),\n )\n if (!row) throw new HttpError(400, 'This link has expired or was used already. Sign up again.')\n return row\n}\n\n/**\n * \\`POST /api/newsletter/unsubscribe?token=…\\`: the page's button, and the one-click\n * unsubscribe mail clients send (RFC 8058) from the List-Unsubscribe header. Always 200, so a\n * token says nothing about who it belongs to, and a second click is not an error.\n */\nexport async function unsubscribe(request: Request, env: NewsletterEnv): Promise<Response> {\n const unsubscribeToken = new URL(request.url).searchParams.get('token') ?? ''\n await migrate(env.DB, migrations)\n await queryRows(\n env.DB,\n \\`UPDATE subscribers SET status = 'unsubscribed', reason = 'unsubscribed'\n WHERE unsubscribe_token = ? AND status IN ('pending', 'subscribed') RETURNING email\\`,\n [unsubscribeToken],\n (raw) => raw,\n )\n return Response.json({ unsubscribed: true })\n}\n\nconst ISSUE_COLUMNS = \\`issues.id, issues.subject, issues.created_at AS createdAt, issues.total,\n (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status != 'failed') AS sent,\n (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status = 'failed') AS failed\\`\n\nasync function readIssue(db: Database, id: string): Promise<Issue> {\n const [issue] = await queryRows(\n db,\n \\`SELECT \\${ISSUE_COLUMNS} FROM issues WHERE id = ?\\`,\n [id],\n parseIssue,\n )\n if (!issue) throw new HttpError(404, 'No such issue')\n return issue\n}\n\n/** Subscriber counts and the last 20 issues, for the composer. */\nexport async function overview(env: NewsletterEnv, key: string): Promise<Overview> {\n await requireKey(env, key)\n await migrate(env.DB, migrations)\n const subscribers: SubscriberCounts = {\n pending: 0,\n subscribed: 0,\n unsubscribed: 0,\n suppressed: 0,\n }\n const counts = await queryRows(\n env.DB,\n 'SELECT status, COUNT(*) AS n FROM subscribers GROUP BY status',\n [],\n (raw) => ({ status: column(raw, 'status'), n: Number(cell(raw, 'n')) }),\n )\n for (const { status, n } of counts) {\n if (status === 'pending' || status === 'subscribed') subscribers[status] = n\n if (status === 'unsubscribed' || status === 'suppressed') subscribers[status] = n\n }\n const issues = await queryRows(\n env.DB,\n \\`SELECT \\${ISSUE_COLUMNS} FROM issues ORDER BY created_at DESC LIMIT 20\\`,\n [],\n parseIssue,\n )\n return { subscribers, issues }\n}\n\n/** The issue as readers will get it, for the composer's preview. */\nexport async function preview(\n env: NewsletterEnv,\n input: IssueInput,\n origin: string,\n): Promise<{ html: string; bytes: number }> {\n await requireKey(env, input.key)\n const { html, stats } = renderIssue(input.subject, input.body, origin)\n return { html, bytes: stats.bytes }\n}\n\n/**\n * Sends an issue to every confirmed subscriber: the issue is stored, and its readers go onto\n * the NEWSLETTER queue in chunks, which \\`deliver\\` sends at the queue's pace.\n */\nexport async function sendIssue(\n env: NewsletterEnv,\n input: IssueInput,\n origin: string,\n): Promise<Issue> {\n await requireKey(env, input.key)\n mailer(env) // Deployed without SES, refuse now rather than fail in the queue.\n try {\n // An issue too large to send (Gmail clips it) is refused here, not retried in the queue.\n assertSendable(renderIssue(input.subject, input.body, origin))\n } catch (error) {\n throw new HttpError(400, error instanceof Error ? error.message : String(error))\n }\n await migrate(env.DB, migrations)\n const readers = await queryRows(\n env.DB,\n \"SELECT email FROM subscribers WHERE status = 'subscribed' ORDER BY email\",\n [],\n (raw) => column(raw, 'email'),\n )\n const id = crypto.randomUUID()\n await queryRows(\n env.DB,\n 'INSERT INTO issues (id, subject, body, total, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id',\n [id, input.subject, input.body, readers.length, new Date().toISOString()],\n (raw) => raw,\n )\n const messages: { body: NewsletterMessage }[] = []\n for (let i = 0; i < readers.length; i += CHUNK) {\n messages.push({ body: { issueId: id, emails: readers.slice(i, i + CHUNK), origin } })\n }\n // sendBatch takes at most 100 messages a call.\n for (let i = 0; i < messages.length; i += 100) {\n await env.NEWSLETTER.sendBatch(messages.slice(i, i + 100))\n }\n const issue = await readIssue(env.DB, id)\n await writeRoom(env.ROOMS, issueRoom(id), 'issue', { ...issue })\n return issue\n}\n\nfunction parseMessage(raw: unknown): NewsletterMessage {\n if (typeof raw === 'object' && raw !== null) {\n const { issueId, emails, origin } = raw as Record<string, unknown>\n if (\n typeof issueId === 'string' &&\n ISSUE_ID.test(issueId) &&\n Array.isArray(emails) &&\n emails.every((e) => typeof e === 'string') &&\n typeof origin === 'string'\n ) {\n return { issueId, emails: emails as string[], origin }\n }\n }\n throw new Error('Malformed newsletter message')\n}\n\n/**\n * The NEWSLETTER queue's consumer: sends each reader in the message their copy, with their\n * own unsubscribe link and the one-click headers bulk senders need. A reader who left or was\n * suppressed since the issue was queued is skipped. SES throttling or a server error throws,\n * and the queue retries the message: whoever was sent already has a delivery row and is not\n * sent again. A refusal for one address (an invalid one, say) is recorded and the rest go on.\n */\nexport async function deliver(env: NewsletterEnv, batch: NewsletterBatch): Promise<void> {\n await migrate(env.DB, migrations)\n const send = mailer(env)\n for (const { body } of batch.messages) {\n const { issueId, emails, origin } = parseMessage(body)\n const [issue] = await queryRows(\n env.DB,\n 'SELECT subject, body FROM issues WHERE id = ?',\n [issueId],\n (raw) => ({ subject: column(raw, 'subject'), body: column(raw, 'body') }),\n )\n if (!issue) continue\n const rendered = renderIssue(issue.subject, issue.body, origin)\n const placeholders = emails.map(() => '?').join(', ')\n const readers = await queryRows(\n env.DB,\n \\`SELECT s.email, s.unsubscribe_token AS token FROM subscribers s\n WHERE s.email IN (\\${placeholders}) AND s.status = 'subscribed'\n AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.issue_id = ? AND d.email = s.email)\\`,\n [...emails, issueId],\n (raw) => ({ email: column(raw, 'email'), token: column(raw, 'token') }),\n )\n for (const { email, token: readerToken } of readers) {\n const unsubscribe = \\`\\${origin}/api/newsletter/unsubscribe?token=\\${readerToken}\\`\n let status: string\n let detail: string | null = null\n try {\n status = await send(\n email,\n {\n ...rendered,\n html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),\n text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),\n },\n {\n 'List-Unsubscribe': \\`<\\${unsubscribe}>\\`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n },\n )\n } catch (error) {\n if (!(error instanceof SesError) || error.retryable) throw error\n status = 'failed'\n detail = \\`\\${error.code ?? error.status}: \\${error.message}\\`.slice(0, 300)\n }\n await queryRows(\n env.DB,\n \\`INSERT INTO deliveries (issue_id, email, status, detail, at) VALUES (?, ?, ?, ?, ?)\n ON CONFLICT DO NOTHING RETURNING email\\`,\n [issueId, email, status, detail, new Date().toISOString()],\n (raw) => raw,\n )\n }\n const progress = await readIssue(env.DB, issueId)\n await writeRoom(env.ROOMS, issueRoom(issueId), 'issue', { ...progress })\n }\n}\n\n/**\n * SES's feedback, delivered by SNS: a permanent bounce or a complaint suppresses the address\n * for good. Sending to them again is what gets an SES account put under review.\n */\nexport async function receiveFeedback(request: Request, env: NewsletterEnv): Promise<Response> {\n if (!env.SNS_TOPIC_ARN) throw new HttpError(503, 'Set SNS_TOPIC_ARN in wrangler.jsonc (README)')\n return handleSns(request, {\n topicArn: env.SNS_TOPIC_ARN,\n onNotification: async ({ message }) => {\n const event = parseSesNotification(message)\n const suppress =\n event.kind === 'complaint' || (event.kind === 'bounce' && event.bounceType === 'Permanent')\n if (!suppress || event.recipients.length === 0) return\n await migrate(env.DB, migrations)\n // D1 binds at most 100 parameters; one bounce rarely names more than a few addresses.\n const recipients = event.recipients\n .slice(0, 90)\n .map((address) => address.trim().toLowerCase())\n await queryRows(\n env.DB,\n \\`UPDATE subscribers SET status = 'suppressed', reason = ?\n WHERE email IN (\\${recipients.map(() => '?').join(', ')}) RETURNING email\\`,\n [event.kind, ...recipients],\n (raw) => raw,\n )\n },\n })\n}\n`\n}\n\nfunction cfNewsletterRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Input,\n Link,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport type { FormEvent } from 'react'\nimport { api } from '../api'\n\nconst client = createClient(api)\nconst sentTo = signal<string | null>(null)\n/** Set only in \\`vite dev\\` without SES: the confirmation link, to open instead of an email. */\nconst devLink = signal<string | null>(null)\nconst failure = signal<string | null>(null)\nconst sending = signal(false)\n\nasync function signUp(event: FormEvent<HTMLFormElement>): Promise<void> {\n event.preventDefault()\n const email = new FormData(event.currentTarget).get('email')\n if (typeof email !== 'string') return\n failure.value = null\n sending.value = true\n try {\n const { devLink: link } = await client.subscribe({ body: { email } })\n sentTo.value = email\n devLink.value = link\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not sign you up'\n } finally {\n sending.value = false\n }\n}\n\nexport default function Newsletter() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Newsletter</Heading>\n <Text muted>\n An email now and then. We send a link first: you are on the list only once you open it,\n and every issue has a one-click unsubscribe.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <form onSubmit={(event) => void signUp(event)}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input name=\"email\" type=\"email\" label=\"Email\" autoComplete=\"email\" required />\n <Button type=\"submit\" loading={sending.value}>\n Subscribe\n </Button>\n </Flex>\n </form>\n </CardContent>\n </Card>\n {sentTo.value ? (\n <Alert variant=\"success\" title=\"Check your inbox\">\n If {sentTo.value} is not on the list yet, a confirmation link is on its way.\n </Alert>\n ) : null}\n {devLink.value ? (\n <Alert variant=\"info\" title=\"vite dev sends no email without SES\">\n <Link href={devLink.value}>Open the confirmation link</Link>\n </Alert>\n ) : null}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not signed up\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\nfunction cfNewsletterConfirmRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'\nimport { api } from '../../api'\nimport { router } from '../../router'\n\nconst client = createClient(api)\nconst confirmed = signal<string | null>(null)\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n/**\n * The page a confirmation link opens. It confirms only when you press the button: mail\n * scanners open every link in a message, and would otherwise subscribe whoever was typed in.\n */\nasync function confirm(): Promise<void> {\n const token = new URLSearchParams(router.search.value).get('token')\n if (!token) {\n failure.value = 'This link has no token. Sign up again.'\n return\n }\n busy.value = true\n failure.value = null\n try {\n confirmed.value = (await client.confirmSubscription({ body: { token } })).email\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not confirm'\n } finally {\n busy.value = false\n }\n}\n\nexport default function ConfirmSubscription() {\n useSignals()\n return (\n <Flex gap={4}>\n <Heading level={1}>Confirm your subscription</Heading>\n {confirmed.value ? (\n <Alert variant=\"success\" title=\"You are on the list\">\n The next issue goes to {confirmed.value}.\n </Alert>\n ) : (\n <Flex gap={2}>\n <Text muted>One click and you get the newsletter.</Text>\n <Flex direction=\"horizontal\">\n <Button loading={busy.value} onClick={() => void confirm()}>\n Yes, subscribe me\n </Button>\n </Flex>\n </Flex>\n )}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not confirmed\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\nfunction cfNewsletterUnsubscribeRouteTsx(): string {\n return `import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'\nimport { router } from '../../router'\n\nconst done = signal(false)\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n/**\n * The footer link of every issue. Like the confirmation, it acts on a button press, never on\n * opening the page. Mail clients that support one-click unsubscribe skip this page and POST\n * to the same endpoint from the List-Unsubscribe header.\n */\nasync function unsubscribe(): Promise<void> {\n const token = new URLSearchParams(router.search.value).get('token') ?? ''\n busy.value = true\n failure.value = null\n try {\n const response = await fetch(\\`/api/newsletter/unsubscribe?token=\\${encodeURIComponent(token)}\\`, {\n method: 'POST',\n })\n if (!response.ok) throw new Error(\\`Request failed with status \\${response.status}\\`)\n done.value = true\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not unsubscribe'\n } finally {\n busy.value = false\n }\n}\n\nexport default function Unsubscribe() {\n useSignals()\n return (\n <Flex gap={4}>\n <Heading level={1}>Unsubscribe</Heading>\n {done.value ? (\n <Alert variant=\"success\" title=\"You are unsubscribed\">\n You will get no more issues. Sign up again any time.\n </Alert>\n ) : (\n <Flex gap={2}>\n <Text muted>Stop getting the newsletter at this address.</Text>\n <Flex direction=\"horizontal\">\n <Button variant=\"destructive\" loading={busy.value} onClick={() => void unsubscribe()}>\n Unsubscribe\n </Button>\n </Flex>\n </Flex>\n )}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not unsubscribed\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\nfunction cfNewsletterSendRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { connectRoom } from '@cascivo/app/sync'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Input,\n Text,\n Textarea,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport type { MouseEvent } from 'react'\nimport { api } from '../../api'\nimport { MAX_BODY, MAX_SUBJECT, parseIssue } from '../../newsletter'\nimport type { Issue, Overview } from '../../newsletter'\n\nconst client = createClient(api)\nconst overview = signal<Overview | null>(null)\nconst previewHtml = signal<string | null>(null)\nconst busy = signal<'load' | 'preview' | 'send' | null>(null)\nconst failure = signal<string | null>(null)\nconst notice = signal<string | null>(null)\n\n/** The composer's fields, read from the form the clicked button belongs to. */\nfunction fields(event: MouseEvent<HTMLButtonElement>) {\n const form = event.currentTarget.form\n const data = form ? new FormData(form) : new FormData()\n const read = (name: string) => {\n const value = data.get(name)\n return typeof value === 'string' ? value : ''\n }\n return { key: read('key'), subject: read('subject'), body: read('body') }\n}\n\nasync function run(kind: 'load' | 'preview' | 'send', task: () => Promise<void>): Promise<void> {\n busy.value = kind\n failure.value = null\n notice.value = null\n try {\n await task()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Something went wrong'\n } finally {\n busy.value = null\n }\n}\n\n/** Shows an issue's progress as the Worker pushes it, until every reader has had their copy. */\nfunction watch(issue: Issue): void {\n const room = connectRoom(\\`/api/newsletter/issues/\\${issue.id}/live\\`)\n const pushed = room.signal<Issue | null>('issue', null, (raw) =>\n raw === null ? null : parseIssue(raw),\n )\n const stop = pushed.signal.subscribe((latest) => {\n const current = overview.peek()\n if (!latest || !current) return\n overview.value = {\n ...current,\n issues: current.issues.map((i) => (i.id === latest.id ? latest : i)),\n }\n if (latest.sent + latest.failed >= latest.total) {\n stop()\n room.close()\n }\n })\n}\n\nfunction load(event: MouseEvent<HTMLButtonElement>): void {\n const { key } = fields(event)\n void run('load', async () => {\n overview.value = await client.newsletterOverview({ body: { key } })\n })\n}\n\nfunction preview(event: MouseEvent<HTMLButtonElement>): void {\n const input = fields(event)\n void run('preview', async () => {\n previewHtml.value = (await client.previewIssue({ body: input })).html\n })\n}\n\nfunction send(event: MouseEvent<HTMLButtonElement>): void {\n const input = fields(event)\n const readers = overview.value?.subscribers.subscribed ?? 0\n if (!window.confirm(\\`Send \"\\${input.subject}\" to \\${readers} subscribers?\\`)) return\n void run('send', async () => {\n const issue = await client.sendIssue({ body: input })\n overview.value = await client.newsletterOverview({ body: { key: input.key } })\n notice.value = \\`Queued for \\${issue.total} subscribers.\\`\n watch(issue)\n })\n}\n\nexport default function SendNewsletter() {\n useSignals()\n const counts = overview.value?.subscribers\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Send the newsletter</Heading>\n <Text muted>\n Written in Markdown, rendered with @cascivo/email, sent through Amazon SES to every\n confirmed subscriber. Only someone with NEWSLETTER_KEY can send.\n </Text>\n </Flex>\n <form onSubmit={(event) => event.preventDefault()}>\n <Flex gap={3}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input name=\"key\" type=\"password\" label=\"Newsletter key\" autoComplete=\"off\" required />\n <Button variant=\"secondary\" loading={busy.value === 'load'} onClick={load}>\n Show subscribers\n </Button>\n </Flex>\n <Input name=\"subject\" label=\"Subject\" maxLength={MAX_SUBJECT} required />\n <Textarea\n name=\"body\"\n label=\"Body\"\n hint=\"Markdown: headings, **bold**, links, lists, quotes and images.\"\n rows={12}\n maxLength={MAX_BODY}\n required\n />\n <Flex direction=\"horizontal\" gap={2} wrap>\n <Button variant=\"secondary\" loading={busy.value === 'preview'} onClick={preview}>\n Preview\n </Button>\n <Button loading={busy.value === 'send'} disabled={!counts} onClick={send}>\n Send to {counts ? counts.subscribed : '…'} subscribers\n </Button>\n </Flex>\n </Flex>\n </form>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not done\">\n {failure.value}\n </Alert>\n ) : null}\n {notice.value ? (\n <Alert variant=\"success\" title=\"Sending\">\n {notice.value}\n </Alert>\n ) : null}\n {previewHtml.value ? (\n <Card>\n <CardContent>\n {/* sandbox: the preview runs no script and cannot reach this page. */}\n <iframe\n title=\"Preview\"\n sandbox=\"\"\n srcDoc={previewHtml.value}\n width=\"100%\"\n height=\"640\"\n />\n </CardContent>\n </Card>\n ) : null}\n {counts ? (\n <Flex direction=\"horizontal\" gap={2} wrap>\n <Badge variant=\"success\">{counts.subscribed} subscribed</Badge>\n <Badge variant=\"warning\">{counts.pending} not confirmed</Badge>\n <Badge variant=\"secondary\">{counts.unsubscribed} unsubscribed</Badge>\n <Badge variant=\"destructive\">{counts.suppressed} suppressed</Badge>\n </Flex>\n ) : null}\n {overview.value && overview.value.issues.length === 0 ? (\n <EmptyState title=\"No issues yet\" description=\"Write one above and send it.\" />\n ) : null}\n {overview.value && overview.value.issues.length > 0 ? (\n <Flex gap={2} role=\"list\" aria-label=\"Issues\">\n {overview.value.issues.map((issue) => (\n <Flex key={issue.id} role=\"listitem\" direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={issue.sent + issue.failed >= issue.total ? 'success' : 'warning'}>\n {issue.sent}/{issue.total} sent\n </Badge>\n {issue.failed > 0 ? <Badge variant=\"destructive\">{issue.failed} failed</Badge> : null}\n <Text>{issue.subject}</Text>\n <Text size=\"sm\" muted>\n {new Date(issue.createdAt).toLocaleString()}\n </Text>\n </Flex>\n ))}\n </Flex>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example checkout` with `--auth email`: a subscription plan and Stripe's portal --- */\n\nfunction cfBillingTs(): string {\n return `/**\n * The subscription plan /billing sells, shared by the Worker (worker/billing.ts) and the page.\n * A subscription belongs to a signed-in user, so this exists only with --auth email.\n */\n\n/** The plan. The Worker sends this to Stripe; the page only displays it. */\nexport const PLAN = {\n name: 'Pro',\n description: 'Everything in the app, billed monthly. Cancel any time from the billing portal.',\n /** In the currency's smallest unit, per interval: 900 is €9.00. */\n amount: 900,\n currency: 'eur',\n interval: 'month' as const,\n}\n\n/** \\`none\\` before the first subscription; otherwise Stripe's subscription status. */\nexport type BillingStatus =\n | 'none'\n | 'incomplete'\n | 'incomplete_expired'\n | 'trialing'\n | 'active'\n | 'past_due'\n | 'canceled'\n | 'unpaid'\n | 'paused'\n\nconst STATUSES: readonly BillingStatus[] = [\n 'none',\n 'incomplete',\n 'incomplete_expired',\n 'trialing',\n 'active',\n 'past_due',\n 'canceled',\n 'unpaid',\n 'paused',\n]\n\nexport interface Billing {\n status: BillingStatus\n /**\n * Whether the plan's features are on: the subscription is active or trialing, or past due\n * while Stripe retries the renewal (\\`isEntitled\\` from @cascivo/app/stripe, in the Worker).\n */\n active: boolean\n /** When the current period ends: the next charge, or the end of a cancelled plan. */\n currentPeriodEnd: string | null\n /** Cancelled, but running until currentPeriodEnd. */\n cancelAtPeriodEnd: boolean\n /** Has a Stripe customer, so the billing portal can open. */\n canManage: boolean\n}\n\nexport function parseBilling(raw: unknown): Billing {\n if (typeof raw === 'object' && raw !== null) {\n const { status, active, currentPeriodEnd, cancelAtPeriodEnd, canManage } = raw as Record<\n string,\n unknown\n >\n const known = STATUSES.find((s) => s === status)\n if (\n known &&\n typeof active === 'boolean' &&\n (currentPeriodEnd === null || typeof currentPeriodEnd === 'string') &&\n typeof cancelAtPeriodEnd === 'boolean' &&\n typeof canManage === 'boolean'\n ) {\n return { status: known, active, currentPeriodEnd, cancelAtPeriodEnd, canManage }\n }\n }\n throw new Error('Malformed billing')\n}\n\n/** Where to send the browser next: Stripe's checkout or its billing portal. */\nexport function parseRedirect(raw: unknown): { url: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { url } = raw as Record<string, unknown>\n if (typeof url === 'string' && url.startsWith('https://')) return { url }\n }\n throw new Error('Malformed redirect')\n}\n\nexport function parseSyncInput(raw: unknown): { sessionId: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { sessionId } = raw as Record<string, unknown>\n if (typeof sessionId === 'string' && /^cs_[\\\\w]{1,250}$/.test(sessionId)) return { sessionId }\n }\n throw new Error('Expected { sessionId }: a Checkout Session id')\n}\n`\n}\n\nfunction cfBillingWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { requireUser } from '@cascivo/app/auth-server'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { isEntitled, requireEntitlement } from '@cascivo/app/stripe'\nimport type { Invoice, Subscription } from '@cascivo/app/stripe'\nimport {\n Body,\n Button,\n Container,\n Head,\n Heading,\n Html,\n Preview,\n Section,\n Text,\n renderEmail,\n} from '@cascivo/email'\nimport { createElement as h } from 'react'\nimport { PLAN, parseBilling } from '../src/billing'\nimport type { Billing } from '../src/billing'\nimport { SHOP_NAME, formatPrice } from '../src/checkout'\nimport { refused, stripeOf } from './checkout'\nimport type { BillingHooks, ReceiptSender } from './checkout'\n\nconst migrations = [\n {\n id: '0001_billing',\n statements: [\n \\`CREATE TABLE billing (\n user_id TEXT PRIMARY KEY,\n customer_id TEXT,\n subscription_id TEXT,\n status TEXT NOT NULL,\n current_period_end INTEGER,\n cancel_at_period_end INTEGER NOT NULL DEFAULT 0,\n updated_at TEXT NOT NULL,\n reminded TEXT\n )\\`,\n ],\n },\n]\n\nexport interface BillingEnv {\n DB: Database\n EMAIL: ReceiptSender\n RECEIPT_FROM: string\n STRIPE_SECRET_KEY?: string\n}\n\ninterface Row {\n status: string\n customerId: string | null\n currentPeriodEnd: number | null\n cancelAtPeriodEnd: number\n}\n\nconst cell = (raw: unknown, key: string): unknown =>\n typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined\n\nfunction parseRow(raw: unknown): Row {\n const status = cell(raw, 'status')\n const customerId = cell(raw, 'customerId')\n const end = cell(raw, 'currentPeriodEnd')\n if (typeof status !== 'string') throw new Error('Malformed billing row')\n return {\n status,\n customerId: typeof customerId === 'string' ? customerId : null,\n currentPeriodEnd: typeof end === 'number' ? end : null,\n cancelAtPeriodEnd: Number(cell(raw, 'cancelAtPeriodEnd')),\n }\n}\n\nasync function readRow(db: Database, userId: string): Promise<Row | null> {\n await migrate(db, migrations)\n const [row] = await queryRows(\n db,\n \\`SELECT status, customer_id AS customerId, current_period_end AS currentPeriodEnd,\n cancel_at_period_end AS cancelAtPeriodEnd FROM billing WHERE user_id = ?\\`,\n [userId],\n parseRow,\n )\n return row ?? null\n}\n\n/** A stored row as the page sees it; the status is checked against the known ones. */\nfunction toBilling(row: Row | null): Billing {\n const billing = parseBilling({\n status: row?.status ?? 'none',\n active: false,\n currentPeriodEnd:\n row?.currentPeriodEnd != null ? new Date(row.currentPeriodEnd * 1000).toISOString() : null,\n cancelAtPeriodEnd: row?.cancelAtPeriodEnd === 1,\n canManage: row?.customerId != null,\n })\n return { ...billing, active: isEntitled(billing.status) }\n}\n\n/**\n * Stores a subscription's current state for the user its metadata names. The metadata is set\n * by startSubscription, server side: a subscription made any other way (a Payment Link, the\n * dashboard) names no user here and is ignored. A different subscription replaces the stored\n * one only when that one is over, so a late event about an old plan cannot end a new one.\n */\nasync function store(db: Database, subscription: Subscription): Promise<void> {\n const userId = subscription.metadata['user']\n if (!userId) return\n await migrate(db, migrations)\n await queryRows(\n db,\n \\`INSERT INTO billing\n (user_id, customer_id, subscription_id, status, current_period_end, cancel_at_period_end, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (user_id) DO UPDATE SET\n customer_id = excluded.customer_id, subscription_id = excluded.subscription_id,\n status = excluded.status, current_period_end = excluded.current_period_end,\n cancel_at_period_end = excluded.cancel_at_period_end, updated_at = excluded.updated_at\n WHERE billing.subscription_id IS excluded.subscription_id\n OR billing.status NOT IN ('active', 'trialing', 'past_due')\n RETURNING user_id\\`,\n [\n userId,\n subscription.customerId,\n subscription.id,\n subscription.status,\n subscription.currentPeriodEnd,\n subscription.cancelAtPeriodEnd ? 1 : 0,\n new Date().toISOString(),\n ],\n (raw) => raw,\n )\n}\n\n/**\n * The webhook's part (worker/checkout.ts passes subscription events here). The event's copy\n * may be stale, since events arrive out of order: the subscription is read back from Stripe.\n */\nexport async function syncSubscription(env: BillingEnv, subscriptionId: string): Promise<void> {\n await store(env.DB, await stripeOf(env).retrieveSubscription(subscriptionId))\n}\n\n/** The signed-in user's plan. */\nexport async function getBilling(env: BillingEnv, request: Request): Promise<Billing> {\n const user = await requireUser(env.DB, request)\n return toBilling(await readRow(env.DB, user.id))\n}\n\n/**\n * Refuses a user whose plan is not on: 401 signed out, 402 without the plan. Call it in the\n * Worker before serving a paid feature, never trusting what the page shows.\n */\nexport async function requirePlan(env: BillingEnv, request: Request): Promise<void> {\n const user = await requireUser(env.DB, request)\n requireEntitlement((await readRow(env.DB, user.id))?.status)\n}\n\n/** The email for a renewal that could not be charged, with where to pay it. */\nfunction renderPaymentFailed(invoice: Invoice, payHref: string) {\n const subject = \\`Your \\${PLAN.name} payment did not go through\\`\n const amount = formatPrice(invoice.amountDue, invoice.currency)\n return renderEmail(\n h(\n Html,\n null,\n h(Head, { title: subject }),\n h(\n Body,\n null,\n h(Preview, null, \\`We could not charge \\${amount}. Update your card to keep \\${PLAN.name}.\\`),\n h(\n Container,\n null,\n h(\n Section,\n { padding: 32 },\n h(Heading, { level: 1 }, 'Your payment did not go through'),\n h(\n Text,\n null,\n \\`We could not charge \\${amount} for \\${SHOP_NAME} \\${PLAN.name}. Your plan stays on while we try again; pay with another card to keep it.\\`,\n ),\n h(Button, { href: payHref }, 'Update payment'),\n ),\n ),\n ),\n ),\n { subject },\n )\n}\n\n/**\n * A renewal Stripe could not charge (\\`invoice.payment_failed\\`). The customer hears about it\n * once per attempt, with Stripe's page for paying the invoice with another card. The plan stays\n * on while Stripe retries (\\`past_due\\`); the Stripe dashboard's failed-payment settings decide\n * when it ends. Only customers this app bills are written to: a retried event, or an invoice\n * for something else on the same Stripe account, sends nothing.\n */\nexport async function remindPayment(\n env: BillingEnv,\n invoice: Invoice,\n origin: string,\n): Promise<void> {\n if (!invoice.customerId || !invoice.customerEmail) return\n await migrate(env.DB, migrations)\n const attempt = \\`\\${invoice.id}:\\${invoice.attemptCount}\\`\n const [row] = await queryRows(\n env.DB,\n 'UPDATE billing SET reminded = ? WHERE customer_id = ? AND reminded IS NOT ? RETURNING user_id',\n [attempt, invoice.customerId, attempt],\n (raw) => raw,\n )\n if (!row) return\n const message = renderPaymentFailed(invoice, invoice.hostedInvoiceUrl ?? \\`\\${origin}/billing\\`)\n if (import.meta.env.DEV) {\n console.log(\\`[billing] payment reminder for \\${invoice.customerEmail}: \"\\${message.subject}\"\\`)\n return\n }\n if (!env.RECEIPT_FROM) {\n console.warn('[billing] no payment reminder sent: set RECEIPT_FROM in wrangler.jsonc')\n return\n }\n try {\n await env.EMAIL.send({\n from: env.RECEIPT_FROM,\n to: invoice.customerEmail,\n subject: message.subject,\n text: message.text,\n html: message.html,\n })\n } catch (error) {\n // Logged, not thrown: Stripe retrying the event would not send it again.\n console.error('[billing] payment reminder not sent:', error)\n }\n}\n\n/** What the Stripe webhook (worker/checkout.ts) hands over to billing. */\nexport function billingHooks(env: BillingEnv): BillingHooks {\n return {\n subscription: (id) => syncSubscription(env, id),\n paymentFailed: (invoice, origin) => remindPayment(env, invoice, origin),\n }\n}\n\n/** Opens a subscription checkout for PLAN, naming the user in the subscription's metadata. */\nexport async function startSubscription(\n env: BillingEnv,\n request: Request,\n origin: string,\n): Promise<{ url: string }> {\n const user = await requireUser(env.DB, request)\n const row = await readRow(env.DB, user.id)\n if (row && toBilling(row).active) {\n throw new HttpError(409, 'You already have the plan. Manage it in the billing portal.')\n }\n const stripe = stripeOf(env)\n try {\n const session = await stripe.createCheckoutSession({\n mode: 'subscription',\n lineItems: [{ ...PLAN, quantity: 1 }],\n // Stripe fills in {CHECKOUT_SESSION_ID}, so the page can sync before any webhook.\n successUrl: \\`\\${origin}/billing?session={CHECKOUT_SESSION_ID}\\`,\n cancelUrl: \\`\\${origin}/billing\\`,\n // Signed in with a provider that shares no email: Stripe's checkout asks for one.\n ...(row?.customerId\n ? { customer: row.customerId }\n : user.email\n ? { customerEmail: user.email }\n : {}),\n clientReferenceId: user.id,\n subscriptionMetadata: { user: user.id },\n })\n if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')\n return { url: session.url }\n } catch (error) {\n if (error instanceof HttpError) throw error\n refused(error)\n }\n}\n\n/**\n * Back from Stripe's checkout: reads the session and its subscription, so the page is right\n * before the webhook arrives (or in \\`vite dev\\` without \\`stripe listen\\`). Only the user the\n * subscription names can sync it.\n */\nexport async function syncBilling(\n env: BillingEnv,\n request: Request,\n sessionId: string,\n): Promise<Billing> {\n const user = await requireUser(env.DB, request)\n const stripe = stripeOf(env)\n try {\n const session = await stripe.retrieveCheckoutSession(sessionId)\n if (session.mode === 'subscription' && session.subscriptionId) {\n const subscription = await stripe.retrieveSubscription(session.subscriptionId)\n if (subscription.metadata['user'] !== user.id) {\n throw new HttpError(403, 'This checkout belongs to another account')\n }\n await store(env.DB, subscription)\n }\n } catch (error) {\n if (error instanceof HttpError) throw error\n refused(error)\n }\n return toBilling(await readRow(env.DB, user.id))\n}\n\n/** Opens Stripe's Customer Portal: plan, card, invoices and cancellation, all hosted. */\nexport async function openPortal(\n env: BillingEnv,\n request: Request,\n origin: string,\n): Promise<{ url: string }> {\n const user = await requireUser(env.DB, request)\n const row = await readRow(env.DB, user.id)\n if (!row?.customerId) throw new HttpError(409, 'Subscribe first: there is nothing to manage yet')\n try {\n return await stripeOf(env).createPortalSession({\n customer: row.customerId,\n returnUrl: \\`\\${origin}/billing\\`,\n })\n } catch (error) {\n refused(error)\n }\n}\n`\n}\n\nfunction cfBillingRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Link,\n Spinner,\n Text,\n signal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { auth } from '../auth'\nimport { PLAN } from '../billing'\nimport type { Billing, BillingStatus } from '../billing'\nimport { formatPrice } from '../checkout'\nimport { router } from '../router'\n\nconst client = createClient(api)\nconst billing = signal<Billing | null>(null)\nconst busy = signal<'subscribe' | 'portal' | null>(null)\nconst failure = signal<string | null>(null)\n\n/**\n * Loads the plan. Back from Stripe's checkout, the URL carries the session id: syncing it\n * shows the new subscription at once, before the webhook. Then the id leaves the URL.\n */\nasync function load(): Promise<void> {\n failure.value = null\n try {\n const sessionId = new URLSearchParams(router.search.peek()).get('session')\n if (sessionId) {\n billing.value = await client.syncBilling({ body: { sessionId } })\n router.navigate('/billing', { replace: true })\n } else {\n billing.value = await client.getBilling()\n }\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not load billing'\n }\n}\n\n/** Both buttons leave for a Stripe page: checkout, or the billing portal. */\nasync function leaveFor(kind: 'subscribe' | 'portal'): Promise<void> {\n busy.value = kind\n failure.value = null\n try {\n const { url } =\n kind === 'subscribe' ? await client.startSubscription() : await client.openBillingPortal()\n location.assign(url)\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Stripe did not open'\n busy.value = null\n }\n}\n\nconst LABEL: Record<BillingStatus, string> = {\n none: 'No plan',\n incomplete: 'Payment pending',\n incomplete_expired: 'Payment expired',\n trialing: 'Trial',\n active: 'Active',\n past_due: 'Payment failed: retrying',\n canceled: 'Cancelled',\n unpaid: 'Unpaid',\n paused: 'Paused',\n}\n\nexport default function BillingPage() {\n useSignals()\n useSignalEffect(() => {\n if (auth.user.value) void load()\n })\n const user = auth.user.value\n const plan = billing.value\n\n if (user === undefined) return <Spinner label=\"Loading\" />\n if (user === null) {\n return (\n <Flex gap={4}>\n <Heading level={1}>Billing</Heading>\n <Text>\n <Link href=\"/account\">Sign in</Link> to subscribe: a plan belongs to your account.\n </Text>\n </Flex>\n )\n }\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Billing</Heading>\n <Text muted>\n Paid on Stripe's hosted checkout; changed, paused or cancelled in Stripe's billing portal.\n The Worker keeps your plan in step through Stripe's webhook.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Heading level={2}>{PLAN.name}</Heading>\n {plan ? (\n <Badge variant={plan.active ? 'success' : 'secondary'}>{LABEL[plan.status]}</Badge>\n ) : null}\n </Flex>\n <Text muted>{PLAN.description}</Text>\n <Text size=\"lg\">\n {formatPrice(PLAN.amount, PLAN.currency)} a {PLAN.interval}\n </Text>\n {plan?.currentPeriodEnd ? (\n <Text size=\"sm\" muted>\n {plan.cancelAtPeriodEnd ? 'Ends' : 'Renews'} on{' '}\n {new Date(plan.currentPeriodEnd).toLocaleDateString()}\n </Text>\n ) : null}\n <Flex direction=\"horizontal\" gap={2} wrap>\n {plan && !plan.active ? (\n <Button\n loading={busy.value === 'subscribe'}\n onClick={() => void leaveFor('subscribe')}\n >\n Subscribe\n </Button>\n ) : null}\n {plan?.canManage ? (\n <Button\n variant=\"secondary\"\n loading={busy.value === 'portal'}\n onClick={() => void leaveFor('portal')}\n >\n Manage billing\n </Button>\n ) : null}\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not done\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example social`: scheduled posts to connected LinkedIn and Mastodon accounts --- */\n\n/** Daily, off the hour: renews Threads tokens in their last 30 days (\\`renewConnections\\`). */\nconst SOCIAL_CRON = '17 4 * * *'\n\nfunction cfSocialTs(): string {\n return `import {\n blueskyPublisher,\n bufferPublisher,\n linkedinPublisher,\n mastodonPublisher,\n threadsPublisher,\n} from '@cascivo/app/social'\nimport type { Publisher, SocialImage, SocialPost } from '@cascivo/app/social'\nimport { defineUploads } from '@cascivo/app/uploads'\n\n/**\n * Posts scheduled to the accounts a user connected, shared by the Worker (worker/social.ts)\n * and the /social page. The publishers run on both sides: the page shows what a network would\n * refuse while you type, and the Worker refuses the same post before scheduling it.\n */\n\nexport const NETWORKS = {\n bluesky: 'Bluesky',\n buffer: 'Buffer',\n linkedin: 'LinkedIn',\n mastodon: 'Mastodon',\n threads: 'Threads',\n} as const\nexport type Network = keyof typeof NETWORKS\n\nexport function isNetwork(value: string): value is Network {\n return Object.keys(NETWORKS).includes(value)\n}\n\n/**\n * Networks with no idempotency key: a request that timed out may have posted, so their posts\n * are never retried.\n */\nexport function postsOnce(network: Network): boolean {\n return network === 'buffer' || network === 'linkedin' || network === 'threads'\n}\n\n/**\n * Images attached to a post: uploaded through the Worker into R2 (worker/social.ts). 1 MB and\n * JPEG or PNG, which every network here takes (Bluesky's limit is 1 MB).\n */\nexport const IMAGES = defineUploads({\n path: '/api/social/images',\n maxBytes: 1_000_000,\n types: ['image/jpeg', 'image/png'],\n})\nexport const MAX_IMAGES = 4\n\n/**\n * Threads and Buffer fetch images by URL. The Worker passes one that signs a link to the image\n * (worker/social-post.ts); the page only checks posts, so this stands in there.\n */\nexport type ImageLink = (image: SocialImage) => Promise<string>\nconst checkOnly: ImageLink = async () => {\n throw new Error('Only the Worker links images')\n}\n\nconst publishers: Record<Exclude<Network, 'buffer' | 'threads'>, Publisher> = {\n bluesky: blueskyPublisher(),\n linkedin: linkedinPublisher(),\n mastodon: mastodonPublisher(),\n}\n\n/** A Mastodon server's own limits (\\`mastodonServerLimits\\`), which many set above 500. */\nexport interface ServerLimits {\n maxChars: number\n maxImages: number\n urlWeight: number\n}\n\n/**\n * The publisher for an account. A Buffer channel posts through Buffer, checked against the\n * limit of the network behind it (\\`service\\`); a Mastodon account, against its server's.\n */\nexport function publisherFor(\n account: { network: Network; service: string | null; limits?: ServerLimits | null },\n imageLink: ImageLink = checkOnly,\n): Publisher {\n if (account.network === 'buffer') {\n return bufferPublisher({\n uploadImage: imageLink,\n ...(account.service ? { service: account.service } : {}),\n })\n }\n if (account.network === 'threads') return threadsPublisher({ uploadImage: imageLink })\n if (account.network === 'mastodon' && account.limits) return mastodonPublisher(account.limits)\n return publishers[account.network]\n}\n\nexport const MAX_TEXT = 5000\nexport const MAX_ACCOUNTS = 10\n/** How far ahead a post can be scheduled. */\nexport const MAX_DAYS_AHEAD = 365\n/** Buffer's request budget: this many per 15 minutes, for every user of the app together. */\nexport const BUFFER_BUDGET = 100\n\nexport interface Account {\n id: string\n network: Network\n /** \\`@ada.bsky.social\\`, \\`@ada@hachyderm.io\\`, or the name on a LinkedIn profile. */\n label: string\n /** \\`expiring\\`: the token ends soon and cannot be renewed (LinkedIn): connect it again. */\n status: 'active' | 'expiring' | 'reconnect'\n /** For a Buffer channel, the network behind it (\\`instagram\\`, \\`twitter\\`, …); else \\`null\\`. */\n service: string | null\n /** For a Mastodon account, its server's limits; else \\`null\\`. */\n limits: ServerLimits | null\n}\n\n/** \\`queued\\`: handed to Buffer, which holds it until the time (it shows in Buffer's queue). */\nexport type TargetStatus = 'pending' | 'publishing' | 'queued' | 'posted' | 'failed'\n\nexport interface Target {\n accountId: string\n network: Network\n service: string | null\n label: string\n status: TargetStatus\n url: string | null\n error: string | null\n}\n\nexport type PostStatus = 'scheduled' | 'publishing' | 'done' | 'partial' | 'failed' | 'cancelled'\n\nexport interface Link {\n url: string\n /** LinkedIn and Bluesky show it on the card; Mastodon builds its own card from the page. */\n title: string\n}\n\n/** An image on a post: its key in the user's uploads, its type, and its description. */\nexport interface PostImage {\n key: string\n type: string\n alt: string\n}\n\nexport interface ScheduledPost {\n id: string\n text: string\n link: Link | null\n images: PostImage[]\n /** ISO time it goes out. */\n at: string\n /** Buffer accounts are handed to Buffer at once, to hold until \\`at\\`. */\n inBuffer: boolean\n status: PostStatus\n targets: Target[]\n}\n\nexport interface Social {\n /** The networks this app can connect (LinkedIn needs its client id and secret). */\n networks: Network[]\n accounts: Account[]\n posts: ScheduledPost[]\n /** Buffer requests this app made in the current 15 minutes, of 100; \\`null\\` without Buffer. */\n bufferUsed: number | null\n}\n\nexport interface PostInput {\n text: string\n link: Link | null\n images: PostImage[]\n accountIds: string[]\n /** ISO time, or \\`null\\` for now. */\n at: string | null\n /** Hand Buffer accounts to Buffer now, to hold until \\`at\\`. */\n inBuffer: boolean\n}\n\n/** What \\`publishers\\` check: the post as a network sees it. */\n/**\n * What \\`publishers\\` check and post: the post as a network sees it. Checking needs only each\n * image's type and description; posting passes the bytes (\\`data\\`, in the same order).\n */\nexport function asSocialPost(\n input: { text: string; link: Link | null; images: readonly PostImage[] },\n data: readonly Blob[] = [],\n): SocialPost {\n const images = input.images.map((image, i) => ({\n data: data[i] ?? new Blob([], { type: image.type }),\n alt: image.alt,\n }))\n return {\n text: input.text,\n ...(input.link ? { link: input.link } : {}),\n ...(images.length > 0 ? { images } : {}),\n }\n}\n\nconst record = (raw: unknown, what: string): Record<string, unknown> => {\n if (typeof raw !== 'object' || raw === null) throw new Error(\\`Malformed \\${what}\\`)\n return raw as Record<string, unknown>\n}\n\nconst text = (value: unknown, what: string): string => {\n if (typeof value !== 'string') throw new Error(\\`Malformed \\${what}\\`)\n return value\n}\n\nfunction parseLink(raw: unknown): Link | null {\n if (raw === null || raw === undefined) return null\n const { url, title } = record(raw, 'link')\n return { url: text(url, 'link url'), title: text(title, 'link title') }\n}\n\nconst IMAGE_KEY = /^[0-9a-f-]{36}\\\\/[\\\\w.-]{1,100}$/\n\nfunction parseImages(raw: unknown): PostImage[] {\n if (raw === undefined || raw === null) return []\n if (!Array.isArray(raw) || raw.length > MAX_IMAGES) {\n throw new Error(\\`Attach at most \\${MAX_IMAGES} images\\`)\n }\n return raw.map((item) => {\n const r = record(item, 'image')\n const key = text(r['key'], 'image key')\n const type = text(r['type'], 'image type')\n const alt = text(r['alt'], 'image description').trim()\n if (!IMAGE_KEY.test(key) || !IMAGES.types.includes(type)) throw new Error('Malformed image')\n if (!alt || alt.length > 1000) throw new Error('Describe each image (alt text), briefly')\n return { key, type, alt }\n })\n}\n\nexport function parsePostInput(raw: unknown): PostInput {\n const r = record(raw, 'post')\n const body = text(r['text'], 'text')\n if (body.length > MAX_TEXT) throw new Error(\\`Keep the text under \\${MAX_TEXT} characters\\`)\n const link = parseLink(r['link'])\n if (link && !/^https?:\\\\/\\\\//.test(link.url)) throw new Error('The link must be an http(s) URL')\n const ids = r['accountIds']\n if (\n !Array.isArray(ids) ||\n ids.length === 0 ||\n ids.length > MAX_ACCOUNTS ||\n !ids.every((id) => typeof id === 'string')\n ) {\n throw new Error(\\`Pick 1 to \\${MAX_ACCOUNTS} accounts\\`)\n }\n const at = r['at'] ?? null\n if (at !== null && (typeof at !== 'string' || Number.isNaN(Date.parse(at)))) {\n throw new Error('Send the time as an ISO date')\n }\n return {\n text: body,\n link,\n images: parseImages(r['images']),\n accountIds: [...new Set(ids)],\n at,\n inBuffer: r['inBuffer'] === true,\n }\n}\n\nfunction parseAccount(raw: unknown): Account {\n const r = record(raw, 'account')\n const network = text(r['network'], 'network')\n const status = text(r['status'], 'status')\n if (!isNetwork(network)) throw new Error('Malformed network')\n if (status !== 'active' && status !== 'expiring' && status !== 'reconnect') {\n throw new Error('Malformed account status')\n }\n return {\n id: text(r['id'], 'account id'),\n network,\n label: text(r['label'], 'label'),\n status,\n service: typeof r['service'] === 'string' ? r['service'] : null,\n limits: parseLimits(r['limits']),\n }\n}\n\nfunction parseLimits(raw: unknown): ServerLimits | null {\n if (raw === null || raw === undefined) return null\n const r = record(raw, 'limits')\n const count = (value: unknown) => {\n if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {\n throw new Error('Malformed limits')\n }\n return value\n }\n return {\n maxChars: count(r['maxChars']),\n maxImages: count(r['maxImages']),\n urlWeight: count(r['urlWeight']),\n }\n}\n\nconst TARGET_STATUSES: readonly TargetStatus[] = [\n 'pending',\n 'publishing',\n 'queued',\n 'posted',\n 'failed',\n]\nconst POST_STATUSES: readonly PostStatus[] = [\n 'scheduled',\n 'publishing',\n 'done',\n 'partial',\n 'failed',\n 'cancelled',\n]\n\nfunction parseTarget(raw: unknown): Target {\n const r = record(raw, 'target')\n const network = text(r['network'], 'network')\n const status = TARGET_STATUSES.find((s) => s === r['status'])\n if (!isNetwork(network) || !status) throw new Error('Malformed target')\n return {\n accountId: text(r['accountId'], 'account id'),\n network,\n service: typeof r['service'] === 'string' ? r['service'] : null,\n label: text(r['label'], 'label'),\n status,\n url: typeof r['url'] === 'string' ? r['url'] : null,\n error: typeof r['error'] === 'string' ? r['error'] : null,\n }\n}\n\nexport function parseScheduledPost(raw: unknown): ScheduledPost {\n const r = record(raw, 'post')\n const status = POST_STATUSES.find((s) => s === r['status'])\n if (!status || !Array.isArray(r['targets'])) throw new Error('Malformed post')\n return {\n id: text(r['id'], 'post id'),\n text: text(r['text'], 'text'),\n link: parseLink(r['link']),\n images: parseImages(r['images']),\n at: text(r['at'], 'time'),\n // D1 hands back 1 and 0.\n inBuffer: r['inBuffer'] === true || r['inBuffer'] === 1,\n status,\n targets: r['targets'].map(parseTarget),\n }\n}\n\nexport function parseSocial(raw: unknown): Social {\n const r = record(raw, 'social')\n const networks = r['networks']\n if (!Array.isArray(networks) || !Array.isArray(r['accounts']) || !Array.isArray(r['posts'])) {\n throw new Error('Malformed social')\n }\n return {\n networks: networks.filter((n): n is Network => typeof n === 'string' && isNetwork(n)),\n accounts: r['accounts'].map(parseAccount),\n posts: r['posts'].map(parseScheduledPost),\n bufferUsed: typeof r['bufferUsed'] === 'number' ? r['bufferUsed'] : null,\n }\n}\n`\n}\n\nfunction cfSocialWorkerTs(opts: ScaffoldOptions): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { currentUser, requireUser } from '@cascivo/app/auth-server'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport {\n bluesky,\n blueskyClientMetadata,\n buffer,\n blueskyJwks,\n linkedin,\n mastodon,\n parseBlueskyKey,\n threads,\n} from '@cascivo/app/oauth'\nimport type { OAuthProvider } from '@cascivo/app/oauth'\nimport {\n connectionTokens,\n expiringConnections,\n handleConnections,\n listConnections,\n mastodonRegistrations,\n refreshConnections,\n} from '@cascivo/app/oauth-server'\nimport type { Connection } from '@cascivo/app/oauth-server'\nimport { bufferChannels, mastodonServerLimits } from '@cascivo/app/social'\nimport { handleUploads } from '@cascivo/app/uploads-server'\nimport type { UploadBucket } from '@cascivo/app/uploads-server'\nimport {\n asSocialPost,\n BUFFER_BUDGET,\n IMAGES,\n isNetwork,\n MAX_DAYS_AHEAD,\n NETWORKS,\n parseScheduledPost,\n publisherFor,\n} from '../src/social'\nimport type {\n Account,\n PostImage,\n PostInput,\n PostStatus,\n ScheduledPost,\n ServerLimits,\n Social,\n} from '../src/social'\n\n/** What the Workflow behind a scheduled post (worker/social-post.ts) is started with. */\nexport interface SocialPostParams {\n postId: string\n userId: string\n}\n\n/** What sending a reminder needs of the Email Service binding (\\`send_email\\`). */\nexport interface ReminderSender {\n send(message: {\n from: string\n to: string\n subject: string\n text: string\n html: string\n }): Promise<unknown>\n}\n\n/** The R2 bucket images are uploaded to (\\`SOCIAL_MEDIA\\` in wrangler.jsonc). */\nexport type SocialMediaBucket = UploadBucket\n\n/** What scheduling needs of the Worker's env. */\nexport interface SocialEnv {\n DB: Database\n SOCIAL_MEDIA: SocialMediaBucket\n EMAIL: ReminderSender\n /** Who reconnect reminders come from, and the deployed app they link to (wrangler.jsonc). */\n REMINDER_FROM: string\n APP_URL: string\n /** Seals the connected accounts' tokens (and Mastodon's app registrations). */\n AUTH_SECRET?: string\n LINKEDIN_CLIENT_ID?: string\n LINKEDIN_CLIENT_SECRET?: string\n /** An ES256 private JWK with a kid: makes Bluesky sessions last (README). */\n BLUESKY_PRIVATE_JWK?: string\n /** A Buffer app client (README); Buffer is offered once its id is set. */\n BUFFER_CLIENT_ID?: string\n BUFFER_CLIENT_SECRET?: string\n /** A Meta app with the Threads use case (README); Threads is offered once both are set. */\n THREADS_APP_ID?: string\n THREADS_APP_SECRET?: string\n SOCIAL_POST: Workflow<SocialPostParams>\n}\n\nconst APP_NAME = '${opts.name.replace(/[\\\\\\\\']/g, '')}'\n\nconst migrations = [\n {\n id: '0001_social',\n statements: [\n \\`CREATE TABLE social_posts (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL,\n text TEXT NOT NULL,\n link_url TEXT,\n link_title TEXT,\n at TEXT NOT NULL,\n status TEXT NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n 'CREATE INDEX social_posts_user ON social_posts (user_id, at)',\n \\`CREATE TABLE social_targets (\n post_id TEXT NOT NULL REFERENCES social_posts (id),\n account_id TEXT NOT NULL,\n network TEXT NOT NULL,\n service TEXT,\n label TEXT NOT NULL,\n status TEXT NOT NULL,\n url TEXT,\n error TEXT,\n PRIMARY KEY (post_id, account_id)\n )\\`,\n \\`CREATE TABLE social_buffer_channels (\n connection_id TEXT NOT NULL,\n channel_id TEXT NOT NULL,\n service TEXT NOT NULL,\n name TEXT NOT NULL,\n fetched_at INTEGER NOT NULL,\n PRIMARY KEY (connection_id, channel_id)\n )\\`,\n ],\n },\n {\n id: '0002_social_server_limits',\n statements: [\n \\`CREATE TABLE social_server_limits (\n server TEXT PRIMARY KEY,\n max_chars INTEGER NOT NULL,\n max_images INTEGER NOT NULL,\n url_weight INTEGER NOT NULL,\n fetched_at INTEGER NOT NULL\n )\\`,\n ],\n },\n {\n id: '0003_social_in_buffer',\n statements: ['ALTER TABLE social_posts ADD COLUMN in_buffer INTEGER NOT NULL DEFAULT 0'],\n },\n {\n id: '0004_social_buffer_budget',\n statements: [\n 'CREATE TABLE social_buffer_budget (slot INTEGER PRIMARY KEY, used INTEGER NOT NULL)',\n ],\n },\n {\n id: '0005_social_reminders',\n statements: [\n \\`CREATE TABLE social_reminders (\n connection_id TEXT NOT NULL,\n expires_at INTEGER NOT NULL,\n sent_at TEXT NOT NULL,\n PRIMARY KEY (connection_id, expires_at)\n )\\`,\n ],\n },\n {\n id: '0006_social_images',\n statements: [\"ALTER TABLE social_posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'\"],\n },\n]\n\nexport const secretOf = (env: SocialEnv): string => env.AUTH_SECRET ?? ''\n\n/** Bluesky fetches this app's client metadata here: its URL is the client id. */\nconst BLUESKY_METADATA = '/api/bluesky/client-metadata.json'\nconst BLUESKY_JWKS = '/api/bluesky/jwks.json'\nconst BLUESKY_SCOPES = ['atproto', 'transition:generic']\n\nconst blueskyKey = (env: SocialEnv) =>\n env.BLUESKY_PRIVATE_JWK ? parseBlueskyKey(env.BLUESKY_PRIVATE_JWK) : undefined\n\n/**\n * The accounts a user can connect: Bluesky and Mastodon always (Bluesky reads this app's\n * client metadata; the app registers itself with each Mastodon server); Buffer, LinkedIn and\n * Threads once their app's values are set. Each asks for the scopes to post.\n */\nexport function socialProviders(env: SocialEnv): OAuthProvider[] {\n const key = blueskyKey(env)\n const providers: OAuthProvider[] = [\n bluesky({\n clientMetadataPath: BLUESKY_METADATA,\n scopes: BLUESKY_SCOPES,\n ...(key ? { privateKey: key } : {}),\n }),\n ]\n if (env.BUFFER_CLIENT_ID) {\n providers.push(\n buffer({\n clientId: env.BUFFER_CLIENT_ID,\n ...(env.BUFFER_CLIENT_SECRET ? { clientSecret: env.BUFFER_CLIENT_SECRET } : {}),\n }),\n )\n }\n if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) {\n providers.push(\n linkedin({\n clientId: env.LINKEDIN_CLIENT_ID,\n clientSecret: env.LINKEDIN_CLIENT_SECRET,\n scopes: ['openid', 'profile', 'w_member_social'],\n }),\n )\n }\n if (env.THREADS_APP_ID && env.THREADS_APP_SECRET) {\n providers.push(threads({ clientId: env.THREADS_APP_ID, clientSecret: env.THREADS_APP_SECRET }))\n }\n providers.push(\n mastodon({\n appName: APP_NAME,\n scopes: ['profile', 'write:statuses', 'write:media'],\n registrations: mastodonRegistrations(env.DB, secretOf(env)),\n }),\n )\n return providers\n}\n\n/**\n * /api/connections: list, connect (\\`/api/connections/mastodon?server=hachyderm.io\\`) and remove\n * the signed-in user's accounts. A failed connection comes back to /social with \\`?error=\\`.\n */\nexport function connections(env: SocialEnv): (request: Request) => Promise<Response | null> {\n return handleConnections(env.DB, {\n secret: secretOf(env),\n providers: socialProviders(env),\n errorPath: '/social',\n })\n}\n\n/**\n * The daily Cron Trigger: renews Threads tokens in their last 30 days. A Threads token renews\n * only while it still works, so one nobody posts with would otherwise lapse after 60 days.\n */\nexport async function renewConnections(env: SocialEnv): Promise<void> {\n const { renewed, failed } = await refreshConnections(env.DB, {\n secret: secretOf(env),\n providers: socialProviders(env),\n })\n if (renewed + failed > 0) console.log(\\`[social] renewed \\${renewed}, could not renew \\${failed}\\`)\n}\n\nconst escapeHtml = (value: string) =>\n value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"')\n\n/**\n * The daily Cron Trigger, after renewing: a token that cannot be renewed (LinkedIn's 60 days)\n * and ends within a week gets its owner one email with a link to connect it again. Once per\n * token: connecting again issues a new one, with a new expiry.\n */\nexport async function remindExpiring(env: SocialEnv): Promise<void> {\n if (!env.REMINDER_FROM || !env.APP_URL) {\n console.log('[social] set REMINDER_FROM and APP_URL in wrangler.jsonc to send reminders')\n return\n }\n await migrate(env.DB, migrations)\n for (const { connection, email } of await expiringConnections(env.DB)) {\n if (!email || !isNetwork(connection.provider) || connection.expiresAt === null) continue\n const [sent] = await queryRows(\n env.DB,\n 'SELECT sent_at FROM social_reminders WHERE connection_id = ? AND expires_at = ?',\n [connection.id, connection.expiresAt],\n (raw) => raw,\n )\n if (sent) continue\n const network = NETWORKS[connection.provider]\n const account = connection.handle ?? connection.name ?? network\n const path = \\`/api/connections/\\${connection.provider}?returnTo=/social\\`\n const url = new URL(path, env.APP_URL).href\n const ends = new Date(connection.expiresAt * 1000).toUTCString().slice(0, 16)\n await env.EMAIL.send({\n from: env.REMINDER_FROM,\n to: email,\n subject: \\`Connect \\${network} again before \\${ends}\\`,\n text: \\`\\${network} lets this app post as \\${account} only until \\${ends}. Connect it again to keep scheduled posts going: \\${url}\\`,\n html: \\`<p>\\${escapeHtml(network)} lets this app post as \\${escapeHtml(account)} only until \\${escapeHtml(ends)}.</p><p><a href=\"\\${escapeHtml(url)}\">Connect it again</a> to keep scheduled posts going.</p>\\`,\n })\n await env.DB.prepare(\n 'INSERT INTO social_reminders (connection_id, expires_at, sent_at) VALUES (?, ?, ?)',\n )\n .bind(connection.id, connection.expiresAt, new Date().toISOString())\n .run()\n }\n}\n\n/**\n * Bluesky's client metadata and public key, which its servers fetch (no session). Without\n * BLUESKY_PRIVATE_JWK the app is a public client and serves no key.\n */\nexport function blueskyClient(env: SocialEnv, request: Request): Response | null {\n const url = new URL(request.url)\n if (request.method !== 'GET') return null\n const key = blueskyKey(env)\n if (url.pathname === BLUESKY_METADATA) {\n return Response.json(\n blueskyClientMetadata({\n origin: url.origin,\n redirectPaths: ['/api/connections/bluesky/callback'],\n clientName: APP_NAME,\n clientMetadataPath: BLUESKY_METADATA,\n scopes: BLUESKY_SCOPES,\n jwksPath: BLUESKY_JWKS,\n ...(key ? { privateKey: key } : {}),\n }),\n )\n }\n if (url.pathname === BLUESKY_JWKS && key) return Response.json(blueskyJwks(key))\n return null\n}\n\n/** Where a user's image lives in the bucket: under their own prefix, so only they reach it. */\nexport const mediaKey = (userId: string, key: string) => \\`social/\\${userId}/\\${key}\\`\n\n/**\n * \\`/api/social/images\\`: the signed-in user's uploads (\\`@cascivo/app/uploads-server\\`), stored\n * under their own prefix, so they can attach, preview and post only their own.\n */\nexport async function images(env: SocialEnv, request: Request): Promise<Response | null> {\n const { pathname } = new URL(request.url)\n if (pathname !== IMAGES.path && !pathname.startsWith(\\`\\${IMAGES.path}/\\`)) return null\n const user = await currentUser(env.DB, request)\n if (!user) return Response.json({ error: 'Sign in first' }, { status: 401 })\n return handleUploads(IMAGES, env.SOCIAL_MEDIA, { prefix: mediaKey(user.id, '') })(request)\n}\n\nconst MEDIA_PATH = '/api/social/media/'\n\nasync function mediaSigningKey(env: SocialEnv): Promise<CryptoKey> {\n const secret = secretOf(env)\n if (secret.length < 32) throw new Error('Set AUTH_SECRET (32+ characters) to link images')\n return crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(\\`social-media:\\${secret}\\`),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign', 'verify'],\n )\n}\n\nconst toHex = (bytes: ArrayBuffer) =>\n [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('')\n\n/**\n * A link to an image that works without a session until \\`until\\` (ms): Threads and Buffer\n * fetch images by URL. Signed with AUTH_SECRET; the image itself stays private in R2.\n */\nexport async function signedMediaUrl(env: SocialEnv, objectKey: string, until: number) {\n if (!env.APP_URL) throw new Error('Set APP_URL in wrangler.jsonc so networks can fetch images')\n const expires = String(Math.ceil(until / 1000))\n const signature = await crypto.subtle.sign(\n 'HMAC',\n await mediaSigningKey(env),\n new TextEncoder().encode(\\`\\${objectKey}:\\${expires}\\`),\n )\n const path = MEDIA_PATH + objectKey.split('/').map(encodeURIComponent).join('/')\n const url = new URL(path, env.APP_URL)\n url.searchParams.set('expires', expires)\n url.searchParams.set('signature', toHex(signature))\n return url.href\n}\n\n/** \\`GET /api/social/media/<key>?expires&signature\\`: an image, for a link \\`signedMediaUrl\\` made. */\nexport async function media(env: SocialEnv, request: Request): Promise<Response | null> {\n const url = new URL(request.url)\n if (request.method !== 'GET' || !url.pathname.startsWith(MEDIA_PATH)) return null\n const refused = () => new Response('Not found', { status: 404 })\n let objectKey: string\n try {\n objectKey = decodeURIComponent(url.pathname.slice(MEDIA_PATH.length))\n } catch {\n return refused()\n }\n const expires = url.searchParams.get('expires') ?? ''\n const signature = url.searchParams.get('signature') ?? ''\n if (!/^\\\\d{1,12}$/.test(expires) || Number(expires) * 1000 < Date.now()) return refused()\n if (!/^[0-9a-f]{64}$/.test(signature)) return refused()\n const bytes = new Uint8Array(signature.match(/../g)!.map((pair) => parseInt(pair, 16)))\n const valid = await crypto.subtle.verify(\n 'HMAC',\n await mediaSigningKey(env),\n bytes,\n new TextEncoder().encode(\\`\\${objectKey}:\\${expires}\\`),\n )\n if (!valid) return refused()\n const object = await env.SOCIAL_MEDIA.get(objectKey)\n if (!object) return refused()\n return new Response(object.body, {\n headers: {\n 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',\n 'cache-control': 'private, max-age=300',\n 'x-content-type-options': 'nosniff',\n },\n })\n}\n\n/** A Buffer channel's account id: its connection, and the channel within it. */\nexport const BUFFER_SEPARATOR = '~'\n\n/** Buffer's request budget is small: its channel list is kept for an hour. */\nconst CHANNELS_TTL_MS = 60 * 60 * 1000\n\ninterface ChannelRow {\n id: string\n service: string\n name: string\n fetchedAt: number\n}\n\nfunction parseChannelRow(raw: unknown): ChannelRow {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed channel row')\n const { channel_id, service, name, fetched_at } = raw as Record<string, unknown>\n if (typeof channel_id !== 'string' || typeof service !== 'string' || typeof name !== 'string') {\n throw new Error('Malformed channel row')\n }\n return { id: channel_id, service, name, fetchedAt: Number(fetched_at) }\n}\n\n/**\n * Buffer's request budget (\\`BUFFER_BUDGET\\` per 15 minutes, for every user of this app\n * together), counted per window as the app spends it, so the page can show it and channel lists\n * can yield when it runs low. Buffer enforces the real one (a 429 with Retry-After).\n */\nconst BUFFER_WINDOW_MS = 15 * 60 * 1000\n/** Below this, channel lists are not refreshed: the rest is kept for posting. */\nconst BUFFER_RESERVE = 20\n\nconst bufferWindow = () => Math.floor(Date.now() / BUFFER_WINDOW_MS)\n\n/** Counts one Buffer request against the current window. */\nexport async function spendBuffer(env: SocialEnv): Promise<void> {\n await migrate(env.DB, migrations)\n await env.DB.batch([\n env.DB.prepare(\n \\`INSERT INTO social_buffer_budget (slot, used) VALUES (?, 1)\n ON CONFLICT (slot) DO UPDATE SET used = used + 1\\`,\n ).bind(bufferWindow()),\n env.DB.prepare('DELETE FROM social_buffer_budget WHERE slot < ?').bind(bufferWindow() - 1),\n ])\n}\n\n/** Buffer requests this app has made in the current window. */\nasync function bufferUsed(env: SocialEnv): Promise<number> {\n await migrate(env.DB, migrations)\n const [row] = await queryRows(\n env.DB,\n 'SELECT used FROM social_buffer_budget WHERE slot = ?',\n [bufferWindow()],\n (raw) =>\n typeof raw === 'object' && raw !== null ? Number((raw as { used?: unknown }).used) : 0,\n )\n return row ?? 0\n}\n\n/**\n * A Buffer connection's channels, from D1 when fresh, else from Buffer. If Buffer cannot be\n * asked, or its budget is running low, the last list known is used.\n */\nasync function channelsOf(env: SocialEnv, connection: Connection, userId: string) {\n const cached = await queryRows(\n env.DB,\n 'SELECT channel_id, service, name, fetched_at FROM social_buffer_channels WHERE connection_id = ?',\n [connection.id],\n parseChannelRow,\n )\n const fresh = cached.length > 0 && cached.every((c) => Date.now() - c.fetchedAt < CHANNELS_TTL_MS)\n if (fresh || (cached.length > 0 && (await bufferUsed(env)) > BUFFER_BUDGET - BUFFER_RESERVE)) {\n return cached\n }\n try {\n const { tokens } = await connectionTokens(\n env.DB,\n { secret: secretOf(env), providers: socialProviders(env) },\n { connectionId: connection.id, userId },\n )\n await spendBuffer(env)\n const listed = await bufferChannels(tokens, connection.subject)\n await env.DB.batch([\n env.DB.prepare('DELETE FROM social_buffer_channels WHERE connection_id = ?').bind(\n connection.id,\n ),\n ...listed.map((c) =>\n env.DB.prepare(\n \\`INSERT INTO social_buffer_channels (connection_id, channel_id, service, name, fetched_at)\n VALUES (?, ?, ?, ?, ?)\\`,\n ).bind(connection.id, c.id, c.service, c.name, Date.now()),\n ),\n ])\n return listed\n } catch (error) {\n console.warn('[social] could not list Buffer channels:', error)\n return cached\n }\n}\n\n/** A Mastodon server's limits change rarely: they are kept for a day. */\nconst LIMITS_TTL_MS = 24 * 60 * 60 * 1000\n\n/**\n * A Mastodon server's own limits, from D1 when fresh, else from the server. Shared by every\n * account on that server; a server that does not answer gets Mastodon's defaults for now.\n */\nexport async function serverLimits(env: SocialEnv, server: string): Promise<ServerLimits> {\n await migrate(env.DB, migrations)\n const [cached] = await queryRows(\n env.DB,\n \\`SELECT max_chars AS maxChars, max_images AS maxImages, url_weight AS urlWeight, fetched_at AS fetchedAt\n FROM social_server_limits WHERE server = ?\\`,\n [server],\n (raw) => {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed limits row')\n const { maxChars, maxImages, urlWeight, fetchedAt } = raw as Record<string, unknown>\n return {\n limits: {\n maxChars: Number(maxChars),\n maxImages: Number(maxImages),\n urlWeight: Number(urlWeight),\n },\n fetchedAt: Number(fetchedAt),\n }\n },\n )\n if (cached && Date.now() - cached.fetchedAt < LIMITS_TTL_MS) return cached.limits\n const limits = await mastodonServerLimits(server)\n await env.DB.prepare(\n \\`INSERT INTO social_server_limits (server, max_chars, max_images, url_weight, fetched_at)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT (server) DO UPDATE SET max_chars = excluded.max_chars,\n max_images = excluded.max_images, url_weight = excluded.url_weight,\n fetched_at = excluded.fetched_at\\`,\n )\n .bind(server, limits.maxChars, limits.maxImages, limits.urlWeight, Date.now())\n .run()\n return limits\n}\n\nasync function userAccounts(env: SocialEnv, userId: string): Promise<Account[]> {\n await migrate(env.DB, migrations)\n const accounts: Account[] = []\n for (const connection of await listConnections(env.DB, userId)) {\n if (!isNetwork(connection.provider)) continue\n if (connection.provider !== 'buffer') {\n accounts.push({\n id: connection.id,\n network: connection.provider,\n label: connection.handle ?? connection.name ?? connection.subject,\n status: connection.status,\n service: null,\n limits:\n connection.provider === 'mastodon' && connection.server\n ? await serverLimits(env, connection.server)\n : null,\n })\n continue\n }\n // Each channel in Buffer is an account of its own to post to.\n for (const channel of await channelsOf(env, connection, userId)) {\n accounts.push({\n id: \\`\\${connection.id}\\${BUFFER_SEPARATOR}\\${channel.id}\\`,\n network: 'buffer',\n label: \\`\\${channel.name} (\\${channel.service}, via Buffer)\\`,\n status: connection.status,\n service: channel.service,\n limits: null,\n })\n }\n }\n return accounts\n}\n\nexport async function readPosts(db: Database, where: string, params: unknown[]) {\n await migrate(db, migrations)\n const posts = await queryRows(\n db,\n \\`SELECT id, text, link_url AS linkUrl, link_title AS linkTitle, images, at, in_buffer AS inBuffer,\n status FROM social_posts WHERE \\${where} ORDER BY at DESC LIMIT 50\\`,\n params,\n (raw) => raw as Record<string, unknown>,\n )\n if (posts.length === 0) return []\n const ids = posts.map((p) => String(p['id']))\n const targets = await queryRows(\n db,\n \\`SELECT post_id AS postId, account_id AS accountId, network, service, label, status, url, error\n FROM social_targets WHERE post_id IN (\\${ids.map(() => '?').join(', ')})\\`,\n ids,\n (raw) => raw as Record<string, unknown>,\n )\n return posts.map((p) =>\n parseScheduledPost({\n id: p['id'],\n text: p['text'],\n link:\n typeof p['linkUrl'] === 'string'\n ? { url: p['linkUrl'], title: p['linkTitle'] ?? '' }\n : null,\n images: JSON.parse(String(p['images'] ?? '[]')) as unknown,\n at: p['at'],\n inBuffer: p['inBuffer'],\n status: p['status'],\n targets: targets.filter((t) => t['postId'] === p['id']),\n }),\n )\n}\n\n/** The signed-in user's networks, accounts and recent posts: everything /social shows. */\nexport async function getSocial(env: SocialEnv, request: Request): Promise<Social> {\n const user = await requireUser(env.DB, request)\n return {\n networks: socialProviders(env)\n .map((p) => p.id)\n .filter(isNetwork),\n accounts: await userAccounts(env, user.id),\n posts: await readPosts(env.DB, 'user_id = ?', [user.id]),\n bufferUsed: env.BUFFER_CLIENT_ID ? await bufferUsed(env) : null,\n }\n}\n\n/**\n * Schedules a post: each account must be the user's and connected, and each network's\n * publisher must accept the post, before anything is stored. A Workflow then waits until the\n * time and posts to each account (worker/social-post.ts).\n */\nexport async function schedulePost(\n env: SocialEnv,\n request: Request,\n input: PostInput,\n): Promise<ScheduledPost> {\n const user = await requireUser(env.DB, request)\n const accounts = new Map((await userAccounts(env, user.id)).map((a) => [a.id, a]))\n const chosen = input.accountIds.map((id) => {\n const account = accounts.get(id)\n if (!account) throw new HttpError(400, 'Pick accounts you have connected')\n if (account.status === 'reconnect') {\n throw new HttpError(400, \\`Connect \\${account.label} again first\\`)\n }\n return account\n })\n // Each image must be one this user uploaded; its stored type is the one that counts.\n const images: PostImage[] = []\n for (const image of input.images) {\n const stored = await env.SOCIAL_MEDIA.head(mediaKey(user.id, image.key))\n const type = stored?.httpMetadata?.contentType\n if (!stored || !type) throw new HttpError(400, 'An image is missing: attach it again')\n images.push({ ...image, type })\n }\n const post = asSocialPost({ ...input, images })\n for (const account of chosen) {\n const problems = publisherFor(account).check(post)\n if (problems.length > 0) {\n throw new HttpError(400, \\`\\${account.label}: \\${problems.map((p) => p.message).join('; ')}\\`)\n }\n }\n const now = Date.now()\n const at = input.at ? Date.parse(input.at) : now\n if (at > now + MAX_DAYS_AHEAD * 86_400_000) {\n throw new HttpError(400, \\`Schedule at most \\${MAX_DAYS_AHEAD} days ahead\\`)\n }\n const id = crypto.randomUUID()\n const status: PostStatus = 'scheduled'\n await migrate(env.DB, migrations)\n await env.DB.batch([\n env.DB.prepare(\n \\`INSERT INTO social_posts (id, user_id, text, link_url, link_title, images, at, in_buffer,\n status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\\`,\n ).bind(\n id,\n user.id,\n input.text,\n input.link?.url ?? null,\n input.link?.title ?? null,\n JSON.stringify(images),\n new Date(Math.max(at, now)).toISOString(),\n input.inBuffer ? 1 : 0,\n status,\n new Date(now).toISOString(),\n ),\n ...chosen.map((account) =>\n env.DB.prepare(\n \\`INSERT INTO social_targets (post_id, account_id, network, service, label, status)\n VALUES (?, ?, ?, ?, ?, 'pending')\\`,\n ).bind(id, account.id, account.network, account.service, account.label),\n ),\n ])\n await env.SOCIAL_POST.create({ id, params: { postId: id, userId: user.id } })\n const [stored] = await readPosts(env.DB, 'id = ?', [id])\n if (!stored) throw new Error('The post was not stored')\n return stored\n}\n\n/** Cancels a post that has not started going out. */\nexport async function cancelPost(\n env: SocialEnv,\n request: Request,\n id: string,\n): Promise<ScheduledPost> {\n const user = await requireUser(env.DB, request)\n await migrate(env.DB, migrations)\n const cancelled = await queryRows(\n env.DB,\n \\`UPDATE social_posts SET status = 'cancelled'\n WHERE id = ? AND user_id = ? AND status = 'scheduled' RETURNING id\\`,\n [id, user.id],\n (raw) => raw,\n )\n if (cancelled.length === 0) throw new HttpError(409, 'Only a scheduled post can be cancelled')\n try {\n await (await env.SOCIAL_POST.get(id)).terminate()\n } catch (error) {\n // The Workflow checks the status before it posts, so a missed terminate posts nothing.\n console.warn('[social] could not stop the workflow:', error)\n }\n const [post] = await readPosts(env.DB, 'id = ?', [id])\n if (!post) throw new Error('The post disappeared')\n return post\n}\n`\n}\n\nfunction cfSocialPostTs(): string {\n return `import { queryRows } from '@cascivo/app/db'\nimport { ConnectionError, connectionTokens, markReconnect } from '@cascivo/app/oauth-server'\nimport { PublishError } from '@cascivo/app/social'\nimport { WorkflowEntrypoint } from 'cloudflare:workers'\nimport type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'\nimport { asSocialPost, NETWORKS, postsOnce, publisherFor } from '../src/social'\nimport type { ImageLink, PostImage, ScheduledPost, Target } from '../src/social'\nimport type { Env } from './index'\nimport {\n BUFFER_SEPARATOR,\n mediaKey,\n readPosts,\n secretOf,\n serverLimits,\n signedMediaUrl,\n socialProviders,\n spendBuffer,\n} from './social'\nimport type { SocialPostParams } from './social'\n\ntype TargetResult = Pick<Target, 'status' | 'url' | 'error'>\n\n/**\n * One scheduled post, as a Workflow: it sleeps until the post is due, then posts to each\n * account in its own step, so one network failing does not hold up or repeat the others.\n *\n * Retrying is decided per network. Mastodon takes an idempotency key and Bluesky a fixed record\n * key, so a step that failed mid-request is retried and cannot post twice. Buffer, LinkedIn and\n * Threads take none (\\`postsOnce\\`): their steps are never retried, and an account found mid-post\n * (a crash between the request and the record) is reported for a person to check instead of\n * being posted again.\n */\nexport class SocialPost extends WorkflowEntrypoint<Env, SocialPostParams> {\n override async run(event: WorkflowEvent<SocialPostParams>, step: WorkflowStep) {\n const { postId, userId } = event.payload\n const post = await step.do('load', () => loadPost(this.env, postId))\n if (!post) return\n // A Workflow refuses to sleep until a past time (\"post now\"). Decided in a step, so a\n // replay after the sleep takes the same path.\n const due = await step.do('due later', async () => Date.parse(post.at) > Date.now())\n const postTo = async (target: Target) => {\n const name = \\`post to \\${target.accountId}\\`\n try {\n await step.do(\n name,\n postsOnce(target.network)\n ? { retries: { limit: 0, delay: '1 second' } }\n : { retries: { limit: 3, delay: '30 seconds', backoff: 'exponential' } },\n () => publishTo(this.env, post, target, userId),\n )\n } catch (error) {\n await step.do(\\`\\${name}: give up\\`, () =>\n record(this.env, postId, target.accountId, {\n status: 'failed',\n url: null,\n error: postsOnce(target.network)\n ? \\`\\${NETWORKS[target.network]} did not answer. Check before posting this again.\\`\n : \\`\\${NETWORKS[target.network]} did not take it: \\${String(error)}\\`,\n }),\n )\n }\n }\n // \"Let Buffer hold it\": Buffer accounts go to Buffer now, with the post's time, and wait in\n // Buffer's queue (editable there) instead of here. Cancelling here cannot withdraw them.\n const handedOver =\n due && post.inBuffer ? post.targets.filter((t) => t.network === 'buffer') : []\n for (const target of handedOver) await postTo(target)\n if (due) await step.sleepUntil('wait until due', new Date(post.at))\n const started = await step.do('start', () =>\n setStatus(this.env, postId, 'scheduled', 'publishing'),\n )\n // Cancelled while it waited.\n if (!started) return\n for (const target of post.targets) {\n if (!handedOver.includes(target)) await postTo(target)\n }\n await step.do('finish', () => finish(this.env, postId))\n }\n}\n\nasync function loadPost(env: Env, postId: string): Promise<ScheduledPost | null> {\n const [post] = await readPosts(env.DB, 'id = ?', [postId])\n return post ?? null\n}\n\nasync function setStatus(env: Env, postId: string, from: string, to: string): Promise<boolean> {\n const rows = await queryRows(\n env.DB,\n 'UPDATE social_posts SET status = ? WHERE id = ? AND status = ? RETURNING id',\n [to, postId, from],\n (raw) => raw,\n )\n return rows.length === 1\n}\n\nasync function record(env: Env, postId: string, accountId: string, result: TargetResult) {\n await env.DB.prepare(\n 'UPDATE social_targets SET status = ?, url = ?, error = ? WHERE post_id = ? AND account_id = ?',\n )\n .bind(result.status, result.url, result.error, postId, accountId)\n .run()\n}\n\nasync function publishTo(env: Env, post: ScheduledPost, target: Target, userId: string) {\n // Claim the account before calling the network, so a second attempt knows a first began.\n const claimed = await queryRows(\n env.DB,\n \\`UPDATE social_targets SET status = 'publishing'\n WHERE post_id = ? AND account_id = ? AND status = 'pending' RETURNING status\\`,\n [post.id, target.accountId],\n (raw) => raw,\n )\n if (claimed.length === 0) {\n const [current] = await readPosts(env.DB, 'id = ?', [post.id])\n const now = current?.targets.find((t) => t.accountId === target.accountId)\n if (!now || now.status !== 'publishing') return\n if (postsOnce(target.network)) {\n await record(env, post.id, target.accountId, {\n status: 'failed',\n url: null,\n error: \\`Interrupted while posting. Check \\${NETWORKS[target.network]} before posting this again.\\`,\n })\n return\n }\n }\n // A Buffer channel's id is its connection's, then the channel's.\n const [connectionId = '', channelId] = target.accountId.split(BUFFER_SEPARATOR)\n try {\n const { connection, tokens } = await connectionTokens(\n env.DB,\n { secret: secretOf(env), providers: socialProviders(env) },\n { connectionId, userId },\n )\n // Checked again as it goes out, against the same server limits the composer used.\n const limits =\n target.network === 'mastodon' && connection.server\n ? await serverLimits(env, connection.server)\n : null\n // The images' bytes, from R2. Threads and Buffer fetch them instead, by a signed link that\n // lasts until a day after the post is due (Buffer may fetch a queued one only then).\n const data = await Promise.all(post.images.map((image) => loadImage(env, userId, image)))\n const keys = new Map(data.map((blob, i) => [blob, mediaKey(userId, post.images[i]!.key)]))\n const until = Math.max(Date.parse(post.at), Date.now()) + 86_400_000\n const imageLink: ImageLink = (image) => signedMediaUrl(env, keys.get(image.data)!, until)\n if (target.network === 'buffer') await spendBuffer(env)\n const published = await publisherFor({ ...target, limits }, imageLink).publish(\n { tokens, subject: channelId ?? connection.subject, server: connection.server },\n asSocialPost(post, data),\n // Fixed for every attempt: Mastodon's key, and Bluesky's record key, make a retry safe.\n { idempotencyKey: \\`\\${post.id}:\\${target.accountId}\\`, createdAt: new Date(post.at) },\n )\n await record(env, post.id, target.accountId, {\n // Buffer, given a later time, holds the post in its queue.\n status:\n target.network === 'buffer' && Date.parse(post.at) > Date.now() + 60_000\n ? 'queued'\n : 'posted',\n url: published.url,\n error: null,\n })\n } catch (error) {\n if (\n error instanceof ConnectionError ||\n (error instanceof PublishError && error.kind === 'reconnect')\n ) {\n await markReconnect(env.DB, connectionId)\n await record(env, post.id, target.accountId, {\n status: 'failed',\n url: null,\n error: \\`\\${target.label} needs connecting again before it can post.\\`,\n })\n return\n }\n if (error instanceof PublishError && !error.retryable) {\n await record(env, post.id, target.accountId, {\n status: 'failed',\n url: null,\n error: error.message,\n })\n return\n }\n // Retryable, or the network did not answer: the step's retry policy decides.\n throw error\n }\n}\n\nasync function loadImage(env: Env, userId: string, image: PostImage): Promise<Blob> {\n const object = await env.SOCIAL_MEDIA.get(mediaKey(userId, image.key))\n if (!object) throw new PublishError('social', 'invalid', 'An image of this post is gone')\n return new Blob([await new Response(object.body).arrayBuffer()], { type: image.type })\n}\n\nasync function finish(env: Env, postId: string) {\n const [post] = await readPosts(env.DB, 'id = ?', [postId])\n if (!post) return\n const posted = post.targets.filter((t) => t.status === 'posted' || t.status === 'queued').length\n const status = posted === post.targets.length ? 'done' : posted > 0 ? 'partial' : 'failed'\n await setStatus(env, postId, 'publishing', status)\n}\n`\n}\n\nfunction cfSocialRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { startUpload } from '@cascivo/app/uploads'\nimport type { Upload } from '@cascivo/app/uploads'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n Checkbox,\n FileUploader,\n Flex,\n Heading,\n Input,\n Link,\n Spinner,\n Text,\n Textarea,\n signal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport type { UploaderFile } from '@cascivo/react'\nimport type { FormEvent } from 'react'\nimport { api } from '../api'\nimport { auth } from '../auth'\nimport { router } from '../router'\nimport { asSocialPost, BUFFER_BUDGET, IMAGES, MAX_IMAGES, NETWORKS, publisherFor } from '../social'\nimport type { Account, PostImage, PostStatus, Social, TargetStatus } from '../social'\n\nconst client = createClient(api)\nconst social = signal<Social | null>(null)\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n// The composer. Kept in signals so each network's check runs as you type.\nconst text = signal('')\nconst linkUrl = signal('')\nconst linkTitle = signal('')\nconst at = signal('')\nconst chosen = signal<string[]>([])\nconst inBuffer = signal(false)\n// Images go up to R2 as soon as they are picked; each needs a description before posting.\nconst uploads = signal<Upload[]>([])\nconst alts = signal<Record<string, string>>({})\n\nfunction addImages(files: File[]): void {\n const room = MAX_IMAGES - uploads.value.length\n uploads.value = [...uploads.value, ...files.slice(0, room).map((f) => startUpload(IMAGES, f))]\n}\n\nfunction removeImage(id: string): void {\n uploads.value.find((u) => u.id === id)?.abort()\n uploads.value = uploads.value.filter((u) => u.id !== id)\n}\n\n/** The uploaded images, in order, with their descriptions. */\nfunction attached(): PostImage[] {\n return uploads.value.flatMap((upload) => {\n const stored = upload.result.value\n return stored ? [{ key: stored.key, type: stored.type, alt: alts.value[upload.id] ?? '' }] : []\n })\n}\n\nasync function load(): Promise<void> {\n try {\n social.value = await client.getSocial()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not load your accounts'\n }\n}\n\n/** Why connecting an account failed: /api/connections sends it back as \\`?error=\\`. */\nconst REASONS: Record<string, string> = {\n denied: 'You did not allow access, so nothing was connected.',\n expired: 'Connecting took too long, or started in another browser. Try again.',\n state_mismatch: 'That did not match the connection this browser started. Try again.',\n provider_error: 'The network did not confirm the account. Try again.',\n bad_server: 'That server or handle could not be reached, or did not check out. Check the name.',\n signed_out: 'Sign in first, then connect an account.',\n}\n\n/** Badge's tones. */\ntype Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'\n\nconst ACCOUNT_TONE: Record<Account['status'], Tone> = {\n active: 'success',\n expiring: 'warning',\n reconnect: 'danger',\n}\nconst POST_TONE: Record<PostStatus, Tone> = {\n scheduled: 'info',\n publishing: 'info',\n done: 'success',\n partial: 'warning',\n failed: 'danger',\n cancelled: 'neutral',\n}\nconst TARGET_TONE: Record<TargetStatus, Tone> = {\n pending: 'neutral',\n publishing: 'info',\n queued: 'info',\n posted: 'success',\n failed: 'danger',\n}\n\nfunction draft() {\n const url = linkUrl.value.trim()\n return asSocialPost({\n text: text.value,\n link: url ? { url, title: linkTitle.value.trim() } : null,\n images: attached(),\n })\n}\n\n/** What each chosen network would refuse, from the same checks the Worker runs. */\nfunction problems(accounts: Account[]): string[] {\n return accounts\n .filter((a) => chosen.value.includes(a.id))\n .flatMap((account) =>\n publisherFor(account)\n .check(draft())\n .map((p) => \\`\\${account.label}: \\${p.message}\\`),\n )\n}\n\nasync function schedule(event: FormEvent<HTMLFormElement>): Promise<void> {\n event.preventDefault()\n busy.value = true\n failure.value = null\n try {\n const url = linkUrl.value.trim()\n await client.schedulePost({\n body: {\n text: text.value,\n link: url ? { url, title: linkTitle.value.trim() } : null,\n images: attached(),\n accountIds: chosen.value,\n // datetime-local is the browser's local time; the Worker keeps UTC.\n at: at.value ? new Date(at.value).toISOString() : null,\n inBuffer: inBuffer.value,\n },\n })\n text.value = ''\n linkUrl.value = ''\n linkTitle.value = ''\n at.value = ''\n inBuffer.value = false\n uploads.value = []\n alts.value = {}\n await load()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Not scheduled'\n } finally {\n busy.value = false\n }\n}\n\nasync function cancel(id: string): Promise<void> {\n try {\n await client.cancelPost({ params: { id } })\n await load()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Not cancelled'\n }\n}\n\nasync function remove(id: string): Promise<void> {\n // A Buffer channel goes with its whole Buffer connection (the id before the ~).\n const connectionId = id.split('~')[0] ?? id\n const response = await fetch(\\`/api/connections/\\${encodeURIComponent(connectionId)}\\`, {\n method: 'DELETE',\n })\n if (!response.ok) failure.value = 'The account was not removed'\n chosen.value = chosen.value.filter((c) => c !== id)\n await load()\n}\n\nfunction toggle(id: string, on: boolean): void {\n chosen.value = on ? [...chosen.value, id] : chosen.value.filter((c) => c !== id)\n}\n\nexport default function SocialPage() {\n useSignals()\n useSignalEffect(() => {\n if (auth.user.value) void load()\n })\n const user = auth.user.value\n const data = social.value\n const reason = new URLSearchParams(router.search.value).get('error')\n\n if (user === undefined) return <Spinner label=\"Loading\" />\n if (user === null) {\n return (\n <Flex gap={4}>\n <Heading level={1}>Social</Heading>\n <Text>\n <Link href=\"/account\">Sign in</Link> to connect your accounts and schedule posts.\n </Text>\n </Flex>\n )\n }\n const usable = data?.accounts.filter((a) => a.status !== 'reconnect') ?? []\n const images: UploaderFile[] = uploads.value.map((upload) => ({\n id: upload.id,\n name: upload.name,\n size: upload.size,\n status: upload.status.value === 'done' ? 'complete' : upload.status.value,\n ...(upload.error.value ? { errorMessage: upload.error.value } : {}),\n }))\n const uploading = uploads.value.some((upload) => upload.status.value === 'uploading')\n const blocking = data ? problems(data.accounts) : []\n const viaBuffer = usable.some((a) => a.network === 'buffer' && chosen.value.includes(a.id))\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Social</Heading>\n <Text muted>\n Write once, post to Bluesky, LinkedIn, Mastodon and the channels in your Buffer, now or at\n a time you pick.\n </Text>\n </Flex>\n {reason ? (\n <Alert variant=\"destructive\" title=\"Not connected\">\n {REASONS[reason] ?? 'Connecting failed. Try again.'}\n </Alert>\n ) : null}\n {location.hostname === 'localhost' ? (\n <Alert variant=\"info\" title=\"Connecting Bluesky in development\">\n Bluesky's development sign-in only returns to 127.0.0.1: open{' '}\n <Link href={\\`http://127.0.0.1:\\${location.port}/social\\`}>this page on 127.0.0.1</Link>.\n </Alert>\n ) : null}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Something went wrong\">\n {failure.value}\n </Alert>\n ) : null}\n {data === null ? (\n <Spinner label=\"Loading\" />\n ) : (\n <>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Heading level={2}>Accounts</Heading>\n {data.accounts.length === 0 ? <Text muted>No accounts connected yet.</Text> : null}\n {data.accounts.map((account) => (\n <Flex key={account.id} direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Text>\n {NETWORKS[account.network]}: {account.label}\n </Text>\n <Badge variant={ACCOUNT_TONE[account.status]}>\n {account.status === 'expiring'\n ? 'expires soon: connect again'\n : account.status}\n </Badge>\n <Button size=\"sm\" variant=\"ghost\" onClick={() => void remove(account.id)}>\n Remove\n </Button>\n </Flex>\n ))}\n {data.bufferUsed !== null ? (\n <Text muted>\n Buffer requests in the last 15 minutes, for everyone on this app:{' '}\n {data.bufferUsed} of {BUFFER_BUDGET}.\n </Text>\n ) : null}\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n {data.networks.includes('buffer') ? (\n <Button asChild variant=\"secondary\">\n <a href=\"/api/connections/buffer?returnTo=/social\">Connect Buffer</a>\n </Button>\n ) : null}\n {data.networks.includes('linkedin') ? (\n <Button asChild variant=\"secondary\">\n <a href=\"/api/connections/linkedin?returnTo=/social\">Connect LinkedIn</a>\n </Button>\n ) : null}\n {data.networks.includes('threads') ? (\n <Button asChild variant=\"secondary\">\n <a href=\"/api/connections/threads?returnTo=/social\">Connect Threads</a>\n </Button>\n ) : null}\n {/* Plain GET forms: the Worker redirects to the account or server named. */}\n <form method=\"get\" action=\"/api/connections/bluesky\">\n <input type=\"hidden\" name=\"returnTo\" value=\"/social\" />\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input\n name=\"server\"\n label=\"Bluesky handle\"\n placeholder=\"you.bsky.social\"\n required\n />\n <Button type=\"submit\" variant=\"secondary\">\n Connect Bluesky\n </Button>\n </Flex>\n </form>\n <form method=\"get\" action=\"/api/connections/mastodon\">\n <input type=\"hidden\" name=\"returnTo\" value=\"/social\" />\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input\n name=\"server\"\n label=\"Mastodon server\"\n placeholder=\"mastodon.social\"\n required\n />\n <Button type=\"submit\" variant=\"secondary\">\n Connect Mastodon\n </Button>\n </Flex>\n </form>\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n <Card>\n <CardContent>\n <form onSubmit={(event) => void schedule(event)}>\n <Flex gap={3}>\n <Heading level={2}>New post</Heading>\n <Textarea\n label=\"Text\"\n value={text.value}\n onInput={(event) => (text.value = event.currentTarget.value)}\n rows={5}\n />\n <Flex direction=\"horizontal\" gap={2} wrap>\n <Input\n label=\"Link (optional)\"\n type=\"url\"\n value={linkUrl.value}\n onInput={(event) => (linkUrl.value = event.currentTarget.value)}\n />\n <Input\n label=\"Link title (LinkedIn shows it)\"\n value={linkTitle.value}\n onInput={(event) => (linkTitle.value = event.currentTarget.value)}\n />\n </Flex>\n <FileUploader\n multiple\n label={\\`Images (up to \\${MAX_IMAGES}, JPEG or PNG, 1 MB each)\\`}\n accept={IMAGES.types.join(',')}\n maxSize={IMAGES.maxBytes}\n files={images}\n onFilesAdded={addImages}\n onRemove={removeImage}\n />\n {uploads.value\n .filter((upload) => upload.status.value === 'done')\n .map((upload) => (\n <Input\n key={upload.id}\n label={\\`Describe \\${upload.name} (alt text)\\`}\n required\n value={alts.value[upload.id] ?? ''}\n onInput={(event) =>\n (alts.value = { ...alts.value, [upload.id]: event.currentTarget.value })\n }\n />\n ))}\n {usable.length === 0 ? (\n <Text muted>Connect an account to post.</Text>\n ) : (\n usable.map((account) => (\n <Checkbox\n key={account.id}\n label={\\`\\${NETWORKS[account.network]}: \\${account.label}\\`}\n checked={chosen.value.includes(account.id)}\n onChange={(event) => toggle(account.id, event.currentTarget.checked)}\n />\n ))\n )}\n <Input\n label=\"When (empty: now)\"\n type=\"datetime-local\"\n value={at.value}\n onInput={(event) => (at.value = event.currentTarget.value)}\n />\n {at.value && viaBuffer ? (\n <Checkbox\n label=\"Let Buffer hold it: Buffer accounts go to Buffer's queue now, where you can still edit them\"\n checked={inBuffer.value}\n onChange={(event) => (inBuffer.value = event.currentTarget.checked)}\n />\n ) : null}\n {blocking.length > 0 ? (\n <Alert variant=\"warning\" title=\"Not ready to post\">\n {blocking.join(' ')}\n </Alert>\n ) : null}\n <Flex direction=\"horizontal\">\n <Button\n type=\"submit\"\n loading={busy.value}\n disabled={chosen.value.length === 0 || blocking.length > 0 || uploading}\n >\n {at.value ? 'Schedule' : 'Post now'}\n </Button>\n </Flex>\n </Flex>\n </form>\n </CardContent>\n </Card>\n <Flex gap={3}>\n <Heading level={2}>Posts</Heading>\n {data.posts.length === 0 ? <Text muted>Nothing scheduled yet.</Text> : null}\n {data.posts.map((post) => (\n <Card key={post.id}>\n <CardContent>\n <Flex gap={2}>\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={POST_TONE[post.status]}>{post.status}</Badge>\n <Text muted>{new Date(post.at).toLocaleString()}</Text>\n {post.status === 'scheduled' ? (\n <Button size=\"sm\" variant=\"ghost\" onClick={() => void cancel(post.id)}>\n Cancel\n </Button>\n ) : null}\n </Flex>\n <Text>{post.text}</Text>\n {post.targets.map((target) => (\n <Flex\n key={target.accountId}\n direction=\"horizontal\"\n align=\"center\"\n gap={2}\n wrap\n >\n {post.status === 'cancelled' && target.status === 'queued' ? (\n <Badge variant=\"warning\">still in Buffer's queue</Badge>\n ) : post.status === 'cancelled' ? (\n <Badge variant=\"neutral\">not sent</Badge>\n ) : (\n <Badge variant={TARGET_TONE[target.status]}>{target.status}</Badge>\n )}\n <Text>\n {NETWORKS[target.network]}: {target.label}\n </Text>\n {target.url ? <Link href={target.url}>View</Link> : null}\n {target.error ? <Text muted>{target.error}</Text> : null}\n </Flex>\n ))}\n </Flex>\n </CardContent>\n </Card>\n ))}\n </Flex>\n </>\n )}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--auth email`: accounts with emailed sign-in links (@cascivo/app/auth) --- */\n\nfunction cfAuthTs(): string {\n return `import { createAuth } from '@cascivo/app/auth'\n\n/**\n * Who is signed in, shared by every page: \\`auth.user.value\\` is \\`undefined\\` while the first\n * check runs, then the user or \\`null\\`. The Worker side is \\`handleAuth\\` in worker/index.ts.\n */\nexport const auth = createAuth()\n`\n}\n\nfunction cfAuthWorkerTs(): string {\n return `/** What sending a sign-in link needs of the Email Service binding (\\`send_email\\`). */\nexport interface SignInSender {\n send(message: {\n from: string\n to: string\n subject: string\n text: string\n html: string\n }): Promise<unknown>\n}\n\nconst escape = (value: string) =>\n value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"')\n\n/**\n * Emails a sign-in link. \\`vite dev\\` sends nothing: the link is logged, and the Account page\n * shows it (\\`exposeLink\\` in worker/index.ts).\n */\nexport async function sendSignInLink(\n sender: SignInSender,\n from: string,\n email: string,\n url: string,\n): Promise<void> {\n if (import.meta.env.DEV) {\n console.log(\\`[auth] sign-in link for \\${email}: \\${url}\\`)\n return\n }\n if (!from) throw new Error('Set AUTH_FROM in wrangler.jsonc to an address on your domain')\n await sender.send({\n from,\n to: email,\n subject: 'Your sign-in link',\n text: \\`Sign in: \\${url}\\\\n\\\\nThe link works once, for 15 minutes. If you did not ask for it, ignore this email.\\`,\n html: \\`<p><a href=\"\\${escape(url)}\">Sign in</a></p><p>The link works once, for 15 minutes. If you did not ask for it, ignore this email.</p>\\`,\n })\n}\n`\n}\n\nfunction cfAccountRouteTsx(opts: ScaffoldOptions): string {\n const email = emailSignIn(opts)\n const oauth = oauthSignIn(opts)\n const lead = email\n ? oauth\n ? 'Continue with GitHub, Google or LinkedIn, or get a one-time link by email. No password.'\n : 'No password: we email you a link that signs you in once.'\n : 'Continue with GitHub, Google or LinkedIn. No password.'\n return `import {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,${email ? '\\n Input,\\n Link,' : ''}\n Spinner,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\n${email ? \"import type { FormEvent } from 'react'\\n\" : ''}import { auth } from '../auth'${oauth ? \"\\nimport { router } from '../router'\" : ''}\n${\n email\n ? `\nconst sentTo = signal<string | null>(null)\n/** Set only in \\`vite dev\\`, where no email is sent: the link to open instead. */\nconst devLink = signal<string | null>(null)\nconst failure = signal<string | null>(null)\nconst sending = signal(false)\n\nasync function start(event: FormEvent<HTMLFormElement>): Promise<void> {\n event.preventDefault()\n const email = new FormData(event.currentTarget).get('email')\n if (typeof email !== 'string') return\n failure.value = null\n sending.value = true\n try {\n const { link } = await auth.start(email)\n sentTo.value = email\n devLink.value = link\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not send the link'\n } finally {\n sending.value = false\n }\n}\n`\n : ''\n}${\n oauth\n ? `\nconst LABELS: Record<string, string> = { github: 'GitHub', google: 'Google', linkedin: 'LinkedIn' }\n\n/** The providers the Worker offers: those with an id and a secret set (README). */\nconst providers = signal<string[] | null>(null)\nauth.providers().then(\n (list) => {\n providers.value = list\n },\n () => {\n providers.value = []\n },\n)\n\n/** Why the last sign-in with a provider failed: the Worker sends it back as \\`?error=\\`. */\nconst REASONS: Record<string, string> = {\n denied: 'You did not allow access, so you are not signed in.',\n expired: 'The sign-in took too long, or started in another browser. Try again.',\n state_mismatch: 'That sign-in did not match the one this browser started. Try again.',\n provider_error: 'The provider did not confirm who you are. Try again, or use another way in.',\n identity_in_use: 'That account is already linked to another user here.',\n}\n`\n : ''\n }\nexport default function Account() {\n useSignals()\n const user = auth.user.value${\n oauth\n ? `\n const reason = new URLSearchParams(router.search.value).get('error')`\n : ''\n }\n\n if (user === undefined) return <Spinner label=\"Loading\" />\n if (user) {\n return (\n <Flex gap={4}>\n <Heading level={1}>Account</Heading>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Text>{user.email ? \\`Signed in as \\${user.email}\\` : 'Signed in'}</Text>\n <Flex direction=\"horizontal\">\n <Button variant=\"secondary\" onClick={() => void auth.signOut()}>\n Sign out\n </Button>\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n </Flex>\n )\n }\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Sign in</Heading>\n ${\n // Prettier's width: a lead too long for one line goes on its own.\n `<Text muted>${lead}</Text>`.length + 8 > 100\n ? `<Text muted>\\n ${lead}\\n </Text>`\n : `<Text muted>${lead}</Text>`\n }\n </Flex>${\n oauth\n ? `\n {reason ? (\n <Alert variant=\"destructive\" title=\"Not signed in\">\n {REASONS[reason] ?? 'Sign-in failed. Try again.'}\n </Alert>\n ) : null}\n {providers.value === null ? (\n <Spinner label=\"Loading\" />\n ) : providers.value.length === 0 ? (\n <Alert variant=\"info\" title=\"No provider is set up yet\">\n Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, or the GOOGLE_ pair, in .dev.vars (README).\n </Alert>\n ) : (\n <Flex direction=\"horizontal\" gap={2} wrap>\n {providers.value.map((id) => (\n // A plain link: the Worker redirects to the provider, and back here afterwards.\n <Button key={id} asChild variant=\"secondary\">\n <a href={auth.signInUrl(id, '/account')}>Continue with {LABELS[id] ?? id}</a>\n </Button>\n ))}\n </Flex>\n )}`\n : ''\n }${\n email\n ? `\n {sentTo.value ? (\n <Alert variant=\"success\" title=\"Check your email\">\n We sent a sign-in link to {sentTo.value}. It works once, for 15 minutes.\n </Alert>\n ) : null}\n {devLink.value ? (\n <Alert variant=\"info\" title=\"vite dev sends no email\">\n <Link href={devLink.value}>Open the sign-in link</Link>\n </Alert>\n ) : null}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not sent\">\n {failure.value}\n </Alert>\n ) : null}\n <form onSubmit={(event) => void start(event)}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input name=\"email\" type=\"email\" label=\"Email\" autoComplete=\"email\" required />\n <Button type=\"submit\" loading={sending.value}>\n Email me a link\n </Button>\n </Flex>\n </form>`\n : ''\n }\n </Flex>\n )\n}\n`\n}\n\nfunction cfVerifyRouteTsx(): string {\n return `import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'\nimport { auth } from '../../auth'\nimport { router } from '../../router'\n\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n/**\n * The page a sign-in link opens. It signs in only when you press the button: mail scanners\n * open every link in a message, and a link that signed in on open would be used up by them.\n */\nasync function signIn(): Promise<void> {\n const token = new URLSearchParams(router.search.value).get('token')\n if (!token) {\n failure.value = 'This link has no token. Request a new one.'\n return\n }\n busy.value = true\n failure.value = null\n try {\n await auth.verify(token)\n router.navigate('/account', { replace: true })\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not sign in'\n } finally {\n busy.value = false\n }\n}\n\nexport default function VerifySignIn() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Sign in</Heading>\n <Text muted>Finish signing in on this device.</Text>\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not signed in\">\n {failure.value}\n </Alert>\n ) : null}\n <Flex direction=\"horizontal\">\n <Button loading={busy.value} onClick={() => void signIn()}>\n Sign in\n </Button>\n </Flex>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example publish`: views published as pages, stored in D1 --- */\n\nfunction cfPagesTs(): string {\n return `import type { ViewConfig } from '@cascivo/render'\nimport { validateView } from '@cascivo/render/validate'\n\n/**\n * A published page: a title and a view. Shared by the Worker (which stores pages) and the\n * app (which builds and shows them). A view is data, not code — it can only arrange cascivo\n * components the manifests describe — so publishing one needs no sandbox and no deploy.\n */\nexport interface PageInput {\n title: string\n view: ViewConfig\n}\n\nexport interface PageSummary {\n slug: string\n title: string\n createdAt: string\n}\n\nexport interface Page extends PageInput, PageSummary {}\n\n/** Largest view accepted, in characters of its JSON. */\nexport const MAX_VIEW_LENGTH = 32_000\n\nconst SLUG = /^[a-z0-9]{10}$/\n\nexport function isSlug(value: string): boolean {\n return SLUG.test(value)\n}\n\n/**\n * Checks a page against the component manifests (\\`validateView\\`: known components and\n * props, and no URL that could run script). The Worker runs it before storing a page, and\n * \\`<CascivoView>\\` runs the same check again before rendering one.\n */\nexport function parsePageInput(raw: unknown): PageInput {\n if (typeof raw !== 'object' || raw === null) throw new Error('Expected { title, view }')\n const { title, view } = raw as Record<string, unknown>\n if (typeof title !== 'string' || title.trim() === '' || title.length > 80) {\n throw new Error('title: 1–80 characters')\n }\n if (JSON.stringify(view ?? null).length > MAX_VIEW_LENGTH) {\n throw new Error(\\`view: at most \\${MAX_VIEW_LENGTH} characters of JSON\\`)\n }\n const result = validateView(view)\n if (!result.valid) {\n throw new Error(result.errors.map((e) => \\`\\${e.path}: \\${e.message}\\`).join('\\\\n'))\n }\n // validateView has checked the whole shape, so the cast states a proven fact.\n return { title: title.trim(), view: view as ViewConfig }\n}\n\nexport function parsePageSummary(raw: unknown): PageSummary {\n if (typeof raw === 'object' && raw !== null) {\n const { slug, title, createdAt } = raw as Record<string, unknown>\n if (\n typeof slug === 'string' &&\n isSlug(slug) &&\n typeof title === 'string' &&\n typeof createdAt === 'string'\n ) {\n return { slug, title, createdAt }\n }\n }\n throw new Error('Malformed page')\n}\n\nexport function parsePage(raw: unknown): Page {\n const summary = parsePageSummary(raw)\n const { view } = raw as Record<string, unknown>\n return { ...summary, ...parsePageInput({ title: summary.title, view }) }\n}\n`\n}\n\nfunction cfPagesWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { isSlug, parsePage } from '../src/pages'\nimport type { Page, PageInput, PageSummary } from '../src/pages'\n\nconst migrations = [\n {\n id: '0001_pages',\n statements: [\n \\`CREATE TABLE pages (\n slug TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n view TEXT NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n ],\n },\n]\n\nconst ready = async (db: Database) => {\n await migrate(db, migrations)\n return db\n}\n\n/** Ten random base-36 characters: not guessable, so an unlisted page stays unlisted. */\nfunction newSlug(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(10))\n return Array.from(bytes, (b) => (b % 36).toString(36)).join('')\n}\n\n/**\n * A stored row back into a page. The view is JSON text written by this Worker, but it is\n * parsed and validated again like anything read from storage: an older version of the app,\n * or a hand in the D1 console, may have written it.\n */\nfunction pageFromRow(raw: unknown): Page {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed page row')\n const row = raw as Record<string, unknown>\n if (typeof row['view'] !== 'string') throw new Error('Malformed page row')\n return parsePage({ ...row, view: JSON.parse(row['view']) })\n}\n\nexport async function publishPage(db: Database, page: PageInput): Promise<PageSummary> {\n const summary = { slug: newSlug(), title: page.title, createdAt: new Date().toISOString() }\n await (\n await ready(db)\n )\n .prepare('INSERT INTO pages (slug, title, view, created_at) VALUES (?, ?, ?, ?)')\n .bind(summary.slug, summary.title, JSON.stringify(page.view), summary.createdAt)\n .run()\n return summary\n}\n\nexport async function getPage(db: Database, slug: string): Promise<Page> {\n if (!isSlug(slug)) throw new HttpError(404, 'No such page')\n const [page] = await queryRows(\n await ready(db),\n 'SELECT slug, title, view, created_at AS createdAt FROM pages WHERE slug = ?',\n [slug],\n pageFromRow,\n )\n if (!page) throw new HttpError(404, 'No such page')\n return page\n}\n`\n}\n\nfunction cfPageHtmlTs(): string {\n return `import { CascivoView } from '@cascivo/render'\nimport { Flex, Heading } from '@cascivo/react'\nimport { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport type { Page } from '../src/pages'\n\n/** The static-assets binding (\\`assets.binding\\` in wrangler.jsonc): the built index.html. */\nexport interface Assets {\n fetch(request: Request): Promise<Response>\n}\n\nconst escapeHtml = (value: string) =>\n value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n\nconst ENTITIES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n}\n\n/** Rendered markup back to its visible text, for a link preview's description. */\nfunction textOf(html: string): string {\n return html\n .replace(/<[^>]+>/g, ' ')\n .replace(/&(?:amp|lt|gt|quot|#39|#x27);/g, (entity) => ENTITIES[entity] ?? entity)\n .replace(/\\\\s+/g, ' ')\n .trim()\n}\n\n/** The route file whose stylesheets a published page needs, as the build manifest names it. */\nconst PAGE_ROUTE = 'src/routes/p/[slug].tsx'\n\n/**\n * The stylesheets the published-page route loads, from Vite's build manifest (vite.config.ts\n * writes it as asset-manifest.json). The route is loaded lazily, so its component CSS is\n * linked by JavaScript; a reader without JavaScript needs it linked in the HTML. \\`vite dev\\`\n * has no manifest, and there the CSS arrives with the JavaScript anyway.\n */\nasync function pageStylesheets(request: Request, assets: Assets): Promise<string[]> {\n const response = await assets.fetch(new Request(new URL('/asset-manifest.json', request.url)))\n if (!response.ok) return []\n const manifest: unknown = await response.json().catch(() => null)\n if (typeof manifest !== 'object' || manifest === null) return []\n const chunks = manifest as Record<string, unknown>\n const found = new Set<string>()\n const seen = new Set<string>()\n const walk = (key: string) => {\n if (seen.has(key)) return\n seen.add(key)\n const chunk = chunks[key]\n if (typeof chunk !== 'object' || chunk === null) return\n const { css, imports } = chunk as Record<string, unknown>\n if (Array.isArray(css)) for (const file of css) if (typeof file === 'string') found.add(file)\n if (Array.isArray(imports)) for (const next of imports) if (typeof next === 'string') walk(next)\n }\n walk(PAGE_ROUTE)\n return [...found]\n}\n\n/**\n * The app's index.html for a published page, with the page in it: a title and description\n * for search engines and link previews (which run no JavaScript), and the rendered view in\n * a \\`<noscript>\\` for readers without JavaScript. The app then starts as usual and renders\n * the page itself.\n */\nexport async function renderPageHtml(\n request: Request,\n page: Page,\n assets: Assets,\n imageUrl: string | null,\n): Promise<Response> {\n const shell = await assets.fetch(new Request(new URL('/', request.url)))\n const view = renderToStaticMarkup(createElement(CascivoView, { config: page.view }))\n // The same layout as src/routes/p/[slug].tsx, so it is styled by the same stylesheets.\n const body = renderToStaticMarkup(\n createElement(\n Flex,\n { gap: 4 },\n createElement(Heading, { level: 1 }, page.title),\n createElement(CascivoView, { config: page.view }),\n ),\n )\n const description = textOf(view).slice(0, 160)\n const url = new URL(request.url)\n url.search = ''\n const title = escapeHtml(page.title)\n const meta = [\n \\`<title>\\${title}</title>\\`,\n \\`<meta name=\"description\" content=\"\\${escapeHtml(description)}\" />\\`,\n \\`<link rel=\"canonical\" href=\"\\${escapeHtml(url.href)}\" />\\`,\n '<meta property=\"og:type\" content=\"article\" />',\n \\`<meta property=\"og:title\" content=\"\\${title}\" />\\`,\n \\`<meta property=\"og:description\" content=\"\\${escapeHtml(description)}\" />\\`,\n \\`<meta property=\"og:url\" content=\"\\${escapeHtml(url.href)}\" />\\`,\n ...(imageUrl\n ? [\n \\`<meta property=\"og:image\" content=\"\\${escapeHtml(imageUrl)}\" />\\`,\n '<meta property=\"og:image:width\" content=\"1200\" />',\n '<meta property=\"og:image:height\" content=\"630\" />',\n '<meta name=\"twitter:card\" content=\"summary_large_image\" />',\n ]\n : ['<meta name=\"twitter:card\" content=\"summary\" />']),\n ].join('\\\\n ')\n const stylesheets = (await pageStylesheets(request, assets))\n .map((file) => \\`<link rel=\"stylesheet\" href=\"/\\${escapeHtml(file)}\" />\\`)\n .join('\\\\n ')\n const html = (await shell.text())\n .replace(/<title>[\\\\s\\\\S]*?<\\\\/title>/, stylesheets ? \\`\\${meta}\\\\n \\${stylesheets}\\` : meta)\n .replace(\n '<div id=\"root\"></div>',\n // Before the app's root, which fills the viewport: after it, the page would start below\n // the fold.\n \\`<noscript><main style=\"padding: 1.5rem\">\\${body}</main></noscript>\\\\n <div id=\"root\"></div>\\`,\n )\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' },\n })\n}\n`\n}\n\nfunction cfPagePreviewTs(): string {\n return `import { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { exportPage } from '@cascivo/app/export'\nimport type { ExportBrowser } from '@cascivo/app/export'\nimport { getPage } from './pages'\n\nconst migrations = [\n {\n id: '0001_page_previews',\n statements: ['CREATE TABLE page_previews (slug TEXT PRIMARY KEY, png BLOB NOT NULL)'],\n },\n]\n\n/** A stored BLOB as bytes: D1 returns one as an array of numbers. */\nfunction bytesOf(raw: unknown): Uint8Array | null {\n const png =\n typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>)['png'] : null\n if (png instanceof ArrayBuffer) return new Uint8Array(png)\n if (ArrayBuffer.isView(png)) return new Uint8Array(png.buffer, png.byteOffset, png.byteLength)\n if (Array.isArray(png) && png.every((b) => typeof b === 'number')) return Uint8Array.from(png)\n return null\n}\n\n/**\n * A published page's link-preview image, 1200 × 630: rendered from the page by Browser Run on\n * its first request and kept in D1, so a page shared a thousand times starts one browser.\n */\nexport async function pagePreview(\n db: Database,\n slug: string,\n origin: string,\n launch: () => Promise<ExportBrowser>,\n): Promise<Response> {\n await getPage(db, slug) // 404 for a page that does not exist\n await migrate(db, migrations)\n const [stored] = await queryRows(\n db,\n 'SELECT png FROM page_previews WHERE slug = ?',\n [slug],\n bytesOf,\n )\n let png = stored ?? null\n if (!png) {\n png = await exportPage(launch, new URL(\\`/p/\\${slug}\\`, origin).href, {\n format: 'png',\n viewport: { width: 1200, height: 630 },\n fullPage: false,\n })\n await db\n .prepare('INSERT OR REPLACE INTO page_previews (slug, png) VALUES (?, ?)')\n .bind(slug, png)\n .run()\n }\n return new Response(new Uint8Array(png), {\n headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' },\n })\n}\n`\n}\n\nfunction cfPublishRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { CascivoView } from '@cascivo/render'\nimport type { ViewConfig } from '@cascivo/render'\nimport { validateView } from '@cascivo/render/validate'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Grid,\n Heading,\n Input,\n Link,\n Text,\n Textarea,\n computed,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport type { ChangeEvent } from 'react'\nimport { api } from '../api'\nimport type { PageSummary } from '../pages'\n\nconst client = createClient(api)\n\nconst EXAMPLE: ViewConfig = {\n view: {\n regions: {\n main: [\n {\n component: 'Card',\n props: { padding: 'lg' },\n children: [\n {\n component: 'Flex',\n props: { gap: 3 },\n children: [\n { component: 'Badge', props: { variant: 'success' }, children: 'Live' },\n { component: 'ProgressBar', props: { value: 3, max: 5, label: 'Steps done' } },\n {\n component: 'Alert',\n props: { variant: 'info', title: 'Published from a view' },\n children: 'Edit the JSON on /publish and publish again for a new page.',\n },\n {\n component: 'Link',\n props: { href: 'https://developers.cloudflare.com/workers/' },\n children: 'Cloudflare Workers docs',\n },\n ],\n },\n ],\n },\n ],\n },\n },\n}\n\nconst title = signal('Launch checklist')\nconst draft = signal(JSON.stringify(EXAMPLE, null, 2))\nconst published = signal<PageSummary | null>(null)\nconst failure = signal<string | null>(null)\n\n/**\n * The draft as a view to preview, or what is wrong with it. The Worker checks a page again\n * before storing it: this check is for the preview, not a guard.\n */\nconst checked = computed((): { view: ViewConfig } | { errors: string[] } => {\n let raw: unknown\n try {\n raw = JSON.parse(draft.value)\n } catch (error) {\n return { errors: [error instanceof Error ? error.message : 'Not JSON'] }\n }\n const result = validateView(raw)\n if (!result.valid) return { errors: result.errors.map((e) => \\`\\${e.path}: \\${e.message}\\`) }\n // validateView has checked the whole shape, so the cast states a proven fact.\n return { view: raw as ViewConfig }\n})\n\nasync function publish(): Promise<void> {\n failure.value = null\n published.value = null\n const current = checked.value\n if ('errors' in current) {\n failure.value = current.errors.join('\\\\n')\n return\n }\n try {\n published.value = await client.publishPage({\n body: { title: title.value, view: current.view },\n })\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not publish'\n }\n}\n\nexport default function Publish() {\n useSignals()\n const current = checked.value\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Publish</Heading>\n <Text muted>\n Write a view — or have an agent write one — and publish it as a page. A view only arranges\n this app's components, so a page needs no sandbox and no deploy.\n </Text>\n </Flex>\n <Grid cols={2} gap={4}>\n <Flex gap={3}>\n <Input\n label=\"Title\"\n value={title.value}\n onChange={(event: ChangeEvent<HTMLInputElement>) => {\n title.value = event.currentTarget.value\n }}\n />\n <Textarea\n label=\"View (JSON)\"\n rows={18}\n spellCheck={false}\n value={draft.value}\n onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {\n draft.value = event.currentTarget.value\n }}\n />\n <Flex direction=\"horizontal\" align=\"center\" gap={3} wrap>\n <Button onClick={() => void publish()}>Publish</Button>\n {published.value ? (\n <Link href={\\`/p/\\${published.value.slug}\\`}>Open /p/{published.value.slug}</Link>\n ) : null}\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not published\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n <Card>\n <CardContent>\n {'errors' in current ? (\n <Flex gap={1}>\n {current.errors.map((error) => (\n <Text key={error} size=\"sm\" muted>\n {error}\n </Text>\n ))}\n </Flex>\n ) : (\n <CascivoView config={current.view} />\n )}\n </CardContent>\n </Card>\n </Grid>\n </Flex>\n )\n}\n`\n}\n\nfunction cfPageRouteTsx(): string {\n return `import type { RouteProps } from '@cascivo/app'\nimport { createClient } from '@cascivo/app/api'\nimport { CascivoView } from '@cascivo/render'\nimport {\n EmptyState,\n Flex,\n Heading,\n Spinner,\n signal,\n useEffectPropSignal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../../api'\nimport type { Page } from '../../pages'\n\nconst client = createClient(api)\n/** Pages already fetched, by slug; \\`null\\` when the slug has no page. */\nconst pages = signal<Readonly<Record<string, Page | null>>>({})\n\nasync function load(slug: string): Promise<void> {\n if (slug in pages.peek()) return\n try {\n const page = await client.getPage({ params: { slug } })\n pages.value = { ...pages.peek(), [slug]: page }\n } catch {\n pages.value = { ...pages.peek(), [slug]: null }\n }\n}\n\n/** \\`/p/:slug\\` — a published page, rendered from its view with the app's own components. */\nexport default function PublishedPage({ params }: RouteProps<'/p/:slug'>) {\n useSignals()\n const slug = useEffectPropSignal(params.slug)\n useSignalEffect(() => {\n void load(slug.value)\n })\n const page = pages.value[params.slug]\n\n if (page === undefined) return <Spinner label=\"Loading\" />\n if (page === null) {\n return <EmptyState title=\"No such page\" description=\"It may never have been published.\" />\n }\n return (\n <Flex gap={4}>\n <Heading level={1}>{page.title}</Heading>\n <CascivoView config={page.view} onInvalid=\"render\" />\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example voice`: a voice assistant on the Agents SDK and Workers AI speech --- */\n\nfunction cfVoiceTs(): string {\n return `import { signal } from '@cascivo/react'\nimport { VoiceClient } from 'agents/voice/client'\nimport type { TranscriptMessage, VoiceStatus } from 'agents/voice/client'\n\n/**\n * The /voice page's connection to its agent (worker/voice.ts). \\`VoiceClient\\` does the audio:\n * the microphone, streaming it to the Worker, playing the replies, and noticing when you talk\n * over one. This file turns its events into signals, which the page reads.\n */\n\n/** The Durable Object class; the client connects to /agents/voice/<conversation>. */\nexport const VOICE_AGENT = 'Voice'\n\n/** One line of the conversation, from this call or an earlier one. */\nexport interface Line {\n role: 'user' | 'assistant'\n text: string\n}\n\nexport const status = signal<VoiceStatus>('idle')\nexport const connected = signal(false)\n/** The conversation as the agent stored it when this tab connected. */\nexport const history = signal<Line[]>([])\n/** Turns since then; \\`transcript\\` from VoiceClient, minus what \\`history\\` already holds. */\nexport const transcript = signal<TranscriptMessage[]>([])\n/** What the speech-to-text model has heard so far of the current sentence. */\nexport const interim = signal<string | null>(null)\n/** Microphone level, 0–1. */\nexport const level = signal(0)\nexport const muted = signal(false)\nexport const error = signal<string | null>(null)\n\nlet client: VoiceClient | null = null\n/** How much of VoiceClient's transcript the last history message already covered. */\nlet covered = 0\n\n/**\n * Checks the history the agent sends on connect (worker/voice.ts). It crossed the network, so\n * anything that is not a list of { role, content } lines is ignored rather than trusted.\n */\nexport function parseHistory(raw: unknown): Line[] | null {\n if (typeof raw !== 'object' || raw === null) return null\n const { type, messages } = raw as Record<string, unknown>\n if (type !== 'history' || !Array.isArray(messages)) return null\n const lines: Line[] = []\n for (const message of messages) {\n if (typeof message !== 'object' || message === null) continue\n const { role, content } = message as Record<string, unknown>\n if ((role === 'user' || role === 'assistant') && typeof content === 'string') {\n lines.push({ role, text: content })\n }\n }\n return lines\n}\n\n/** One conversation per tab: a reload keeps it, a new tab starts another. */\nfunction conversationId(): string {\n const key = 'voice-conversation'\n try {\n const saved = sessionStorage.getItem(key)\n if (saved && /^[\\\\w-]{1,64}$/.test(saved)) return saved\n const id = crypto.randomUUID()\n sessionStorage.setItem(key, id)\n return id\n } catch {\n return crypto.randomUUID()\n }\n}\n\n/** Connects once; later calls return the same client. */\nexport function voice(): VoiceClient {\n if (client) return client\n const c = new VoiceClient({ agent: VOICE_AGENT, name: conversationId() })\n c.addEventListener('statuschange', (value) => (status.value = value))\n c.addEventListener('connectionchange', (value) => (connected.value = value))\n c.addEventListener('transcriptchange', (value) => (transcript.value = value.slice(covered)))\n c.addEventListener('custommessage', (raw) => {\n const lines = parseHistory(raw)\n if (!lines) return\n history.value = lines\n covered = c.transcript.length\n transcript.value = []\n })\n c.addEventListener('interimtranscript', (value) => (interim.value = value))\n c.addEventListener('audiolevelchange', (value) => (level.value = value))\n c.addEventListener('mutechange', (value) => (muted.value = value))\n c.addEventListener('error', (value) => (error.value = value))\n c.connect()\n client = c\n return c\n}\n`\n}\n\nfunction cfVoiceWorkerTs(): string {\n return `import { Agent } from 'agents'\nimport type { Connection } from 'agents'\nimport { withVoice, WorkersAIFluxSTT, WorkersAITTS } from 'agents/voice'\nimport type { TextSource, VoiceTurnContext } from 'agents/voice'\nimport type { Env } from './index'\nimport { scriptedReply, ScriptedTranscriber, silentSpeech } from './scripted-voice'\n\n/** Any Workers AI text model; replies stream into speech sentence by sentence. */\nconst MODEL = '@cf/meta/llama-3.3-70b-instruct-fp8-fast'\n\n/** How many earlier messages a reload shows. */\nconst HISTORY_SHOWN = 50\n\nconst SYSTEM = \\`You are a voice assistant inside a web app. Your replies are spoken aloud, so\nanswer in one to three short sentences of plain speech: no lists, no markdown, no emoji.\\`\n\n// \\`vite dev\\` has no Workers AI, so there the voice is a stand-in (worker/scripted-voice.ts):\n// it \"hears\" a fixed question every few seconds of audio and answers without sound. A\n// deployed Worker uses Workers AI for all three steps, and so does \\`VITE_REAL_AI=1 vite dev\\`.\nconst scripted = import.meta.env.DEV && import.meta.env['VITE_REAL_AI'] !== '1'\n\nconst VoiceAgent = withVoice(Agent<Env>)\n\n/**\n * One Durable Object per conversation: speech in, text through a model, speech out. It keeps\n * the conversation in its SQLite database, so a reconnect carries on where it was.\n */\nexport class Voice extends VoiceAgent {\n transcriber = scripted ? new ScriptedTranscriber() : new WorkersAIFluxSTT(this.env.AI)\n tts = scripted ? silentSpeech : new WorkersAITTS(this.env.AI)\n\n /** VoiceClient starts empty: send a new connection the conversation so far (src/voice.ts). */\n onConnect(connection: Connection): void {\n connection.send(\n JSON.stringify({ type: 'history', messages: this.getConversationHistory(HISTORY_SHOWN) }),\n )\n }\n\n async onTurn(transcript: string, { messages, signal }: VoiceTurnContext): Promise<TextSource> {\n if (scripted) return scriptedReply(transcript)\n try {\n // A stream of server-sent events; the voice pipeline reads the text out of it.\n const stream = await this.env.AI.run(\n MODEL,\n {\n messages: [\n { role: 'system', content: SYSTEM },\n ...messages,\n { role: 'user', content: transcript },\n ],\n stream: true,\n },\n { signal },\n )\n if (!(stream instanceof ReadableStream)) throw new Error('Workers AI returned no stream')\n return stream\n } catch (error) {\n if (signal.aborted) throw error\n // Said aloud rather than left as silence; the cause is in the Worker's logs.\n console.error('[voice] the model call failed:', error)\n return 'Sorry, I could not reach the model just now.'\n }\n }\n}\n`\n}\n\nfunction cfScriptedVoiceTs(): string {\n return `import type {\n TTSProvider,\n Transcriber,\n TranscriberSession,\n TranscriberSessionOptions,\n} from 'agents/voice'\n\n/**\n * \\`vite dev\\` stand-ins for Workers AI's speech models, which have no local mode. They keep the\n * whole call working offline — microphone, streaming, turns, transcript — without a model:\n * the transcriber \"hears\" a fixed question for every few seconds of sound, and the replies\n * are text only. Production never uses them (see worker/voice.ts).\n */\n\n/** 16 kHz, 16-bit mono: this many bytes is three seconds of audio. */\nconst BYTES_PER_TURN = 16_000 * 2 * 3\nconst QUESTIONS = ['What can you do?', 'How do I deploy this app?', 'Thanks, that is all.']\n\nexport class ScriptedTranscriber implements Transcriber {\n createSession(options: TranscriberSessionOptions = {}): TranscriberSession {\n let bytes = 0\n let turn = 0\n return {\n feed(chunk) {\n bytes += chunk.byteLength\n if (bytes < BYTES_PER_TURN) return\n bytes = 0\n options.onUtterance?.(QUESTIONS[turn++ % QUESTIONS.length]!)\n },\n close() {},\n }\n }\n}\n\n/** No sound in \\`vite dev\\`: the reply shows in the transcript only. */\nexport const silentSpeech: TTSProvider = { synthesize: async () => null }\n\nconst REPLIES: Record<string, string> = {\n 'What can you do?':\n 'I am the development stand-in. Deploy, or set VITE_REAL_AI=1, for Workers AI.',\n 'How do I deploy this app?': 'Run the deploy script. It builds the page and the Worker together.',\n}\n\nexport function scriptedReply(transcript: string): string {\n return REPLIES[transcript] ?? \\`You said: \\${transcript}\\`\n}\n`\n}\n\nfunction cfVoiceRouteTsx(): string {\n return `import {\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Input,\n ProgressBar,\n Text,\n useSignals,\n} from '@cascivo/react'\nimport type { FormEvent } from 'react'\nimport {\n connected,\n error,\n history,\n interim,\n level,\n muted,\n status,\n transcript,\n voice,\n} from '../voice'\nimport type { Line } from '../voice'\n\nconst STATUS_LABEL = {\n idle: 'Not in a call',\n listening: 'Listening',\n thinking: 'Thinking',\n speaking: 'Speaking',\n} as const\n\nfunction sendTyped(event: FormEvent<HTMLFormElement>): void {\n event.preventDefault()\n const form = event.currentTarget\n const text = new FormData(form).get('message')\n if (typeof text !== 'string' || text.trim() === '') return\n voice().sendText(text.trim())\n form.reset()\n}\n\nexport default function VoicePage() {\n useSignals()\n const client = voice()\n const inCall = status.value !== 'idle'\n const lines: Line[] = [\n ...history.value,\n ...transcript.value.map((m) => ({ role: m.role, text: m.text })),\n ]\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Voice</Heading>\n <Text muted>\n Talk to an assistant on Workers AI: speech to text, a model, and text to speech, all in a\n Durable Object. Speak over a reply to interrupt it.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Flex direction=\"horizontal\" align=\"center\" wrap gap={3}>\n {inCall ? (\n <Button variant=\"destructive\" onClick={() => client.endCall()}>\n End call\n </Button>\n ) : (\n <Button disabled={!connected.value} onClick={() => void client.startCall()}>\n Start call\n </Button>\n )}\n <Button variant=\"secondary\" disabled={!inCall} onClick={() => client.toggleMute()}>\n {muted.value ? 'Unmute' : 'Mute'}\n </Button>\n <Badge variant={status.value === 'idle' ? 'secondary' : 'success'}>\n {connected.value ? STATUS_LABEL[status.value] : 'Connecting'}\n </Badge>\n </Flex>\n {inCall ? (\n <ProgressBar label=\"Microphone\" value={Math.round(level.value * 100)} max={100} />\n ) : null}\n {error.value ? <Text muted>{error.value}</Text> : null}\n </Flex>\n </CardContent>\n </Card>\n <Flex gap={2} role=\"log\" aria-live=\"polite\" aria-label=\"Conversation\">\n {lines.length === 0 && !interim.value ? (\n <EmptyState\n title=\"Start a call and say something\"\n description=\"Or type below: a typed message gets a reply too, spoken if you are in a call.\"\n />\n ) : null}\n {lines.map((message, index) => (\n // The conversation only grows, so a line's position is a stable key.\n <Flex key={index} gap={1}>\n <Text size=\"sm\" muted>\n {message.role === 'user' ? 'You' : 'Assistant'}\n </Text>\n <Text>{message.text}</Text>\n </Flex>\n ))}\n {interim.value ? <Text muted>{interim.value}…</Text> : null}\n </Flex>\n <form onSubmit={sendTyped}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2}>\n <Input name=\"message\" label=\"Or type\" placeholder=\"Ask something\" autoComplete=\"off\" />\n <Button type=\"submit\" variant=\"secondary\" disabled={!connected.value}>\n Send\n </Button>\n </Flex>\n </form>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example live`: an ops dashboard fed by a Queue (@cascivo/app/live) --- */\n\nfunction cfOpsTs(): string {\n return `import { defineLive } from '@cascivo/app/live'\n\n/**\n * The live dashboard's metrics, shared by the Worker (which records them) and the page (which\n * charts them). Each event adds to its second's totals, and the room keeps two minutes.\n */\nexport const ops = defineLive({ metrics: ['orders', 'revenue', 'errors'], window: 120 })\n\n/** The dashboard's room. Name one per team or tenant if each needs its own. */\nexport const OPS_ROOM = 'ops'\n\nexport interface Accepted {\n accepted: number\n}\n\nexport function parseAccepted(raw: unknown): Accepted {\n if (typeof raw === 'object' && raw !== null) {\n const { accepted } = raw as Record<string, unknown>\n if (typeof accepted === 'number') return { accepted }\n }\n throw new Error('Malformed reply')\n}\n`\n}\n\nfunction cfOpsRouteTsx(): string {\n return `import { Kpi, LineChart } from '@cascivo/charts'\nimport { createClient } from '@cascivo/app/api'\nimport { watchLive } from '@cascivo/app/live'\nimport type { LiveEvent, LivePoint } from '@cascivo/app/live'\nimport {\n Badge,\n Card,\n CardContent,\n Flex,\n Grid,\n Heading,\n Text,\n Toggle,\n computed,\n signal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { ops } from '../ops'\n\ntype Metric = (typeof ops.metrics)[number]\n\nconst client = createClient(api)\n// One connection for the app's lifetime. The room sends the whole window when it opens, so a\n// reload or a dropped connection comes back with the last two minutes.\nconst live = watchLive(ops, '/api/live')\nconst lastMinute = computed(() => live.points.value.slice(-60))\nconst total = (points: LivePoint<Metric>[], metric: Metric) =>\n points.reduce((sum, point) => sum + point.values[metric], 0)\n\nconst simulating = signal(true)\n\n/** Stand-in traffic: a few orders every half second, some of them failing. */\nfunction sendTraffic(): void {\n const events: LiveEvent<Metric>[] = Array.from(\n { length: 1 + Math.floor(Math.random() * 6) },\n () =>\n Math.random() < 0.05\n ? { values: { errors: 1 } }\n : { values: { orders: 1, revenue: Math.round(20 + Math.random() * 180) } },\n )\n client\n .sendEvents({ body: events })\n .catch((error: unknown) => console.warn('Could not send events', error))\n}\n\nexport default function Ops() {\n useSignals()\n useSignalEffect(() => {\n if (!simulating.value) return\n const timer = setInterval(sendTraffic, 500)\n return () => clearInterval(timer)\n })\n const points = live.points.value\n const minute = lastMinute.value\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Ops</Heading>\n <Text muted>\n Events go through a Queue into a Durable Object, which sends each second to every open\n dashboard.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={3}>\n <Badge variant={live.connection.value === 'open' ? 'success' : 'warning'}>\n {live.connection.value === 'open' ? 'Live' : 'Reconnecting'}\n </Badge>\n <Toggle\n label=\"Send simulated traffic\"\n checked={simulating.value}\n onValueChange={(on) => {\n simulating.value = on\n }}\n />\n </Flex>\n </Flex>\n <Grid cols={3} gap={3}>\n <Kpi\n label=\"Orders, last minute\"\n value={total(minute, 'orders').toLocaleString()}\n sparkline={minute.map((p) => p.values.orders)}\n />\n <Kpi\n label=\"Revenue, last minute\"\n value={\\`$\\${total(minute, 'revenue').toLocaleString()}\\`}\n sparkline={minute.map((p) => p.values.revenue)}\n />\n <Kpi\n label=\"Errors, last minute\"\n value={total(minute, 'errors').toLocaleString()}\n sparkline={minute.map((p) => p.values.errors)}\n />\n </Grid>\n <Card>\n <CardContent>\n <LineChart\n title=\"Orders and errors per second\"\n series={[\n { id: 'orders', label: 'Orders', data: points, y: (p) => p.values.orders },\n { id: 'errors', label: 'Errors', data: points, y: (p) => p.values.errors },\n ]}\n x={(p) => new Date(p.at)}\n y={(p) => p.values.orders}\n />\n </CardContent>\n </Card>\n <Card>\n <CardContent>\n <LineChart\n title=\"Revenue per second\"\n series={[{ id: 'revenue', label: 'Revenue', data: points }]}\n x={(p) => new Date(p.at)}\n y={(p) => p.values.revenue}\n />\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example crud`: a D1 table behind DataTable's server mode (@cascivo/app/db) --- */\n\nfunction cfCustomersTs(): string {\n return `import { defineTable } from '@cascivo/app/db'\n\n/**\n * The customers table, shared by the Worker (which queries it) and the page (which shows\n * it). The columns say what the table may sort, search and filter by — anything else in a\n * query is refused before any SQL is built.\n */\nexport const customersTable = defineTable({\n table: 'customers',\n key: 'id',\n columns: {\n id: {},\n name: { sort: true, search: true, filter: 'text' },\n email: { sort: true, search: true },\n plan: { sort: true, filter: 'select' },\n seats: { sort: true, filter: 'range' },\n created_at: { sort: true },\n },\n})\n\nexport const PLANS = ['free', 'team', 'enterprise'] as const\nexport type Plan = (typeof PLANS)[number]\n\nexport interface Customer {\n id: string\n name: string\n email: string\n plan: Plan\n seats: number\n created_at: string\n}\n\n/** What the form sends: a customer without the fields the Worker sets. */\nexport type CustomerInput = Omit<Customer, 'id' | 'created_at'>\n\nconst EMAIL = /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/\n\nfunction isPlan(value: unknown): value is Plan {\n return typeof value === 'string' && (PLANS as readonly string[]).includes(value)\n}\n\n/** A form submission crosses the network: checked on the Worker before it is stored. */\nexport function parseCustomerInput(raw: unknown): CustomerInput {\n if (typeof raw !== 'object' || raw === null) throw new Error('Send a customer')\n const { name, email, plan, seats } = raw as Record<string, unknown>\n if (typeof name !== 'string' || name.trim().length === 0 || name.length > 120) {\n throw new Error('A name is 1 to 120 characters')\n }\n if (typeof email !== 'string' || !EMAIL.test(email) || email.length > 200) {\n throw new Error('That is not an email address')\n }\n if (!isPlan(plan)) throw new Error(\\`The plan is one of \\${PLANS.join(', ')}\\`)\n if (typeof seats !== 'number' || !Number.isInteger(seats) || seats < 1 || seats > 100_000) {\n throw new Error('Seats is a whole number from 1')\n }\n return { name: name.trim(), email: email.trim().toLowerCase(), plan, seats }\n}\n\n/** A row from D1, or from the Worker's response. */\nexport function parseCustomer(raw: unknown): Customer {\n if (typeof raw === 'object' && raw !== null) {\n const { id, created_at } = raw as Record<string, unknown>\n if (typeof id === 'string' && typeof created_at === 'string') {\n return { id, created_at, ...parseCustomerInput(raw) }\n }\n }\n throw new Error('Malformed customer')\n}\n\nexport function parseDeleted(raw: unknown): { id: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { id } = raw as Record<string, unknown>\n if (typeof id === 'string') return { id }\n }\n throw new Error('Malformed delete result')\n}\n`\n}\n\nfunction cfCustomersPageTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport type { TableQuery } from '@cascivo/app/db'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport type { Customer, CustomerInput, Plan } from './customers'\n\nconst client = createClient(api)\n\nexport const PAGE_SIZE = 10\n\n/** The current page, as the Worker returned it for \\`query\\`. */\nexport const rows = signal<Customer[]>([])\nexport const total = signal(0)\nexport const loadError = signal<string | null>(null)\n\n/** The plan filter above the table; '' is every plan. */\nexport const plan = signal<Plan | ''>('')\n\nlet query: TableQuery = { sort: undefined, search: '', filters: {}, page: 1, pageSize: PAGE_SIZE }\nlet latest = 0\n\n/** Fetches the page for \\`next\\` (or the current query again); a slower, older answer is dropped. */\nexport async function load(next: TableQuery = query): Promise<void> {\n query = next\n const request = ++latest\n const filters = plan.value\n ? { ...next.filters, plan: { kind: 'select' as const, values: [plan.value] } }\n : next.filters\n try {\n const page = await client.customers({ body: { ...next, filters } })\n if (request !== latest) return\n rows.value = page.rows\n total.value = page.total\n loadError.value = null\n } catch (error) {\n if (request === latest)\n loadError.value = error instanceof Error ? error.message : 'Could not load'\n }\n}\n\nexport function setPlan(next: Plan | ''): void {\n plan.value = next\n void load({ ...query, page: 1 })\n}\n\nexport async function save(id: string | null, input: CustomerInput): Promise<void> {\n if (id) await client.updateCustomer({ params: { id }, body: input })\n else await client.createCustomer({ body: input })\n await load()\n}\n\nexport async function remove(id: string): Promise<void> {\n await client.deleteCustomer({ params: { id } })\n await load()\n}\n`\n}\n\nfunction cfCustomersWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryTable } from '@cascivo/app/db'\nimport type { Database, TablePage, TableQuery } from '@cascivo/app/db'\nimport { customersTable, parseCustomer } from '../src/customers'\nimport type { Customer, CustomerInput } from '../src/customers'\nimport { migrations } from './migrations'\n\n/** Every handler starts here: the schema is applied once per isolate, before the first query. */\nasync function ready(db: Database): Promise<Database> {\n await migrate(db, migrations)\n return db\n}\n\n/** A second customer with the same email is a conflict the form can show, not a 500. */\nfunction uniqueEmail(error: unknown): never {\n if (/UNIQUE/i.test(String(error))) throw new HttpError(409, 'A customer with that email exists')\n throw error\n}\n\nexport async function listCustomers(db: Database, query: TableQuery): Promise<TablePage<Customer>> {\n return queryTable(await ready(db), customersTable, query, parseCustomer)\n}\n\nexport async function createCustomer(db: Database, input: CustomerInput): Promise<Customer> {\n const customer: Customer = {\n id: crypto.randomUUID(),\n created_at: new Date().toISOString(),\n ...input,\n }\n await (\n await ready(db)\n )\n .prepare(\n 'INSERT INTO customers (id, name, email, plan, seats, created_at) VALUES (?, ?, ?, ?, ?, ?)',\n )\n .bind(customer.id, input.name, input.email, input.plan, input.seats, customer.created_at)\n .run()\n .catch(uniqueEmail)\n return customer\n}\n\nexport async function updateCustomer(\n db: Database,\n id: string,\n input: CustomerInput,\n): Promise<Customer> {\n const row = await (\n await ready(db)\n )\n .prepare(\n 'UPDATE customers SET name = ?, email = ?, plan = ?, seats = ? WHERE id = ? RETURNING *',\n )\n .bind(input.name, input.email, input.plan, input.seats, id)\n .first()\n .catch(uniqueEmail)\n if (!row) throw new HttpError(404, 'No such customer')\n return parseCustomer(row)\n}\n\nexport async function deleteCustomer(db: Database, id: string): Promise<{ id: string }> {\n const row = await (\n await ready(db)\n )\n .prepare('DELETE FROM customers WHERE id = ? RETURNING id')\n .bind(id)\n .first()\n if (!row) throw new HttpError(404, 'No such customer')\n return { id }\n}\n`\n}\n\nfunction cfMigrationsTs(): string {\n return `import type { Migration } from '@cascivo/app/db'\n\nconst FIRST = [\n 'Ada',\n 'Grace',\n 'Alan',\n 'Edsger',\n 'Barbara',\n 'Donald',\n 'Margaret',\n 'Ken',\n 'Frances',\n 'Tim',\n]\nconst LAST = ['Labs', 'Systems', 'Works', 'Studio', 'Group', 'Cloud']\nconst PLANS = ['free', 'team', 'enterprise']\n\n/** 60 sample customers, so the table has pages to sort, search and filter from the start. */\nfunction seed(): string {\n const rows = Array.from({ length: 60 }, (_, i) => {\n const name = \\`\\${FIRST[i % FIRST.length]} \\${LAST[i % LAST.length]}\\`\n const email = \\`\\${name.toLowerCase().replace(' ', '.')}\\${i}@example.com\\`\n const day = String((i % 28) + 1).padStart(2, '0')\n return \\`('seed-\\${i}', '\\${name}', '\\${email}', '\\${PLANS[i % 3]}', \\${((i * 37) % 250) + 1}, '2026-0\\${(i % 9) + 1}-\\${day}')\\`\n })\n return \\`INSERT INTO customers (id, name, email, plan, seats, created_at) VALUES \\${rows.join(', ')}\\`\n}\n\n/**\n * The schema, applied by the Worker itself on its first query (\\`migrate\\` from\n * \\`@cascivo/app/db\\`): a fresh deploy, a Deploy button or a temporary account needs no step.\n * Append new migrations; never edit one that has shipped.\n */\nexport const migrations: Migration[] = [\n {\n id: '0001_customers',\n statements: [\n \\`CREATE TABLE customers (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n email TEXT NOT NULL UNIQUE,\n plan TEXT NOT NULL,\n seats INTEGER NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n 'CREATE INDEX customers_plan ON customers (plan)',\n seed(),\n ],\n },\n]\n`\n}\n\nfunction cfCustomersRouteTsx(): string {\n return `import type { Column } from '@cascivo/react'\nimport {\n Badge,\n Button,\n DataTable,\n Flex,\n Heading,\n Input,\n Modal,\n NumberInput,\n Select,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { PLANS } from '../customers'\nimport type { Customer, CustomerInput, Plan } from '../customers'\nimport {\n PAGE_SIZE,\n load,\n loadError,\n plan,\n remove,\n rows,\n save,\n setPlan,\n total,\n} from '../customers-page'\n\n/** The customer in the form: \\`id\\` null for a new one. */\nconst editing = signal<{ id: string | null; draft: CustomerInput } | null>(null)\nconst formError = signal<string | null>(null)\nconst saving = signal(false)\n\nvoid load()\n\nconst COLUMNS: Column<Customer>[] = [\n { key: 'name', header: 'Name', sortable: true, filter: 'text' },\n { key: 'email', header: 'Email', sortable: true },\n {\n key: 'plan',\n header: 'Plan',\n sortable: true,\n render: (row) => (\n <Badge variant={row.plan === 'enterprise' ? 'success' : 'secondary'}>{row.plan}</Badge>\n ),\n },\n { key: 'seats', header: 'Seats', sortable: true, align: 'end', filter: 'range' },\n {\n key: 'created_at',\n header: 'Since',\n sortable: true,\n render: (row) => row.created_at.slice(0, 10),\n },\n]\n\nfunction edit(customer: Customer | null) {\n formError.value = null\n editing.value = customer\n ? {\n id: customer.id,\n draft: {\n name: customer.name,\n email: customer.email,\n plan: customer.plan,\n seats: customer.seats,\n },\n }\n : { id: null, draft: { name: '', email: '', plan: 'team', seats: 5 } }\n}\n\nfunction change(patch: Partial<CustomerInput>) {\n if (editing.value)\n editing.value = { ...editing.value, draft: { ...editing.value.draft, ...patch } }\n}\n\nasync function submit() {\n if (!editing.value) return\n saving.value = true\n formError.value = null\n try {\n await save(editing.value.id, editing.value.draft)\n editing.value = null\n } catch (error) {\n // The Worker's message: \"A customer with that email exists\", \"That is not an email address\"…\n formError.value = error instanceof Error ? error.message : 'Could not save'\n } finally {\n saving.value = false\n }\n}\n\nfunction CustomerForm() {\n useSignals()\n const current = editing.value\n if (!current) return null\n const { draft } = current\n return (\n <Modal\n open\n onClose={() => (editing.value = null)}\n title={current.id ? 'Edit customer' : 'New customer'}\n footer={\n <Flex direction=\"horizontal\" gap={2} justify=\"end\">\n <Button variant=\"ghost\" onClick={() => (editing.value = null)}>\n Cancel\n </Button>\n <Button onClick={() => void submit()} loading={saving.value}>\n Save\n </Button>\n </Flex>\n }\n >\n <Flex gap={3}>\n <Input label=\"Name\" value={draft.name} onChange={(e) => change({ name: e.target.value })} />\n <Input\n label=\"Email\"\n type=\"email\"\n value={draft.email}\n onChange={(e) => change({ email: e.target.value })}\n />\n <Select\n label=\"Plan\"\n value={draft.plan}\n options={PLANS.map((p) => ({ value: p, label: p }))}\n onChange={(e) => change({ plan: e.target.value as Plan })}\n />\n <NumberInput\n label=\"Seats\"\n min={1}\n value={draft.seats}\n onValueChange={(v) => change({ seats: v ?? 1 })}\n />\n {formError.value ? <Text muted>{formError.value}</Text> : null}\n </Flex>\n </Modal>\n )\n}\n\nexport default function Customers() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Customers</Heading>\n <Text muted>\n A D1 table behind DataTable's server mode: sorting, search, filters and paging all run\n as SQL in the Worker.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Select\n ariaLabel=\"Plan\"\n value={plan.value}\n options={[\n { value: '', label: 'Every plan' },\n ...PLANS.map((p) => ({ value: p, label: p })),\n ]}\n onChange={(e) => setPlan(e.target.value as Plan | '')}\n />\n <Button onClick={() => edit(null)}>New customer</Button>\n </Flex>\n </Flex>\n {loadError.value ? <Text muted>{loadError.value}</Text> : null}\n <DataTable\n columns={COLUMNS}\n rows={rows.value}\n getRowId={(row) => row.id}\n searchable\n pagination={{ pageSize: PAGE_SIZE }}\n server={{ totalItems: total.value, onQueryChange: (query) => void load(query) }}\n rowActions={(row) => [\n { id: 'edit', label: 'Edit', onSelect: () => edit(row) },\n { id: 'delete', label: 'Delete', destructive: true, onSelect: () => void remove(row.id) },\n ]}\n />\n <CustomerForm />\n </Flex>\n )\n}\n`\n}\n\nfunction cfPrettierIgnore(): string {\n return `${prettierIgnore()}# Rewritten by @cascivo/app/vite whenever a route file changes.\nsrc/routes.gen.ts\n`\n}\n\nfunction cfGitignore(): string {\n return `node_modules\ndist\n.wrangler\n.dev.vars*\n!.dev.vars.example\n*.local\n.DS_Store\n`\n}\n\nfunction cfReadme(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n const runtime = runtimeOf(opts)\n return `# ${opts.name}\n\nA [cascivo](https://cascivo.com) app on Cloudflare: a client-rendered\n${runtime === 'preact' ? 'Preact' : 'React'} app and its API, deployed as one Worker.\nThere is no server rendering; the browser gets the app, and \\`/api/*\\` reaches the Worker.\n\n## Develop\n\n\\`\\`\\`sh\n${installAllCommand(pm)}\n${runScriptCommand(pm, 'dev')}\n\\`\\`\\`\n\n\\`vite dev\\` runs \\`worker/index.ts\\` in workerd, the same runtime as production, via\n\\`@cloudflare/vite-plugin\\`.\n\n## Deploy\n\n\\`\\`\\`sh\nnpx wrangler login # once\n${runExplicitCommand(pm, 'deploy')}\n\\`\\`\\`\n\n## Share a preview (no account)\n\n\\`\\`\\`sh\n${runExplicitCommand(pm, 'deploy:preview')}\n\\`\\`\\`\n\nThis builds the app, then deploys it to a temporary Cloudflare account with\n\\`wrangler deploy --temporary\\`. You need no sign-up. It prints two URLs:\n\n- **A public \\`workers.dev\\` URL** for the app.\n- **A claim URL.** Open it and sign in within 60 minutes to keep the deployment; otherwise it is deleted.\n\nIt works only while wrangler is logged out. If you are logged in, use \\`deploy\\` instead.\nA temporary account supports Workers, static assets, KV, D1 and Durable Objects. It does not\nsupport Workers AI, R2, Workflows or Browser Run.${\n usesAgents(opts)\n ? ` So a preview serves the app, but its ${hasExample(opts, 'agent') ? 'assistant' : 'voice page'} cannot reach the model: deploy it\\nto your own account for that.`\n : ''\n }\n\n## Structure\n\n- \\`src/routes/\\` — one file per page. \\`index.tsx\\` is \\`/\\`, \\`settings.tsx\\` is\n \\`/settings\\`, \\`c/[id].tsx\\` is \\`/c/:id\\` (it receives \\`params.id\\`), \\`404.tsx\\` is\n everything else. \\`src/routes.gen.ts\\` is rewritten from this folder; do not edit it.\n- \\`src/api.ts\\` — the API contract both sides import: endpoints, their paths, and parsers\n for what crosses the network.\n- \\`worker/index.ts\\` — the API's handlers, typed from \\`src/api.ts\\`. Add bindings (KV,\n D1, R2, Durable Objects, Workers AI) in \\`wrangler.jsonc\\`; handlers receive them as \\`env\\`.\n- \\`src/live.ts\\` + \\`src/LiveCard.tsx\\` — a streaming endpoint read through the typed\n client into signals.\n- \\`src/App.tsx\\` — the nav (real \\`href\\`s) and the \\`RouterView\\`.\n- \\`src/Shell.tsx\\` — the app shell (header + side nav + content slot).\n\nPersist client state with \\`persistedSignal\\` from \\`@cascivo/storage\\` (localStorage or\nIndexedDB).${\n hasExample(opts, 'board')\n ? `\n\n## Board (multiplayer)\n\n\\`/board\\` is a shared board. Open it in two windows: notes, edits and cursors sync live.\n\n- \\`src/board.ts\\` — \\`connectRoom('/api/rooms/<name>')\\` from \\`@cascivo/app/sync\\`; \\`room.map('notes', parseNote)\\`\n is a signal every visitor shares, and \\`room.presence\\` carries cursors.\n- \\`worker/index.ts\\` — exports \\`SyncRoom\\`, one Durable Object per room, and routes\n \\`/api/rooms/:name\\` to it (bound as \\`ROOMS\\` in \\`wrangler.jsonc\\`).\n- \\`/board?room=team\\` is a separate board.\n\nEach path (one note) is last-writer-wins in the order the room receives writes, so two people\nediting the same note at once settle on one value; editing different notes never collides.\nDurable Objects work on a temporary account, so \\`deploy:preview\\` shares a live board with\nno Cloudflare account.`\n : ''\n }${\n hasExample(opts, 'notes')\n ? `\n\n## Notes (local-first)\n\n\\`/notes\\` keeps working when the connection drops. Edits are saved on this device and sync\nwhen the room is reachable again; a badge shows how many are still waiting.\n\n- \\`src/notes.ts\\` — \\`connectRoom(url, { storage: indexedDBDriver() })\\`: the room's last\n state and every unconfirmed edit are kept in IndexedDB, so offline edits survive a reload\n or a closed tab and go out on the next connection. \\`room.unsynced\\` counts them. (Opening\n the app with no network at all also needs its files cached, by a service worker.)\n- The list is \\`/notes?list=<name>\\`. Without one, each browser makes its own and remembers\n it; open the same \\`?list=\\` on another device to sync with it.\n- It uses the same \\`SyncRoom\\` Durable Object as any other room (\\`ROOMS\\` in\n \\`wrangler.jsonc\\`). To query notes across lists, override \\`SyncRoom.onWrite\\` and mirror\n each write into D1.\n\nA note is last-writer-wins: an edit made offline replaces whatever the note held when it\narrives. Edits to different notes never collide.`\n : ''\n }${\n hasExample(opts, 'import')\n ? `\n\n## Import (background job)\n\n\\`/import\\` runs a CSV import as a Cloudflare Workflow and shows its progress live.\n\n- \\`worker/import-job.ts\\` — \\`ImportJob\\`, the Workflow. Each \\`step.do\\` is retried on its own\n and its result kept, so a failure or a deploy resumes the job where it was. Put your writes\n in \\`importContacts\\`.\n- \\`src/import-job.ts\\` — the job (\\`defineJob\\` from \\`@cascivo/app/jobs\\`): its steps and the\n parser for its result, shared by both sides.\n- Progress is a read-only room: the Workflow reports with \\`jobReporter\\`\n (\\`@cascivo/app/jobs-server\\`), and the page watches \\`/api/jobs/:id\\` with \\`watchJob\\`. The job\n id is in the URL, so a reload or another tab picks the job back up.\n\nReport from inside a \\`step.do\\`: a Workflow replays \\`run()\\` from the top after each step, and\na report outside one would run again. Workflows run in \\`vite dev\\` locally, but not on a\ntemporary account, so \\`deploy:preview\\` serves the page without starting imports.`\n : ''\n }${\n hasExample(opts, 'files')\n ? `\n\n## Files (uploads)\n\n\\`/files\\` uploads into R2 through the Worker, with progress, and shows resized previews.\n\n- \\`src/upload-policy.ts\\` — what the app accepts (types, size), shared by both sides: the page\n checks a file before sending it, and the Worker enforces the same policy. SVG and HTML are\n refused by design; served from your origin they would run script.\n- \\`worker/index.ts\\` — \\`handleUploads\\` (\\`@cascivo/app/uploads-server\\`) stores uploads, in\n parts above 16 MiB, under keys the Worker chooses, and serves them back with headers that\n stop any file running script. \\`?w=320\\` returns a WebP preview from Cloudflare Images.\n- \\`src/files.ts\\` — \\`startUpload\\` gives each file progress and status signals.\n\nEach caller (by IP) may start 20 uploads a minute (\\`ratelimits\\` in \\`wrangler.jsonc\\`).${\n opts.auth\n ? ''\n : `\n**It has no auth.** Anyone who can reach the app can upload: put Cloudflare Access in front of\nit (\\`--auth access\\`), add accounts (\\`--auth email\\`), or check who is asking in\n\\`worker/index.ts\\` before \\`handleUploads\\` runs.`\n } Create the bucket once before deploying:\n\\`npx wrangler r2 bucket create ${packageName(opts.name)}-files\\`. R2 and Images do not run on a\ntemporary account.`\n : ''\n }${\n hasExample(opts, 'export')\n ? `\n\n## Report (PDF and PNG export)\n\n\\`/report\\` has \"Download PDF\" and \"Download PNG\" buttons. The Worker renders the page in\nCloudflare's Browser Run and returns the file.\n\n- \\`worker/index.ts\\` — \\`handleExport\\` (\\`@cascivo/app/export\\`) serves\n \\`/api/export?page=/report&format=pdf\\`. It opens the page at this app's own origin with\n \\`?export=1\\`, waits for the network to go quiet, and prints it. Only paths of this app\n outside \\`/api/\\` can be exported.\n- \\`src/App.tsx\\` — \\`isExporting()\\` drops the shell, so the file holds the page alone.\n- For a scheduled report, call \\`exportPage\\` from a Cron Trigger and attach the PDF to an\n email (\\`sendEmail\\` from \\`@cascivo/email\\` takes \\`attachments\\`).\n\nThe browser opens the page without the visitor's cookies, so a page that needs a session\nrenders signed out. Each export starts a browser session, which is billed, so each caller\n(by IP) may start 20 a minute (\\`ratelimits\\` in \\`wrangler.jsonc\\`). In \\`vite dev\\` Browser Run starts a local Chrome (downloaded on\nfirst use); it does not run on a temporary account.`\n : ''\n }${\n hasExample(opts, 'live')\n ? `\n\n## Ops (live dashboard)\n\n\\`/ops\\` charts orders, revenue and errors per second, and every open copy of the page moves\ntogether as events arrive.\n\n- \\`src/ops.ts\\` — \\`defineLive\\` (\\`@cascivo/app/live\\`): the metrics, and two minutes of\n per-second history.\n- \\`worker/index.ts\\` — \\`POST /api/events\\` puts events on the \\`EVENTS\\` queue. The \\`queue\\`\n handler adds each batch into a \\`LiveRoom\\` Durable Object with \\`recordLive\\`, which keeps\n per-second totals and sends each change to the browsers watching \\`/api/live\\` (read-only).\n- \\`src/routes/ops.tsx\\` — \\`watchLive\\` gives the window as a signal; the page sends simulated\n traffic while its switch is on. Real producers are anything with the queue binding: another\n Worker, a cron, a webhook handler.\n\nA new viewer, or one back from a dropped connection, starts with the whole window. Delivery is\nat least once: a batch retried after a lost reply counts twice. \\`/api/events\\` has no auth, so\nanyone can move the numbers: check who is sending before real use (\\`--auth access\\` puts\nCloudflare Access in front of the Worker). Create the queue once before deploying:\n\\`npx wrangler queues create ${packageName(opts.name)}-events\\`.`\n : ''\n }${\n hasExample(opts, 'usage')\n ? `\n\n## Usage (analytics)\n\nEvery API request is recorded in Workers Analytics Engine, and \\`/usage\\` charts the last\n24 hours: requests per hour, the busiest routes, errors and latency.\n\n- \\`src/usage.ts\\` — \\`defineMetrics\\` (\\`@cascivo/app/analytics\\`) names the columns once\n (\\`path\\`, \\`method\\`, \\`status\\`, \\`duration_ms\\`), so writes and queries never disagree about\n which blob is which.\n- \\`worker/index.ts\\` — \\`handleAndRecord\\` writes one data point per API request.\n- \\`worker/usage.ts\\` — the queries, over Analytics Engine's SQL API.\n\nWriting needs only the binding. **Reading needs two secrets**, because the SQL API is HTTP\nwith an account token:\n\n\\`\\`\\`sh\nnpx wrangler secret put CF_ACCOUNT_ID\nnpx wrangler secret put CF_API_TOKEN # a token with Account Analytics: Read\n\\`\\`\\`\n\nFor \\`vite dev\\`, put both in \\`.dev.vars\\`. Data written locally is not in your account's\ndataset, so in development the charts show what your deployed app recorded. Until the secrets\nexist, the page says what to set.`\n : ''\n }${\n hasExample(opts, 'search')\n ? `\n\n## Search (by meaning)\n\n\\`/search\\` finds help articles by what a question means: \"how do I get my money back\" finds\nRefunds, though they share no words. Each article is embedded with Workers AI\n(\\`@cf/baai/bge-base-en-v1.5\\`) into a Vectorize index; a question is embedded the same way\nand matched against it.\n\n- \\`worker/articles.ts\\` — the articles, seeded into D1. Replace them with your own content.\n- \\`worker/search.ts\\` — \\`search\\` and \\`indexArticles\\`. \"Index articles\" on the page embeds\n every article; Vectorize applies writes a few seconds later.\n\nCreate the index once before deploying, with the embedding model's dimensions:\n\\`npx wrangler vectorize create ${packageName(opts.name)}-articles --dimensions=768 --metric=cosine\\`.\nNeither Vectorize nor Workers AI runs locally, so \\`vite dev\\` searches by keyword with SQLite's\nfull-text search instead, and the page says so. \\`VITE_REAL_AI=1 ${runScriptCommand(pm, 'dev')}\\` uses the real ones\n(after \\`npx wrangler login\\`). Workers AI and Vectorize bill per use beyond their free\nallocations, and a temporary account has neither.`\n : ''\n }${\n hasExample(opts, 'digest')\n ? `\n\n## Weekly digest (Cron Trigger)\n\nEvery Monday at 08:00 UTC (\\`triggers.crons\\` in \\`wrangler.jsonc\\`), the Worker renders\n\\`/report\\` to a PDF with Browser Run and emails it. \\`/digest\\` lists each run — sent,\nskipped or failed, and why — and \"Send now\" runs one at once.\n\n- \\`worker/digest.ts\\` — \\`runDigest\\`: \\`exportPage\\` (\\`@cascivo/app/export\\`), then Email\n Service with the PDF attached. Every run is recorded in D1, so a digest that did not arrive\n says why.\n- \\`worker/index.ts\\` — the \\`scheduled\\` handler the Cron Trigger calls.\n\nSet \\`DIGEST_TO\\` (comma-separated), \\`DIGEST_FROM\\` (an address on a domain onboarded to Email\nService) and \\`APP_URL\\` (the deployed app's origin: a cron has no request to read it from) in\n\\`wrangler.jsonc\\`. Until they are set, runs are recorded as skipped. To fire the cron in\n\\`vite dev\\`, open \\`/cdn-cgi/handler/scheduled\\`; \"Send now\" uses the page's own origin, so it\nneeds no \\`APP_URL\\`. Browser Run starts a local Chrome in development.`\n : ''\n }${\n hasExample(opts, 'webhooks')\n ? `\n\n## Webhooks\n\n\\`/webhooks\\` lists GitHub webhook deliveries as they arrive. Point a repository's webhook at\n\\`https://<your app>/api/webhooks/github\\` (content type \\`application/json\\`) with a secret, and\ngive the Worker the same secret:\n\\`npx wrangler secret put WEBHOOK_SECRET\\`. Locally it is in \\`.dev.vars\\`, and \"Send a test\ndelivery\" signs one with it.\n\n- \\`worker/webhooks.ts\\` — \\`verifyWebhook\\` (\\`@cascivo/app/guard\\`) checks the\n \\`X-Hub-Signature-256\\` HMAC over the raw body before anything is parsed, and refuses a bad\n signature with a 401. Each delivery is stored in D1 under GitHub's delivery id, so a retry is ignored, and a\n new one is written to a read-only room the page watches.\n- \\`verifyWebhook\\` also checks Stripe (\\`scheme: 'stripe'\\`) and Standard Webhooks\n (\\`'standard'\\`: Svix, Clerk, Resend…), both with a five-minute timestamp window against\n replays.\n\nThe page answers within milliseconds, well inside GitHub's ten seconds. For slow work, send the\ndelivery to a Queue from \\`receiveGithub\\` and answer at once.`\n : ''\n }${\n hasExample(opts, 'checkout')\n ? `\n\n## Checkout (Stripe)\n\n\\`/checkout\\` sells one product on Stripe's hosted Checkout page: the card never touches this\napp. Stripe tells the Worker when the payment succeeds; the Worker marks the order paid, pushes\nit to the order page, and emails a receipt rendered with \\`@cascivo/email\\`.\n\n1. In the Stripe dashboard, in test mode, copy the secret key (\\`sk_test_…\\`) into \\`.dev.vars\\`\n as \\`STRIPE_SECRET_KEY\\`, then run the app and buy with the card \\`4242 4242 4242 4242\\`.\n The order page confirms the payment by reading the session back from Stripe, so this works\n before any webhook is set up.\n2. To receive the webhook locally, run\n \\`stripe listen --forward-to localhost:5173/api/stripe/webhook\\` (the Stripe CLI) and put the\n \\`whsec_…\\` secret it prints in \\`.dev.vars\\` as \\`STRIPE_WEBHOOK_SECRET\\`.\n3. Deployed: \\`npx wrangler secret put STRIPE_SECRET_KEY\\` and\n \\`npx wrangler secret put STRIPE_WEBHOOK_SECRET\\`. In the dashboard, add a webhook endpoint\n at \\`https://<your app>/api/stripe/webhook\\` for \\`checkout.session.completed\\`,\n \\`checkout.session.async_payment_succeeded\\`, \\`checkout.session.async_payment_failed\\`,\n \\`checkout.session.expired\\`, \\`charge.refunded\\`, \\`charge.dispute.created\\` and\n \\`charge.dispute.closed\\`; its signing secret is \\`STRIPE_WEBHOOK_SECRET\\`.\n4. Receipts: set \\`RECEIPT_FROM\\` in \\`wrangler.jsonc\\` to an address on a domain you have\n onboarded to Email Service. \\`vite dev\\` renders each receipt and logs it instead.\n\nWhat you sell is \\`PRODUCT\\` in \\`src/checkout.ts\\`. The Worker sends that price to Stripe, so a\nbrowser cannot change it.\n\n- \\`worker/checkout.ts\\` — \\`createStripe\\` (\\`@cascivo/app/stripe\\`) creates the session, with\n the order id as its idempotency key. The webhook is checked by \\`verifyWebhook\\` (scheme\n \\`stripe\\`: signature and a five-minute window) before \\`parseStripeEvent\\` reads it.\n- An order moves from \\`pending\\` to \\`paid\\`, \\`failed\\` or \\`expired\\` once. A retried event, or\n the page getting there before the webhook, changes nothing, so the receipt goes out once.\n Orders are found by Stripe's session id, never by \\`client_reference_id\\`, which a buyer can\n set on a Payment Link.\n- A bank debit completes the session as \\`unpaid\\`: the order stays pending until\n \\`async_payment_succeeded\\` or \\`async_payment_failed\\` arrives, possibly days later.\n- Refunds are made in the Stripe dashboard (or with \\`createRefund\\`); \\`charge.refunded\\` records\n the amount, and an order refunded in full becomes \\`refunded\\`. A chargeback makes it\n \\`disputed\\` until it is decided: answer it with evidence in the dashboard. Both find the\n order by the payment it stored when it was paid.\n- \\`src/routes/checkout/[order].tsx\\` — where Stripe sends the buyer back. It watches the\n order's read-only room, so it updates when the webhook arrives.\n\nEach caller (by IP) may start 20 checkouts a minute.${\n usesBilling(opts)\n ? `\n\n### Subscriptions (\\`/billing\\`)\n\nSigned-in users subscribe to \\`PLAN\\` (\\`src/billing.ts\\`) on Stripe's checkout and change,\npause or cancel it in Stripe's hosted billing portal, so the app has no billing screens to build.\n\n1. Add \\`customer.subscription.created\\`, \\`customer.subscription.updated\\`,\n \\`customer.subscription.deleted\\` and \\`invoice.payment_failed\\` to the webhook endpoint's\n events.\n2. In the Stripe dashboard, save the Customer Portal's settings once (test mode too): until\n then, Stripe refuses to open it.\n3. Gate a paid feature in the Worker with \\`await billingStore.requirePlan(env, request)\\` (402\n without the plan), never on what the page shows.\n\n- \\`worker/billing.ts\\` — the subscription names its user in its metadata, which only the\n Worker sets (\\`client_reference_id\\` can be set by a buyer on a Payment Link). Every\n subscription event is read back from Stripe before it is stored, because events arrive out of\n order, and a late event about an older subscription cannot end a live one.\n- Back from checkout, \\`/billing\\` reads the session and its subscription at once, so it is right\n before the webhook arrives, and only for the user the subscription names.\n- A renewal that cannot be charged leaves the plan on (\\`past_due\\`) while Stripe retries, and\n emails the customer once per attempt with Stripe's page to pay with another card. How long\n Stripe retries, and whether it then cancels, is set in the dashboard (Billing → Subscriptions\n and emails); turn off Stripe's own failed-payment emails there, or customers get two.`\n : `\n\nSubscriptions need an account to belong to: \\`cascivo create --framework cloudflare\n--example checkout --auth email\\` adds a \\`/billing\\` page with a monthly plan and Stripe's\nbilling portal.`\n }`\n : ''\n }${\n hasExample(opts, 'newsletter')\n ? `\n\n## Newsletter (Amazon SES)\n\n\\`/newsletter\\` signs readers up; \\`/newsletter/send\\` writes an issue in Markdown, previews it\nas the email it will be (rendered with \\`@cascivo/email\\`), and sends it to every confirmed\nreader through Amazon SES. It runs in \\`vite dev\\` with nothing set up: emails are logged instead\nof sent, and the sign-up page shows the confirmation link. The composer's key is in\n\\`.dev.vars\\` (\\`NEWSLETTER_KEY\\`).\n\nTo send for real:\n\n1. In SES, verify the domain you send from (SES gives DNS records for DKIM; add them in\n Cloudflare DNS, with an SPF and a DMARC record), and ask AWS to move the account out of the\n sandbox, where it can mail only verified addresses.\n2. Create an IAM user allowed \\`ses:SendEmail\\` and nothing else, and give its keys to the\n Worker: \\`npx wrangler secret put AWS_ACCESS_KEY_ID\\` and \\`AWS_SECRET_ACCESS_KEY\\` (in\n \\`.dev.vars\\` locally). Set \\`AWS_REGION\\` and \\`NEWSLETTER_FROM\\` in \\`wrangler.jsonc\\`, and\n \\`npx wrangler secret put NEWSLETTER_KEY\\` to a long random value.\n3. Bounces and complaints: create an SNS topic, set it as the SES identity's bounce and\n complaint notification topic, and subscribe \\`https://<your app>/api/sns/ses\\` to it (HTTPS).\n Put the topic's ARN in \\`SNS_TOPIC_ARN\\`. The Worker confirms the subscription itself.\n4. Create the queue once: \\`npx wrangler queues create ${newsletterQueue(opts)}\\`.\n\n- \\`worker/newsletter.ts\\` — double opt-in: a sign-up gets a confirmation link (valid a day,\n resent at most every ten minutes) and gets no issue until it is opened. Sending stores the\n issue and puts its readers on the \\`NEWSLETTER\\` queue, 25 per message; the consumer sends\n them one by one through \\`createSes\\` (\\`@cascivo/app/ses\\`), handed to \\`sendEmail\\` from\n \\`@cascivo/email\\`, which checks each message before it leaves, one message at a time. Each\n send is recorded, so a message retried after SES throttling skips whoever already has it.\n- Every issue carries \\`List-Unsubscribe\\` and \\`List-Unsubscribe-Post\\` (one-click\n unsubscribe, RFC 8058, which Gmail and Yahoo require of bulk senders) and a footer link to\n \\`/newsletter/unsubscribe\\`.\n- \\`handleSns\\` checks each SNS message's signature against SNS's certificate before\n \\`parseSesNotification\\` reads it. A permanent bounce or a complaint suppresses the address\n for good: mailing it again is what gets an SES account reviewed.\n- \\`worker/newsletter-email.ts\\` — the issue and confirmation emails. The body is Markdown drawn\n through the email primitives; raw HTML in it stays literal text.\n\nSign-ups and every use of the newsletter key count against the rate limit (20 a minute per\nIP). Sending reads every confirmed address into memory; past a few hundred thousand readers,\npage through them instead.`\n : ''\n }${\n hasExample(opts, 'social')\n ? `\n\n## Social posts\n\n\\`/social\\` connects Bluesky, Buffer, LinkedIn, Mastodon and Threads accounts and posts to them,\nnow or at a time you pick.\n\n- \\`worker/social.ts\\` — \\`handleConnections\\` (\\`@cascivo/app/oauth-server\\`) answers\n \\`/api/connections/*\\`: connect, list, remove. Tokens are sealed in D1 with \\`AUTH_SECRET\\`.\n Scheduling checks that each account is yours and connected, and that each network's\n publisher (\\`@cascivo/app/social\\`) accepts the post, before anything is stored.\n- \\`worker/social-post.ts\\` — \\`SocialPost\\`, a Workflow per post: it sleeps until the post\n is due, then posts to each account in its own step. Bluesky and Mastodon steps retry, safely:\n Mastodon takes an idempotency key, and a Bluesky post's record key is fixed by its time and\n id. Buffer, LinkedIn and Threads steps never do: they cannot deduplicate, so an interrupted\n post there is reported for you to check rather than sent twice.\n- \\`src/social.ts\\` — the shared types, and the publishers the page also runs, so what a\n network would refuse shows while you type.\n- Images: the composer uploads them into the \\`SOCIAL_MEDIA\\` R2 bucket through the Worker\n (\\`@cascivo/app/uploads\\`), each user under their own prefix; up to 4, JPEG or PNG, 1 MB each\n (Bluesky's limit), each with a description. Bluesky, LinkedIn and Mastodon get the bytes.\n Threads and Buffer fetch images by URL, so they get a link to \\`/api/social/media/…\\` signed\n with \\`AUTH_SECRET\\`. It is valid until a day after the post is due, and needs \\`APP_URL\\` set\n to the deployed app. Uploads stay in R2: add a lifecycle rule\n (\\`wrangler r2 bucket lifecycle add\\`) to expire old ones.\n\n**Bluesky** needs no set-up either: people type their handle. Bluesky's servers read this\napp's client metadata from \\`/api/bluesky/client-metadata.json\\`, so the deployed app must be\nreachable at its URL. In \\`vite dev\\` the app is Bluesky's development client, which may only\nreturn to \\`127.0.0.1\\`: open \\`http://127.0.0.1:5173\\`, not \\`localhost\\`. Without a key the app is a\npublic client and sessions end after two weeks; for sessions that last, make a key and set it\nas the \\`BLUESKY_PRIVATE_JWK\\` secret:\n\n\\`\\`\\`sh\nnode -e \"crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign']).then(k=>crypto.subtle.exportKey('jwk',k.privateKey)).then(j=>console.log(JSON.stringify({...j,kid:'k1'})))\"\n\\`\\`\\`\n\n**Mastodon** needs no set-up: people type their server, and the app registers itself there\non first use.\n\n**Buffer** reaches the networks connected there (X, Instagram, TikTok, …): register an app\nclient at https://publish.buffer.com/settings/api with \\`…/api/connections/buffer/callback\\` as\nits redirect URL, and set \\`BUFFER_CLIENT_ID\\` (and \\`BUFFER_CLIENT_SECRET\\` for a confidential\nclient). Each channel in a connected Buffer is an account in the composer, checked against\nits network's limit. Buffer counts every request against your plan's budget (100 per 15\nminutes on every plan), for all your users together, so the channel list is kept for an hour.\nBuffer cannot deduplicate, so its posts are never retried, like LinkedIn's. **LinkedIn** needs an app at https://www.linkedin.com/developers/apps with the\n\"Share on LinkedIn\" product${oauthSignIn(opts) ? ' (the same app as sign-in works, with both products)' : ''}; add\n\\`…/api/connections/linkedin/callback\\` as a redirect URL and set \\`LINKEDIN_CLIENT_ID\\` and\n\\`LINKEDIN_CLIENT_SECRET\\`. LinkedIn tokens last 60 days and cannot be renewed: the page shows\n\"expires soon\" a week ahead, and connecting again renews the account in place. The daily Cron\nTrigger also emails the owner once, a week ahead, with a link to connect again\n(\\`remindExpiring\\`): set \\`REMINDER_FROM\\` in \\`wrangler.jsonc\\` to an address on a domain you\nhave onboarded to Email Service, and \\`APP_URL\\` to the deployed app. People whose provider shared\nno email see the page's notice only.\n\n**Threads** needs a Meta app with the \"Access the Threads API\" use case\n(https://developers.facebook.com/apps), the \\`threads_basic\\` and \\`threads_content_publish\\`\npermissions, and \\`…/api/connections/threads/callback\\` as a redirect callback URL; set\n\\`THREADS_APP_ID\\` and \\`THREADS_APP_SECRET\\` (the Threads app id and secret, not the Meta\napp's). Until Meta's App Review and business verification pass, only people added as testers\non the app can connect. A Threads token lasts 60 days and renews itself only while it still\nworks, so a daily Cron Trigger (\\`${SOCIAL_CRON}\\`, \\`renewConnections\\`) renews those in their last\n30 days; try it in \\`vite dev\\` at \\`/cdn-cgi/handler/scheduled\\`. Threads takes 500 characters,\ncounting an emoji as several, and cannot deduplicate either.\n\nConnecting an account and posting count against the rate limit (20 a minute per IP).\nWorkflows need a real Cloudflare account to deploy.`\n : ''\n }${\n hasAccounts(opts)\n ? `\n\n## Accounts (${emailSignIn(opts) ? (oauthSignIn(opts) ? 'email, GitHub, Google and LinkedIn sign-in' : 'email sign-in') : 'GitHub, Google and LinkedIn sign-in'})\n\n${\n emailSignIn(opts) && oauthSignIn(opts)\n ? 'Anyone can create an account by continuing with GitHub, Google or LinkedIn, or with their email address, on `/account`.'\n : oauthSignIn(opts)\n ? 'Anyone can create an account by continuing with GitHub, Google or LinkedIn on `/account`.'\n : 'Anyone can create an account with their email address: `/account` emails a one-time link, and opening it signs them in with a session cookie.'\n} **Every API write needs a signed-in user; reads stay public.**\n\n- \\`worker/index.ts\\` — ${[emailSignIn(opts) ? '`handleAuth` (`@cascivo/app/auth-server`)' : '', oauthSignIn(opts) ? '`handleOAuth` (`@cascivo/app/oauth-server`)' : ''].filter(Boolean).join(' and ')} answer${emailSignIn(opts) && oauthSignIn(opts) ? '' : 's'} \\`/api/auth/*\\`, and\n \\`requireUser\\` refuses any other write without a session (401) or from another site (403).\n Call \\`requireUser(env.DB, request)\\` in a handler to know who is asking.${\n emailSignIn(opts)\n ? `\n- \\`worker/auth.ts\\` — sends the link through Email Service. Set \\`AUTH_FROM\\` in\n \\`wrangler.jsonc\\` to an address on a domain you have onboarded to Email Service.`\n : ''\n }\n- \\`src/auth.ts\\` — \\`auth.user\\`, a signal every page can read. \\`user.email\\` is \\`null\\` for\n someone whose provider shared no verified address.${\n emailSignIn(opts)\n ? `\n- \\`src/routes/signin/verify.tsx\\` — the page a link opens. It signs in on a button press,\n because mail scanners open every link in a message.`\n : ''\n }\n\nUsers${emailSignIn(opts) ? ', links' : ''} and sessions live in D1, stored as hashes. ${emailSignIn(opts) ? 'Links expire after 15 minutes and work once; sessions' : 'Sessions'} last 30 days.${emailSignIn(opts) ? ' Each caller (by IP) may request 20 links a minute. In `vite dev` no email is sent: the Account page shows the link instead.' : ''} WebSocket connections\n(rooms, agents) are not covered by the write rule; check \\`currentUser\\` before forwarding them\nif they need a user.${\n oauthSignIn(opts)\n ? `\n\n### GitHub, Google and LinkedIn\n\n1. **GitHub**: create an OAuth App at https://github.com/settings/developers with the\n callback URL \\`http://localhost:5173/api/auth/oauth/github/callback\\` for \\`vite dev\\`\n (an OAuth App takes one callback URL, so make a second app for the deployed one).\n2. **Google**: in the Google Cloud console, create an OAuth client of type \"Web application\"\n with the redirect URIs \\`http://localhost:5173/api/auth/oauth/google/callback\\` and\n \\`https://<your app>/api/auth/oauth/google/callback\\`.\n3. **LinkedIn**: create an app at https://www.linkedin.com/developers/apps, add the product\n \"Sign In with LinkedIn using OpenID Connect\", and under Auth add both redirect URLs\n (\\`…/api/auth/oauth/linkedin/callback\\` on localhost and on your app).\n4. Put the ids and secrets in \\`.dev.vars\\`. Deployed: \\`npx wrangler secret put\\` each\n \\`*_CLIENT_ID\\` and \\`*_CLIENT_SECRET\\` you use, and \\`AUTH_SECRET\\` (a random one:\n \\`openssl rand -base64 32\\`). A provider is offered once both its values are set.\n\nPeople are matched by their account at the provider, not by email. A new sign-in joins an\nexisting account only through an email the provider has verified (GitHub's primary verified\naddress, Google's and LinkedIn's \\`email_verified\\`). A signed-in user who follows another provider's link\nadds it to their account.`\n : ''\n }`\n : ''\n }${\n opts.auth === 'access'\n ? `\n\n## Access (who may use the app)\n\nThe Worker refuses every request Cloudflare Access did not let through: \\`requireAccess\\`\n(\\`@cascivo/app/guard\\`) at the top of \\`worker/index.ts\\` verifies the token Access signs.\n\n1. In Cloudflare One (Zero Trust), add a self-hosted Access application for this app's\n hostname, with a policy saying who may sign in.\n2. Put your team domain (\\`<team>.cloudflareaccess.com\\`) and the application's Audience (AUD)\n tag in \\`vars\\` in \\`wrangler.jsonc\\`, and deploy.\n\nUntil both are set, every API request answers 500 (\"Access is not configured\"). Access guards\nthe pages at the edge; the Worker's check also refuses API calls that reach it around Access,\nat \\`*.workers.dev\\` for example (set \\`\"workers_dev\": false\\` if the app should have no public\naddress at all). \\`vite dev\\` skips the check. A temporary account has no Access, so an app made\nwith \\`--auth access\\` cannot use \\`deploy:preview\\`.`\n : ''\n }${\n hasExample(opts, 'crud')\n ? `\n\n## Customers (D1)\n\n\\`/customers\\` is a D1 table behind \\`DataTable\\`'s server mode: sorting, search, the name and\nseats filters, and paging all run as SQL in the Worker, and the page shows one page at a time.\n\n- \\`src/customers.ts\\` — \\`defineTable\\` (\\`@cascivo/app/db\\`) lists what may be sorted, searched and\n filtered. \\`queryTable\\` builds SQL from that list only, with every value a bound parameter:\n a query for any other column is refused with a 400.\n- \\`worker/migrations.ts\\` — the schema and 60 sample rows. The Worker applies them itself on its\n first query (\\`migrate\\`), so a fresh deploy, the Deploy button and \\`deploy:preview\\` need no\n migration step. Append migrations; never edit one that has shipped.\n- \\`worker/customers.ts\\` — create, update and delete, each checked by \\`parseCustomerInput\\`.\n\nD1 works on a temporary account, so \\`deploy:preview\\` shares the table with no sign-up. If you\nprefer wrangler's own migrations (\\`wrangler d1 migrations apply\\`), move the SQL into\n\\`migrations/\\` and drop the \\`migrate\\` call.`\n : ''\n }${\n hasExample(opts, 'agent')\n ? `\n\n## Assistant (generative UI)\n\n\\`/assistant\\` is a chat whose answers can be UI. The model calls a \\`show_view\\` tool with a view\nconfig; the Worker checks it against the component manifests (\\`validateView\\` from\n\\`@cascivo/render/validate\\`) and returns any errors to the model, which fixes them and calls\nagain. The page renders the result with \\`<CascivoView>\\`: real components, no generated code.\n\n- \\`worker/assistant.ts\\` — \\`Assistant\\`, an \\`AIChatAgent\\` (Cloudflare's Agents SDK): one\n Durable Object per conversation, which stores the messages and streams replies to every open\n tab. The system prompt, the \\`show_view\\` tool and the model (\\`MODEL\\`, any Workers AI model\n with tool calling) are here.\n- \\`src/assistant.ts\\` — \\`checkView\\`, the one check both sides run on a view.\n- \\`src/routes/assistant.tsx\\` — \\`useAgent\\` + \\`useAgentChat\\`; one conversation per browser.\n- \\`worker/scripted-model.ts\\` — \\`vite dev\\` answers from this scripted model, so the page works\n offline and without an account. \\`VITE_REAL_AI=1 ${runScriptCommand(pm, 'dev')}\\` uses Workers AI instead\n (after \\`npx wrangler login\\`); a deployed Worker always does.\n\nWorkers AI bills per use beyond its free daily allocation.`\n : ''\n }${\n hasExample(opts, 'publish')\n ? `\n\n## Publish (views as pages)\n\n\\`/publish\\` turns a view — the JSON that \\`<CascivoView>\\` renders, which an agent can write\ntoo — into a page at \\`/p/<slug>\\`, with no deploy. A view is data, not code: it can only\narrange this app's components with props their manifests allow, so a published page runs no\ncode of its author's and needs no sandbox.\n\n- \\`src/pages.ts\\` — \\`parsePageInput\\` checks a page with \\`validateView\\`\n (\\`@cascivo/render/validate\\`): known components and props, and no link or image URL that\n could run script. The Worker runs it before storing a page; \\`<CascivoView>\\` checks again\n before rendering one.\n- \\`worker/pages.ts\\` — pages in D1, under a random ten-character slug.\n- \\`src/routes/publish.tsx\\` — the editor, with a live preview.\n- \\`src/routes/p/[slug].tsx\\` — a published page.\n- \\`worker/page-html.ts\\` — the Worker answers \\`/p/<slug>\\` with index.html carrying the\n page's title, description and Open Graph tags, so a shared link previews properly, and the\n rendered page in a \\`<noscript>\\` for readers without JavaScript.${\n hasExample(opts, 'export')\n ? `\n- \\`worker/page-preview.ts\\` — the preview image (\\`og:image\\`, 1200 × 630): Browser Run\n renders the page once, and D1 keeps the PNG.`\n : ''\n }\n\n${\n opts.auth\n ? ''\n : `**Anyone who can reach the app can publish**, and a page is served from your domain: put\nCloudflare Access in front (\\`--auth access\\`), add accounts (\\`--auth email\\`), or check who\nis publishing in \\`worker/index.ts\\`. `\n}Each caller (by IP) may publish 20 pages a minute (\\`ratelimits\\` in\n\\`wrangler.jsonc\\`). D1 works on a temporary account.`\n : ''\n }${\n hasExample(opts, 'voice')\n ? `\n\n## Voice\n\n\\`/voice\\` is a voice assistant: press \"Start call\", talk, and it answers aloud. Talk over a\nreply to interrupt it. Typed messages work too.\n\n- \\`worker/voice.ts\\` — \\`Voice\\`, an Agent with the Agents SDK's voice pipeline\n (\\`withVoice\\` from \\`agents/voice\\`): Workers AI speech to text (Flux, which also decides when\n you have finished speaking), a text model (\\`MODEL\\`) and text to speech. One Durable Object\n per conversation stores the transcript, which the model gets as context.\n- \\`src/voice.ts\\` — \\`VoiceClient\\` (\\`agents/voice/client\\`) captures the microphone, streams\n it, and plays the replies; its events become signals the page reads.\n- \\`worker/scripted-voice.ts\\` — \\`vite dev\\` stand-ins, because Workers AI has no local mode:\n the transcriber \"hears\" a fixed question for every three seconds of sound, and the replies\n are text only. \\`VITE_REAL_AI=1 ${runScriptCommand(pm, 'dev')}\\` uses Workers AI instead (after\n \\`npx wrangler login\\`); a deployed Worker always does.\n\nBrowsers allow the microphone only on a secure origin: \\`localhost\\` or HTTPS. Workers AI\nbills per use beyond its free daily allocation, and a temporary account has no Workers AI.`\n : ''\n }\n\nAdd more components with \\`npx cascivo add <component>\\`.\n\n## ${runtime === 'preact' ? 'Preact or React' : 'React or Preact'}\n\n${\n hasExample(opts, 'agent')\n ? `This app runs on React. The Agents SDK's hooks call React 19's \\`use()\\`, which\nPreact's compat layer does not implement, so the assistant page needs React. (The other pages\nwould run on Preact unchanged: the source is typed against React either way.)`\n : `The source is typed against React. The runtime is one plugin in \\`vite.config.ts\\`:\n\\`@preact/preset-vite\\` runs it on Preact, \\`@vitejs/plugin-react\\` on React. Switching\nneeds no source changes; swap the plugin and the matching dependencies. For this starter,\nPreact ships about a third of the client JS (~27 KB gzip against ~85 KB).`\n}\n`\n}\n\nfunction cfAgentsMd(opts: ScaffoldOptions): string {\n return `${agentsMd(opts)}\n## Server\n\nThe API contract is \\`src/api.ts\\` (\\`defineApi\\` from \\`@cascivo/app/api\\`); its handlers are\n\\`worker/index.ts\\` (\\`createHandler\\`), and the app calls it through \\`createClient\\`. Add an\nendpoint to the contract first, with a parser for its input and output: parse every payload\nthat crosses the network, never cast it. A streaming endpoint's handler is an async\ngenerator. \\`wrangler.jsonc\\` routes only \\`/api/*\\` to the Worker.\n\n## Pages\n\nA page is a file in \\`src/routes/\\` with a default export: \\`c/[id].tsx\\` serves \\`/c/:id\\` and\nreceives \\`{ params }: RouteProps<'/c/:id'>\\`. Never edit \\`src/routes.gen.ts\\`. Link with\n\\`href\\`s (SideNav items, \\`router.Link\\`); navigate from code with \\`router.navigate(path)\\`,\nand build a path with \\`buildPath('/c/:id', { id })\\`, both from \\`@cascivo/app\\`.\n\n## Sharing\n\nTo show the user a running version, run \\`${runExplicitCommand(opts.pm ?? 'npm', 'deploy:preview')}\\`.\nIt needs no Cloudflare account. Give the user both URLs it prints: the live one, and the\nclaim URL. The deployment is public, and it is deleted after 60 minutes unless the user\nopens the claim URL and signs in.\n`\n}\n\nfunction cfShellTsx(opts: ScaffoldOptions): string {\n const shell = shellTsx(opts)\n const start = shell.indexOf(' * Adding a router?')\n const end = shell.indexOf(' */\\nexport function Shell')\n // This app already routes, so the Vite scaffold's \"adding a router\" advice does not apply.\n return (\n shell.slice(0, start) +\n ' * Routing lives in src/router.ts; nav items carry `href`s, and main.tsx registers the\\n' +\n \" * router's Link so they navigate client-side.\\n\" +\n shell.slice(end)\n )\n}\n\n/** Local secrets for `vite dev` (.gitignore'd); deployed, each is a `wrangler secret`. */\nfunction cfDevVars(opts: ScaffoldOptions): string {\n return [\n ...(hasExample(opts, 'webhooks')\n ? [\n '# The secret vite dev signs and checks test webhook deliveries with.',\n 'WEBHOOK_SECRET=dev-only-webhook-secret',\n ]\n : []),\n ...(hasExample(opts, 'checkout')\n ? [\n '# Stripe, in test mode (README): the secret key from the dashboard, and the signing',\n '# secret `stripe listen` prints.',\n 'STRIPE_SECRET_KEY=',\n 'STRIPE_WEBHOOK_SECRET=',\n ]\n : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n '# The key /newsletter/send asks for. Deployed, choose a long random one.',\n 'NEWSLETTER_KEY=dev-only-newsletter-key',\n '# An IAM user allowed ses:SendEmail (README). Without them, vite dev logs each email.',\n 'AWS_ACCESS_KEY_ID=',\n 'AWS_SECRET_ACCESS_KEY=',\n ]\n : []),\n ...(oauthSignIn(opts)\n ? [\n '# Seals the sign-in state between the redirect and the callback. Deployed, a random one:',\n '# openssl rand -base64 32',\n 'AUTH_SECRET=dev-only-auth-secret-0123456789abcdef',\n '# A GitHub OAuth App, a Google OAuth client and a LinkedIn app (README).',\n '# Each provider is offered once both its id and secret are set.',\n 'GITHUB_CLIENT_ID=',\n 'GITHUB_CLIENT_SECRET=',\n 'GOOGLE_CLIENT_ID=',\n 'GOOGLE_CLIENT_SECRET=',\n 'LINKEDIN_CLIENT_ID=',\n 'LINKEDIN_CLIENT_SECRET=',\n ]\n : hasExample(opts, 'social')\n ? [\n \"# Seals connected accounts' tokens. Deployed, a random one: openssl rand -base64 32\",\n 'AUTH_SECRET=dev-only-auth-secret-0123456789abcdef',\n '# A LinkedIn app with \"Share on LinkedIn\" (README). Mastodon needs nothing.',\n 'LINKEDIN_CLIENT_ID=',\n 'LINKEDIN_CLIENT_SECRET=',\n ]\n : []),\n ...(hasExample(opts, 'social')\n ? [\n '# Optional: an ES256 private JWK with a kid, so Bluesky sessions last (README).',\n 'BLUESKY_PRIVATE_JWK=',\n '# Optional: a Buffer app client (publish.buffer.com/settings/api), to post to its channels.',\n 'BUFFER_CLIENT_ID=',\n 'BUFFER_CLIENT_SECRET=',\n '# Optional: a Meta app with the Threads use case (README), to post to Threads.',\n 'THREADS_APP_ID=',\n 'THREADS_APP_SECRET=',\n ]\n : []),\n ]\n .map((line) => `${line}\\n`)\n .join('')\n}\n\n/**\n * The secrets the app reads, with no values: committed, unlike .dev.vars. The \"Deploy to\n * Cloudflare\" button asks for each one it lists; `cp .dev.vars.example .dev.vars` starts a\n * fresh clone.\n */\nfunction cfDevVarsExample(opts: ScaffoldOptions): string {\n return cfDevVars(opts)\n .split('\\n')\n .map((line) => (line.startsWith('#') ? line : line.replace(/=.*$/, '=')))\n .join('\\n')\n}\n\n/**\n * What each setting is for, in package.json's \\`cloudflare.bindings\\`: the \"Deploy to Cloudflare\"\n * button shows it beside the field it asks the deployer to fill in.\n */\nfunction cfBindingDescriptions(opts: ScaffoldOptions): Record<string, { description: string }> {\n const describe = (entries: [string, string][]) =>\n Object.fromEntries(entries.map(([name, description]) => [name, { description }]))\n return {\n ...(hasExample(opts, 'webhooks')\n ? describe([\n [\n 'WEBHOOK_SECRET',\n 'The secret of the GitHub webhook that posts to `/api/webhooks/github`.',\n ],\n ])\n : {}),\n ...(hasExample(opts, 'checkout')\n ? describe([\n [\n 'STRIPE_SECRET_KEY',\n 'Your Stripe secret key, from the [API keys page](https://dashboard.stripe.com/test/apikeys). A test key (`sk_test_…`) takes test cards only.',\n ],\n [\n 'STRIPE_WEBHOOK_SECRET',\n 'The signing secret (`whsec_…`) of a Stripe webhook endpoint at `https://<this app>/api/stripe/webhook`. No endpoint yet? Enter `later`: orders are confirmed on their own page without it. Set the real one with `npx wrangler secret put STRIPE_WEBHOOK_SECRET`.',\n ],\n [\n 'RECEIPT_FROM',\n 'The From address of receipts, on a domain onboarded to Cloudflare Email Service. Leave it empty to send none.',\n ],\n ])\n : {}),\n ...(hasExample(opts, 'newsletter')\n ? describe([\n [\n 'NEWSLETTER_KEY',\n 'The key `/newsletter/send` asks for. Make a long random one: `openssl rand -hex 32`.',\n ],\n ['AWS_ACCESS_KEY_ID', 'An IAM user allowed `ses:SendEmail` and nothing else (README).'],\n ['AWS_SECRET_ACCESS_KEY', \"That IAM user's secret access key.\"],\n ['AWS_REGION', 'The SES region your sending domain is verified in, e.g. `eu-west-1`.'],\n ['NEWSLETTER_FROM', 'The From address of issues, on your SES-verified domain.'],\n [\n 'SNS_TOPIC_ARN',\n 'The SNS topic SES reports bounces and complaints to, subscribed to `https://<this app>/api/sns/ses`.',\n ],\n ])\n : {}),\n ...(oauthSignIn(opts)\n ? describe([\n [\n 'AUTH_SECRET',\n 'Seals the sign-in state between the redirect to GitHub, Google or LinkedIn and the way back. Make a random one: `openssl rand -base64 32`.',\n ],\n [\n 'GITHUB_CLIENT_ID',\n 'The client id of a [GitHub OAuth App](https://github.com/settings/developers) whose callback URL is `https://<this app>/api/auth/oauth/github/callback`. Leave it empty to offer no GitHub sign-in.',\n ],\n ['GITHUB_CLIENT_SECRET', \"That OAuth App's client secret.\"],\n [\n 'GOOGLE_CLIENT_ID',\n 'The client id of a Google OAuth client (Web application) with the redirect URI `https://<this app>/api/auth/oauth/google/callback`. Leave it empty to offer no Google sign-in.',\n ],\n ['GOOGLE_CLIENT_SECRET', \"That OAuth client's secret.\"],\n [\n 'LINKEDIN_CLIENT_ID',\n 'The client id of a LinkedIn app with the \"Sign In with LinkedIn using OpenID Connect\" product and the redirect URL `https://<this app>/api/auth/oauth/linkedin/callback`. Leave it empty to offer no LinkedIn sign-in.',\n ],\n ['LINKEDIN_CLIENT_SECRET', \"That LinkedIn app's primary client secret.\"],\n ])\n : hasExample(opts, 'social')\n ? describe([\n [\n 'AUTH_SECRET',\n 'Seals the tokens of the accounts people connect. Make a random one: `openssl rand -base64 32`.',\n ],\n [\n 'LINKEDIN_CLIENT_ID',\n 'The client id of a LinkedIn app with the \"Share on LinkedIn\" product and the redirect URL `https://<this app>/api/connections/linkedin/callback`. Leave it empty to offer no LinkedIn.',\n ],\n ['LINKEDIN_CLIENT_SECRET', \"That LinkedIn app's primary client secret.\"],\n ])\n : {}),\n ...(hasExample(opts, 'social')\n ? describe([\n [\n 'BLUESKY_PRIVATE_JWK',\n 'Optional. An ES256 private key as a JWK with a `kid` (README shows how to make one). With it, Bluesky sessions last until revoked; without it, two weeks.',\n ],\n [\n 'BUFFER_CLIENT_ID',\n 'Optional. A Buffer app client (https://publish.buffer.com/settings/api) with the redirect URL `https://<this app>/api/connections/buffer/callback`. Leave it empty to offer no Buffer.',\n ],\n ['BUFFER_CLIENT_SECRET', \"That Buffer app client's secret (none for a public client).\"],\n [\n 'THREADS_APP_ID',\n 'Optional. The Threads app id of a Meta app with the Threads use case, and `https://<this app>/api/connections/threads/callback` as its redirect callback URL. Leave it empty to offer no Threads.',\n ],\n ['THREADS_APP_SECRET', \"That Threads app's secret.\"],\n ])\n : {}),\n }\n}\n\nfunction buildCloudflareScaffold(opts: ScaffoldOptions, sections: Section[]): ScaffoldFile[] {\n const runtime = runtimeOf(opts)\n const routeFiles = [\n ...sections.map((s, i) => ({\n file: cfSectionFile(s, i),\n contents: i === 0 ? cfFirstRouteTsx(s) : cfRouteTsx(s, i),\n })),\n { file: '404.tsx', contents: cf404Tsx() },\n ...(hasExample(opts, 'agent')\n ? [{ file: 'assistant.tsx', contents: cfAssistantRouteTsx() }]\n : []),\n ...(hasExample(opts, 'board') ? [{ file: 'board.tsx', contents: cfBoardRouteTsx() }] : []),\n ...(hasExample(opts, 'notes') ? [{ file: 'notes.tsx', contents: cfNotesRouteTsx() }] : []),\n ...(hasExample(opts, 'import') ? [{ file: 'import.tsx', contents: cfImportRouteTsx() }] : []),\n ...(hasExample(opts, 'files') ? [{ file: 'files.tsx', contents: cfFilesRouteTsx() }] : []),\n ...(hasExample(opts, 'export') ? [{ file: 'report.tsx', contents: cfReportRouteTsx() }] : []),\n ...(hasExample(opts, 'usage') ? [{ file: 'usage.tsx', contents: cfUsageRouteTsx() }] : []),\n ...(hasExample(opts, 'crud')\n ? [{ file: 'customers.tsx', contents: cfCustomersRouteTsx() }]\n : []),\n ...(hasExample(opts, 'live') ? [{ file: 'ops.tsx', contents: cfOpsRouteTsx() }] : []),\n ...(hasExample(opts, 'voice') ? [{ file: 'voice.tsx', contents: cfVoiceRouteTsx() }] : []),\n ...(hasExample(opts, 'publish')\n ? [\n { file: 'publish.tsx', contents: cfPublishRouteTsx() },\n { file: 'p/[slug].tsx', contents: cfPageRouteTsx() },\n ]\n : []),\n ...(hasExample(opts, 'webhooks')\n ? [{ file: 'webhooks.tsx', contents: cfWebhooksRouteTsx() }]\n : []),\n ...(hasExample(opts, 'digest') ? [{ file: 'digest.tsx', contents: cfDigestRouteTsx() }] : []),\n ...(hasExample(opts, 'search') ? [{ file: 'search.tsx', contents: cfSearchRouteTsx() }] : []),\n ...(hasExample(opts, 'checkout')\n ? [\n { file: 'checkout.tsx', contents: cfCheckoutRouteTsx() },\n { file: 'checkout/[order].tsx', contents: cfOrderRouteTsx() },\n ]\n : []),\n ...(usesBilling(opts) ? [{ file: 'billing.tsx', contents: cfBillingRouteTsx() }] : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n { file: 'newsletter.tsx', contents: cfNewsletterRouteTsx() },\n { file: 'newsletter/confirm.tsx', contents: cfNewsletterConfirmRouteTsx() },\n { file: 'newsletter/unsubscribe.tsx', contents: cfNewsletterUnsubscribeRouteTsx() },\n { file: 'newsletter/send.tsx', contents: cfNewsletterSendRouteTsx() },\n ]\n : []),\n ...(hasExample(opts, 'social') ? [{ file: 'social.tsx', contents: cfSocialRouteTsx() }] : []),\n ...(hasAccounts(opts) ? [{ file: 'account.tsx', contents: cfAccountRouteTsx(opts) }] : []),\n ...(emailSignIn(opts) ? [{ file: 'signin/verify.tsx', contents: cfVerifyRouteTsx() }] : []),\n ]\n return [\n { path: 'package.json', contents: cfPackageJson(opts) },\n { path: 'tsconfig.json', contents: cfTsconfig(opts) },\n ...(needsWorkerTypes(opts)\n ? [{ path: 'tsconfig.worker.json', contents: cfWorkerTsconfig() }]\n : []),\n { path: 'vite.config.ts', contents: cfViteConfig(runtime, opts) },\n { path: 'wrangler.jsonc', contents: wranglerJsonc(opts) },\n { path: 'index.html', contents: indexHtml(opts) },\n { path: 'eslint.config.js', contents: eslintConfig() },\n { path: '.prettierrc', contents: prettierrc() },\n { path: '.prettierignore', contents: cfPrettierIgnore() },\n { path: '.gitignore', contents: cfGitignore() },\n { path: 'README.md', contents: cfReadme(opts) },\n { path: 'AGENTS.md', contents: cfAgentsMd(opts) },\n { path: 'worker/index.ts', contents: cfWorkerTs(opts) },\n { path: 'src/api.ts', contents: cfApiTs(opts) },\n { path: 'src/live.ts', contents: cfLiveTs() },\n { path: 'src/LiveCard.tsx', contents: cfLiveCardTsx() },\n { path: 'src/main.tsx', contents: cfMainTsx() },\n { path: 'src/vite-env.d.ts', contents: viteEnv() },\n { path: 'src/router.ts', contents: cfRouterTs() },\n { path: 'src/App.tsx', contents: cfAppTsx(sections, opts) },\n { path: 'src/Shell.tsx', contents: cfShellTsx(opts) },\n ...(hasExample(opts, 'agent')\n ? [\n { path: 'worker/assistant.ts', contents: cfAssistantWorkerTs() },\n { path: 'worker/scripted-model.ts', contents: cfScriptedModelTs() },\n { path: 'src/assistant.ts', contents: cfAssistantTs() },\n { path: 'src/assistant.module.css', contents: cfAssistantCss() },\n ]\n : []),\n ...(hasExample(opts, 'notes') ? [{ path: 'src/notes.ts', contents: cfNotesTs() }] : []),\n ...(hasExample(opts, 'import')\n ? [\n { path: 'src/import-job.ts', contents: cfImportJobTs() },\n { path: 'src/import-page.ts', contents: cfImportPageTs() },\n { path: 'worker/import-job.ts', contents: cfImportWorkflowTs() },\n ]\n : []),\n ...(hasExample(opts, 'crud')\n ? [\n { path: 'src/customers.ts', contents: cfCustomersTs() },\n { path: 'src/customers-page.ts', contents: cfCustomersPageTs() },\n { path: 'worker/customers.ts', contents: cfCustomersWorkerTs() },\n { path: 'worker/migrations.ts', contents: cfMigrationsTs() },\n ]\n : []),\n ...(hasExample(opts, 'live') ? [{ path: 'src/ops.ts', contents: cfOpsTs() }] : []),\n ...(hasExample(opts, 'search')\n ? [\n { path: 'src/search.ts', contents: cfSearchTs() },\n { path: 'worker/search.ts', contents: cfSearchWorkerTs() },\n { path: 'worker/articles.ts', contents: cfArticlesTs() },\n ]\n : []),\n ...(hasExample(opts, 'digest')\n ? [\n { path: 'src/digest.ts', contents: cfDigestTs() },\n { path: 'worker/digest.ts', contents: cfDigestWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'webhooks')\n ? [\n { path: 'src/webhooks.ts', contents: cfWebhooksTs() },\n { path: 'worker/webhooks.ts', contents: cfWebhooksWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'checkout')\n ? [\n { path: 'src/checkout.ts', contents: cfCheckoutTs(opts) },\n { path: 'worker/checkout.ts', contents: cfCheckoutWorkerTs() },\n ]\n : []),\n ...(usesBilling(opts)\n ? [\n { path: 'src/billing.ts', contents: cfBillingTs() },\n { path: 'worker/billing.ts', contents: cfBillingWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n { path: 'src/newsletter.ts', contents: cfNewsletterTs() },\n { path: 'worker/newsletter.ts', contents: cfNewsletterWorkerTs() },\n { path: 'worker/newsletter-email.ts', contents: cfNewsletterEmailTs(opts) },\n ]\n : []),\n ...(hasExample(opts, 'webhooks') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter') ||\n hasExample(opts, 'social') ||\n oauthSignIn(opts)\n ? [\n { path: '.dev.vars', contents: cfDevVars(opts) },\n { path: '.dev.vars.example', contents: cfDevVarsExample(opts) },\n ]\n : []),\n ...(hasAccounts(opts) ? [{ path: 'src/auth.ts', contents: cfAuthTs() }] : []),\n ...(hasExample(opts, 'social')\n ? [\n { path: 'src/social.ts', contents: cfSocialTs() },\n { path: 'worker/social.ts', contents: cfSocialWorkerTs(opts) },\n { path: 'worker/social-post.ts', contents: cfSocialPostTs() },\n ]\n : []),\n ...(emailSignIn(opts) ? [{ path: 'worker/auth.ts', contents: cfAuthWorkerTs() }] : []),\n ...(hasExample(opts, 'publish')\n ? [\n { path: 'src/pages.ts', contents: cfPagesTs() },\n { path: 'worker/pages.ts', contents: cfPagesWorkerTs() },\n { path: 'worker/page-html.ts', contents: cfPageHtmlTs() },\n ...(hasExample(opts, 'export')\n ? [{ path: 'worker/page-preview.ts', contents: cfPagePreviewTs() }]\n : []),\n ]\n : []),\n ...(hasExample(opts, 'voice')\n ? [\n { path: 'src/voice.ts', contents: cfVoiceTs() },\n { path: 'worker/voice.ts', contents: cfVoiceWorkerTs() },\n { path: 'worker/scripted-voice.ts', contents: cfScriptedVoiceTs() },\n ]\n : []),\n ...(hasExample(opts, 'usage')\n ? [\n {\n path: 'src/usage.ts',\n contents: cfUsageTs().replace(\n \"dataset: 'app_usage'\",\n `dataset: '${usageDataset(opts)}'`,\n ),\n },\n { path: 'worker/usage.ts', contents: cfUsageWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'files')\n ? [\n { path: 'src/upload-policy.ts', contents: cfUploadPolicyTs() },\n { path: 'src/files.ts', contents: cfFilesTs() },\n { path: 'src/files.module.css', contents: cfFilesCss() },\n ]\n : []),\n ...(hasExample(opts, 'board')\n ? [\n { path: 'src/board.ts', contents: cfBoardTs() },\n { path: 'src/board.module.css', contents: cfBoardCss() },\n ]\n : []),\n ...routeFiles.map(({ file, contents }) => ({ path: `src/routes/${file}`, contents })),\n // Written now so \\`tsc\\` passes before the first \\`vite\\` run; the plugin keeps it current.\n {\n path: 'src/routes.gen.ts',\n contents: generateRoutes(\n routeFiles.map((r) => r.file),\n './routes',\n ),\n },\n ]\n}\n\nexport function buildScaffold(opts: ScaffoldOptions): ScaffoldFile[] {\n const sections = resolveSections(opts.sections)\n if (opts.framework === 'astro') return buildAstroScaffold(opts, sections)\n if (opts.framework === 'cloudflare') {\n // The digest emails the report page the export example adds, so it brings that along.\n const examples =\n opts.examples?.includes('digest') && !opts.examples.includes('export')\n ? [...opts.examples, 'export' as const]\n : opts.examples\n // Social posts belong to a user: without --auth, it brings sign-in with providers.\n const auth = opts.auth ?? (examples?.includes('social') ? 'oauth' : undefined)\n return buildCloudflareScaffold(\n { ...opts, ...(examples ? { examples } : {}), ...(auth ? { auth } : {}) },\n sections,\n )\n }\n return [\n { path: 'package.json', contents: packageJson(opts) },\n { path: 'tsconfig.json', contents: tsconfig() },\n { path: 'vite.config.ts', contents: viteConfig() },\n { path: 'index.html', contents: indexHtml(opts) },\n // No `cascivo.config.ts`. It configures where `cascivo add` copies source, which a\n // prebuilt-path app never does — and its presence was how `doctor` decided a project\n // was copy-paste, so the scaffolder's own output failed `doctor --ci` and was told to\n // install the two packages the docs forbid. `cascivo add` writes the config itself the\n // first time it is used.\n { path: 'eslint.config.js', contents: eslintConfig() },\n { path: '.prettierrc', contents: prettierrc() },\n { path: '.prettierignore', contents: prettierIgnore() },\n { path: '.gitignore', contents: gitignore() },\n { path: 'README.md', contents: readme(opts) },\n { path: 'AGENTS.md', contents: agentsMd(opts) },\n { path: 'src/main.tsx', contents: mainTsx() },\n { path: 'src/vite-env.d.ts', contents: viteEnv() },\n { path: 'src/App.tsx', contents: appTsx(sections) },\n { path: 'src/Shell.tsx', contents: shellTsx(opts) },\n ...sections.map((s) => ({\n path: `src/sections/${s.component}.tsx`,\n contents: sectionTsx(s),\n })),\n ]\n}\n\nfunction buildAstroScaffold(opts: ScaffoldOptions, sections: Section[]): ScaffoldFile[] {\n return [\n { path: 'package.json', contents: astroPackageJson(opts) },\n { path: 'tsconfig.json', contents: astroTsconfig() },\n { path: 'astro.config.mjs', contents: astroConfig() },\n { path: 'eslint.config.js', contents: eslintConfig() },\n { path: '.prettierrc', contents: prettierrc() },\n { path: '.prettierignore', contents: prettierIgnore() },\n { path: '.gitignore', contents: astroGitignore() },\n { path: 'README.md', contents: astroReadme(opts, sections) },\n { path: 'AGENTS.md', contents: agentsMd(opts) },\n { path: 'src/styles/layers.css', contents: astroLayersCss() },\n { path: 'src/layouts/Layout.astro', contents: astroLayout(opts) },\n { path: 'src/components/Shell.tsx', contents: astroShellTsx(opts, sections) },\n ...sections.map((s, i) => ({\n path: `src/pages/${sectionPageFile(s, i)}`,\n contents: astroPage(s, i),\n })),\n ...sections.map((s) => ({\n path: `src/components/${s.component}.tsx`,\n contents: astroSectionTsx(s),\n })),\n ]\n}\n\nconst DEFAULT_SECTIONS = ['Dashboard', 'Reports', 'Settings']\n\nexport async function create(args: string[], cwd: string = process.cwd()): Promise<void> {\n const yes = args.includes('--yes') || args.includes('-y')\n // Skip flag values (e.g. `bun` in `--pm bun`) so the project name is the first\n // real positional, not a flag's argument.\n const nameArg = positionalArgs(args, [\n 'pm',\n 'package-manager',\n 'theme',\n 'sections',\n 'template',\n 'framework',\n 'runtime',\n 'example',\n 'auth',\n ])[0]\n const themeArg = flagValue(args, 'theme')\n const sectionsArg = flagValue(args, 'sections')\n\n const frameworkArg = (flagValue(args, 'framework') ?? '').toLowerCase()\n if (frameworkArg && !(FRAMEWORKS as readonly string[]).includes(frameworkArg)) {\n console.error(`Unknown framework \"${frameworkArg}\". Expected one of: ${FRAMEWORKS.join(', ')}.`)\n process.exitCode = 1\n return\n }\n\n const runtimeArg = (flagValue(args, 'runtime') ?? '').toLowerCase()\n if (runtimeArg && !isRuntime(runtimeArg)) {\n console.error(`Unknown runtime \"${runtimeArg}\". Expected one of: ${RUNTIMES.join(', ')}.`)\n process.exitCode = 1\n return\n }\n\n const exampleArgs = (flagValue(args, 'example') ?? '')\n .split(',')\n .map((e) => e.trim().toLowerCase())\n .filter(Boolean)\n const badExample = exampleArgs.find((e) => !isExample(e))\n if (badExample) {\n console.error(`Unknown example \"${badExample}\". Expected one of: ${EXAMPLES.join(', ')}.`)\n process.exitCode = 1\n return\n }\n\n const authArg = parseAuth(flagValue(args, 'auth'))\n if (authArg === 'invalid') {\n console.error(\n `Unknown auth \"${flagValue(args, 'auth') ?? ''}\". Expected: access, email, oauth or email,oauth.`,\n )\n process.exitCode = 1\n return\n }\n\n const pmFlag = resolvePackageManagerFlag(args)\n if ('error' in pmFlag) {\n console.error(pmFlag.error)\n process.exitCode = 1\n return\n }\n // The new project has no lock file of its own, but the directory it lands IN usually does\n // — scaffolding into an existing workspace is the common case. `preferLockfileOverUserAgent`\n // makes that walk-up outrank `npm_config_user_agent`, which `npx` always reports as npm no\n // matter what the surrounding repo uses (2026-08-14 §11). With no lock file anywhere up the\n // tree, detection still falls back to the launcher.\n const pm = detectPackageManager(cwd, {\n preferLockfileOverUserAgent: true,\n ...(pmFlag.pm ? { override: pmFlag.pm } : {}),\n })\n\n const interactive = !yes && stdin.isTTY\n const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null\n\n try {\n let name = nameArg\n if (!name && rl) {\n name = (await rl.question('Project name? [my-cascivo-app]: ')).trim()\n }\n name = name || 'my-cascivo-app'\n\n let theme = (themeArg ?? '').toLowerCase()\n if (!(THEMES as readonly string[]).includes(theme) && rl) {\n theme = (await rl.question(`Theme? (${THEMES.join('/')}) [light]: `)).trim().toLowerCase()\n }\n const resolvedTheme: ThemeName = (THEMES as readonly string[]).includes(theme)\n ? (theme as ThemeName)\n : 'light'\n\n let sectionsInput = sectionsArg\n if (!sectionsInput && rl) {\n sectionsInput = (\n await rl.question(`Nav sections? (comma-separated) [${DEFAULT_SECTIONS.join(', ')}]: `)\n ).trim()\n }\n const sections = (sectionsInput ?? '')\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n\n let framework = frameworkArg\n if (!framework && rl) {\n framework = (await rl.question(`Framework? (${FRAMEWORKS.join('/')}) [react-vite]: `))\n .trim()\n .toLowerCase()\n }\n const resolvedFramework: Framework = (FRAMEWORKS as readonly string[]).includes(framework)\n ? (framework as Framework)\n : 'react-vite'\n\n if (exampleArgs.length > 0 && resolvedFramework !== 'cloudflare') {\n console.error('--example needs --framework cloudflare (it adds a Worker-backed page).')\n process.exitCode = 1\n return\n }\n if (exampleArgs.includes('social') && authArg === 'access') {\n console.error(\n '--example social needs accounts (posts belong to a user): use --auth email, oauth or ' +\n 'email,oauth, or leave --auth out to get oauth.',\n )\n process.exitCode = 1\n return\n }\n if (authArg && resolvedFramework !== 'cloudflare') {\n console.error('--auth needs --framework cloudflare (it guards the Worker).')\n process.exitCode = 1\n return\n }\n if (exampleArgs.includes('agent') && runtimeArg === 'preact') {\n console.error(\n \"--example agent needs --runtime react: the Agents SDK's hooks call React 19's use(), \" +\n 'which Preact does not implement.',\n )\n process.exitCode = 1\n return\n }\n\n const opts: ScaffoldOptions = {\n name,\n framework: resolvedFramework,\n theme: resolvedTheme,\n sections: sections.length > 0 ? sections : DEFAULT_SECTIONS,\n pm,\n ...(isRuntime(runtimeArg) ? { runtime: runtimeArg } : {}),\n ...(exampleArgs.length > 0 ? { examples: exampleArgs.filter(isExample) } : {}),\n ...(authArg ? { auth: authArg } : {}),\n }\n\n const targetDir = join(cwd, name)\n if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {\n console.error(`Target directory \"${name}\" already exists and is not empty.`)\n process.exitCode = 1\n return\n }\n\n const files = buildScaffold(opts)\n for (const file of files) {\n await writeFileSafe(join(targetDir, file.path), file.contents)\n }\n\n console.log(\n `\\nCreated ${name} (${resolvedFramework}) with the ${resolvedTheme} theme ` +\n `(${files.length} files).`,\n )\n\n const templateSpec = flagValue(args, 'template')\n if (templateSpec) {\n const { add } = await import('./add.js')\n const { loadConfig } = await import('../utils/config.js')\n console.log(`\\nInstalling template \"${templateSpec}\"…`)\n await add([templateSpec], await loadConfig(), { cwd: targetDir, pm })\n }\n\n console.log('\\nNext steps:')\n console.log(` cd ${name}`)\n console.log(` ${installAllCommand(pm)}`)\n console.log(` ${runScriptCommand(pm, 'dev')}`)\n // Two things the output used to leave the adopter to discover (2026-08-14 §11, §1).\n console.log('\\nGood to know:')\n console.log(' No cascivo.config.ts is written — this app uses the prebuilt @cascivo/react')\n console.log(' packages and never copies source. `cascivo add <component>` writes the')\n console.log(' config itself the first time you vendor a component.')\n if (resolvedFramework === 'cloudflare') {\n console.log('\\n worker/index.ts is the API (wrangler.jsonc routes /api/* to it); `dev`')\n console.log(' runs it in workerd. Deploy with `npx wrangler login` once, then the')\n console.log(` deploy script: ${runExplicitCommand(pm, 'deploy')}`)\n console.log('\\n No account yet? Share a 60-minute preview, claimable into a free account:')\n console.log(` ${runExplicitCommand(pm, 'deploy:preview')}`)\n } else if (resolvedFramework === 'astro') {\n console.log('\\n Pages are real Astro routes — no client router to add. Only src/')\n console.log(' components/Shell.tsx hydrates (client:load, for the mobile nav drawer);')\n console.log(' page content is server-rendered and ships no JS. See')\n console.log(' https://cascivo.com/docs/using-with-astro.md')\n } else {\n console.log('\\n Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,')\n console.log(' and register your Link once with setLinkComponent — see')\n console.log(' https://cascivo.com/docs/using-with-a-router.md')\n }\n } finally {\n rl?.close()\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-Tw9wTNoc.mjs","names":["t","e","detectPackageManager","THEMES","writeFileSafe","positionalArgs","resolvePackageManagerFlag","flagValue","CASCIVO_VERSIONS","SIGNALS_PEER","normalizePath","escapeRegex","compilePath","compareSpecificity","f","fileToRoutePath","generateRoutes","i","a","e","n","t","specifier","r","o","s","V","installAllCommand","pm","runScriptCommand","script","runExplicitCommand","FRAMEWORKS","RUNTIMES","EXAMPLES","isExample","value","isRuntime","slug","label","pascalCase","safe","p","brandName","words","name","w","kept","word","packageName","resolveSections","seenKeys","seenComponents","sections","labels","trimmed","key","component","packageJson","pkg","opts","formatJson","items","tsconfig","viteConfig","indexHtml","mainTsx","viteEnv","appTsx","sectionImports","unionType","navItems","renderedSections","shellTsx","appShellOpenTag","sectionTsx","section","eslintConfig","header","nav","line","prettierrc","prettierIgnore","gitignore","staticShareSection","COMPATIBILITY_DATE","readme","agentsMd","sectionPath","index","sectionPageFile","astroPackageJson","astroConfig","astroTsconfig","astroLayersCss","astroLayout","astroShellTsx","astroSectionTsx","astroPage","astroGitignore","astroReadme","routes","cfPackageJson","agent","hasExample","workerTypes","needsWorkerTypes","preact","runtimeOf","bindings","cfBindingDescriptions","cfTsconfig","cfg","cfWorkerTsconfig","cfViteConfig","ai","usesWorkersAi","plugin","runtime","call","clause","jsoncArray","indent","wranglerJsonc","publish","rooms","usesRooms","usesAgents","objects","comments","usesLimiter","emailSignIn","c","wranglerVars","SOCIAL_CRON","usesD1","hasAccounts","usageDataset","wranglerQueues","digest","social","checkout","newsletter","emailAuth","names","entries","vars","entry","comment","queues","newsletterQueue","q","usesBilling","oauthSignIn","parseAuth","parts","raw","example","cfApiTs","imports","files","usage","crud","live","webhooks","search","billing","cfWorkerTs","exports","voice","d1","limiter","access","oauth","accounts","guards","custom","isAsync","serverRooms","signedPaths","readerPaths","openPaths","item","prefix","cfLiveTs","cfLiveCardTsx","cfSectionPath","cfSectionFile","cfRouterTs","cfAppTsx","cfMainTsx","cfFirstRouteTsx","cfRouteTsx","cf404Tsx","cfBoardTs","cfBoardRouteTsx","cfBoardCss","cfAssistantTs","cfAssistantWorkerTs","cfScriptedModelTs","cfAssistantRouteTsx","cfAssistantCss","cfNotesTs","cfNotesRouteTsx","cfImportJobTs","cfImportPageTs","cfImportWorkflowTs","cfImportRouteTsx","cfUploadPolicyTs","cfFilesTs","cfFilesRouteTsx","cfFilesCss","cfReportRouteTsx","cfUsageTs","cfUsageWorkerTs","cfUsageRouteTsx","cfSearchTs","cfSearchWorkerTs","cfArticlesTs","cfSearchRouteTsx","cfDigestTs","cfDigestWorkerTs","cfDigestRouteTsx","cfWebhooksTs","cfWebhooksWorkerTs","cfWebhooksRouteTsx","cfCheckoutTs","cfCheckoutWorkerTs","cfCheckoutRouteTsx","cfOrderRouteTsx","cfNewsletterTs","cfNewsletterEmailTs","cfNewsletterWorkerTs","cfNewsletterRouteTsx","cfNewsletterConfirmRouteTsx","cfNewsletterUnsubscribeRouteTsx","cfNewsletterSendRouteTsx","cfBillingTs","cfBillingWorkerTs","cfBillingRouteTsx","cfSocialTs","cfSocialWorkerTs","cfSocialPostTs","cfSocialRouteTsx","cfAuthTs","cfAuthWorkerTs","cfAccountRouteTsx","email","lead","cfVerifyRouteTsx","cfPagesTs","cfPagesWorkerTs","cfPageHtmlTs","cfPagePreviewTs","cfPublishRouteTsx","cfPageRouteTsx","cfVoiceTs","cfVoiceWorkerTs","cfScriptedVoiceTs","cfVoiceRouteTsx","cfOpsTs","cfOpsRouteTsx","cfCustomersTs","cfCustomersPageTs","cfCustomersWorkerTs","cfMigrationsTs","cfCustomersRouteTsx","cfPrettierIgnore","cfGitignore","cfReadme","cfAgentsMd","cfShellTsx","shell","start","end","cfDevVars","cfDevVarsExample","describe","description","buildCloudflareScaffold","routeFiles","file","buildScaffold","buildAstroScaffold","examples","auth","DEFAULT_SECTIONS","create","yes","args","nameArg","themeArg","sectionsArg","frameworkArg","runtimeArg","exampleArgs","badExample","authArg","pmFlag","cwd","rl","stdin","createInterface","stdout","theme","resolvedTheme","sectionsInput","framework","resolvedFramework","targetDir","join","existsSync","readdirSync","templateSpec","add","loadConfig"],"sources":["../src/generated/versions.ts","../../app/dist/path-DJjeYL9s.js","../../app/dist/vite.js","../src/commands/create.ts"],"sourcesContent":["// GENERATED by scripts/registry/cli-versions.ts — do not edit by hand.\n/**\n * Exact published versions, baked in at build time.\n *\n * Scaffolded apps pin these rather than `\"latest\"`: the cascivo packages version\n * independently on 0.x, so a floating specifier can resolve a mutually incompatible set,\n * and GETTING-STARTED.md tells adopters to pin exactly. Regenerate with `pnpm regen`.\n */\nexport const CASCIVO_VERSIONS: Record<string, string> = {\n '@cascivo/react': '1.8.0',\n '@cascivo/themes': '1.0.2',\n '@cascivo/charts': '1.8.0',\n '@cascivo/icons': '1.1.1',\n '@cascivo/eslint-config': '0.4.2',\n '@cascivo/app': '1.8.0',\n '@cascivo/render': '1.8.0',\n '@cascivo/storage': '1.8.0',\n '@cascivo/email': '0.6.0',\n}\n\n/** `@cascivo/core`'s declared `@preact/signals-react` peer range. */\nexport const SIGNALS_PEER = '>=3.0.0'\n","function normalizePath(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\\/+$/,``)||`/`:t}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\\]\\\\]/g,`\\\\$&`)}function compilePath(e){let t=normalizePath(e).split(`/`).filter(Boolean),n=[],r=[],i=``;return t.forEach((a,o)=>{if(a===`*`){if(o!==t.length-1)throw Error(`\"*\" must be the last segment of a path pattern: \"${e}\"`);n.push(`*`),r.push(1),i+=`(?:/(.*))?`}else if(a.startsWith(`:`)){let t=a.slice(1);if(!/^[A-Za-z_$][\\w$]*$/.test(t))throw Error(`Invalid param name \"${t}\" in path pattern \"${e}\"`);n.push(t),r.push(2),i+=`/([^/]+)`}else r.push(3),i+=`/${escapeRegex(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function matchPath(e,t){let n=e.regex.exec(normalizePath(t));if(!n)return null;let r={};try{e.keys.forEach((e,t)=>{let i=n[t+1]??``;r[e]=e===`*`?i.split(`/`).map(decodeURIComponent).join(`/`):decodeURIComponent(i)})}catch{return null}return r}function compareSpecificity(e,t){let n=Math.max(e.score.length,t.score.length);for(let r=0;r<n;r++){let n=(t.score[r]??0)-(e.score[r]??0);if(n!==0)return n}return 0}function buildPath(e,t){let n=t;return`/${normalizePath(e).split(`/`).filter(Boolean).map(t=>{if(t===`*`)return(n[`*`]??``).split(`/`).map(encodeURIComponent).join(`/`);if(t.startsWith(`:`)){let r=n[t.slice(1)];if(r===void 0)throw Error(`Missing param \"${t.slice(1)}\" for \"${e}\"`);return encodeURIComponent(r)}return t}).filter(e=>e!==``).join(`/`)}`}export{normalizePath as a,matchPath as i,compareSpecificity as n,compilePath as r,buildPath as t};","import{n as e,r as t}from\"./path-DJjeYL9s.js\";import{existsSync as n,readFileSync as r,readdirSync as i,statSync as a,writeFileSync as o}from\"node:fs\";import{dirname as s,join as c,relative as l,resolve as u,sep as d}from\"node:path\";var f=/\\.(tsx|jsx)$/;function fileToRoutePath(e){if(!f.test(e)||/\\.(test|spec)\\.[jt]sx$/.test(e))return;let t=e.replace(f,``).split(`/`);if(!t.some(e=>e.startsWith(`_`)))return t.length===1&&t[0]===`404`?null:(t.at(-1)===`index`&&t.pop(),`/${t.map((n,r)=>{if(/^\\[\\.\\.\\.[A-Za-z_$][\\w$]*\\]$/.test(n)){if(r!==t.length-1)throw Error(`routes/${e}: a [...rest] segment must be last`);return`*`}let i=/^\\[([A-Za-z_$][\\w$]*)\\]$/.exec(n);if(i)return`:${i[1]}`;if(/[[\\]]/.test(n))throw Error(`routes/${e}: malformed segment \"${n}\"`);return n}).join(`/`)}`)}function scanRoutes(e){if(!n(e))return[];let t=[],walk=n=>{for(let r of i(n).sort()){let i=c(n,r);a(i).isDirectory()?walk(i):t.push(l(e,i).split(d).join(`/`))}};return walk(e),t}function generateRoutes(n,r){let i=[],a=/* @__PURE__ */ new Map;for(let e of n){let t=fileToRoutePath(e);if(t===void 0)continue;let n=a.get(t);if(n)throw Error(`routes/${n} and routes/${e} both define ${t??`the 404 route`}`);a.set(t,e),i.push({file:e,path:t})}let specifier=e=>`${r}/${e.replace(f,``)}`,o=i.filter(e=>e.path!==null).map(e=>({...e,compiled:t(e.path)})).sort((t,n)=>e(t.compiled,n.compiled)||t.path.localeCompare(n.path)),s=i.find(e=>e.path===null);return[`// Generated by @cascivo/app/vite from the routes directory — do not edit.`,`// Add, rename or delete a route file and this file is rewritten.`,`import { lazyRoute } from '@cascivo/app'`,`import type { Route } from '@cascivo/app'`,``,`export const routes: Route[] = [`,...o.map(e=>` lazyRoute('${e.path}', () => import('${specifier(e.file)}')),`),`]`,``,s?`export const notFound: Route | undefined = lazyRoute('*', () => import('${specifier(s.file)}'))`:`export const notFound: Route | undefined = undefined`,``,\"/** Every route pattern. Fill one with `buildPath` for a typed link. */\",`export type AppPath = ${o.length>0?o.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`\n`)}function cascivoRoutes(e={}){let t=process.cwd(),dir=()=>u(t,e.dir??`src/routes`),out=()=>u(t,e.out??`src/routes.gen.ts`);function write(){let e=l(s(out()),dir()).split(d).join(`/`);e.startsWith(`.`)||(e=`./${e}`);let t=generateRoutes(scanRoutes(dir()),e);(n(out())?r(out(),`utf8`):null)!==t&&o(out(),t)}return{name:`cascivo:routes`,enforce:`pre`,configResolved(e){t=e.root},buildStart(){write()},configureServer(e){e.watcher.add(dir());let onChange=e=>{u(e).startsWith(dir()+d)&&write()};e.watcher.on(`add`,onChange),e.watcher.on(`unlink`,onChange)}}}export{cascivoRoutes,fileToRoutePath,generateRoutes,scanRoutes};","import { existsSync, readdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { stdin, stdout } from 'node:process'\nimport { createInterface } from 'node:readline/promises'\nimport {\n detectPackageManager,\n THEMES,\n type PackageManager,\n type ThemeName,\n} from '../utils/config.js'\nimport { flagValue, positionalArgs, resolvePackageManagerFlag } from '../utils/args.js'\nimport { writeFileSafe } from '../utils/fs.js'\nimport { CASCIVO_VERSIONS, SIGNALS_PEER } from '../generated/versions.js'\n// Bundled into the CLI (a devDependency): the scaffold writes the same routes.gen.ts the\n// app's own Vite plugin will, so the two cannot drift.\nimport { generateRoutes } from '@cascivo/app/vite'\n\n/**\n * Exact published versions, baked in at build time by `scripts/registry/cli-versions.ts`.\n *\n * This used to be the literal `'latest'` for every cascivo dependency — the loosest\n * possible specifier on a set of independently-versioned 0.x packages, and the direct\n * opposite of GETTING-STARTED.md's \"pin **exact** versions (no `^`)\". A scaffold that\n * contradicts the docs on the very first file an adopter opens undermines every other rule\n * those docs state, so the pins are generated rather than hand-written.\n */\nconst V = CASCIVO_VERSIONS\n\n/** Install-everything command for a package manager (`pnpm install`, `yarn`, …). */\nfunction installAllCommand(pm: PackageManager): string {\n return pm === 'yarn' ? 'yarn' : `${pm} install`\n}\n\n/** Run-a-script command for a package manager (`npm run dev` vs `pnpm dev`). */\nfunction runScriptCommand(pm: PackageManager, script: string): string {\n return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`\n}\n\n/**\n * Like `runScriptCommand`, but always through `run`. A script named after a built-in\n * command needs it: `pnpm deploy` is pnpm's own workspace deploy, not the `deploy` script.\n */\nfunction runExplicitCommand(pm: PackageManager, script: string): string {\n return `${pm} run ${script}`\n}\n\n/** Project shape `create` emits. */\nexport type Framework = 'react-vite' | 'astro' | 'cloudflare'\n\nexport const FRAMEWORKS = ['react-vite', 'astro', 'cloudflare'] as const\n\n/** Client runtime for `--framework cloudflare`. The source is identical for both. */\nexport type Runtime = 'preact' | 'react'\n\nexport const RUNTIMES = ['preact', 'react'] as const\n\n/** Optional demo pages for `--framework cloudflare`. */\nexport type Example =\n | 'board'\n | 'agent'\n | 'notes'\n | 'import'\n | 'files'\n | 'export'\n | 'usage'\n | 'crud'\n | 'live'\n | 'voice'\n | 'publish'\n | 'webhooks'\n | 'digest'\n | 'search'\n | 'checkout'\n | 'newsletter'\n | 'social'\n\nexport const EXAMPLES = [\n 'board',\n 'agent',\n 'notes',\n 'import',\n 'files',\n 'export',\n 'usage',\n 'crud',\n 'live',\n 'voice',\n 'publish',\n 'webhooks',\n 'digest',\n 'search',\n 'checkout',\n 'newsletter',\n 'social',\n] as const\n\nfunction isExample(value: string): value is Example {\n return (EXAMPLES as readonly string[]).includes(value)\n}\n\nfunction isRuntime(value: string): value is Runtime {\n return (RUNTIMES as readonly string[]).includes(value)\n}\n\nexport interface ScaffoldOptions {\n /** Project directory + package name. */\n name: string\n /** Project shape. Defaults to `react-vite`. */\n framework?: Framework\n /** Theme imported in the entry CSS and set on `<html data-theme>`. */\n theme: ThemeName\n /** Display labels for the side-nav sections (one section component each). */\n sections: string[]\n /** Package manager for the generated README's commands (default npm). */\n pm?: PackageManager\n /**\n * Client runtime for the `cloudflare` framework. Defaults to `preact`, or `react` with the\n * `agent` example, whose Agents SDK hooks need React (see {@link runtimeOf}).\n */\n runtime?: Runtime\n /** Extra demo pages for the `cloudflare` framework. */\n examples?: Example[]\n /**\n * `cloudflare` framework only. `access`: the Worker refuses every request Cloudflare Access\n * did not let through. `email`: accounts with emailed sign-in links; `oauth`: accounts with\n * GitHub, Google and LinkedIn sign-in; `email,oauth`: both on one page. With accounts, every API write\n * needs a signed-in user.\n */\n auth?: Auth\n}\n\nexport type Auth = 'access' | 'email' | 'oauth' | 'email,oauth'\n\nexport interface ScaffoldFile {\n /** Path relative to the project root. */\n path: string\n contents: string\n}\n\ninterface Section {\n /** Discriminant used in the section signal + conditional render. */\n key: string\n /** Human-readable label shown in the nav and section heading. */\n label: string\n /** PascalCase component + file name. */\n component: string\n}\n\n/** Slug suitable for a union-member string literal: lower-kebab, alnum only. */\nfunction slug(label: string): string {\n return (\n label\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'section'\n )\n}\n\n/** PascalCase identifier derived from a free-text label. */\nfunction pascalCase(label: string): string {\n const parts = label\n .trim()\n .split(/[^a-zA-Z0-9]+/)\n .filter(Boolean)\n const joined = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')\n const safe = joined || 'Section'\n return /^[0-9]/.test(safe) ? `Section${safe}` : safe\n}\n\n/**\n * Short, human-readable brand for the shell header.\n *\n * The directory name is the wrong thing to render verbatim: a dated demo directory\n * (`vercel-dashboard-2026-07-30-take2`) produced a 45-character brand in the top-left of\n * every page. Take the leading words, title-case them, and stop — a brand is a label, not a\n * slug. Bare numbers and `v2`-style segments are dropped rather than counted.\n */\nexport function brandName(name: string): string {\n const words = name\n .trim()\n .split(/[^a-zA-Z0-9]+/)\n .filter((w) => w !== '' && !/^\\d+$/.test(w) && !/^v\\d+$/i.test(w))\n const kept: string[] = []\n for (const word of words) {\n if (kept.length >= 3) break\n if (kept.length > 0 && kept.join(' ').length + 1 + word.length > 24) break\n kept.push(word.charAt(0).toUpperCase() + word.slice(1))\n }\n return kept.join(' ') || 'App'\n}\n\n/** Normalize the project name into a valid npm package name. */\nfunction packageName(name: string): string {\n return (\n name\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'cascivo-app'\n )\n}\n\n/** Turn raw section labels into unique keyed/component-named sections. */\nfunction resolveSections(labels: string[]): Section[] {\n const seenKeys = new Set<string>()\n const seenComponents = new Set<string>()\n const sections: Section[] = []\n for (const label of labels) {\n const trimmed = label.trim()\n if (!trimmed) continue\n let key = slug(trimmed)\n let component = pascalCase(trimmed)\n let n = 2\n while (seenKeys.has(key) || seenComponents.has(component)) {\n key = `${slug(trimmed)}-${n}`\n component = `${pascalCase(trimmed)}${n}`\n n++\n }\n seenKeys.add(key)\n seenComponents.add(component)\n sections.push({ key, label: trimmed, component })\n }\n return sections.length > 0 ? sections : [{ key: 'home', label: 'Home', component: 'Home' }]\n}\n\nfunction packageJson(opts: ScaffoldOptions): string {\n const pkg = {\n name: packageName(opts.name),\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'vite',\n build: 'tsc && vite build',\n preview: 'vite preview',\n typecheck: 'tsc --noEmit',\n lint: 'eslint .',\n format: 'prettier --write .',\n 'format:check': 'prettier --check .',\n },\n // Prebuilt path (Path B): `@cascivo/react` and `@cascivo/themes` only.\n //\n // `@cascivo/core` and `@cascivo/tokens` are deliberately absent. AI-RULES.md: \"**Never**\n // add `@cascivo/core` to a prebuilt-path app's package.json — it is only a transitive\n // dependency there, and everything is re-exported from `@cascivo/react`.\"\n // GETTING-STARTED.md says the same of `@cascivo/tokens`, which arrives with\n // `@cascivo/themes` as a direct dependency. The scaffold used to declare both and then\n // import from them, so it violated two documented rules and taught the pattern by example.\n //\n // `@preact/signals-react` IS declared: it is a required peer that the generated\n // `App.tsx` calls into via `useSignals()`. It used to be omitted entirely and survived\n // only by hoisting — a phantom dependency that breaks under pnpm's strict layout.\n dependencies: {\n '@cascivo/react': V['@cascivo/react']!,\n '@cascivo/themes': V['@cascivo/themes']!,\n '@preact/signals-react': SIGNALS_PEER,\n react: '^19.0.0',\n 'react-dom': '^19.0.0',\n },\n devDependencies: {\n '@cascivo/eslint-config': V['@cascivo/eslint-config']!,\n '@eslint/js': '^9.0.0',\n '@types/react': '^19.0.0',\n '@types/react-dom': '^19.0.0',\n '@vitejs/plugin-react': '^5.0.0',\n eslint: '^9.0.0',\n 'eslint-plugin-react-hooks': '^7.0.0',\n prettier: '^3.0.0',\n typescript: '^5.7.0',\n // Registers the TypeScript parser AND the `files` patterns that make ESLint look at\n // .ts/.tsx at all. Without it ESLint 9's default `files` is **/*.{js,cjs,mjs}, so\n // `lint` on a TypeScript-only app exits 0 having inspected zero files — a green check\n // that proves nothing (2026-08-08 report B).\n 'typescript-eslint': '^8.0.0',\n vite: '^7.0.0',\n },\n }\n return JSON.stringify(pkg, null, 2) + '\\n'\n}\n\n/**\n * JSON as Prettier prints it: an array of strings stays on one line. `JSON.stringify` breaks\n * every array across lines, so a fresh app failed its own `format:check` on tsconfig.json.\n */\nfunction formatJson(value: unknown): string {\n const json = JSON.stringify(value, null, 2).replace(\n /\\[\\n\\s+(\"[^\"\\n]*\"(?:,\\n\\s+\"[^\"\\n]*\")*)\\n\\s*\\]/g,\n (_, items: string) => `[${items.split(/,\\n\\s+/).join(', ')}]`,\n )\n return json + '\\n'\n}\n\nfunction tsconfig(): string {\n const cfg = {\n compilerOptions: {\n target: 'ES2022',\n useDefineForClassFields: true,\n lib: ['ES2022', 'DOM', 'DOM.Iterable'],\n module: 'ESNext',\n skipLibCheck: true,\n moduleResolution: 'bundler',\n allowImportingTsExtensions: true,\n resolveJsonModule: true,\n isolatedModules: true,\n moduleDetection: 'force',\n noEmit: true,\n jsx: 'react-jsx',\n strict: true,\n noUnusedLocals: true,\n noUnusedParameters: true,\n noFallthroughCasesInSwitch: true,\n },\n include: ['src'],\n }\n return formatJson(cfg)\n}\n\nfunction viteConfig(): string {\n return `import react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [react()],\n})\n`\n}\n\nfunction indexHtml(opts: ScaffoldOptions): string {\n return `<!doctype html>\n<html lang=\"en\" data-theme=\"${opts.theme}\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>${brandName(opts.name)}</title>\n <style>\n @layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component,\n cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.example, cascivo.override;\n /* cascivo.example is this app's own slot — above the component/blocks layers so your\n styles win, below cascivo.override which stays free for one-off hotfixes. The\n generated AGENTS.md tells the agent to write there, and this statement is what makes\n that legal: a layer used but never declared falls to the end of the cascade and\n beats everything, which is the opposite of what the ordering is for. */\n /* Third-party CSS goes in the low-priority vendor layer so it can't beat cascivo:\n @import url('some-lib/styles.css') layer(vendor); — see docs/THIRD-PARTY-CSS.md */\n @layer cascivo.reset {\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n }\n @layer cascivo.base {\n html,\n body,\n #root {\n height: 100%;\n }\n }\n </style>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.tsx\"></script>\n </body>\n</html>\n`\n}\n\nfunction mainTsx(): string {\n return `import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\n\nconst root = document.getElementById('root')\nif (root) {\n ReactDOM.createRoot(root).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>,\n )\n}\n`\n}\n\nfunction viteEnv(): string {\n return `/// <reference types=\"vite/client\" />\\n`\n}\n\n// No `opts`: the theme import and the brand moved to `Shell.tsx`, which owns the app chrome.\nfunction appTsx(sections: Section[]): string {\n const sectionImports = sections\n .map((s) => `import { ${s.component} } from './sections/${s.component}'`)\n .join('\\n')\n\n const unionType = sections.map((s) => `'${s.key}'`).join(' | ')\n\n const navItems = sections\n .map(\n (s) => ` {\n label: '${s.label.replace(/'/g, \"\\\\'\")}',\n active: section.value === '${s.key}',\n onClick: (e) => {\n e.preventDefault()\n section.value = '${s.key}'\n },\n },`,\n )\n .join('\\n')\n\n const renderedSections = sections\n .map((s) => ` {section.value === '${s.key}' && <${s.component} />}`)\n .join('\\n')\n\n // Everything comes from `@cascivo/react` on the prebuilt path — it re-exports the\n // `@cascivo/core` primitives, so importing from `@cascivo/core` directly would make it a\n // phantom dependency (and AI-RULES.md forbids declaring it here). There is likewise no\n // `import '@cascivo/tokens'`: the tokens arrive with the theme stylesheet.\n return `'use client'\nimport { signal, useSignals, type SideNavItem } from '@cascivo/react'\nimport { Shell } from './Shell'\n${sectionImports}\n\ntype Section = ${unionType}\n\nconst section = signal<Section>('${sections[0]!.key}')\n\nexport default function App() {\n useSignals()\n\n const navItems: SideNavItem[] = [\n${navItems}\n ]\n\n return (\n <Shell navItems={navItems}>\n${renderedSections}\n </Shell>\n )\n}\n`\n}\n\n/**\n * The app shell, as its own component with a `children` slot.\n *\n * Split out of `App.tsx` because the shell composition — AppShell + ShellHeader + SideNav —\n * is the valuable part of the scaffold, and it used to be welded to the signal-driven\n * section switcher. A 2026-08-14 adopter prompted for \"a dashboard with Vite and React\n * Router\", then deleted `App.tsx` and all of `src/sections/` — the majority of what `create`\n * generated — and re-derived this wiring by hand.\n *\n * Now adding a router means deleting `App.tsx` + `src/sections/` and rendering `<Shell>` from\n * the root route's layout, with `navItems` carrying `href` instead of `onClick`. Nothing in\n * here needs to change.\n */\nfunction shellTsx(opts: ScaffoldOptions): string {\n return `'use client'\nimport { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'\nimport type { ReactNode } from 'react'\n\nimport '@cascivo/themes/${opts.theme}.css'\n// No '@cascivo/react/styles.css' here. On a bundler build each component imports its\n// own CSS, so you ship exactly what you use — this app emits well under 100 kB of entry\n// CSS instead of the ~273 kB aggregate sheet. Import the aggregate ONLY if you drop the\n// bundler (CDN / single-file setup). See https://cascivo.com/docs/getting-started.md\n// The theme import above is always required — themes are never automatic.\n\nexport interface ShellProps {\n /** Side-nav entries. Use \\`href\\` for a routed app, \\`onClick\\` for local state. */\n navItems: SideNavItem[]\n children: ReactNode\n}\n\n/**\n * App shell: header + side nav + a content slot.\n *\n * Adding a router? Keep this file. Delete \\`App.tsx\\` and \\`src/sections/\\`, render\n * \\`<Shell navItems={…}>\\` from your root route's layout with your \\`<Outlet />\\` as children,\n * and give each nav item an \\`href\\` instead of an \\`onClick\\`.\n *\n * For those hrefs to become real router links, call \\`setLinkComponent\\` ONCE at startup in\n * \\`main.tsx\\` — see the \"Adding a router\" section of README.md for the exact snippet, or\n * https://cascivo.com/docs/using-with-a-router.md for the full recipe.\n */\nexport function Shell({ navItems, children }: ShellProps) {\n return (\n${appShellOpenTag(opts, 'navItems')}\n {children}\n </AppShell>\n )\n}\n`\n}\n\nfunction sectionTsx(section: Section): string {\n // No inline styles: the generated AGENTS.md tells the agent not to write them, and a\n // scaffold that models the opposite teaches the opposite. The page inset comes from\n // AppShell's own \\`padding\\` prop, and the stacking from \\`Flex\\` — note \\`gap\\` takes a\n // NUMBER (a space-scale step), not a string.\n return `import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'\n\nexport function ${section.component}() {\n return (\n <Flex gap={6}>\n <Flex gap={2}>\n <Heading level={1}>${section.label}</Heading>\n <Text muted>\n Edit <code>src/sections/${section.component}.tsx</code> to build out this page.\n </Text>\n </Flex>\n\n <Card>\n <CardHeader>\n <CardTitle>Get started</CardTitle>\n </CardHeader>\n <CardContent>\n <Text>\n This page is wired into the app shell. Add components with{' '}\n <code>npx cascivo add <component></code>.\n </Text>\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\n/**\n * ESLint flat config for the scaffold.\n *\n * `@cascivo/eslint-config` is wired in from the start because\n * `eslint-plugin-react-hooks@7`'s `recommended-latest` reports every `signal.value = next`\n * — the idiom AI-RULES.md mandates and the generated `App.tsx` below uses — as\n * `Error: This value cannot be modified`. Without this, `pnpm lint` on a freshly scaffolded\n * app errors on every piece of state it ships with.\n */\nfunction eslintConfig(): string {\n return `import js from '@eslint/js'\nimport tseslint from 'typescript-eslint'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport cascivo from '@cascivo/eslint-config'\n\nexport default [\n { ignores: ['dist/**'] },\n js.configs.recommended,\n // Registers the TypeScript parser and the .ts/.tsx \\`files\\` patterns. WITHOUT THIS ESLINT\n // LINTS NOTHING: its default \\`files\\` is **/*.{js,cjs,mjs}, so every file in this app is\n // skipped with \"File ignored because no matching configuration was supplied\" and the\n // \\`lint\\` script exits 0 having checked zero files.\n ...tseslint.configs.recommended,\n // NOTE the \\`.flat\\` — the plugin exports both \\`configs['recommended-latest']\\` (the legacy\n // eslintrc shape, which applies NOTHING here and reports no error) and this one.\n reactHooks.configs.flat['recommended-latest'],\n // Spread LAST — flat config is last-wins. This turns off \\`react-hooks/immutability\\`,\n // which reports cascivo's signal writes (\\`signal.value = next\\`) as errors. That rule\n // fires on the very first \\`signal.value = x\\` you write, so this is not optional wiring:\n // see https://cascivo.com/docs/using-with-strict-eslint.md\n ...cascivo,\n]\n`\n}\n\n/** Prettier config matching the style the scaffold's own generated source is written in. */\n/**\n * The shell's `<AppShell …>` opening tag, laid out as Prettier lays it out: on one line when it\n * fits in the 100-column print width (a short app name), split over its attributes otherwise.\n * Always writing the split form made a fresh app with a short name fail its own format:check.\n */\nfunction appShellOpenTag(opts: ScaffoldOptions, items: string): string {\n const header = `header={<ShellHeader brand={{ name: '${brandName(opts.name).replace(/'/g, \"\\\\'\")}' }} />}`\n const nav = `nav={<SideNav items={${items}} />}`\n const line = ` <AppShell ${header} ${nav}>`\n return line.length <= 100 ? line : ` <AppShell\\n ${header}\\n ${nav}\\n >`\n}\n\nfunction prettierrc(): string {\n return JSON.stringify({ semi: false, singleQuote: true, printWidth: 100 }, null, 2) + '\\n'\n}\n\nfunction prettierIgnore(): string {\n return `dist\nnode_modules\n# Vendored cascivo source is formatted upstream — reformatting it makes every\n# \\`cascivo add\\` update a merge conflict.\nsrc/components/ui/\n`\n}\n\nfunction gitignore(): string {\n return `node_modules\ndist\n*.local\n.DS_Store\n`\n}\n\n/**\n * \"Share a preview\" for a static build: Cloudflare Drop takes the folder with no account, and\n * `wrangler deploy --temporary --assets` is the same thing from a terminal (or an agent).\n */\nfunction staticShareSection(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n return `\n## Share a preview\n\nNo Cloudflare account needed. Run \\`${runScriptCommand(pm, 'build')}\\`, then either:\n\n- drag the \\`dist/\\` folder onto [Cloudflare Drop](https://www.cloudflare.com/drop/), or\n- from a terminal: \\`npx wrangler deploy --temporary --assets dist --name ${packageName(opts.name)} --compatibility-date ${COMPATIBILITY_DATE}\\`\n\nEither way you get a public URL that lasts 60 minutes. Sign in to Cloudflare within that hour\nto keep it.\n`\n}\n\nfunction readme(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n return `# ${opts.name}\n\nA [cascivo](https://cascivo.com) app — Vite + React + TypeScript, pre-wired with\nthe cascivo app shell, side navigation, and the \\`${opts.theme}\\` theme.\n\n## Develop\n\n\\`\\`\\`sh\n${installAllCommand(pm)}\n${runScriptCommand(pm, 'dev')}\n\\`\\`\\`\n\n## Structure\n\n- \\`src/Shell.tsx\\` — the app shell (header + side nav + content slot). Router-agnostic.\n- \\`src/App.tsx\\` — nav items and which section is showing\n- \\`src/sections/\\` — one component per nav item\n\nAdd more components with \\`npx cascivo add <component>\\`.\n\n## Adding a router\n\nThis app switches sections with a signal, not a router. To add one (React Router,\nTanStack Router, …):\n\n1. **Keep \\`src/Shell.tsx\\`.** Delete \\`src/App.tsx\\` and \\`src/sections/\\`.\n2. Render \\`<Shell navItems={…}>\\` from your root route's layout, with your \\`<Outlet />\\`\n as its children.\n3. Give each nav item an \\`href\\` instead of \\`onClick\\`.\n4. Register your router's Link **once** at startup, in \\`src/main.tsx\\`:\n\n\\`\\`\\`tsx\nimport { setLinkComponent } from '@cascivo/react'\nimport type { LinkComponentProps } from '@cascivo/react'\nimport { Link } from 'react-router'\n\nsetLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href ?? '#'} {...rest} />)\n\\`\\`\\`\n\nThat one call makes \\`SideNav\\`, \\`ShellHeader\\` and \\`Breadcrumb\\` render real router links.\nLinks you write in page content use \\`<Link asChild>\\` instead — two kinds of link, two\nmechanisms. Full recipe: https://cascivo.com/docs/using-with-a-router.md\n${staticShareSection(opts)}`\n}\n\nfunction agentsMd(opts: ScaffoldOptions): string {\n return `# Agent instructions — ${opts.name}\n\nThis is a [cascivo](https://cascivo.com) app. When generating or editing CSS, follow\nthe **layer contract** — cascivo styles live in cascade layers, and layer order beats\nselector specificity.\n\n## CSS layer contract\n\n1. Every declaration goes inside an \\`@layer\\` block. Unlayered CSS beats all layers\n regardless of specificity — never emit it.\n2. Never invent layer names. Write only: your app slot \\`cascivo.example\\` (declared in\n the order statement in \\`index.html\\`) for page styles, and\n \\`@layer cascivo.override { … }\\` for hotfixes / one-off overrides — it beats\n everything cascivo ships.\n3. Never nest layers deeper than the shipped \\`cascivo.blocks.<name>\\` pattern. For\n sub-elements, use native CSS nesting inside one layer block, not new sublayers.\n4. Third-party CSS: \\`@import url('lib/styles.css') layer(vendor);\\` with \\`vendor\\`\n declared before the cascivo layers. Don't import vendor CSS from JavaScript — route it\n through a CSS file, or use \\`@cascivo/vite-plugin\\` (\\`cascivoLayers\\`) to layer it.\n5. Style with \\`--cascivo-*\\` tokens, not raw values.\n\nThis app's declared layer order (in \\`index.html\\`):\n\n\\`\\`\\`css\n@layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component,\n cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.example, cascivo.override;\n\\`\\`\\`\n\n### Worked example — nesting, not new layers\n\n\\`\\`\\`css\n@layer cascivo.override {\n .projectCard {\n /* Sub-elements nest inside the one block — no cascivo.card.status sublayer. */\n .statusBadge {\n color: var(--cascivo-color-text-subtle);\n .pulseDot {\n background: var(--cascivo-color-success);\n }\n }\n }\n}\n\\`\\`\\`\n\n## Routing\n\nIf you add a router, keep \\`src/Shell.tsx\\` and delete \\`src/App.tsx\\` + \\`src/sections/\\`.\n\ncascivo links come in **two kinds**, wired two different ways. Do not intercept\n\\`onClick\\`, and do not hand-wrap nav items:\n\n1. **Config-driven navs** (\\`SideNav\\`, \\`ShellHeader\\`, \\`Breadcrumb\\`, \\`Switcher\\`) render\n through a module singleton. Register your router's Link once, in \\`src/main.tsx\\`:\n \\`setLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href ?? '#'} {...rest} />)\\`\n2. **Links in page content** use \\`asChild\\`:\n \\`<Link asChild><RouterLink to=\"/x\">x</RouterLink></Link>\\`\n\nFull recipe: https://cascivo.com/docs/using-with-a-router.md\n\n## Types\n\nThe vocabulary types are on a subpath: \\`import type { Tone } from '@cascivo/react/types'\\`\n(also \\`Progress\\`, \\`SpaceStep\\`). \\`Status.status\\` and \\`Badge.variant\\` use them, so a\n\\`Record<MyState, Tone>\\` is the supported way to map domain states onto tones. **Never**\nadd \\`@cascivo/core\\` to this app's dependencies — it is transitive here.\n\nMore: cascivo's machine-readable guide is at https://cascivo.com/llms.txt.\n`\n}\n\n/** Build the full set of files for a new cascivo app. Pure — no filesystem I/O. */\n/* ------------------------------------------------------------------------- *\n * Astro scaffold (`--framework astro`)\n *\n * Shaped around what Astro is actually for, rather than transliterating the Vite SPA:\n * pages are real routes (no router to add later), page CONTENT is cascivo components\n * rendered with no client directive at all — server HTML, zero JS — and the only island\n * is the shell, which needs JS for its mobile nav drawer.\n *\n * The `astro.config.mjs` this emits carries one non-obvious required line —\n * `vite.resolve.noExternal` — without which SSR'd islands render unstyled. Baking that in\n * is most of why this scaffold is worth having; see the comment on `astroConfig` and\n * docs/USING-WITH-ASTRO.md.\n * ------------------------------------------------------------------------- */\n\n/** Route for a section. The first section owns the index route. */\nfunction sectionPath(section: Section, index: number): string {\n return index === 0 ? '/' : `/${section.key}`\n}\n\n/** Page file for a section, relative to `src/pages/`. */\nfunction sectionPageFile(section: Section, index: number): string {\n return index === 0 ? 'index.astro' : `${section.key}.astro`\n}\n\nfunction astroPackageJson(opts: ScaffoldOptions): string {\n const pkg = {\n name: packageName(opts.name),\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'astro dev',\n build: 'astro build',\n preview: 'astro preview',\n typecheck: 'astro check',\n lint: 'eslint .',\n format: 'prettier --write .',\n 'format:check': 'prettier --check .',\n },\n // Same prebuilt-path rule as the Vite scaffold: `@cascivo/react` + `@cascivo/themes`\n // only. `@cascivo/core` and `@cascivo/tokens` are transitive and must not be declared.\n dependencies: {\n '@astrojs/react': '^5.0.0',\n '@cascivo/react': V['@cascivo/react']!,\n '@cascivo/themes': V['@cascivo/themes']!,\n '@preact/signals-react': SIGNALS_PEER,\n astro: '^7.0.0',\n react: '^19.0.0',\n 'react-dom': '^19.0.0',\n },\n devDependencies: {\n '@astrojs/check': '^0.9.0',\n '@cascivo/eslint-config': V['@cascivo/eslint-config']!,\n '@eslint/js': '^9.0.0',\n '@types/react': '^19.0.0',\n '@types/react-dom': '^19.0.0',\n eslint: '^9.0.0',\n 'eslint-plugin-react-hooks': '^7.0.0',\n prettier: '^3.0.0',\n typescript: '^5.7.0',\n 'typescript-eslint': '^8.0.0',\n },\n }\n return JSON.stringify(pkg, null, 2) + '\\n'\n}\n\nfunction astroConfig(): string {\n return `// @ts-check\nimport { defineConfig } from 'astro/config'\nimport react from '@astrojs/react'\n\nexport default defineConfig({\n integrations: [react()],\n vite: {\n resolve: {\n // REQUIRED, and the single least obvious line in this app.\n //\n // Vite externalizes node_modules packages in its server build, so an externalized\n // @cascivo/react is imported by Node at runtime and its module graph is never walked\n // — and Astro collects a page's CSS by walking that graph. Without this, SSR'd\n // islands (client:load, client:visible) render with correct class names and no rules\n // anywhere in the output. Nothing warns; it reads as a theming problem.\n //\n // It must be \\`resolve.noExternal\\`, NOT \\`ssr.noExternal\\`: Astro prerenders static\n // routes in its own Vite environment, which \\`ssr.*\\` does not reach.\n // https://cascivo.com/docs/using-with-astro.md\n noExternal: [/^@cascivo\\\\//],\n },\n },\n})\n`\n}\n\nfunction astroTsconfig(): string {\n const cfg = {\n extends: 'astro/tsconfigs/strict',\n include: ['.astro/types.d.ts', '**/*'],\n exclude: ['dist'],\n compilerOptions: { jsx: 'react-jsx' },\n }\n return JSON.stringify(cfg, null, 2) + '\\n'\n}\n\n/**\n * The cascade layer order plus this app's own slot.\n *\n * A standalone CSS file imported BEFORE the theme, rather than a `<style>` in the layout:\n * layers take their order from first appearance, so a statement that lands after the\n * theme's CSS cannot reorder anything, and `vendor` would end up winning over every\n * cascivo layer instead of losing to all of them.\n */\nfunction astroLayersCss(): string {\n return `/* Cascade layer order. Must load before any cascivo CSS — see the note in\n src/layouts/Layout.astro. Later layers beat earlier ones regardless of specificity.\n\n cascivo.example is this app's own slot: above the component and blocks layers so your\n page styles win, below cascivo.override which stays free for one-off hotfixes.\n Third-party CSS goes in vendor so it cannot beat cascivo:\n @import url('some-lib/styles.css') layer(vendor);\n See https://cascivo.com/docs/third-party-css.md */\n@layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component,\n cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.example, cascivo.override;\n\n@layer cascivo.reset {\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n}\n\n@layer cascivo.base {\n html,\n body {\n block-size: 100%;\n }\n}\n`\n}\n\nfunction astroLayout(opts: ScaffoldOptions): string {\n return `---\n// Import order matters: the layer statement must be established before any cascivo CSS.\nimport '../styles/layers.css'\nimport '@cascivo/themes/${opts.theme}.css'\n\n// No '@cascivo/react/styles.css'. Each component brings its own CSS through the module\n// graph, so you ship only what your pages use. Import the aggregate ONLY if you drop the\n// bundler entirely. The theme import above is always required — themes are never automatic.\n\ninterface Props {\n title: string\n}\n\nconst { title } = Astro.props\n---\n\n<!doctype html>\n<html lang=\"en\" data-theme=\"${opts.theme}\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>{title} · ${brandName(opts.name)}</title>\n </head>\n <body>\n <slot />\n </body>\n</html>\n`\n}\n\n/**\n * The app shell — the ONLY island in the scaffold.\n *\n * It is hydrated because AppShell's nav drawer is interactive on small screens. Page\n * content is slotted in as children and stays server-rendered HTML, so a page costs the\n * shell's JS and nothing more.\n */\nfunction astroShellTsx(opts: ScaffoldOptions, sections: Section[]): string {\n const navItems = sections\n .map((s, i) => ` { label: '${s.label.replace(/'/g, \"\\\\'\")}', href: '${sectionPath(s, i)}' },`)\n .join('\\n')\n\n return `import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'\nimport type { ReactNode } from 'react'\n\nconst NAV: { label: string; href: string }[] = [\n${navItems}\n]\n\nexport interface ShellProps {\n /** Current route, e.g. \\`Astro.url.pathname\\`. Marks the matching nav item active. */\n activePath: string\n children: ReactNode\n}\n\n/**\n * Header + side nav + a content slot.\n *\n * Nav items carry \\`href\\`, so navigation is a real page load and Astro's router handles\n * it — there is no client router to register and no \\`setLinkComponent\\` call to make.\n */\nexport function Shell({ activePath, children }: ShellProps) {\n const items: SideNavItem[] = NAV.map((item) => ({\n ...item,\n active: item.href === activePath,\n }))\n\n return (\n${appShellOpenTag(opts, 'items')}\n {children}\n </AppShell>\n )\n}\n`\n}\n\n/**\n * A section's content as a plain React component with NO \\`'use client'\\`.\n *\n * Astro renders it on the server and ships no JavaScript for it. Add a client directive at\n * the call site in the page only when a section actually needs interactivity.\n */\nfunction astroSectionTsx(section: Section): string {\n return `import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'\n\nexport function ${section.component}() {\n return (\n <Flex gap={6}>\n <Flex gap={2}>\n <Heading level={1}>${section.label}</Heading>\n <Text muted>\n Edit <code>src/components/${section.component}.tsx</code> to build out this page.\n </Text>\n </Flex>\n\n <Card>\n <CardHeader>\n <CardTitle>Get started</CardTitle>\n </CardHeader>\n <CardContent>\n <Text>\n This page is server-rendered — no JavaScript ships for it. Add components with{' '}\n <code>npx cascivo add <component></code>.\n </Text>\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\nfunction astroPage(section: Section, index: number): string {\n return `---\nimport Layout from '../layouts/Layout.astro'\nimport { Shell } from '../components/Shell'\nimport { ${section.component} } from '../components/${section.component}'\n---\n\n<Layout title=\"${section.label}\">\n {/* client:load hydrates the shell for its mobile nav drawer. The section below stays\n server-rendered HTML — it is slotted in as children and ships no JS. */}\n <Shell client:load activePath=\"${sectionPath(section, index)}\">\n <${section.component} />\n </Shell>\n</Layout>\n`\n}\n\nfunction astroGitignore(): string {\n return `node_modules\ndist\n.astro\n*.local\n.DS_Store\n`\n}\n\nfunction astroReadme(opts: ScaffoldOptions, sections: Section[]): string {\n const pm = opts.pm ?? 'npm'\n const routes = sections\n .map((s, i) => `- \\`${sectionPath(s, i)}\\` — \\`src/pages/${sectionPageFile(s, i)}\\``)\n .join('\\n')\n\n return `# ${opts.name}\n\nA [cascivo](https://cascivo.com) app — Astro + React islands + TypeScript, pre-wired with\nthe cascivo app shell, side navigation, and the \\`${opts.theme}\\` theme.\n\n## Develop\n\n\\`\\`\\`sh\n${installAllCommand(pm)}\n${runScriptCommand(pm, 'dev')}\n\\`\\`\\`\n\n## Routes\n\n${routes}\n\nAdd a page by dropping a new \\`.astro\\` file in \\`src/pages/\\` — Astro routes it by filename.\nAdd it to \\`NAV\\` in \\`src/components/Shell.tsx\\` to get a nav entry.\n\n## What hydrates, and what doesn't\n\nOnly \\`Shell\\` carries a client directive (\\`client:load\\`), because its nav drawer is\ninteractive on small screens. Everything inside it is slotted in as children and stays\nserver-rendered HTML, so a page ships the shell's JavaScript and nothing else.\n\nNeed a section to be interactive? Give it a directive at the call site in the page:\n\n\\`\\`\\`astro\n<${sections[0]!.component} client:visible />\n\\`\\`\\`\n\nA component that reads \\`signal.value\\` during render must also call \\`useSignals()\\` from\n\\`@cascivo/react\\` as its first statement — Astro applies no signals transform.\n\n## Styling\n\n\\`src/styles/layers.css\\` declares the cascade layer order and this app's own\n\\`cascivo.example\\` slot. It is imported **before** the theme in \\`src/layouts/Layout.astro\\`,\nand that order matters: layers take their position from first appearance, so a statement\nloaded after the theme cannot reorder anything.\n\nAdd more components with \\`npx cascivo add <component>\\`.\n${staticShareSection(opts)}`\n}\n\n/* ------------------------------------------------------------------------- *\n * Cloudflare scaffold (`--framework cloudflare`)\n *\n * A client-rendered app and its API, deployed as ONE Worker with static assets. No SSR:\n * the browser gets the SPA, `/api/*` reaches the Worker, and `@cloudflare/vite-plugin`\n * runs that Worker in workerd during `vite dev`, so dev and production execute the same\n * runtime. The shape comes from `apps/examples/chat` (its FINDINGS.md), not from a design\n * exercise: the source is typed against React and the runtime is picked in the bundler,\n * shared wire types live in one file both sides import, and streaming goes through\n * `@cascivo/data`.\n * ------------------------------------------------------------------------- */\n\n/** Workers runtime date the scaffold targets. Bump deliberately, never to \"today\". */\nconst COMPATIBILITY_DATE = '2026-09-01'\n\nfunction cfPackageJson(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n const agent = hasExample(opts, 'agent')\n const workerTypes = needsWorkerTypes(opts)\n const preact = runtimeOf(opts) === 'preact'\n const pkg = {\n name: packageName(opts.name),\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'vite',\n build: workerTypes ? 'tsc && tsc -p tsconfig.worker.json && vite build' : 'tsc && vite build',\n preview: 'vite preview',\n deploy: `${runScriptCommand(pm, 'build')} && wrangler deploy`,\n // No account: a temporary one, live for 60 minutes unless claimed (see README).\n 'deploy:preview': `${runScriptCommand(pm, 'build')} && wrangler deploy --temporary`,\n typecheck: workerTypes\n ? 'tsc --noEmit && tsc --noEmit -p tsconfig.worker.json'\n : 'tsc --noEmit',\n lint: 'eslint .',\n format: 'prettier --write .',\n 'format:check': 'prettier --check .',\n },\n // Same prebuilt-path rules as the Vite scaffold (see `packageJson`): no @cascivo/core,\n // no @cascivo/tokens. `@cascivo/app` is the router + typed API this app imports;\n // `@cascivo/data` arrives with it.\n dependencies: {\n '@cascivo/app': V['@cascivo/app']!,\n '@cascivo/react': V['@cascivo/react']!,\n '@cascivo/themes': V['@cascivo/themes']!,\n '@preact/signals-react': SIGNALS_PEER,\n ...(preact ? { preact: '^10.29.0' } : { react: '^19.0.0', 'react-dom': '^19.0.0' }),\n // The /usage and /ops pages' charts.\n ...(hasExample(opts, 'usage') || hasExample(opts, 'live')\n ? { '@cascivo/charts': V['@cascivo/charts']! }\n : {}),\n // The /report page's PDF/PNG export drives Browser Run through Cloudflare's puppeteer.\n ...(hasExample(opts, 'export') ? { '@cloudflare/puppeteer': '^1.4.0' } : {}),\n // The /notes page keeps its room in IndexedDB.\n ...(hasExample(opts, 'notes') ? { '@cascivo/storage': V['@cascivo/storage']! } : {}),\n // The /assistant page: Cloudflare's Agents SDK on the AI SDK, and @cascivo/render to\n // draw (and validate) the views the model builds.\n ...(agent\n ? {\n '@ai-sdk/react': '^4.0.0',\n '@cascivo/render': V['@cascivo/render']!,\n '@cloudflare/ai-chat': '^0.12.1',\n agents: '^0.26.0',\n ai: '^7.0.0',\n 'workers-ai-provider': '^4.0.0',\n zod: '^4.0.0',\n }\n : {}),\n // The /voice page: the Agents SDK's voice pipeline and its browser client.\n ...(hasExample(opts, 'voice') && !agent ? { agents: '^0.26.0' } : {}),\n // The /publish page and published pages render views with @cascivo/render.\n ...(hasExample(opts, 'publish') && !agent\n ? { '@cascivo/render': V['@cascivo/render']! }\n : {}),\n // The Worker renders receipts and newsletters with @cascivo/email (react-dom/server\n // underneath).\n ...(hasExample(opts, 'checkout') || hasExample(opts, 'newsletter')\n ? { '@cascivo/email': V['@cascivo/email']! }\n : {}),\n // The Worker server-renders published pages and emails; under Preact, react-dom/server\n // is preact/compat/server, which needs this.\n ...((hasExample(opts, 'publish') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter')) &&\n preact\n ? { 'preact-render-to-string': '^6.5.0' }\n : {}),\n },\n devDependencies: {\n '@cascivo/eslint-config': V['@cascivo/eslint-config']!,\n '@cloudflare/vite-plugin': '^1.62.0',\n // Types for worker/ (tsconfig.worker.json): the Agents SDK and Workflows extend runtime\n // classes only these declare. Kept out of the app's DOM-typed tsconfig.\n ...(workerTypes ? { '@cloudflare/workers-types': '^5.0.0' } : {}),\n '@eslint/js': '^9.0.0',\n // The source is typed against React even when Preact runs it (see vite.config.ts), so\n // React's types are always installed. Under Preact, `react`/`react-dom` are dev-only:\n // they satisfy cascivo's peer ranges and are aliased away at build time.\n '@types/react': '^19.0.0',\n '@types/react-dom': '^19.0.0',\n ...(preact\n ? {\n '@babel/core': '^7.0.0',\n '@preact/preset-vite': '^2.10.0',\n react: '^19.0.0',\n 'react-dom': '^19.0.0',\n }\n : { '@vitejs/plugin-react': '^6.0.0' }),\n eslint: '^9.0.0',\n 'eslint-plugin-react-hooks': '^7.0.0',\n prettier: '^3.0.0',\n typescript: '^5.7.0',\n 'typescript-eslint': '^8.0.0',\n vite: '^8.0.0',\n wrangler: '^4.143.0',\n },\n }\n const bindings = cfBindingDescriptions(opts)\n return (\n JSON.stringify(\n Object.keys(bindings).length > 0 ? { ...pkg, cloudflare: { bindings } } : pkg,\n null,\n 2,\n ) + '\\n'\n )\n}\n\nfunction cfTsconfig(opts: ScaffoldOptions): string {\n const cfg = JSON.parse(tsconfig()) as { include: string[] }\n // `worker/` is type-checked with the app: it imports `src/api.ts`, and a contract change\n // should fail `tsc` on whichever side was not updated. When the Worker needs Cloudflare's\n // runtime types (see needsWorkerTypes), which clash with the DOM's, it gets its own config\n // (tsconfig.worker.json) and both run in `typecheck`.\n cfg.include = needsWorkerTypes(opts) ? ['src'] : ['src', 'worker']\n return formatJson(cfg)\n}\n\nfunction cfWorkerTsconfig(): string {\n return formatJson({\n extends: './tsconfig.json',\n compilerOptions: {\n lib: ['ES2022'],\n types: ['@cloudflare/workers-types', 'vite/client'],\n },\n include: ['worker'],\n })\n}\n\nfunction cfViteConfig(runtime: Runtime, opts: ScaffoldOptions): string {\n const agent = hasExample(opts, 'agent')\n const ai = usesWorkersAi(opts)\n const plugin =\n runtime === 'preact'\n ? `import preact from '@preact/preset-vite'`\n : `import react from '@vitejs/plugin-react'`\n const call = runtime === 'preact' ? 'preact()' : 'react()'\n const note =\n runtime === 'preact'\n ? `// The source is written against React's types; @preact/preset-vite aliases react and\n// react-dom to preact/compat, so the bundle runs on Preact (about a third of React's JS in\n// this starter: ~27 KB gzip against ~85 KB). To run on React instead, swap this plugin\n// for @vitejs/plugin-react — no source changes.`\n : agent\n ? `// Runs on React: the /assistant page uses the Agents SDK's hooks, which call React 19's\n// use() — preact/compat does not implement it, so this app cannot switch to Preact.`\n : `// Runs on React. To ship Preact instead (about a third of the JS in this starter), swap\n// this plugin for @preact/preset-vite — it aliases react/react-dom to preact/compat; no\n// source changes.`\n return `${plugin}\nimport { cascivoRoutes } from '@cascivo/app/vite'\nimport { cloudflare } from '@cloudflare/vite-plugin'\nimport { defineConfig } from 'vite'\n\n${note}\n//\n// cascivoRoutes() writes src/routes.gen.ts from src/routes/ — one file per page.\n// cloudflare() runs worker/index.ts in workerd during \\`vite dev\\` and builds it with the\n// client, so dev and production execute the same runtime. Request routing is in wrangler.jsonc.${\n ai\n ? `\n//\n// Workers AI has no local mode: with remote bindings on, \\`vite dev\\` needs a Cloudflare login.\n// So they are off, ${\n hasExample(opts, 'search')\n ? `and:${[\n agent ? 'the assistant answers from worker/scripted-model.ts' : '',\n hasExample(opts, 'voice')\n ? 'the voice page uses the stand-ins in worker/scripted-voice.ts'\n : '',\n 'search runs by keyword, on SQLite full-text search (worker/search.ts)',\n ]\n .filter(Boolean)\n .map((clause) => `\\n// - ${clause}`)\n .join('')}`\n : agent && hasExample(opts, 'voice')\n ? `the assistant answers from worker/scripted-model.ts, and the voice\n// page uses the stand-ins in worker/scripted-voice.ts`\n : agent\n ? 'and the assistant answers from worker/scripted-model.ts'\n : 'and the voice page uses the stand-ins in worker/scripted-voice.ts'\n }. Run\n// \\`VITE_REAL_AI=1 vite dev\\` (after \\`wrangler login\\`) to use Workers AI.`\n : ''\n }\nexport default defineConfig({\n${\n ai\n ? ` plugins: [\n ${call},\n cascivoRoutes(),\n cloudflare({ remoteBindings: process.env['VITE_REAL_AI'] === '1' }),\n ],`\n : ` plugins: [${call}, cascivoRoutes(), cloudflare()],`\n}${\n hasExample(opts, 'publish')\n ? `\n // A published page's HTML links the stylesheets its route needs, found in this manifest\n // (worker/page-html.ts), so the page is styled for readers without JavaScript too.\n build: { manifest: 'asset-manifest.json' },`\n : ''\n }\n})\n`\n}\n\n/** A JSONC array property laid out as Prettier does: one line when it fits in 100 columns. */\nfunction jsoncArray(indent: string, key: string, items: string[]): string {\n const line = `${indent}\"${key}\": [${items.join(', ')}],`\n if (line.length <= 100) return line\n return `${indent}\"${key}\": [\\n${items.map((i) => `${indent} ${i},`).join('\\n')}\\n${indent}],`\n}\n\nfunction wranglerJsonc(opts: ScaffoldOptions): string {\n const publish = hasExample(opts, 'publish')\n const rooms = usesRooms(opts)\n const agent = hasExample(opts, 'agent')\n const ai = usesAgents(opts)\n // One Durable Object class per example; a fresh app declares them all in one migration.\n const objects = [\n ...(rooms ? [{ name: 'ROOMS', className: 'SyncRoom' }] : []),\n ...(agent ? [{ name: 'Assistant', className: 'Assistant' }] : []),\n ...(hasExample(opts, 'live') ? [{ name: 'LIVE', className: 'LiveRoom' }] : []),\n ...(hasExample(opts, 'voice') ? [{ name: 'Voice', className: 'Voice' }] : []),\n ]\n const comments = [\n ...(rooms ? ['// ROOMS: one SyncRoom per room (@cascivo/app/sync-server).'] : []),\n ...(agent\n ? ['// Assistant: one AIChatAgent per conversation; it stores the messages in SQLite.']\n : []),\n ...(hasExample(opts, 'live')\n ? [\"// LIVE: the /ops dashboard's per-second totals (@cascivo/app/live-server).\"]\n : []),\n ...(hasExample(opts, 'voice')\n ? ['// Voice: one voice agent per conversation (agents/voice); it stores the transcript.']\n : []),\n ]\n return `// Cloudflare deploy config. \\`${runExplicitCommand(opts.pm ?? 'npm', 'deploy')}\\` builds and ships the SPA and\n// the Worker together. https://developers.cloudflare.com/workers/wrangler/configuration/\n{\n \"name\": \"${packageName(opts.name)}\",\n \"main\": \"./worker/index.ts\",\n \"compatibility_date\": \"${COMPATIBILITY_DATE}\",\n \"assets\": {\n // Client-side app: an unknown path serves index.html, and the client renders it.\n \"not_found_handling\": \"single-page-application\",\n // ${\n publish\n ? 'Only the API and published pages reach the Worker; static assets are served\\n // without invoking it.'\n : 'Only the API reaches the Worker; static assets are served without invoking it.'\n }\n \"run_worker_first\": [${['\"/api/*\"', ...(ai ? ['\"/agents/*\"'] : []), ...(publish ? ['\"/p/*\"'] : [])].join(', ')}],${\n publish\n ? `\n // The Worker reads index.html through this to put a published page into it.\n \"binding\": \"ASSETS\",`\n : ''\n }\n },\n \"observability\": { \"enabled\": true },${\n objects.length > 0\n ? `\n ${comments.join('\\n ')}\n \"durable_objects\": {\n${jsoncArray(\n ' ',\n 'bindings',\n objects.map((o) => `{ \"name\": \"${o.name}\", \"class_name\": \"${o.className}\" }`),\n)}\n },\n${jsoncArray(' ', 'migrations', [`{ \"tag\": \"v1\", \"new_sqlite_classes\": [${objects.map((o) => `\"${o.className}\"`).join(', ')}] }`])}`\n : ''\n }${\n hasExample(opts, 'files') || hasExample(opts, 'social')\n ? `\n // ${[\n hasExample(opts, 'files')\n ? 'Uploaded files, and Cloudflare Images for their resized previews'\n : '',\n hasExample(opts, 'social') ? 'Images attached to social posts (worker/social.ts)' : '',\n ]\n .filter(Boolean)\n .join('; ')}.\n${jsoncArray(' ', 'r2_buckets', [\n ...(hasExample(opts, 'files')\n ? [`{ \"binding\": \"FILES\", \"bucket_name\": \"${packageName(opts.name)}-files\" }`]\n : []),\n ...(hasExample(opts, 'social')\n ? [`{ \"binding\": \"SOCIAL_MEDIA\", \"bucket_name\": \"${packageName(opts.name)}-social-media\" }`]\n : []),\n])}${hasExample(opts, 'files') ? '\\n \"images\": { \"binding\": \"IMAGES\" },' : ''}`\n : ''\n }${\n usesLimiter(opts)\n ? `\n // ${[\n hasExample(opts, 'files') ? 'Uploads' : '',\n hasExample(opts, 'export') ? 'exports' : '',\n hasExample(opts, 'publish') ? 'published pages' : '',\n hasExample(opts, 'digest') ? 'digests sent now' : '',\n hasExample(opts, 'search') ? 'search indexing' : '',\n hasExample(opts, 'checkout') ? 'checkouts started' : '',\n hasExample(opts, 'newsletter') ? 'newsletter sign-ups and composer requests' : '',\n hasExample(opts, 'social') ? 'connecting accounts, posts and image uploads' : '',\n emailSignIn(opts) ? 'sign-in emails' : '',\n ]\n .filter(Boolean)\n .join(', ')\n .replace(/^./, (c) => c.toUpperCase())} per caller: 20 a minute.\n // namespace_id is any number unique within your account:\n // https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/\n${jsoncArray(' ', 'ratelimits', ['{ \"name\": \"LIMITER\", \"namespace_id\": \"1001\", \"simple\": { \"limit\": 20, \"period\": 60 } }'])}`\n : ''\n }${wranglerVars(opts)}${\n hasExample(opts, 'digest') || hasExample(opts, 'social')\n ? `\n // ${[\n hasExample(opts, 'digest') ? 'The weekly digest: Mondays at 08:00 UTC (worker/digest.ts)' : '',\n hasExample(opts, 'social')\n ? 'Threads tokens renewed and reconnect reminders sent, daily at 04:17 UTC (worker/social.ts)'\n : '',\n ]\n .filter(Boolean)\n .join('; ')}.\n \"triggers\": { \"crons\": [${[\n hasExample(opts, 'digest') ? '\"0 8 * * 1\"' : '',\n hasExample(opts, 'social') ? `\"${SOCIAL_CRON}\"` : '',\n ]\n .filter(Boolean)\n .join(', ')}] },`\n : ''\n }${\n usesD1(opts)\n ? `\n // Tables for ${[\n hasExample(opts, 'crud') ? 'customers' : '',\n hasExample(opts, 'publish') ? 'published pages' : '',\n hasExample(opts, 'webhooks') ? 'webhook deliveries' : '',\n hasExample(opts, 'digest') ? 'digest runs' : '',\n hasExample(opts, 'search') ? 'help articles' : '',\n hasExample(opts, 'checkout') ? 'orders' : '',\n hasExample(opts, 'newsletter') ? 'newsletter subscribers and issues' : '',\n hasAccounts(opts) ? 'accounts' : '',\n ]\n .filter(Boolean)\n .join(', ')}.\n // No database_id: wrangler creates the database on first deploy, and the Worker applies its\n // own schema on its first query (\\`migrate\\`).\n${jsoncArray(' ', 'd1_databases', [`{ \"binding\": \"DB\", \"database_name\": \"${packageName(opts.name)}-db\" }`])}`\n : ''\n }${\n hasExample(opts, 'usage')\n ? `\n // Every API request is recorded here (worker/index.ts); /usage reads it back.\n \"analytics_engine_datasets\": [{ \"binding\": \"USAGE\", \"dataset\": \"${usageDataset(opts)}\" }],`\n : ''\n }${wranglerQueues(opts)}${\n hasExample(opts, 'import') || hasExample(opts, 'social')\n ? `\n // ${[\n hasExample(opts, 'import')\n ? 'The CSV import runs as a Workflow (worker/import-job.ts); its progress is a room.'\n : '',\n hasExample(opts, 'social')\n ? 'Each scheduled post is a Workflow that waits until it is due (worker/social-post.ts).'\n : '',\n ]\n .filter(Boolean)\n .join('\\n // ')}\n${jsoncArray(' ', 'workflows', [\n ...(hasExample(opts, 'import')\n ? ['{ \"name\": \"import-job\", \"binding\": \"IMPORT_JOB\", \"class_name\": \"ImportJob\" }']\n : []),\n ...(hasExample(opts, 'social')\n ? [\n `{ \"name\": \"${packageName(opts.name)}-social-post\", \"binding\": \"SOCIAL_POST\", \"class_name\": \"SocialPost\" }`,\n ]\n : []),\n])}`\n : ''\n }${\n usesWorkersAi(opts)\n ? `\n // Workers AI, which the ${[agent ? 'assistant' : '', hasExample(opts, 'voice') ? 'voice agent' : '', hasExample(opts, 'search') ? 'search page' : ''].filter(Boolean).join(' and ')} call${[agent, hasExample(opts, 'voice'), hasExample(opts, 'search')].filter(Boolean).length > 1 ? '' : 's'}.\n \"ai\": { \"binding\": \"AI\" },`\n : ''\n }${\n hasExample(opts, 'search')\n ? `\n // The help articles' embeddings (worker/search.ts). Create it once before deploying (README).\n${jsoncArray(' ', 'vectorize', [`{ \"binding\": \"ARTICLES_INDEX\", \"index_name\": \"${packageName(opts.name)}-articles\" }`])}`\n : ''\n }${\n hasExample(opts, 'export')\n ? `\n // Browser Run: renders pages to PDF/PNG for /api/export.\n \"browser\": { \"binding\": \"BROWSER\" },`\n : ''\n }${\n ai || hasExample(opts, 'export')\n ? `\n // ${[ai ? 'The Agents SDK' : '', hasExample(opts, 'export') ? \"Cloudflare's puppeteer\" : ''].filter(Boolean).join(' and ')} use${ai && hasExample(opts, 'export') ? '' : 's'} Node.js APIs.\n \"compatibility_flags\": [\"nodejs_compat\"],`\n : ''\n }\n // Add bindings here (KV, D1, R2, Durable Objects, Workers AI) and read them from the\n // \\`env\\` argument of the Worker's fetch handler.\n}\n`\n}\n\n/**\n * wrangler.jsonc's plain-text settings, in one `vars` object (a second one would replace the\n * first), and the Email Service binding when anything sends mail.\n */\nfunction wranglerVars(opts: ScaffoldOptions): string {\n const digest = hasExample(opts, 'digest')\n const social = hasExample(opts, 'social')\n const checkout = hasExample(opts, 'checkout')\n const newsletter = hasExample(opts, 'newsletter')\n const emailAuth = emailSignIn(opts)\n const comments = [\n ...(opts.auth === 'access'\n ? ['Cloudflare Access (README). Until both are set, the Worker refuses every request.']\n : []),\n ...(emailAuth\n ? [\n 'Sign-in links go out through Email Service (README). AUTH_FROM must be an address on a',\n 'domain you have onboarded; until it is set, sign-in fails with a clear error.',\n ]\n : []),\n ...(digest\n ? [\n \"The weekly digest (README): who gets it, who sends it, and the deployed app's URL,\",\n 'which the browser opens. Until they are set, each run is recorded as skipped.',\n ]\n : []),\n ...(social\n ? [\n 'Reminders to connect LinkedIn again go out through Email Service from REMINDER_FROM, an',\n `address on a domain you have onboarded, with links to APP_URL (README). Until both are`,\n 'set, no reminder is sent.',\n ]\n : []),\n ...(checkout\n ? [\n 'Receipts for paid orders go out through Email Service from RECEIPT_FROM, an address',\n 'on a domain you have onboarded (README). Until it is set, no receipt is sent.',\n ]\n : []),\n ...(newsletter\n ? [\n 'The newsletter sends through Amazon SES (README): the region your sending identity is',\n 'verified in, its From address, and the SNS topic SES reports bounces and complaints to.',\n ]\n : []),\n ]\n const names = [\n ...(opts.auth === 'access' ? ['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] : []),\n ...(emailAuth ? ['AUTH_FROM'] : []),\n ...(digest ? ['DIGEST_TO', 'DIGEST_FROM', 'APP_URL'] : []),\n ...(social ? ['REMINDER_FROM', ...(digest ? [] : ['APP_URL'])] : []),\n ...(checkout ? ['RECEIPT_FROM'] : []),\n ...(newsletter ? ['AWS_REGION', 'NEWSLETTER_FROM', 'SNS_TOPIC_ARN'] : []),\n ]\n if (names.length === 0) return ''\n const entries = names.map((name) => `\"${name}\": \"\"`)\n const line = ` \"vars\": { ${entries.join(', ')} },`\n const vars =\n line.length <= 100\n ? line\n : ` \"vars\": {\\n${entries.map((entry) => ` ${entry},`).join('\\n')}\\n },`\n return `\n${comments.map((comment) => ` // ${comment}`).join('\\n')}${emailAuth || digest || checkout || social ? '\\n \"send_email\": [{ \"name\": \"EMAIL\" }],' : ''}\n${vars}`\n}\n\n/** One `queues` object for every example that has a queue (a second one would replace the first). */\nfunction wranglerQueues(opts: ScaffoldOptions): string {\n const queues = [\n ...(hasExample(opts, 'live')\n ? [\n {\n comment: [\n \"Events for /ops: POST /api/events sends them, and the Worker's queue handler takes them in\",\n 'batches of up to 100, or whatever arrived within a second.',\n ],\n binding: 'EVENTS',\n queue: `${packageName(opts.name)}-events`,\n consumer: ['\"max_batch_size\": 100', '\"max_batch_timeout\": 1'],\n },\n ]\n : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n {\n comment: [\n 'Newsletter sends (worker/newsletter.ts): one message of 25 readers at a time, so SES',\n 'is called at a steady pace. A throttled message is retried after 30 seconds.',\n ],\n binding: 'NEWSLETTER',\n queue: newsletterQueue(opts),\n consumer: [\n '\"max_batch_size\": 1',\n '\"max_concurrency\": 1',\n '\"max_retries\": 10',\n '\"retry_delay\": 30',\n ],\n },\n ]\n : []),\n ]\n if (queues.length === 0) return ''\n return `\n${queues.flatMap((q) => q.comment.map((line) => ` // ${line}`)).join('\\n')}\n \"queues\": {\n${jsoncArray(\n ' ',\n 'producers',\n queues.map((q) => `{ \"binding\": \"${q.binding}\", \"queue\": \"${q.queue}\" }`),\n)}\n \"consumers\": [\n${queues\n .map(\n (q) => ` {\n \"queue\": \"${q.queue}\",\n${q.consumer.map((line) => ` ${line},`).join('\\n')}\n },`,\n )\n .join('\\n')}\n ],\n },`\n}\n\n/** A subscription plan needs an account to belong to: checkout with accounts bills one. */\nfunction usesBilling(opts: ScaffoldOptions): boolean {\n return hasExample(opts, 'checkout') && hasAccounts(opts)\n}\n\n/** Accounts of any kind: users, sessions, and API writes that need a signed-in user. */\nfunction hasAccounts(opts: ScaffoldOptions): boolean {\n return emailSignIn(opts) || oauthSignIn(opts)\n}\n\n/** Sign-in by an emailed one-time link (`handleAuth`). */\nfunction emailSignIn(opts: ScaffoldOptions): boolean {\n return opts.auth === 'email' || opts.auth === 'email,oauth'\n}\n\n/**\n * `--auth`: `access`, `email`, `oauth`, or `email,oauth` (either order); `null` when absent,\n * `'invalid'` for anything else, including `access` combined with a sign-in method.\n */\nexport function parseAuth(raw: string | undefined): Auth | null | 'invalid' {\n const parts = [\n ...new Set(\n (raw ?? '')\n .toLowerCase()\n .split(',')\n .map((p) => p.trim()),\n ),\n ].filter(Boolean)\n if (parts.length === 0) return null\n const key = parts.sort().join(',')\n return key === 'access' || key === 'email' || key === 'oauth' || key === 'email,oauth'\n ? key\n : 'invalid'\n}\n\n/** Sign-in with GitHub, Google and LinkedIn (`handleOAuth`). */\nfunction oauthSignIn(opts: ScaffoldOptions): boolean {\n return opts.auth === 'oauth' || opts.auth === 'email,oauth'\n}\n\n/** The newsletter's queue, named after the app like every other resource. */\nfunction newsletterQueue(opts: ScaffoldOptions): string {\n return `${packageName(opts.name)}-newsletter`\n}\n\nfunction hasExample(opts: ScaffoldOptions, example: Example): boolean {\n return opts.examples?.includes(example) ?? false\n}\n\n/**\n * Examples that live in `SyncRoom`s: the board and the notes (at /api/rooms/:name), and the\n * import's job progress (a read-only room at /api/jobs/:id).\n */\n/** The Analytics Engine dataset for `--example usage`: one per app, named after it. */\nfunction usageDataset(opts: ScaffoldOptions): string {\n return `${packageName(opts.name)\n .replace(/[^a-z0-9_]/g, '_')\n .replace(/^[^a-z_]/, '_$&')}_usage`\n}\n\n/**\n * Examples with requests that cost money (storage, a browser) or put content on the app's\n * origin (a published page): rate-limited per caller.\n */\nfunction usesLimiter(opts: ScaffoldOptions): boolean {\n return (\n hasExample(opts, 'files') ||\n hasExample(opts, 'export') ||\n hasExample(opts, 'publish') ||\n hasExample(opts, 'digest') ||\n hasExample(opts, 'search') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter') ||\n hasExample(opts, 'social') ||\n emailSignIn(opts)\n )\n}\n\n/** Examples that keep tables in D1, bound as DB. */\nfunction usesD1(opts: ScaffoldOptions): boolean {\n return (\n hasExample(opts, 'crud') ||\n hasExample(opts, 'publish') ||\n hasExample(opts, 'webhooks') ||\n hasExample(opts, 'digest') ||\n hasExample(opts, 'search') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter') ||\n hasAccounts(opts)\n )\n}\n\n/** Examples that call Workers AI: the Agents SDK ones, and search's embeddings. */\nfunction usesWorkersAi(opts: ScaffoldOptions): boolean {\n return usesAgents(opts) || hasExample(opts, 'search')\n}\n\n/** Examples on Cloudflare's Agents SDK: routed under /agents/*, calling Workers AI. */\nfunction usesAgents(opts: ScaffoldOptions): boolean {\n return hasExample(opts, 'agent') || hasExample(opts, 'voice')\n}\n\nfunction usesRooms(opts: ScaffoldOptions): boolean {\n return (\n hasExample(opts, 'board') ||\n hasExample(opts, 'notes') ||\n hasExample(opts, 'import') ||\n hasExample(opts, 'webhooks') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter')\n )\n}\n\n/**\n * Worker code that extends a runtime class (the Agents SDK's Durable Object, a Workflow)\n * needs Cloudflare's runtime types, which clash with the DOM's: those apps type-check\n * worker/ on its own (tsconfig.worker.json).\n */\nfunction needsWorkerTypes(opts: ScaffoldOptions): boolean {\n return usesAgents(opts) || hasExample(opts, 'import') || hasExample(opts, 'social')\n}\n\n/**\n * The client runtime. Preact by default; React with the `agent` example, because the Agents\n * SDK's hooks (`useAgent`, `useAgentChat`) call React 19's `use()`, which preact/compat\n * does not implement — the build fails on the missing export.\n */\nfunction runtimeOf(opts: ScaffoldOptions): Runtime {\n return opts.runtime ?? (hasExample(opts, 'agent') ? 'react' : 'preact')\n}\n\nfunction cfApiTs(opts: ScaffoldOptions): string {\n const imports = hasExample(opts, 'import')\n const files = hasExample(opts, 'files')\n const usage = hasExample(opts, 'usage')\n const crud = hasExample(opts, 'crud')\n const live = hasExample(opts, 'live')\n const publish = hasExample(opts, 'publish')\n const webhooks = hasExample(opts, 'webhooks')\n const digest = hasExample(opts, 'digest')\n const search = hasExample(opts, 'search')\n const checkout = hasExample(opts, 'checkout')\n const newsletter = hasExample(opts, 'newsletter')\n const billing = usesBilling(opts)\n const social = hasExample(opts, 'social')\n return `import { defineApi, ${imports || files || usage || crud || live || publish || webhooks || digest || search || checkout || newsletter || social ? 'endpoint, ' : ''}stream } from '@cascivo/app/api'\n${crud ? `import { parseTablePage, parseTableQuery } from '@cascivo/app/db'\\n` : ''}${files ? `import { parseStoredFile } from '@cascivo/app/uploads'\\n` : ''}${imports ? `import { parseImportRequest, parseStarted } from './import-job'\\n` : ''}${usage ? `import { parseUsageReport } from './usage'\\n` : ''}${crud ? `import { parseCustomer, parseCustomerInput, parseDeleted } from './customers'\\n` : ''}${live ? `import { ops, parseAccepted } from './ops'\\n` : ''}${publish ? `import { parsePage, parsePageInput, parsePageSummary } from './pages'\\n` : ''}${webhooks ? `import { parseDeliveries, parseTestResult } from './webhooks'\\n` : ''}${digest ? `import { parseDigestRun, parseDigestRuns } from './digest'\\n` : ''}${search ? `import { parseIndexed, parseSearchQuery, parseSearchResult } from './search'\\n` : ''}${billing ? `import { parseBilling, parseRedirect, parseSyncInput } from './billing'\\n` : ''}${checkout ? `import { parseCheckoutStarted, parseOrder } from './checkout'\\n` : ''}${\n newsletter\n ? `import {\n parseConfirmed,\n parseEmailInput,\n parseIssue,\n parseIssueInput,\n parseKeyInput,\n parseOverview,\n parsePreview,\n parseSubscribed,\n parseTokenInput,\n} from './newsletter'\\n`\n : ''\n }${social ? `import { parsePostInput, parseScheduledPost, parseSocial } from './social'\\n` : ''}\n/**\n * The contract between the browser and the Worker. Both import this file: the Worker serves\n * it with \\`createHandler\\`, the app calls it with \\`createClient\\`, and a change that breaks\n * either side is a type error. Add an endpoint here, then its handler in worker/index.ts.\n */\n\n/** How many ticks one stream sends before it ends. */\nexport const TICKS_PER_STREAM = 30\n\nexport interface Tick {\n n: number\n /** ISO timestamp, set by the Worker. */\n at: string\n}\n\n/**\n * Parses one streamed tick. It crosses the network, so it is checked rather than cast —\n * \\`JSON.parse\\` returns \\`any\\`, and \\`as Tick\\` would prove nothing.\n */\nexport function parseTick(raw: unknown): Tick {\n if (typeof raw === 'object' && raw !== null) {\n const { n, at } = raw as Record<string, unknown>\n if (typeof n === 'number' && typeof at === 'string') return { n, at }\n }\n throw new Error('Malformed tick')\n}\n\nexport const api = defineApi({\n ticks: stream({ method: 'GET', path: '/api/ticks', event: parseTick }),${\n imports\n ? `\n // Starts a CSV import; returns the job id to watch (src/import-page.ts).\n startImport: endpoint({\n method: 'POST',\n path: '/api/import',\n input: parseImportRequest,\n output: parseStarted,\n }),`\n : ''\n }${\n files\n ? `\n // The stored files (the uploads themselves are \\`handleUploads\\` in the Worker).\n listFiles: endpoint({\n method: 'GET',\n path: '/api/files',\n output: (raw) => {\n if (!Array.isArray(raw)) throw new Error('Expected a list of files')\n return raw.map(parseStoredFile)\n },\n }),`\n : ''\n }${\n usage\n ? `\n // The last 24 hours of API usage, read from Analytics Engine by the Worker.\n usage: endpoint({ method: 'GET', path: '/api/usage', output: parseUsageReport }),`\n : ''\n }${\n live\n ? `\n // Events for the /ops dashboard, at most 100 at a time, onto the EVENTS queue.\n sendEvents: endpoint({\n method: 'POST',\n path: '/api/events',\n input: ops.parseEvents,\n output: parseAccepted,\n }),`\n : ''\n }${\n publish\n ? `\n // Publishes a view as a page (checked against the manifests), and reads one back.\n publishPage: endpoint({\n method: 'POST',\n path: '/api/pages',\n input: parsePageInput,\n output: parsePageSummary,\n }),\n getPage: endpoint({ method: 'GET', path: '/api/pages/:slug', output: parsePage }),`\n : ''\n }${\n webhooks\n ? `\n // The last 50 webhook deliveries (they arrive at /api/webhooks/github, signed).\n listDeliveries: endpoint({ method: 'GET', path: '/api/webhooks', output: parseDeliveries }),\n // Signs a sample delivery with WEBHOOK_SECRET and runs it through the same path.\n sendTestDelivery: endpoint({\n method: 'POST',\n path: '/api/webhook-test',\n output: parseTestResult,\n }),`\n : ''\n }${\n digest\n ? `\n // The weekly digest's recent runs, and one run now (the Cron Trigger runs it on Mondays).\n digestRuns: endpoint({ method: 'GET', path: '/api/digest/runs', output: parseDigestRuns }),\n runDigest: endpoint({ method: 'POST', path: '/api/digest/run', output: parseDigestRun }),`\n : ''\n }${\n search\n ? `\n // Help articles by meaning (Vectorize), or by keyword in vite dev.\n search: endpoint({\n method: 'POST',\n path: '/api/search',\n input: parseSearchQuery,\n output: parseSearchResult,\n }),\n // Embeds every article into the Vectorize index.\n indexArticles: endpoint({ method: 'POST', path: '/api/search/index', output: parseIndexed }),`\n : ''\n }${\n checkout\n ? `\n // Opens a Stripe Checkout page for the product; the browser goes to its url.\n startCheckout: endpoint({ method: 'POST', path: '/api/checkout', output: parseCheckoutStarted }),\n // An order, checked with Stripe while it is pending (Stripe's webhook settles it too).\n getOrder: endpoint({ method: 'GET', path: '/api/orders/:id', output: parseOrder }),`\n : ''\n }${\n billing\n ? `\n // The signed-in user's subscription (worker/billing.ts), and the ways to change it.\n getBilling: endpoint({ method: 'GET', path: '/api/billing', output: parseBilling }),\n startSubscription: endpoint({\n method: 'POST',\n path: '/api/billing/subscribe',\n output: parseRedirect,\n }),\n syncBilling: endpoint({\n method: 'POST',\n path: '/api/billing/sync',\n input: parseSyncInput,\n output: parseBilling,\n }),\n openBillingPortal: endpoint({\n method: 'POST',\n path: '/api/billing/portal',\n output: parseRedirect,\n }),`\n : ''\n }${\n newsletter\n ? `\n // Signing up sends a confirmation link; only confirmed readers get issues.\n subscribe: endpoint({\n method: 'POST',\n path: '/api/newsletter/subscribe',\n input: parseEmailInput,\n output: parseSubscribed,\n }),\n confirmSubscription: endpoint({\n method: 'POST',\n path: '/api/newsletter/confirm',\n input: parseTokenInput,\n output: parseConfirmed,\n }),\n // The composer's calls, each carrying NEWSLETTER_KEY.\n newsletterOverview: endpoint({\n method: 'POST',\n path: '/api/newsletter/overview',\n input: parseKeyInput,\n output: parseOverview,\n }),\n previewIssue: endpoint({\n method: 'POST',\n path: '/api/newsletter/preview',\n input: parseIssueInput,\n output: parsePreview,\n }),\n sendIssue: endpoint({\n method: 'POST',\n path: '/api/newsletter/issues',\n input: parseIssueInput,\n output: parseIssue,\n }),`\n : ''\n }${\n crud\n ? `\n // One page of customers for DataTable's query (sort, search, filters, page).\n customers: endpoint({\n method: 'POST',\n path: '/api/customers/query',\n input: parseTableQuery,\n output: (raw) => parseTablePage(raw, parseCustomer),\n }),\n createCustomer: endpoint({\n method: 'POST',\n path: '/api/customers',\n input: parseCustomerInput,\n output: parseCustomer,\n }),\n updateCustomer: endpoint({\n method: 'PUT',\n path: '/api/customers/:id',\n input: parseCustomerInput,\n output: parseCustomer,\n }),\n deleteCustomer: endpoint({ method: 'DELETE', path: '/api/customers/:id', output: parseDeleted }),`\n : ''\n }${\n social\n ? `\n // The signed-in user's connected accounts and posts (worker/social.ts).\n getSocial: endpoint({ method: 'GET', path: '/api/social', output: parseSocial }),\n schedulePost: endpoint({\n method: 'POST',\n path: '/api/social/posts',\n input: parsePostInput,\n output: parseScheduledPost,\n }),\n cancelPost: endpoint({\n method: 'POST',\n path: '/api/social/posts/:id/cancel',\n output: parseScheduledPost,\n }),`\n : ''\n }\n})\n`\n}\n\nfunction cfWorkerTs(opts: ScaffoldOptions): string {\n const rooms = usesRooms(opts)\n const agent = hasExample(opts, 'agent')\n const imports = hasExample(opts, 'import')\n const files = hasExample(opts, 'files')\n const exports = hasExample(opts, 'export')\n const usage = hasExample(opts, 'usage')\n const crud = hasExample(opts, 'crud')\n const live = hasExample(opts, 'live')\n const voice = hasExample(opts, 'voice')\n const publish = hasExample(opts, 'publish')\n const webhooks = hasExample(opts, 'webhooks')\n const digest = hasExample(opts, 'digest')\n const search = hasExample(opts, 'search')\n const checkout = hasExample(opts, 'checkout')\n const newsletter = hasExample(opts, 'newsletter')\n const billing = usesBilling(opts)\n const d1 = usesD1(opts)\n const ai = usesAgents(opts)\n const limiter = usesLimiter(opts)\n const access = opts.auth === 'access'\n const emailAuth = emailSignIn(opts)\n const oauth = oauthSignIn(opts)\n const accounts = hasAccounts(opts)\n const social = hasExample(opts, 'social')\n const guards = [\n ...(access ? ['requireAccess'] : []),\n ...(limiter ? ['clientIp', 'rateLimit'] : []),\n ]\n const custom = rooms || ai || files || exports || access || live || limiter || publish || accounts\n const isAsync =\n ai ||\n files ||\n exports ||\n access ||\n limiter ||\n webhooks ||\n publish ||\n checkout ||\n newsletter ||\n accounts\n // Rooms the server writes: never opened through /api/rooms/:name, where clients may write.\n const serverRooms = [\n ...(imports ? ['job-'] : []),\n ...(webhooks ? ['webhooks$'] : []),\n ...(checkout ? ['order-'] : []),\n ...(newsletter ? ['issue-'] : []),\n ]\n // Writes that need no signed-in user even with accounts: webhooks carry a signature instead of a\n // session, and a newsletter's readers have no account.\n const signedPaths = [\n ...(webhooks ? ['/api/webhooks/'] : []),\n ...(checkout ? ['/api/stripe/'] : []),\n ...(newsletter ? ['/api/sns/'] : []),\n ]\n const readerPaths = newsletter\n ? ['/api/newsletter/subscribe', '/api/newsletter/confirm', '/api/newsletter/unsubscribe']\n : []\n const openPaths = [...signedPaths, ...readerPaths]\n return `${usage ? `import type { AnalyticsDataset } from '@cascivo/app/analytics'\\n` : ''}${d1 ? `import type { Database } from '@cascivo/app/db'\\n` : ''}${exports ? `import { handleExport } from '@cascivo/app/export'\\n` : ''}import { createHandler${publish ? ', HttpError' : ''} } from '@cascivo/app/api'\n${accounts ? `import { ${emailAuth ? 'handleAuth, ' : ''}requireUser } from '@cascivo/app/auth-server'\\n` : ''}${oauth ? `import { github, google, linkedin } from '@cascivo/app/oauth'\\nimport type { OAuthProvider } from '@cascivo/app/oauth'\\nimport { handleOAuth } from '@cascivo/app/oauth-server'\\n` : ''}${guards.length > 0 || webhooks || accounts ? `import { ${[...guards, 'guardResponse'].sort().join(', ')} } from '@cascivo/app/guard'\\n${limiter ? `import type { RateLimiter } from '@cascivo/app/guard'\\n` : ''}` : ''}${imports ? `import { jobReporter } from '@cascivo/app/jobs-server'\\n` : ''}${\n files\n ? `import { handleUploads, listUploads } from '@cascivo/app/uploads-server'\nimport type { ImageResizer, UploadBucket } from '@cascivo/app/uploads-server'\n`\n : ''\n }${\n live\n ? `import { recordLive } from '@cascivo/app/live-server'\nimport type { LiveBatch, LiveQueue } from '@cascivo/app/live-server'\n`\n : ''\n }${\n rooms || live\n ? `import { roomResponse } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\n`\n : ''\n }${\n exports\n ? `import puppeteer from '@cloudflare/puppeteer'\nimport type { BrowserWorker } from '@cloudflare/puppeteer'\n`\n : ''\n }${ai ? `import { routeAgentRequest } from 'agents'\\n` : ''}import { api, TICKS_PER_STREAM } from '../src/api'\nimport type { Tick } from '../src/api'\n${imports ? `import { importJob } from '../src/import-job'\\n` : ''}${files ? `import { uploads } from '../src/upload-policy'\\n` : ''}${usage ? `import { usageMetrics } from '../src/usage'\\nimport { usageReport } from './usage'\\n` : ''}${live ? `import { OPS_ROOM, ops } from '../src/ops'\\n` : ''}${emailAuth ? `import { sendSignInLink } from './auth'\\nimport type { SignInSender } from './auth'\\n` : ''}${crud ? `import * as customerStore from './customers'\\n` : ''}${publish ? `import * as pageStore from './pages'\\nimport { renderPageHtml } from './page-html'\\nimport type { Assets } from './page-html'\\n${exports ? `import { pagePreview } from './page-preview'\\n` : ''}` : ''}${webhooks ? `import * as webhookStore from './webhooks'\\nimport { DELIVERIES_ROOM } from '../src/webhooks'\\n` : ''}${digest ? `import * as digestJob from './digest'\\nimport type { DigestSender } from './digest'\\n` : ''}${search ? `import * as articleSearch from './search'\\nimport type { ${ai ? '' : 'Embedder, '}VectorIndex } from './search'\\n` : ''}${billing ? `import * as billingStore from './billing'\\n` : ''}${social ? `import * as socialStore from './social'\\nimport type { ReminderSender, SocialMediaBucket, SocialPostParams } from './social'\\n` : ''}${checkout ? `import * as orderStore from './checkout'\\nimport type { ReceiptSender } from './checkout'\\nimport { ORDER_ID, orderRoom } from '../src/checkout'\\n` : ''}${newsletter ? `import * as newsletterStore from './newsletter'\\nimport type { NewsletterBatch, NewsletterQueue } from './newsletter'\\nimport { ISSUE_ID, issueRoom } from '../src/newsletter'\\n` : ''}${\n rooms\n ? `\n// The Durable Object class behind every room. wrangler.jsonc binds it as ROOMS, and it must be\n// exported from the Worker's main module.\nexport { SyncRoom } from '@cascivo/app/sync-server'\n`\n : ''\n }${\n agent\n ? `\n// The Durable Object behind /assistant: one per conversation (worker/assistant.ts).\nexport { Assistant } from './assistant'\n`\n : ''\n }${\n voice\n ? `\n// The Durable Object behind /voice: one per conversation (worker/voice.ts).\nexport { Voice } from './voice'\n`\n : ''\n }${\n live\n ? `\n// The Durable Object behind /ops: the last two minutes, per second (src/ops.ts).\nexport { LiveRoom } from '@cascivo/app/live-server'\n`\n : ''\n }${\n imports\n ? `\n// The Workflow behind /import (worker/import-job.ts), bound as IMPORT_JOB.\nexport { ImportJob } from './import-job'\n`\n : ''\n }${\n social\n ? `\n// The Workflow behind each scheduled post (worker/social-post.ts), bound as SOCIAL_POST.\nexport { SocialPost } from './social-post'\n`\n : ''\n }\n/**\n * Add bindings (KV, D1, R2, Durable Objects, Workers AI) in wrangler.jsonc and type them\n * here; every handler receives them as \\`env\\`.\n */\n${\n rooms ||\n ai ||\n files ||\n exports ||\n usage ||\n d1 ||\n access ||\n live ||\n accounts ||\n social ||\n webhooks ||\n digest ||\n search ||\n checkout ||\n newsletter\n ? `export interface Env {${ai ? '\\n /** Workers AI, bound in wrangler.jsonc. */\\n AI: Ai' : search ? '\\n /** Workers AI, bound in wrangler.jsonc. */\\n AI: Embedder' : ''}${search ? '\\n ARTICLES_INDEX: VectorIndex' : ''}${rooms ? '\\n ROOMS: RoomNamespace<unknown>' : ''}${imports ? '\\n IMPORT_JOB: Workflow<{ csv: string }>' : ''}${files ? '\\n FILES: UploadBucket\\n IMAGES: ImageResizer' : ''}${exports ? '\\n BROWSER: BrowserWorker' : ''}${usage ? '\\n USAGE: AnalyticsDataset\\n /** Secrets for reading Analytics Engine back (see README). */\\n CF_ACCOUNT_ID?: string\\n CF_API_TOKEN?: string' : ''}${d1 ? '\\n DB: Database' : ''}${publish ? '\\n ASSETS: Assets' : ''}${live ? '\\n LIVE: RoomNamespace<unknown>\\n EVENTS: LiveQueue' : ''}${limiter ? '\\n LIMITER: RateLimiter' : ''}${access ? '\\n /** Set in wrangler.jsonc (see README). */\\n ACCESS_TEAM_DOMAIN: string\\n ACCESS_AUD: string' : ''}${webhooks ? '\\n /** The webhook signing secret: `wrangler secret put WEBHOOK_SECRET` (.dev.vars locally). */\\n WEBHOOK_SECRET: string' : ''}${emailAuth || digest || checkout || social ? `\\n EMAIL: ${[emailAuth ? 'SignInSender' : '', digest ? 'DigestSender' : '', checkout ? 'ReceiptSender' : '', social ? 'ReminderSender' : ''].filter(Boolean).join(' & ')}` : ''}${emailAuth ? '\\n /** The From address of sign-in emails, set in wrangler.jsonc. */\\n AUTH_FROM: string' : ''}${oauth ? '\\n /** Sign-in with GitHub, Google and LinkedIn: `wrangler secret put` (.dev.vars locally). A provider is\\n * offered once both its id and secret are set; AUTH_SECRET seals the sign-in state. */\\n AUTH_SECRET?: string\\n GITHUB_CLIENT_ID?: string\\n GITHUB_CLIENT_SECRET?: string\\n GOOGLE_CLIENT_ID?: string\\n GOOGLE_CLIENT_SECRET?: string\\n LINKEDIN_CLIENT_ID?: string\\n LINKEDIN_CLIENT_SECRET?: string' : social ? \"\\n /** Seals connected accounts' tokens, and LinkedIn's app: `wrangler secret put` (.dev.vars\\n * locally). LinkedIn is offered once both its values are set. */\\n AUTH_SECRET?: string\\n LINKEDIN_CLIENT_ID?: string\\n LINKEDIN_CLIENT_SECRET?: string\" : ''}${social ? '\\n /** Bluesky: an ES256 private JWK (README); unset, the app is a public client. */\\n BLUESKY_PRIVATE_JWK?: string\\n /** A Buffer app client (README); Buffer is offered once its id is set. */\\n BUFFER_CLIENT_ID?: string\\n BUFFER_CLIENT_SECRET?: string\\n /** A Meta app with the Threads use case (README); Threads is offered once both are set. */\\n THREADS_APP_ID?: string\\n THREADS_APP_SECRET?: string\\n SOCIAL_POST: Workflow<SocialPostParams>\\n SOCIAL_MEDIA: SocialMediaBucket\\n /** Reconnect reminders (worker/social.ts), set in wrangler.jsonc. */\\n REMINDER_FROM: string' + (digest ? '' : '\\n APP_URL: string') : ''}${digest ? '\\n /** The weekly digest (worker/digest.ts), set in wrangler.jsonc. */\\n DIGEST_TO: string\\n DIGEST_FROM: string\\n APP_URL: string' : ''}${checkout ? '\\n /** The From address of receipts, set in wrangler.jsonc. */\\n RECEIPT_FROM: string\\n /** Stripe secrets: `wrangler secret put` (.dev.vars locally). Unset until you add them. */\\n STRIPE_SECRET_KEY?: string\\n STRIPE_WEBHOOK_SECRET?: string' : ''}${newsletter ? '\\n NEWSLETTER: NewsletterQueue\\n /** The newsletter (worker/newsletter.ts), set in wrangler.jsonc. */\\n AWS_REGION: string\\n NEWSLETTER_FROM: string\\n SNS_TOPIC_ARN: string\\n /** Secrets: `wrangler secret put` (.dev.vars locally). Unset until you add them. */\\n AWS_ACCESS_KEY_ID?: string\\n AWS_SECRET_ACCESS_KEY?: string\\n NEWSLETTER_KEY?: string' : ''}\n}`\n : `// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- bindings are added as members\nexport interface Env {}`\n}\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n${\n oauth\n ? `\n/** The sign-in providers with both an id and a secret set; the others are not offered. */\nfunction oauthProviders(env: Env): OAuthProvider[] {\n const providers: OAuthProvider[] = []\n if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) {\n providers.push(\n github({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }),\n )\n }\n if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {\n providers.push(\n google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),\n )\n }\n if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) {\n providers.push(\n linkedin({ clientId: env.LINKEDIN_CLIENT_ID, clientSecret: env.LINKEDIN_CLIENT_SECRET }),\n )\n }\n return providers\n}\n`\n : ''\n}${\n limiter\n ? `\n/**\n * Requests that each count against LIMITER:\n${[\n emailAuth ? 'a sign-in email' : '',\n digest ? 'a digest sent now (it starts a browser)' : '',\n search ? 'indexing the articles (it embeds every one)' : '',\n checkout ? 'a checkout started (it creates a Stripe session and an order)' : '',\n billing ? 'a subscription checkout or billing portal opened' : '',\n newsletter ? 'a newsletter sign-up (it sends an email), and each use of the newsletter key' : '',\n files ? 'a new upload (not each part of one)' : '',\n exports ? 'an export' : '',\n publish ? 'a published page' : '',\n social ? 'connecting an account (Mastodon registers the app with the server), or a post' : '',\n]\n .filter(Boolean)\n .map((item) => ` * - ${item}`)\n .join('\\n')}\n */\nfunction countsAgainstLimit(request: Request): boolean {\n const url = new URL(request.url)${\n search\n ? `\n if (url.pathname === '/api/search/index') return request.method === 'POST'`\n : ''\n }${\n digest\n ? `\n if (url.pathname === '/api/digest/run') return request.method === 'POST'`\n : ''\n }${\n checkout\n ? `\n if (url.pathname === '/api/checkout') return request.method === 'POST'`\n : ''\n }${\n billing\n ? `\n if (url.pathname === '/api/billing/subscribe' || url.pathname === '/api/billing/portal') {\n return request.method === 'POST'\n }`\n : ''\n }${\n newsletter\n ? `\n if (/^\\\\/api\\\\/newsletter\\\\/(subscribe|overview|preview|issues)$/.test(url.pathname)) {\n return request.method === 'POST'\n }`\n : ''\n }${\n emailAuth\n ? `\n if (url.pathname === '/api/auth/start') return request.method === 'POST'`\n : ''\n }${\n social\n ? `\n if (/^\\\\/api\\\\/connections\\\\/[a-z-]+$/.test(url.pathname)) return request.method === 'GET'\n if (url.pathname === '/api/social/posts') return request.method === 'POST'\n if (url.pathname.startsWith('/api/social/images')) return request.method !== 'GET'`\n : ''\n }${\n publish\n ? `\n if (url.pathname === '/api/pages') return request.method === 'POST'`\n : ''\n }${\n files\n ? `\n if (url.pathname === uploads.path) {\n const step = url.searchParams.get('multipart')\n return (request.method === 'PUT' && step === null) || step === 'start'\n }`\n : ''\n }\n return ${exports ? \"url.pathname === '/api/export'\" : 'false'}\n}\n`\n : ''\n }\n/**\n * One handler per endpoint in src/api.ts, typed from it. A stream handler is an async\n * generator: each \\`yield\\` is one server-sent event, and returning ends the stream. When the\n * client disconnects, \\`signal\\` aborts.\n */\nconst handleApi = createHandler<typeof api, Env>(api, {\n ticks: async function* ({ signal }) {\n for (let n = 1; n <= TICKS_PER_STREAM && !signal.aborted; n++) {\n const tick: Tick = { n, at: new Date().toISOString() }\n yield tick\n await sleep(1000)\n }\n },${\n imports\n ? `\n // The job's room exists (as \"queued\") before the Workflow starts, so the page has\n // something to show at once.\n startImport: async ({ body, env }) => {\n const id = crypto.randomUUID()\n await jobReporter(importJob, env.ROOMS, id).queued()\n await env.IMPORT_JOB.create({ id, params: { csv: body.csv } })\n return { id }\n },`\n : ''\n }${files ? `\\n listFiles: ({ env }) => listUploads(env.FILES),` : ''}${\n usage\n ? `\n usage: ({ env }) =>\n usageReport(\n env.CF_ACCOUNT_ID && env.CF_API_TOKEN\n ? { accountId: env.CF_ACCOUNT_ID, apiToken: env.CF_API_TOKEN }\n : null,\n ),`\n : ''\n }${\n crud\n ? `\n customers: ({ body, env }) => customerStore.listCustomers(env.DB, body),\n createCustomer: ({ body, env }) => customerStore.createCustomer(env.DB, body),\n updateCustomer: ({ params, body, env }) => customerStore.updateCustomer(env.DB, params.id, body),\n deleteCustomer: ({ params, env }) => customerStore.deleteCustomer(env.DB, params.id),`\n : ''\n }${\n publish\n ? `\n publishPage: ({ body, env }) => pageStore.publishPage(env.DB, body),\n getPage: ({ params, env }) => pageStore.getPage(env.DB, params.slug),`\n : ''\n }${\n webhooks\n ? `\n listDeliveries: ({ env }) => webhookStore.listDeliveries(env.DB),\n sendTestDelivery: ({ request, env }) => webhookStore.sendTestDelivery(request.url, env),`\n : ''\n }${\n search\n ? `\n search: ({ body, env }) => articleSearch.search(env, body.q),\n indexArticles: ({ env }) => articleSearch.indexArticles(env),`\n : ''\n }${\n checkout\n ? `\n startCheckout: ({ request, env }) => orderStore.startCheckout(env, new URL(request.url).origin),\n getOrder: ({ params, request, env }) =>\n orderStore.getOrder(env, params.id, new URL(request.url).origin),`\n : ''\n }${\n billing\n ? `\n getBilling: ({ request, env }) => billingStore.getBilling(env, request),\n startSubscription: ({ request, env }) =>\n billingStore.startSubscription(env, request, new URL(request.url).origin),\n syncBilling: ({ body, request, env }) => billingStore.syncBilling(env, request, body.sessionId),\n openBillingPortal: ({ request, env }) =>\n billingStore.openPortal(env, request, new URL(request.url).origin),`\n : ''\n }${\n newsletter\n ? `\n subscribe: ({ body, request, env }) =>\n newsletterStore.subscribe(env, body.email, new URL(request.url).origin),\n confirmSubscription: ({ body, env }) => newsletterStore.confirm(env, body.token),\n newsletterOverview: ({ body, env }) => newsletterStore.overview(env, body.key),\n previewIssue: ({ body, request, env }) =>\n newsletterStore.preview(env, body, new URL(request.url).origin),\n sendIssue: ({ body, request, env }) =>\n newsletterStore.sendIssue(env, body, new URL(request.url).origin),`\n : ''\n }${\n social\n ? `\n getSocial: ({ request, env }) => socialStore.getSocial(env, request),\n schedulePost: ({ body, request, env }) => socialStore.schedulePost(env, request, body),\n cancelPost: ({ params, request, env }) => socialStore.cancelPost(env, request, params.id),`\n : ''\n }${\n digest\n ? `\n digestRuns: ({ env }) => digestJob.listRuns(env.DB),\n runDigest: ({ request, env }) =>\n digestJob.runDigest(\n env,\n () => puppeteer.launch(env.BROWSER),\n 'manual',\n new URL(request.url).origin,\n ),`\n : ''\n }${\n live\n ? `\n // Each event is placed by the Worker's clock: a browser's is not trusted to say when.\n sendEvents: async ({ body, env }) => {\n const at = Date.now()\n if (body.length > 0) {\n await env.EVENTS.sendBatch(body.map((event) => ({ body: { at, values: event.values } })))\n }\n return { accepted: body.length }\n },`\n : ''\n }\n})\n${\n usage\n ? `\n/** Every API request, recorded in Analytics Engine; src/usage.ts names the columns. */\nasync function handleAndRecord(request: Request, env: Env): Promise<Response> {\n const started = Date.now()\n const response = await handleApi(request, env)\n usageMetrics.write(env.USAGE, {\n path: new URL(request.url).pathname,\n method: request.method,\n status: response.status,\n duration_ms: Date.now() - started,\n })\n return response\n}\n`\n : ''\n}\n// wrangler.jsonc routes only ${ai ? '/api/* and /agents/*' : '/api/*'} here; everything else is a static asset or index.html.\nexport default {\n${\n custom\n ? ` ${isAsync ? 'async ' : ''}fetch(request: Request, env: Env): Promise<Response>${isAsync ? '' : ' | Response'} {${\n access\n ? `\n // Cloudflare Access is in front of the app (README); this refuses a request that came\n // around it (straight to *.workers.dev, say). \\`vite dev\\` has no Access, so not there.\n if (!import.meta.env.DEV) {\n try {\n await requireAccess(request, {\n teamDomain: env.ACCESS_TEAM_DOMAIN,\n audience: env.ACCESS_AUD,\n })\n } catch (error) {\n return guardResponse(error)\n }\n }`\n : ''\n }${\n limiter\n ? `\n if (countsAgainstLimit(request)) {\n try {\n await rateLimit(env.LIMITER, clientIp(request))\n } catch (error) {\n return guardResponse(error)\n }\n }`\n : ''\n }${\n accounts\n ? `${\n emailAuth\n ? `\n // Sign-in links and sessions: /api/auth/* is answered here (worker/auth.ts sends mail).\n const signIn = await handleAuth(env.DB, {\n sendLink: (email, url) => sendSignInLink(env.EMAIL, env.AUTH_FROM, email, url),\n exposeLink: import.meta.env.DEV,\n })(request)\n if (signIn) return signIn`\n : ''\n }${\n oauth\n ? `\n // Sign-in with GitHub, Google and LinkedIn: /api/auth/oauth/* (and /me, /signout). A failed\n // sign-in lands on /account with ?error=.\n const signInWith = await handleOAuth(env.DB, {\n secret: env.AUTH_SECRET ?? '',\n providers: oauthProviders(env),\n errorPath: '/account',\n })(request)\n if (signInWith) return signInWith`\n : ''\n }\n // Every other API write needs a signed-in user; reads stay public.${signedPaths.length > 0 ? '\\n // Webhooks carry a signature instead of a session, and are checked by it.' : ''}${readerPaths.length > 0 ? '\\n // Newsletter readers sign up, confirm and unsubscribe without an account.' : ''}\n if (${\n openPaths.length > 0\n ? `\n request.method !== 'GET' &&\n request.method !== 'HEAD' &&${openPaths\n .map((prefix) => `\\n !new URL(request.url).pathname.startsWith('${prefix}')`)\n .join(' &&')}\n `\n : \"request.method !== 'GET' && request.method !== 'HEAD'\"\n }) {\n try {\n await requireUser(env.DB, request)\n } catch (error) {\n return guardResponse(error)\n }\n }${\n social\n ? `\n // The accounts a user connects to post with (worker/social.ts): /api/connections/*, and\n // the client metadata Bluesky's servers read.\n const blueskyClient = socialStore.blueskyClient(env, request)\n if (blueskyClient) return blueskyClient\n const connected = await socialStore.connections(env)(request)\n if (connected) return connected\n // Images for posts: the user's own uploads, and the signed links Threads and Buffer fetch.\n const image =\n (await socialStore.images(env, request)) ?? (await socialStore.media(env, request))\n if (image) return image`\n : ''\n }`\n : ''\n }${\n ai\n ? `\n // /agents/<agent>/<conversation>: the WebSocket ${[agent ? 'useAgent()' : '', voice ? 'VoiceClient' : ''].filter(Boolean).join(' or ')} opens.\n const agent = await routeAgentRequest(request, env)\n if (agent) return agent`\n : ''\n }${\n files\n ? `\n // Uploads into R2 and the files they stored.${access ? '' : ' Anyone can upload: see README.'}\n const upload = await handleUploads(uploads, env.FILES, { images: env.IMAGES })(request)\n if (upload) return upload`\n : ''\n }${\n exports\n ? `\n // /api/export?page=/report&format=pdf — each export starts a browser.\n const exported = await handleExport(request, { launch: () => puppeteer.launch(env.BROWSER) })\n if (exported) return exported`\n : ''\n }${\n live\n ? `\n // The /ops dashboard's room: browsers watch it, and only the queue handler writes to it.\n if (new URL(request.url).pathname === '/api/live') {\n return roomResponse(request, env.LIVE, OPS_ROOM, { readOnly: true })\n }`\n : ''\n }${\n publish\n ? `\n // A published page: its HTML carries the page's title, description and content, for link\n // previews and readers without JavaScript (worker/page-html.ts). An unknown slug gets the\n // app itself, which says so.\n const pagePath = new URL(request.url).pathname\n const published = /^\\\\/p\\\\/([a-z0-9]{10})$/.exec(pagePath)\n if (published && request.method === 'GET') {\n try {\n const page = await pageStore.getPage(env.DB, published[1]!)${\n exports\n ? `\n const image = new URL(\\`/api/pages/\\${page.slug}/preview.png\\`, request.url).href\n return await renderPageHtml(request, page, env.ASSETS, image)`\n : `\n return await renderPageHtml(request, page, env.ASSETS, null)`\n }\n } catch (error) {\n if (!(error instanceof HttpError)) throw error\n return env.ASSETS.fetch(request)\n }\n }${\n exports\n ? `\n // Its link-preview image, rendered once by Browser Run (worker/page-preview.ts).\n const preview = /^\\\\/api\\\\/pages\\\\/([a-z0-9]{10})\\\\/preview\\\\.png$/.exec(pagePath)\n if (preview && request.method === 'GET') {\n try {\n return await pagePreview(env.DB, preview[1]!, new URL(request.url).origin, () =>\n puppeteer.launch(env.BROWSER),\n )\n } catch (error) {\n return guardResponse(error)\n }\n }`\n : ''\n }`\n : ''\n }${\n webhooks\n ? `\n const path = new URL(request.url).pathname\n // Signed deliveries from GitHub (worker/webhooks.ts); a bad signature is a 401.\n if (path === webhookStore.GITHUB_WEBHOOK_PATH && request.method === 'POST') {\n try {\n return await webhookStore.receiveGithub(request, env)\n } catch (error) {\n return guardResponse(error)\n }\n }\n // New deliveries, pushed to the /webhooks page: it may watch, never write.\n if (path === '/api/webhooks/live') {\n return roomResponse(request, env.ROOMS, DELIVERIES_ROOM, { readOnly: true })\n }`\n : ''\n }${\n checkout\n ? `\n const checkoutPath = new URL(request.url).pathname\n // Stripe's webhook (worker/checkout.ts); a bad signature is a 401.\n if (checkoutPath === orderStore.STRIPE_WEBHOOK_PATH && request.method === 'POST') {\n try {\n return await orderStore.receiveStripe(request, env${\n billing ? ', billingStore.billingHooks(env)' : ''\n })\n } catch (error) {\n return guardResponse(error)\n }\n }\n // An order's page watches its room for what Stripe reports: it may watch, never write.\n const orderLive = /^\\\\/api\\\\/orders\\\\/([^/]+)\\\\/live$/.exec(checkoutPath)\n if (orderLive && ORDER_ID.test(orderLive[1]!)) {\n return roomResponse(request, env.ROOMS, orderRoom(orderLive[1]!), { readOnly: true })\n }`\n : ''\n }${\n newsletter\n ? `\n const newsletterPath = new URL(request.url).pathname\n // One-click unsubscribe (RFC 8058) and the unsubscribe page's button.\n if (newsletterPath === '/api/newsletter/unsubscribe' && request.method === 'POST') {\n return newsletterStore.unsubscribe(request, env)\n }\n // SES bounces and complaints, delivered by SNS (worker/newsletter.ts); verified by signature.\n if (newsletterPath === '/api/sns/ses' && request.method === 'POST') {\n try {\n return await newsletterStore.receiveFeedback(request, env)\n } catch (error) {\n return guardResponse(error)\n }\n }\n // An issue's sending progress, for the composer: it may watch, never write.\n const issueLive = /^\\\\/api\\\\/newsletter\\\\/issues\\\\/([^/]+)\\\\/live$/.exec(newsletterPath)\n if (issueLive && ISSUE_ID.test(issueLive[1]!)) {\n return roomResponse(request, env.ROOMS, issueRoom(issueLive[1]!), { readOnly: true })\n }`\n : ''\n }${\n hasExample(opts, 'board') || hasExample(opts, 'notes')\n ? `\n const room = /^\\\\/api\\\\/rooms\\\\/([^/]+)$/.exec(new URL(request.url).pathname)${\n serverRooms.length > 0\n ? `\n // Rooms only the server writes are watched read-only at their own routes, never opened\n // here: ${[imports ? \"a job's progress\" : '', webhooks ? 'webhook deliveries' : '', checkout ? 'orders' : '', newsletter ? 'newsletter issues' : ''].filter(Boolean).join(', ')}.\n if (room && !/^(${serverRooms.join('|')})/.test(room[1]!)) {\n return roomResponse(request, env.ROOMS, room[1]!)\n }`\n : `\n if (room) return roomResponse(request, env.ROOMS, room[1]!)`\n }`\n : ''\n }${\n imports\n ? `\n // A job's progress: the browser may watch its room, never write to it.\n const job = /^\\\\/api\\\\/jobs\\\\/([\\\\w-]{1,60})$/.exec(new URL(request.url).pathname)\n if (job) {\n return roomResponse(request, env.ROOMS, importJob.roomName(job[1]!), { readOnly: true })\n }`\n : ''\n }\n return ${usage ? 'handleAndRecord' : 'handleApi'}(request, env)\n },`\n : ` fetch: ${usage ? 'handleAndRecord' : 'handleApi'},`\n}${\n digest && social\n ? `\n // The Cron Triggers in wrangler.jsonc, told apart by their schedule.\n async scheduled(event: { cron: string }, env: Env): Promise<void> {\n // Threads tokens renewed, and reminders for those that cannot be (worker/social.ts).\n if (event.cron === '${SOCIAL_CRON}') {\n await socialStore.renewConnections(env)\n return socialStore.remindExpiring(env)\n }\n // The weekly digest, recorded whatever happens.\n await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')\n },`\n : digest\n ? `\n // The Cron Trigger in wrangler.jsonc: the weekly digest, recorded whatever happens.\n async scheduled(_event: unknown, env: Env): Promise<void> {\n await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')\n },`\n : social\n ? `\n // The Cron Trigger in wrangler.jsonc: Threads tokens renewed, then reminders for the ones\n // that cannot be (LinkedIn).\n async scheduled(_event: unknown, env: Env): Promise<void> {\n await socialStore.renewConnections(env)\n await socialStore.remindExpiring(env)\n },`\n : ''\n }${\n live && newsletter\n ? `\n // Two queues, one handler: each batch says which queue it came from. A throw retries it.\n async queue(batch: LiveBatch & NewsletterBatch & { queue: string }, env: Env): Promise<void> {\n // NEWSLETTER: one message of readers at a time, through SES (worker/newsletter.ts).\n if (batch.queue === '${newsletterQueue(opts)}') return newsletterStore.deliver(env, batch)\n // EVENTS: into the dashboard's room.\n await recordLive(\n ops,\n env.LIVE,\n OPS_ROOM,\n batch.messages.map((message) => message.body),\n )\n },`\n : live\n ? `\n // The EVENTS queue, a batch at a time, into the dashboard's room. A throw retries the batch.\n async queue(batch: LiveBatch, env: Env): Promise<void> {\n await recordLive(\n ops,\n env.LIVE,\n OPS_ROOM,\n batch.messages.map((message) => message.body),\n )\n },`\n : newsletter\n ? `\n // The NEWSLETTER queue: one message of readers at a time, through SES\n // (worker/newsletter.ts). A throw retries the message; readers already sent are skipped.\n async queue(batch: NewsletterBatch, env: Env): Promise<void> {\n await newsletterStore.deliver(env, batch)\n },`\n : ''\n }\n}\n`\n}\n\nfunction cfLiveTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport type { Tick } from './api'\n\nconst client = createClient(api)\n\n// Module-level signals: any component that reads them re-renders when they change, and\n// writing them from plain functions needs no hooks.\nexport const ticks = signal<Tick[]>([])\nexport const status = signal<'idle' | 'live' | 'error'>('idle')\nexport const error = signal<string | null>(null)\n\nlet controller: AbortController | null = null\n\n/** Opens the Worker's event stream and appends each tick until it ends or is stopped. */\nexport async function connect(): Promise<void> {\n if (controller) return\n const abort = new AbortController()\n controller = abort\n ticks.value = []\n error.value = null\n status.value = 'live'\n try {\n for await (const tick of client.ticks({ signal: abort.signal })) {\n ticks.value = [...ticks.value, tick]\n }\n status.value = 'idle'\n } catch (cause) {\n if (abort.signal.aborted) {\n status.value = 'idle'\n } else {\n status.value = 'error'\n error.value = cause instanceof Error ? cause.message : String(cause)\n }\n } finally {\n if (controller === abort) controller = null\n }\n}\n\nexport function disconnect(): void {\n controller?.abort()\n}\n`\n}\n\nfunction cfLiveCardTsx(): string {\n return `import {\n Badge,\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n Flex,\n Text,\n useSignals,\n} from '@cascivo/react'\nimport { TICKS_PER_STREAM } from './api'\nimport { connect, disconnect, error, status, ticks } from './live'\n\n/** Streams server-sent events from worker/index.ts into signals via src/live.ts. */\nexport function LiveCard() {\n useSignals()\n const live = status.value === 'live'\n const latest = ticks.value.at(-1)\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>Live from your Worker</CardTitle>\n </CardHeader>\n <CardContent>\n <Flex gap={3}>\n <Text muted>\n <code>worker/index.ts</code> streams events; <code>src/live.ts</code> reads them through\n the typed client for <code>src/api.ts</code>.\n </Text>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Badge variant={live ? 'success' : status.value === 'error' ? 'danger' : 'neutral'}>\n {live ? 'Live' : status.value === 'error' ? 'Error' : 'Idle'}\n </Badge>\n <Text>\n {latest\n ? \\`Tick \\${latest.n} of \\${TICKS_PER_STREAM} at \\${new Date(latest.at).toLocaleTimeString()}\\`\n : 'No events yet'}\n </Text>\n </Flex>\n {error.value !== null && <Text muted>{error.value}</Text>}\n <div>\n <Button\n variant={live ? 'secondary' : 'primary'}\n onClick={live ? disconnect : () => void connect()}\n >\n {live ? 'Disconnect' : 'Connect'}\n </Button>\n </div>\n </Flex>\n </CardContent>\n </Card>\n )\n}\n`\n}\n\n/** Route pattern for a section: the first is `/`, the rest by key. */\nfunction cfSectionPath(section: Section, index: number): string {\n return index === 0 ? '/' : `/${section.key}`\n}\n\n/** Route file for a section, relative to `src/routes/`. */\nfunction cfSectionFile(section: Section, index: number): string {\n return index === 0 ? 'index.tsx' : `${section.key}.tsx`\n}\n\nfunction cfRouterTs(): string {\n return `import { createRouter } from '@cascivo/app'\nimport { notFound, routes } from './routes.gen'\n\n// \\`routes.gen.ts\\` is written by \\`cascivoRoutes()\\` (vite.config.ts) from \\`src/routes/\\`:\n// add, rename or delete a file there and the route table follows.\nexport const router = createRouter({ routes, notFound })\n`\n}\n\nfunction cfAppTsx(sections: Section[], opts: ScaffoldOptions): string {\n const items = sections.map((s, i) => ({ label: s.label, href: cfSectionPath(s, i) }))\n if (hasExample(opts, 'agent')) items.push({ label: 'Assistant', href: '/assistant' })\n if (hasExample(opts, 'board')) items.push({ label: 'Board', href: '/board' })\n if (hasExample(opts, 'notes')) items.push({ label: 'Notes', href: '/notes' })\n if (hasExample(opts, 'import')) items.push({ label: 'Import', href: '/import' })\n if (hasExample(opts, 'files')) items.push({ label: 'Files', href: '/files' })\n if (hasExample(opts, 'export')) items.push({ label: 'Report', href: '/report' })\n if (hasExample(opts, 'usage')) items.push({ label: 'Usage', href: '/usage' })\n if (hasExample(opts, 'crud')) items.push({ label: 'Customers', href: '/customers' })\n if (hasExample(opts, 'live')) items.push({ label: 'Ops', href: '/ops' })\n if (hasExample(opts, 'voice')) items.push({ label: 'Voice', href: '/voice' })\n if (hasExample(opts, 'publish')) items.push({ label: 'Publish', href: '/publish' })\n if (hasExample(opts, 'webhooks')) items.push({ label: 'Webhooks', href: '/webhooks' })\n if (hasExample(opts, 'digest')) items.push({ label: 'Digest', href: '/digest' })\n if (hasExample(opts, 'search')) items.push({ label: 'Search', href: '/search' })\n if (hasExample(opts, 'checkout')) items.push({ label: 'Checkout', href: '/checkout' })\n if (usesBilling(opts)) items.push({ label: 'Billing', href: '/billing' })\n if (hasExample(opts, 'newsletter')) {\n items.push({ label: 'Newsletter', href: '/newsletter' })\n items.push({ label: 'Send newsletter', href: '/newsletter/send' })\n }\n if (hasExample(opts, 'social')) items.push({ label: 'Social', href: '/social' })\n if (hasAccounts(opts)) items.push({ label: 'Account', href: '/account' })\n const navItems = items\n .map(\n (item) => ` {\n label: '${item.label.replace(/'/g, \"\\\\'\")}',\n href: '${item.href}',\n active: path === '${item.href}',\n },`,\n )\n .join('\\n')\n const exports = hasExample(opts, 'export')\n return `import { RouterView } from '@cascivo/app'\n${exports ? `import { isExporting } from '@cascivo/app/export'\\n` : ''}import { Spinner, useSignals, type SideNavItem } from '@cascivo/react'\nimport { router } from './router'\nimport { Shell } from './Shell'\n\nexport default function App() {\n useSignals()\n const path = router.pathname.value\n\n // \\`href\\`s, not click handlers: main.tsx registers the router's Link, so these navigate\n // client-side while middle-click and \"open in new tab\" still work.\n const navItems: SideNavItem[] = [\n${navItems}\n ]\n${\n exports\n ? `\n // Rendered by /api/export: the page alone, without the shell (which gave it its padding).\n if (isExporting()) {\n return (\n <div style={{ padding: 'var(--cascivo-space-8)' }}>\n <RouterView router={router} fallback={<Spinner label=\"Loading\" />} />\n </div>\n )\n }\n`\n : ''\n}\n return (\n <Shell navItems={navItems}>\n <RouterView router={router} fallback={<Spinner label=\"Loading\" />} />\n </Shell>\n )\n}\n`\n}\n\nfunction cfMainTsx(): string {\n return `import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport { setLinkComponent } from '@cascivo/react'\nimport App from './App'\nimport { router } from './router'\n\n// SideNav, ShellHeader and Breadcrumb render their links through the router from here on.\nsetLinkComponent(router.Link)\n\nconst root = document.getElementById('root')\nif (root) {\n ReactDOM.createRoot(root).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>,\n )\n}\n`\n}\n\nfunction cfFirstRouteTsx(section: Section): string {\n return `import { Flex, Heading, Text } from '@cascivo/react'\nimport { LiveCard } from '../LiveCard'\n\nexport default function ${section.component}() {\n return (\n <Flex gap={6}>\n <Flex gap={2}>\n <Heading level={1}>${section.label}</Heading>\n <Text muted>\n Edit <code>src/routes/index.tsx</code> to build out this page.\n </Text>\n </Flex>\n <LiveCard />\n </Flex>\n )\n}\n`\n}\n\nfunction cfRouteTsx(section: Section, index: number): string {\n // The Vite scaffold's section, as a route file: a default export, at its own URL.\n return sectionTsx(section)\n .replace(\n `export function ${section.component}()`,\n `export default function ${section.component}()`,\n )\n .replace(`src/sections/${section.component}.tsx`, `src/routes/${cfSectionFile(section, index)}`)\n}\n\nfunction cf404Tsx(): string {\n return `import { EmptyState } from '@cascivo/react'\n\nexport default function NotFound() {\n return <EmptyState title=\"Page not found\" description=\"There is nothing at this address.\" />\n}\n`\n}\n\n/* --- `--example board`: a multiplayer board on @cascivo/app/sync + a SyncRoom DO --- */\n\nfunction cfBoardTs(): string {\n return `import { connectRoom } from '@cascivo/app/sync'\n\nexport interface Note {\n text: string\n x: number\n y: number\n}\n\nexport interface Cursor {\n x: number\n y: number\n}\n\n/** \\`/board?room=team\\` is its own board; the room name is what the Worker routes on. */\nexport const roomName =\n (new URLSearchParams(location.search).get('room') ?? '').replace(/[^\\\\w-]/g, '').slice(0, 64) ||\n 'lobby'\n\n// One WebSocket to the room's Durable Object. It connects when this module first loads (the\n// /board route is its own chunk) and reconnects on its own.\nexport const room = connectRoom(\\`/api/rooms/\\${roomName}\\`)\n\n/**\n * Values in a room come from other people, so they are parsed, not cast. A note that fails\n * this is ignored rather than breaking the board.\n */\nexport function parseNote(raw: unknown): Note {\n if (typeof raw === 'object' && raw !== null) {\n const { text, x, y } = raw as Record<string, unknown>\n if (typeof text === 'string' && typeof x === 'number' && typeof y === 'number') {\n return { text, x, y }\n }\n }\n throw new Error('Malformed note')\n}\n\nexport function parseCursor(raw: unknown): Cursor | null {\n if (typeof raw === 'object' && raw !== null) {\n const { x, y } = raw as Record<string, unknown>\n if (typeof x === 'number' && typeof y === 'number') return { x, y }\n }\n return null\n}\n\n/** Notes by id. Each note is its own path, so two people editing two notes never collide. */\nexport const notes = room.map('notes', parseNote)\n\nexport function addNote(): void {\n const offset = Object.keys(notes.value).length * 24\n notes.set(crypto.randomUUID(), { text: '', x: 24 + (offset % 240), y: 24 + (offset % 160) })\n}\n`\n}\n\nfunction cfBoardRouteTsx(): string {\n return `import type { PointerEvent } from 'react'\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Text,\n Textarea,\n useSignals,\n} from '@cascivo/react'\nimport { addNote, notes, parseCursor, room, roomName } from '../board'\nimport type { Note } from '../board'\nimport styles from '../board.module.css'\n\nlet frame = 0\n\n/** Shares this pointer's position on the board, at most once per frame. */\nfunction trackCursor(event: PointerEvent<HTMLDivElement>) {\n const rect = event.currentTarget.getBoundingClientRect()\n const cursor = {\n x: Math.round(event.clientX - rect.left),\n y: Math.round(event.clientY - rect.top),\n }\n cancelAnimationFrame(frame)\n frame = requestAnimationFrame(() => room.setPresence(cursor))\n}\n\n/** Drags a note by its handle; every move is a write the whole room sees. */\nfunction startDrag(event: PointerEvent<HTMLDivElement>, id: string, note: Note) {\n if ((event.target as Element).closest('button')) return\n const handle = event.currentTarget\n handle.setPointerCapture(event.pointerId)\n const dx = event.clientX - note.x\n const dy = event.clientY - note.y\n const move = (e: globalThis.PointerEvent) => {\n const current = notes.value[id]\n if (current)\n notes.set(id, { ...current, x: Math.max(0, e.clientX - dx), y: Math.max(0, e.clientY - dy) })\n }\n const up = () => {\n handle.removeEventListener('pointermove', move)\n handle.removeEventListener('pointerup', up)\n }\n handle.addEventListener('pointermove', move)\n handle.addEventListener('pointerup', up)\n}\n\nfunction NoteCard({ id, note }: { id: string; note: Note }) {\n return (\n <div className={styles['note']} style={{ transform: \\`translate(\\${note.x}px, \\${note.y}px)\\` }}>\n <Card>\n <div className={styles['handle']} onPointerDown={(event) => startDrag(event, id, note)}>\n <Text size=\"sm\" muted>\n Drag\n </Text>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-label=\"Delete note\"\n onClick={() => notes.delete(id)}\n >\n ×\n </Button>\n </div>\n <CardContent>\n <Textarea\n aria-label=\"Note text\"\n rows={3}\n value={note.text}\n onChange={(event) => notes.set(id, { ...note, text: event.target.value })}\n />\n </CardContent>\n </Card>\n </div>\n )\n}\n\nexport default function Board() {\n useSignals()\n const connected = room.status.value === 'open'\n const others = Object.entries(room.presence.value).flatMap(([id, raw]) => {\n const cursor = parseCursor(raw)\n return cursor ? [{ id, ...cursor }] : []\n })\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Board</Heading>\n <Text muted>\n Room <code>{roomName}</code>. Open this page in a second window: notes, edits and\n cursors sync live through a Durable Object.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Badge variant={connected ? 'success' : 'neutral'}>\n {connected ? \\`\\${Object.keys(room.presence.value).length + 1} here\\` : 'Connecting…'}\n </Badge>\n <Button onClick={addNote}>Add note</Button>\n </Flex>\n </Flex>\n <div\n className={styles['board']}\n onPointerMove={trackCursor}\n onPointerLeave={() => room.setPresence(null)}\n >\n {Object.entries(notes.value).map(([id, note]) => (\n <NoteCard key={id} id={id} note={note} />\n ))}\n {others.map((cursor) => (\n <span\n key={cursor.id}\n className={styles['cursor']}\n style={{ transform: \\`translate(\\${cursor.x}px, \\${cursor.y}px)\\` }}\n aria-hidden=\"true\"\n />\n ))}\n </div>\n </Flex>\n )\n}\n`\n}\n\nfunction cfBoardCss(): string {\n return `/* Your app's own styles live in the cascivo.example layer (declared in index.html). */\n@layer cascivo.example {\n .board {\n position: relative;\n min-block-size: 32rem;\n overflow: hidden;\n border: 1px dashed var(--cascivo-border-default);\n border-radius: var(--cascivo-radius-surface);\n background: var(--cascivo-color-surface);\n }\n\n /* Positions are shared by everyone in the room, so they are physical (left/top), not\n logical: an RTL visitor must see a note where an LTR one put it. */\n .note {\n position: absolute;\n top: 0;\n left: 0;\n inline-size: 14rem;\n }\n\n .handle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--cascivo-space-1) var(--cascivo-space-2);\n cursor: grab;\n touch-action: none;\n }\n\n .cursor {\n position: absolute;\n top: 0;\n left: 0;\n inline-size: 0.75rem;\n block-size: 0.75rem;\n border-radius: 50%;\n background: var(--cascivo-color-accent);\n pointer-events: none;\n }\n}\n`\n}\n\n/* --- `--example agent`: generative UI on Cloudflare's Agents SDK + @cascivo/render --- */\n\nfunction cfAssistantTs(): string {\n return `import type { ViewConfig } from '@cascivo/render'\nimport { validateView } from '@cascivo/render/validate'\n\n/**\n * Shared by the Worker (worker/assistant.ts) and the page (src/routes/assistant.tsx): the\n * name the agent is routed under, and the one check both sides run on a generated view.\n */\n\n/** The Durable Object class, its wrangler binding and the \\`useAgent({ agent })\\` name. */\nexport const AGENT = 'Assistant'\n\nexport type CheckedView = { title: string; view: ViewConfig } | { errors: string[] }\n\n/**\n * Validates a view the model produced against the component manifests — unknown components,\n * invented props and out-of-range values all come back as errors the model can fix. It runs\n * in the Worker before the result is stored, and again in the browser on the stored message,\n * which crossed the network and is checked rather than trusted.\n */\nexport function checkView(title: unknown, view: unknown): CheckedView {\n if (typeof title !== 'string' || title.length === 0) return { errors: ['title: expected text'] }\n const result = validateView(view)\n if (!result.valid) return { errors: result.errors.map((e) => \\`\\${e.path}: \\${e.message}\\`) }\n // validateView has checked the whole shape, so the cast states a proven fact.\n return { title, view: view as ViewConfig }\n}\n`\n}\n\nfunction cfAssistantWorkerTs(): string {\n return `import { AIChatAgent } from '@cloudflare/ai-chat'\nimport { convertToModelMessages, stepCountIs, streamText, tool } from 'ai'\nimport type { LanguageModel } from 'ai'\nimport { createWorkersAI } from 'workers-ai-provider'\nimport { z } from 'zod'\nimport { checkView } from '../src/assistant'\nimport type { Env } from './index'\nimport { scriptedModel } from './scripted-model'\n\n/** Any Workers AI model with tool calling. */\nconst MODEL = '@cf/moonshotai/kimi-k2.7-code'\n\nconst SYSTEM = \\`You are an assistant inside a web app. When an answer is best shown as UI — a\nsummary, a status overview, a list — call show_view instead of describing it in text.\nA view is { \"view\": { \"regions\": { \"main\": [nodes] } } }.\nA node is { \"component\": Name, \"props\": { ... }, \"children\": [nodes] or \"text\" }.\nComponents: Flex (direction: \"vertical\" | \"horizontal\", gap: 1-8, wrap), Grid (cols: 1-4, gap),\nCard (padding: \"sm\" | \"md\" | \"lg\"; content as children), Badge (variant: \"default\" | \"success\" |\n\"warning\" | \"destructive\"; text as children), Alert (variant: \"info\" | \"success\" | \"warning\" |\n\"destructive\", title; text as children), ProgressBar (value, max, label), Separator,\nEmptyState (title, description).\nIf show_view returns errors, fix exactly those and call it again. Keep text replies short.\\`\n\nconst showView = tool({\n description: 'Show the user a view built from cascivo components. Returns errors to fix, or ok.',\n inputSchema: z.object({\n title: z.string().describe('A short title for the view'),\n view: z.object({ view: z.object({ regions: z.record(z.string(), z.array(z.unknown())) }) }),\n }),\n execute: async ({ title, view }) => checkView(title, view),\n})\n\n/**\n * One Durable Object per conversation: it stores the messages in its SQLite database and\n * streams replies to every open tab over a WebSocket, resuming a stream after a reconnect.\n */\nexport class Assistant extends AIChatAgent<Env> {\n async onChatMessage(_onFinish: unknown, options?: { abortSignal?: AbortSignal }) {\n // \\`vite dev\\` answers from a scripted model, so the page works offline and without an\n // account. A deployed Worker calls Workers AI, and so does \\`VITE_REAL_AI=1 vite dev\\`.\n const scripted = import.meta.env.DEV && import.meta.env['VITE_REAL_AI'] !== '1'\n const model: LanguageModel = scripted\n ? scriptedModel()\n : createWorkersAI({ binding: this.env.AI })(MODEL)\n const result = streamText({\n model,\n system: SYSTEM,\n messages: await convertToModelMessages(this.messages),\n tools: { show_view: showView },\n stopWhen: stepCountIs(4),\n ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}),\n })\n return result.toUIMessageStreamResponse()\n }\n}\n`\n}\n\nfunction cfScriptedModelTs(): string {\n return `import { simulateReadableStream } from 'ai'\nimport { MockLanguageModelV4 } from 'ai/test'\n\n/**\n * A stand-in for Workers AI during \\`vite dev\\`: it answers every message by calling\n * show_view with the view below, then replies with one line of text. It goes through the\n * same tool, validation and streaming as the real model, so the page can be built offline.\n * Production never imports it — worker/assistant.ts reaches it behind \\`import.meta.env.DEV\\`.\n */\nconst VIEW = {\n view: {\n regions: {\n main: [\n {\n component: 'Flex',\n props: { direction: 'vertical', gap: 3 },\n children: [\n {\n component: 'Alert',\n props: { variant: 'info', title: 'Scripted reply' },\n children:\n 'vite dev answers from worker/scripted-model.ts. Deploy to talk to Workers AI.',\n },\n {\n component: 'Grid',\n props: { cols: 3, gap: 3 },\n children: [\n {\n component: 'Card',\n props: { padding: 'md' },\n children: [\n { component: 'Badge', props: { variant: 'success' }, children: 'API healthy' },\n ],\n },\n {\n component: 'Card',\n props: { padding: 'md' },\n children: [\n {\n component: 'Badge',\n props: { variant: 'warning' },\n children: '2 jobs queued',\n },\n ],\n },\n {\n component: 'Card',\n props: { padding: 'md' },\n children: [{ component: 'Badge', children: '14 users online' }],\n },\n ],\n },\n { component: 'ProgressBar', props: { value: 72, label: 'Sprint progress' } },\n ],\n },\n ],\n },\n },\n}\n\nconst usage = {\n inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },\n outputTokens: { total: 0, text: 0, reasoning: 0 },\n}\n\nexport function scriptedModel(): MockLanguageModelV4 {\n return new MockLanguageModelV4({\n doStream: async ({ prompt }) => {\n // After the tool has run, the last prompt message is its result: reply in text.\n if (prompt.at(-1)?.role === 'tool') {\n return {\n stream: simulateReadableStream({\n chunkDelayInMs: 40,\n chunks: [\n { type: 'stream-start', warnings: [] },\n { type: 'text-start', id: 'reply' },\n { type: 'text-delta', id: 'reply', delta: 'Here is the overview ' },\n { type: 'text-delta', id: 'reply', delta: 'you asked for.' },\n { type: 'text-end', id: 'reply' },\n { type: 'finish', usage, finishReason: { unified: 'stop', raw: 'stop' } },\n ],\n }),\n }\n }\n return {\n stream: simulateReadableStream({\n chunkDelayInMs: 40,\n chunks: [\n { type: 'stream-start', warnings: [] },\n {\n type: 'tool-call',\n toolCallId: \\`call-\\${prompt.length}\\`,\n toolName: 'show_view',\n input: JSON.stringify({ title: 'Team overview', view: VIEW }),\n },\n { type: 'finish', usage, finishReason: { unified: 'tool-calls', raw: 'tool_calls' } },\n ],\n }),\n }\n },\n })\n}\n`\n}\n\nfunction cfAssistantRouteTsx(): string {\n return `import type { FormEvent } from 'react'\nimport { CascivoView } from '@cascivo/render'\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Text,\n Textarea,\n useSignalState,\n} from '@cascivo/react'\nimport { useAgentChat } from '@cloudflare/ai-chat/react'\nimport { useAgent } from 'agents/react'\nimport type { UIMessage } from 'ai'\nimport { AGENT, checkView } from '../assistant'\nimport styles from '../assistant.module.css'\n\nconst STORAGE_KEY = 'assistant-conversation'\n\n/**\n * The conversation this browser continues: its Durable Object's name. Kept in localStorage\n * so a reload picks the conversation back up; a new one each visit where storage is blocked.\n */\nfunction conversationName(): string {\n try {\n const saved = localStorage.getItem(STORAGE_KEY)\n if (saved) return saved\n const created = crypto.randomUUID()\n localStorage.setItem(STORAGE_KEY, created)\n return created\n } catch {\n return crypto.randomUUID()\n }\n}\n\nconst conversation = conversationName()\n\nfunction MessagePart({ part }: { part: UIMessage['parts'][number] }) {\n if (part.type === 'text') return <Text>{part.text}</Text>\n if (part.type !== 'tool-show_view') return null\n if (part.state !== 'output-available') {\n return (\n <Badge variant=\"neutral\">\n {part.state === 'output-error' ? 'View failed' : 'Building a view…'}\n </Badge>\n )\n }\n // The stored message came over the network: check it again rather than trusting it.\n const output: unknown = part.output\n const shown =\n typeof output === 'object' && output !== null && 'title' in output && 'view' in output\n ? checkView(output.title, output.view)\n : null\n if (!shown || 'errors' in shown) {\n // The model saw these errors as the tool result and tries again in the next step.\n return (\n <Badge variant=\"warning\">Rejected a view with {shown?.errors.length ?? 1} problems</Badge>\n )\n }\n return (\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Heading level={3}>{shown.title}</Heading>\n <CascivoView config={shown.view} onInvalid=\"render\" />\n </Flex>\n </CardContent>\n </Card>\n )\n}\n\nexport default function Assistant() {\n const agent = useAgent({ agent: AGENT, name: conversation })\n const { messages, sendMessage, status, clearHistory } = useAgentChat({ agent })\n const [draft, setDraft] = useSignalState('')\n const busy = status === 'submitted' || status === 'streaming'\n\n const send = (event: FormEvent) => {\n event.preventDefault()\n const text = draft.value.trim()\n if (!text || busy) return\n void sendMessage({ role: 'user', parts: [{ type: 'text', text }] })\n setDraft('')\n }\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Assistant</Heading>\n <Text muted>\n Ask for an overview, a status page or a list: the agent answers with real components,\n checked against their manifests before they reach you.\n </Text>\n </Flex>\n <Button variant=\"secondary\" onClick={clearHistory} disabled={messages.length === 0}>\n New conversation\n </Button>\n </Flex>\n <ol className={styles['messages']} aria-live=\"polite\">\n {messages.map((message) => (\n <li key={message.id} className={styles[message.role === 'user' ? 'user' : 'assistant']}>\n <Flex gap={2}>\n {message.parts.map((part, index) => (\n <MessagePart key={index} part={part} />\n ))}\n </Flex>\n </li>\n ))}\n </ol>\n <form className={styles['composer']} onSubmit={send}>\n <Textarea\n aria-label=\"Message\"\n rows={2}\n value={draft.value}\n placeholder=\"Show me the team's status\"\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && !event.shiftKey) send(event)\n }}\n />\n <Button type=\"submit\" loading={busy}>\n Send\n </Button>\n </form>\n </Flex>\n )\n}\n`\n}\n\nfunction cfAssistantCss(): string {\n return `/* Your app's own styles live in the cascivo.example layer (declared in index.html). */\n@layer cascivo.example {\n .messages {\n display: flex;\n flex-direction: column;\n gap: var(--cascivo-space-3);\n margin: 0;\n padding: 0;\n list-style: none;\n }\n\n .user {\n align-self: end;\n max-inline-size: 80%;\n padding: var(--cascivo-space-2) var(--cascivo-space-3);\n border-radius: var(--cascivo-radius-md);\n background: var(--cascivo-color-bg-subtle);\n }\n\n .assistant {\n max-inline-size: 100%;\n }\n\n .composer {\n display: flex;\n gap: var(--cascivo-space-2);\n align-items: end;\n }\n\n .composer > :first-child {\n flex: 1;\n }\n}\n`\n}\n\n/* --- `--example notes`: a local-first page on @cascivo/app/sync + IndexedDB --- */\n\nfunction cfNotesTs(): string {\n return `import { connectRoom } from '@cascivo/app/sync'\nimport { indexedDBDriver } from '@cascivo/storage'\n\nexport interface Note {\n title: string\n body: string\n /** Epoch ms of the last edit, for ordering. */\n updatedAt: number\n}\n\nconst LIST_KEY = 'notes-list'\n\n/**\n * Which list this browser shows: \\`?list=\\` when the URL names one, otherwise one made up on\n * first visit and remembered. Open the same \\`?list=\\` on another device to sync with it.\n */\nfunction resolveList(): string {\n const fromUrl = (new URLSearchParams(location.search).get('list') ?? '')\n .replace(/[^\\\\w-]/g, '')\n .slice(0, 48)\n if (fromUrl) return fromUrl\n try {\n const saved = localStorage.getItem(LIST_KEY)\n if (saved) return saved\n const created = crypto.randomUUID().slice(0, 8)\n localStorage.setItem(LIST_KEY, created)\n return created\n } catch {\n return crypto.randomUUID().slice(0, 8)\n }\n}\n\nexport const listName = resolveList()\n\n// \\`storage\\` makes the list local-first: the room's last state and every unconfirmed edit are\n// kept in IndexedDB, so edits made while the connection is down survive a reload or a closed\n// tab, and go to the room when it is reachable again.\nexport const room = connectRoom(\\`/api/rooms/notes-\\${listName}\\`, {\n storage: indexedDBDriver('cascivo-notes'),\n})\n\n/** Notes come from other devices, so they are parsed, not cast. */\nexport function parseNote(raw: unknown): Note {\n if (typeof raw === 'object' && raw !== null) {\n const { title, body, updatedAt } = raw as Record<string, unknown>\n if (typeof title === 'string' && typeof body === 'string' && typeof updatedAt === 'number') {\n return { title, body, updatedAt }\n }\n }\n throw new Error('Malformed note')\n}\n\nexport const notes = room.map('notes', parseNote)\n\nexport function addNote(): void {\n notes.set(crypto.randomUUID(), { title: '', body: '', updatedAt: Date.now() })\n}\n\nexport function editNote(id: string, note: Note, change: Partial<Note>): void {\n notes.set(id, { ...note, ...change, updatedAt: Date.now() })\n}\n`\n}\n\nfunction cfNotesRouteTsx(): string {\n return `import {\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Input,\n Text,\n Textarea,\n useSignals,\n} from '@cascivo/react'\nimport { addNote, editNote, listName, notes, room } from '../notes'\n\n/** Where the list stands: offline edits are counted until the room confirms them. */\nfunction SyncBadge() {\n useSignals()\n const waiting = room.unsynced.value\n if (room.status.value === 'open') {\n return (\n <Badge variant={waiting > 0 ? 'warning' : 'success'}>\n {waiting > 0 ? 'Syncing…' : 'Synced'}\n </Badge>\n )\n }\n return <Badge variant=\"neutral\">{waiting > 0 ? \\`Offline · \\${waiting} waiting\\` : 'Offline'}</Badge>\n}\n\nexport default function Notes() {\n useSignals()\n const sorted = Object.entries(notes.value).sort(([, a], [, b]) => b.updatedAt - a.updatedAt)\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Notes</Heading>\n <Text muted>\n Keeps working offline: edits are saved on this device and sync when you are back. Open{' '}\n <code>/notes?list={listName}</code> on another device to share this list.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <SyncBadge />\n <Button onClick={addNote}>New note</Button>\n </Flex>\n </Flex>\n {sorted.length === 0 ? <Text muted>No notes yet.</Text> : null}\n {sorted.map(([id, note]) => (\n <Card key={id}>\n <CardContent>\n <Flex gap={2}>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Input\n aria-label=\"Title\"\n placeholder=\"Title\"\n value={note.title}\n onChange={(event) => editNote(id, note, { title: event.target.value })}\n />\n <Button variant=\"ghost\" aria-label=\"Delete note\" onClick={() => notes.delete(id)}>\n ×\n </Button>\n </Flex>\n <Textarea\n aria-label=\"Note\"\n rows={3}\n value={note.body}\n onChange={(event) => editNote(id, note, { body: event.target.value })}\n />\n </Flex>\n </CardContent>\n </Card>\n ))}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example import`: a Workflow whose progress streams through @cascivo/app/jobs --- */\n\nfunction cfImportJobTs(): string {\n return `import { defineJob } from '@cascivo/app/jobs'\n\n/**\n * The CSV import job, shared by the Worker (which runs it) and the page (which watches it).\n * Its output crosses the network, so it is parsed on the way in, never cast.\n */\n\n/** Largest CSV the Worker accepts. A Workflow's params are limited in size, too. */\nexport const MAX_CSV_LENGTH = 200_000\n\nexport interface Rejected {\n /** 1-based line in the CSV. */\n line: number\n reason: string\n}\n\nexport interface ImportSummary {\n imported: number\n rejected: Rejected[]\n}\n\nexport function parseImportSummary(raw: unknown): ImportSummary {\n if (typeof raw === 'object' && raw !== null) {\n const { imported, rejected } = raw as Record<string, unknown>\n if (typeof imported === 'number' && Array.isArray(rejected)) {\n return {\n imported,\n rejected: rejected.map((r: unknown) => {\n if (typeof r === 'object' && r !== null) {\n const { line, reason } = r as Record<string, unknown>\n if (typeof line === 'number' && typeof reason === 'string') return { line, reason }\n }\n throw new Error('Malformed rejected row')\n }),\n }\n }\n }\n throw new Error('Malformed import summary')\n}\n\nexport const importJob = defineJob({\n steps: ['Read', 'Check', 'Import'],\n output: parseImportSummary,\n})\n\nexport function parseImportRequest(raw: unknown): { csv: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { csv } = raw as Record<string, unknown>\n if (typeof csv === 'string' && csv.length > 0 && csv.length <= MAX_CSV_LENGTH) return { csv }\n }\n throw new Error(\\`Send { csv } with 1 to \\${MAX_CSV_LENGTH} characters\\`)\n}\n\nexport function parseStarted(raw: unknown): { id: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { id } = raw as Record<string, unknown>\n if (typeof id === 'string' && /^[\\\\w-]{1,60}$/.test(id)) return { id }\n }\n throw new Error('Malformed job id')\n}\n`\n}\n\nfunction cfImportPageTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { watchJob } from '@cascivo/app/jobs'\nimport type { WatchedJob } from '@cascivo/app/jobs'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport { importJob } from './import-job'\nimport type { ImportSummary } from './import-job'\n\nconst client = createClient(api)\n\n/** The job this page shows. Kept in the URL (\\`?job=\\`), so a reload picks it back up. */\nexport const current = signal<WatchedJob<ImportSummary> | null>(null)\nexport const starting = signal(false)\nexport const startError = signal<string | null>(null)\n\nfunction watch(id: string): void {\n current.value?.close()\n current.value = watchJob(importJob, \\`/api/jobs/\\${id}\\`)\n}\n\nconst fromUrl = new URLSearchParams(location.search).get('job')\nif (fromUrl && /^[\\\\w-]{1,60}$/.test(fromUrl)) watch(fromUrl)\n\nexport async function startImport(csv: string): Promise<void> {\n starting.value = true\n startError.value = null\n try {\n const { id } = await client.startImport({ body: { csv } })\n history.replaceState(null, '', \\`/import?job=\\${id}\\`)\n watch(id)\n } catch (error) {\n startError.value = error instanceof Error ? error.message : 'The import could not start'\n } finally {\n starting.value = false\n }\n}\n`\n}\n\nfunction cfImportWorkflowTs(): string {\n return `import { jobReporter } from '@cascivo/app/jobs-server'\nimport { WorkflowEntrypoint } from 'cloudflare:workers'\nimport type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'\nimport { importJob } from '../src/import-job'\nimport type { ImportSummary, Rejected } from '../src/import-job'\nimport type { Env } from './index'\n\ninterface Contact {\n line: number\n name: string\n email: string\n}\n\nconst EMAIL = /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/\nconst CHUNK = 25\n\n/** \\`name,email\\` per line, after a header line. Quoted fields are not supported. */\nfunction readCsv(csv: string): Contact[] {\n const lines = csv.split(/\\\\r?\\\\n/)\n const rows: Contact[] = []\n lines.forEach((text, i) => {\n if (i === 0 || text.trim() === '') return\n const [name = '', email = ''] = text.split(',').map((cell) => cell.trim())\n rows.push({ line: i + 1, name, email })\n })\n return rows\n}\n\nfunction checkRows(rows: Contact[]): { valid: Contact[]; rejected: Rejected[] } {\n const valid: Contact[] = []\n const rejected: Rejected[] = []\n for (const row of rows) {\n if (!row.name) rejected.push({ line: row.line, reason: 'No name' })\n else if (!EMAIL.test(row.email))\n rejected.push({ line: row.line, reason: 'Not an email address' })\n else valid.push(row)\n }\n return { valid, rejected }\n}\n\n/**\n * Write the contacts where they belong (D1, an API…). Here it only waits, so the progress\n * is visible; each chunk is its own step, so a failure retries that chunk alone.\n */\nasync function importContacts(contacts: Contact[]): Promise<void> {\n console.log(\\`import: \\${contacts.length} contacts\\`)\n await new Promise((resolve) => setTimeout(resolve, 400))\n}\n\n/**\n * The import, as a Workflow: each step is retried on failure and its result is kept, so a\n * crash or a deploy resumes the job where it was. Progress goes to the job's room, where the\n * page watches it (\\`watchJob\\`). Every report is made inside a step: a Workflow replays\n * \\`run()\\` from the top after each step, and a report outside one would run again.\n */\nexport class ImportJob extends WorkflowEntrypoint<Env, { csv: string }> {\n override async run(event: WorkflowEvent<{ csv: string }>, step: WorkflowStep) {\n const report = jobReporter(importJob, this.env.ROOMS, event.instanceId)\n let at = 0\n try {\n const rows = await step.do('read', async () => {\n await report.step(0, 'Reading the file')\n return readCsv(event.payload.csv)\n })\n at = 1\n const checked = await step.do('check', async () => {\n await report.step(1, \\`Checking \\${rows.length} rows\\`)\n return checkRows(rows)\n })\n at = 2\n const total = checked.valid.length\n for (let start = 0; start < total; start += CHUNK) {\n await step.do(\\`import \\${start}\\`, async () => {\n await report.progress(2, start / total, \\`Imported \\${start} of \\${total}\\`)\n await importContacts(checked.valid.slice(start, start + CHUNK))\n })\n }\n const summary: ImportSummary = { imported: total, rejected: checked.rejected }\n await step.do('done', () => report.done(summary))\n return summary\n } catch (error) {\n await report.fail(error, at)\n throw error\n }\n }\n}\n`\n}\n\nfunction cfImportRouteTsx(): string {\n return `import type { Step } from '@cascivo/react'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n ProgressBar,\n Steps,\n Text,\n Textarea,\n useSignals,\n useSignalState,\n} from '@cascivo/react'\nimport { importJob } from '../import-job'\nimport type { JobState } from '@cascivo/app/jobs'\nimport type { ImportSummary } from '../import-job'\nimport { current, startError, startImport, starting } from '../import-page'\n\nconst SAMPLE = [\n 'name,email',\n ...Array.from({ length: 120 }, (_, i) => \\`Person \\${i + 1},person\\${i + 1}@example.com\\`),\n 'No Email,',\n ',nameless@example.com',\n].join('\\\\n')\n\n/** One Steps entry per job step, from where the job is. */\nfunction stepsOf(state: JobState<ImportSummary>): Step[] {\n return importJob.steps.map((label, i) => ({\n id: label,\n label,\n state:\n state.status === 'done' || i < state.step\n ? 'complete'\n : i > state.step || state.status === 'queued'\n ? 'pending'\n : state.status === 'failed'\n ? 'error'\n : 'active',\n }))\n}\n\nfunction JobView() {\n useSignals()\n const job = current.value\n if (!job) return null\n const state = job.state.value\n return (\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Steps steps={stepsOf(state)} activeStep={state.step} ariaLabel=\"Import progress\" />\n {state.status === 'running' && state.progress !== null ? (\n <ProgressBar\n value={Math.round(state.progress * 100)}\n label={state.message ?? 'Importing'}\n />\n ) : (\n <Text muted>\n {state.status === 'queued' ? 'Waiting to start…' : (state.message ?? '')}\n </Text>\n )}\n {state.status === 'done' && state.output ? (\n <Alert variant=\"success\" title={\\`Imported \\${state.output.imported} contacts\\`}>\n {state.output.rejected.length === 0\n ? 'Every row was valid.'\n : \\`Rejected: \\${state.output.rejected.map((r) => \\`line \\${r.line} (\\${r.reason})\\`).join(', ')}\\`}\n </Alert>\n ) : null}\n {state.status === 'failed' ? (\n <Alert variant=\"destructive\" title=\"The import failed\">\n {state.error}\n </Alert>\n ) : null}\n </Flex>\n </CardContent>\n </Card>\n )\n}\n\nexport default function Import() {\n useSignals()\n const [csv, setCsv] = useSignalState(SAMPLE)\n const running = current.value?.state.value.status\n const busy = starting.value || running === 'queued' || running === 'running'\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Import</Heading>\n <Text muted>\n A CSV import running as a Cloudflare Workflow. Each step retries on its own and the job\n survives a deploy; progress streams here, and a reload picks the job back up.\n </Text>\n </Flex>\n <Textarea\n aria-label=\"CSV to import\"\n rows={8}\n value={csv.value}\n onChange={(event) => setCsv(event.target.value)}\n />\n <Flex direction=\"horizontal\" gap={2} align=\"center\">\n <Button onClick={() => void startImport(csv.value)} loading={busy} disabled={busy}>\n Import\n </Button>\n {startError.value ? <Text muted>{startError.value}</Text> : null}\n </Flex>\n <JobView />\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example files`: uploads into R2 through @cascivo/app/uploads, Images previews --- */\n\nfunction cfUploadPolicyTs(): string {\n return `import { defineUploads } from '@cascivo/app/uploads'\n\n/**\n * What the app accepts, shared by the page (which checks first) and the Worker (which\n * enforces). SVG and HTML are not on the list, and cannot be: served from this origin they\n * would run script.\n */\nexport const uploads = defineUploads({\n path: '/api/uploads',\n maxBytes: 50 * 1024 * 1024,\n types: ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf'],\n})\n`\n}\n\nfunction cfFilesTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { startUpload } from '@cascivo/app/uploads'\nimport type { StoredFile, Upload } from '@cascivo/app/uploads'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport { uploads } from './upload-policy'\n\nconst client = createClient(api)\n\n/** Files being uploaded now, newest first. */\nexport const inFlight = signal<Upload[]>([])\n/** Files already stored (see listUploads for their order). */\nexport const stored = signal<StoredFile[]>([])\n\nexport async function refresh(): Promise<void> {\n stored.value = await client.listFiles()\n}\n\nexport function addFiles(files: File[]): void {\n for (const file of files) {\n const upload = startUpload(uploads, file)\n inFlight.value = [upload, ...inFlight.value]\n // Once stored, the file moves from the upload list to the file list.\n const stop = upload.status.subscribe((status) => {\n if (status !== 'done') return\n stop()\n inFlight.value = inFlight.value.filter((u) => u !== upload)\n void refresh()\n })\n }\n}\n\nexport function removeUpload(id: string): void {\n const upload = inFlight.value.find((u) => u.id === id)\n upload?.abort()\n inFlight.value = inFlight.value.filter((u) => u.id !== id)\n}\n`\n}\n\nfunction cfFilesRouteTsx(): string {\n return `import type { UploaderFile } from '@cascivo/react'\nimport {\n Card,\n CardContent,\n FileUploader,\n Flex,\n Heading,\n ProgressBar,\n Text,\n useSignals,\n} from '@cascivo/react'\nimport { formatBytes } from '@cascivo/app/uploads'\nimport { addFiles, inFlight, refresh, removeUpload, stored } from '../files'\nimport { uploads } from '../upload-policy'\nimport styles from '../files.module.css'\n\nvoid refresh()\n\nexport default function Files() {\n useSignals()\n const files: UploaderFile[] = inFlight.value.map((upload) => ({\n id: upload.id,\n name: upload.name,\n size: upload.size,\n status: upload.status.value === 'done' ? 'complete' : upload.status.value,\n ...(upload.error.value ? { errorMessage: upload.error.value } : {}),\n }))\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Files</Heading>\n <Text muted>\n Uploads go through the Worker into R2: up to {formatBytes(uploads.maxBytes)} each, large\n files in parts. Images get resized previews from Cloudflare Images.\n </Text>\n </Flex>\n <FileUploader\n multiple\n accept={uploads.types.join(',')}\n maxSize={uploads.maxBytes}\n files={files}\n onFilesAdded={addFiles}\n onRemove={removeUpload}\n />\n {inFlight.value\n .filter((upload) => upload.status.value === 'uploading')\n .map((upload) => (\n <ProgressBar\n key={upload.id}\n value={Math.round(upload.progress.value * 100)}\n label={upload.name}\n />\n ))}\n <div className={styles['grid']}>\n {stored.value.map((file) => (\n <Card key={file.key}>\n <CardContent>\n <Flex gap={2}>\n {file.type.startsWith('image/') ? (\n <img\n className={styles['preview']}\n src={\\`\\${uploads.path}/\\${file.key}?w=320\\`}\n alt={file.name}\n loading=\"lazy\"\n />\n ) : null}\n <a href={\\`\\${uploads.path}/\\${file.key}\\`} target=\"_blank\" rel=\"noreferrer\">\n {file.name}\n </a>\n <Text size=\"sm\" muted>\n {formatBytes(file.size)}\n </Text>\n </Flex>\n </CardContent>\n </Card>\n ))}\n </div>\n </Flex>\n )\n}\n`\n}\n\nfunction cfFilesCss(): string {\n return `/* Your app's own styles live in the cascivo.example layer (declared in index.html). */\n@layer cascivo.example {\n .grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));\n gap: var(--cascivo-space-3);\n }\n\n .preview {\n inline-size: 100%;\n aspect-ratio: 4 / 3;\n object-fit: cover;\n border-radius: var(--cascivo-radius-md);\n background: var(--cascivo-color-bg-subtle);\n }\n}\n`\n}\n\n/* --- `--example export`: a page exported to PDF/PNG by Browser Run (@cascivo/app/export) --- */\n\nfunction cfReportRouteTsx(): string {\n return `import { exportUrl, isExporting } from '@cascivo/app/export'\nimport type { Column } from '@cascivo/react'\nimport { Badge, Button, Card, CardContent, DataTable, Flex, Heading, Text } from '@cascivo/react'\n\ninterface Month {\n month: string\n revenue: number\n customers: number\n change: number\n}\n\nconst ROWS: Month[] = [\n { month: 'July', revenue: 48_200, customers: 312, change: 4.1 },\n { month: 'August', revenue: 51_900, customers: 334, change: 7.7 },\n { month: 'September', revenue: 50_300, customers: 341, change: -3.1 },\n]\n\nconst money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\n\nconst COLUMNS: Column<Month>[] = [\n { key: 'month', header: 'Month' },\n { key: 'revenue', header: 'Revenue', align: 'end', render: (row) => money.format(row.revenue) },\n { key: 'customers', header: 'Customers', align: 'end' },\n {\n key: 'change',\n header: 'Change',\n align: 'end',\n render: (row) => (\n <Badge variant={row.change >= 0 ? 'success' : 'warning'}>\n {row.change >= 0 ? '+' : ''}\n {row.change}%\n </Badge>\n ),\n },\n]\n\n/**\n * A page worth exporting. The buttons download it as a file, rendered by the Worker in\n * Cloudflare's Browser Run; inside that browser (\\`isExporting()\\`) they are left out, and\n * App.tsx drops the shell, so the file holds the report and nothing else.\n */\nexport default function Report() {\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Quarterly report</Heading>\n <Text muted>Q3 revenue and customers, by month.</Text>\n </Flex>\n {isExporting() ? null : (\n <Flex direction=\"horizontal\" gap={2}>\n <Button asChild variant=\"secondary\">\n <a href={exportUrl('/report', 'png')}>Download PNG</a>\n </Button>\n <Button asChild>\n <a href={exportUrl('/report', 'pdf')}>Download PDF</a>\n </Button>\n </Flex>\n )}\n </Flex>\n <Card>\n <CardContent>\n <DataTable columns={COLUMNS} rows={ROWS} getRowId={(row) => row.month} />\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example usage`: API usage in Workers Analytics Engine, charted (@cascivo/app/analytics) --- */\n\nfunction cfUsageTs(): string {\n return `import { defineMetrics } from '@cascivo/app/analytics'\n\n/**\n * What the Worker records for every API request, in Analytics Engine (worker/index.ts writes\n * it). The names are the columns: a query says {path}, never blob1.\n */\nexport const usageMetrics = defineMetrics({\n dataset: 'app_usage',\n blobs: ['path', 'method'],\n doubles: ['status', 'duration_ms'],\n index: 'path',\n})\n\nexport interface UsageReport {\n /** False until the Worker has the secrets it needs to read Analytics Engine. */\n configured: boolean\n hourly: { hour: string; requests: number }[]\n routes: { path: string; requests: number }[]\n totals: { requests: number; errors: number; avgMs: number }\n}\n\nfunction num(raw: unknown): number {\n if (typeof raw !== 'number' || !Number.isFinite(raw)) throw new Error('Expected a number')\n return raw\n}\n\nfunction str(raw: unknown): string {\n if (typeof raw !== 'string') throw new Error('Expected a string')\n return raw\n}\n\nfunction list<T>(raw: unknown, item: (row: Record<string, unknown>) => T): T[] {\n if (!Array.isArray(raw)) throw new Error('Expected a list')\n return raw.map((row: unknown) => {\n if (typeof row !== 'object' || row === null) throw new Error('Expected an object')\n return item(row as Record<string, unknown>)\n })\n}\n\n/** The report crosses the network, so the page parses it. */\nexport function parseUsageReport(raw: unknown): UsageReport {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed usage report')\n const r = raw as Record<string, unknown>\n const totals = r['totals']\n if (typeof r['configured'] !== 'boolean' || typeof totals !== 'object' || totals === null) {\n throw new Error('Malformed usage report')\n }\n const t = totals as Record<string, unknown>\n return {\n configured: r['configured'],\n hourly: list(r['hourly'], (row) => ({\n hour: str(row['hour']),\n requests: num(row['requests']),\n })),\n routes: list(r['routes'], (row) => ({\n path: str(row['path']),\n requests: num(row['requests']),\n })),\n totals: { requests: num(t['requests']), errors: num(t['errors']), avgMs: num(t['avgMs']) },\n }\n}\n`\n}\n\nfunction cfUsageWorkerTs(): string {\n return `import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'\nimport type { AnalyticsCredentials } from '@cascivo/app/analytics'\nimport { usageMetrics } from '../src/usage'\nimport type { UsageReport } from '../src/usage'\n\nconst LAST_DAY = \"timestamp > NOW() - INTERVAL '1' DAY\"\n\n/** A number, or 0 when the query had nothing to sum (an empty day). */\nconst orZero = (row: unknown, name: string): number => {\n try {\n return numberField(row, name)\n } catch {\n return 0\n }\n}\n\n/**\n * The last 24 hours of API usage, from Analytics Engine's SQL API. Counts are\n * \\`SUM(_sample_interval)\\`, never \\`COUNT()\\`: Analytics Engine samples at high volume, and each\n * row stands for \\`_sample_interval\\` requests.\n */\nexport async function usageReport(credentials: AnalyticsCredentials | null): Promise<UsageReport> {\n if (!credentials) {\n return {\n configured: false,\n hourly: [],\n routes: [],\n totals: { requests: 0, errors: 0, avgMs: 0 },\n }\n }\n const [hourly, routes, totals, errors] = await Promise.all([\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT toStartOfInterval(timestamp, INTERVAL '1' HOUR) AS hour, SUM(_sample_interval) AS requests\n FROM {dataset} WHERE \\${LAST_DAY} GROUP BY hour ORDER BY hour\\`,\n ),\n (row) => ({ hour: stringField(row, 'hour'), requests: numberField(row, 'requests') }),\n ),\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT {path} AS path, SUM(_sample_interval) AS requests\n FROM {dataset} WHERE \\${LAST_DAY} GROUP BY path ORDER BY requests DESC LIMIT 8\\`,\n ),\n (row) => ({ path: stringField(row, 'path'), requests: numberField(row, 'requests') }),\n ),\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT SUM(_sample_interval) AS requests,\n SUM(_sample_interval * {duration_ms}) / SUM(_sample_interval) AS avg_ms\n FROM {dataset} WHERE \\${LAST_DAY}\\`,\n ),\n (row) => ({ requests: orZero(row, 'requests'), avgMs: orZero(row, 'avg_ms') }),\n ),\n queryAnalytics(\n credentials,\n usageMetrics.sql(\n \\`SELECT SUM(_sample_interval) AS errors FROM {dataset} WHERE \\${LAST_DAY} AND {status} >= 500\\`,\n ),\n (row) => orZero(row, 'errors'),\n ),\n ])\n return {\n configured: true,\n hourly,\n routes,\n totals: {\n requests: totals[0]?.requests ?? 0,\n errors: errors[0] ?? 0,\n avgMs: Math.round(totals[0]?.avgMs ?? 0),\n },\n }\n}\n`\n}\n\nfunction cfUsageRouteTsx(): string {\n return `import { BarChart, Kpi, LineChart } from '@cascivo/charts'\nimport { createClient } from '@cascivo/app/api'\nimport {\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Grid,\n Heading,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport type { UsageReport } from '../usage'\n\nconst client = createClient(api)\nconst report = signal<UsageReport | null>(null)\nconst failed = signal<string | null>(null)\n\nasync function load(): Promise<void> {\n failed.value = null\n try {\n report.value = await client.usage()\n } catch (error) {\n failed.value = error instanceof Error ? error.message : 'Could not load usage'\n }\n}\nvoid load()\n\n/** Analytics Engine returns hours as \"2026-09-30 14:00:00\", in UTC. */\nconst toDate = (hour: string) => new Date(\\`\\${hour.replace(' ', 'T')}Z\\`)\n\nexport default function Usage() {\n useSignals()\n const data = report.value\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Usage</Heading>\n <Text muted>Every API request, recorded by the Worker in Workers Analytics Engine.</Text>\n </Flex>\n <Button variant=\"secondary\" onClick={() => void load()}>\n Refresh\n </Button>\n </Flex>\n {failed.value ? <Text muted>{failed.value}</Text> : null}\n {data && !data.configured ? (\n <EmptyState\n title=\"Connect Analytics Engine to read usage\"\n description=\"The Worker already records every request. To read them back it needs your account id and an API token with Account Analytics: Read, as secrets: npx wrangler secret put CF_ACCOUNT_ID, then npx wrangler secret put CF_API_TOKEN. For vite dev, put both in .dev.vars.\"\n />\n ) : null}\n {data && data.configured ? (\n <>\n <Grid cols={3} gap={3}>\n <Kpi label=\"Requests, 24 h\" value={data.totals.requests.toLocaleString()} />\n <Kpi label=\"Server errors\" value={data.totals.errors.toLocaleString()} />\n <Kpi label=\"Average latency\" value={\\`\\${data.totals.avgMs} ms\\`} />\n </Grid>\n <Card>\n <CardContent>\n <LineChart\n title=\"Requests per hour\"\n series={[{ id: 'requests', label: 'Requests', data: data.hourly }]}\n x={(d) => toDate(d.hour)}\n y={(d) => d.requests}\n />\n </CardContent>\n </Card>\n <Card>\n <CardContent>\n <BarChart\n title=\"Busiest routes\"\n series={[{ id: 'routes', label: 'Requests', data: data.routes }]}\n x={(d) => d.path}\n y={(d) => d.requests}\n />\n </CardContent>\n </Card>\n </>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example search`: help articles by meaning (Vectorize + Workers AI) --- */\n\nfunction cfSearchTs(): string {\n return `/**\n * /search, shared by the Worker (which searches) and the page (which asks). \\`semantic\\` is\n * Vectorize over Workers AI embeddings; \\`keyword\\` is the \\`vite dev\\` stand-in, SQLite's\n * full-text search, because neither runs locally.\n */\nexport interface SearchHit {\n id: string\n title: string\n body: string\n /** Higher is closer. Cosine similarity when semantic; a rank when keyword. */\n score: number\n}\n\nexport interface SearchResult {\n mode: 'semantic' | 'keyword'\n hits: SearchHit[]\n /** Articles in the vector index, when semantic: 0 until \"Index articles\" has run. */\n indexed: number | null\n}\n\nexport function parseSearchQuery(raw: unknown): { q: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { q } = raw as Record<string, unknown>\n if (typeof q === 'string' && q.trim() !== '' && q.length <= 200) return { q: q.trim() }\n }\n throw new Error('Expected { q }: 1–200 characters')\n}\n\nfunction parseHit(raw: unknown): SearchHit {\n if (typeof raw === 'object' && raw !== null) {\n const { id, title, body, score } = raw as Record<string, unknown>\n if (\n typeof id === 'string' &&\n typeof title === 'string' &&\n typeof body === 'string' &&\n typeof score === 'number'\n ) {\n return { id, title, body, score }\n }\n }\n throw new Error('Malformed search hit')\n}\n\nexport function parseSearchResult(raw: unknown): SearchResult {\n if (typeof raw === 'object' && raw !== null) {\n const { mode, hits, indexed } = raw as Record<string, unknown>\n if (\n (mode === 'semantic' || mode === 'keyword') &&\n Array.isArray(hits) &&\n (indexed === null || typeof indexed === 'number')\n ) {\n return { mode, hits: hits.map(parseHit), indexed }\n }\n }\n throw new Error('Malformed search result')\n}\n\nexport function parseIndexed(raw: unknown): { indexed: number } {\n if (typeof raw === 'object' && raw !== null) {\n const { indexed } = raw as Record<string, unknown>\n if (typeof indexed === 'number') return { indexed }\n }\n throw new Error('Malformed reply')\n}\n`\n}\n\nfunction cfSearchWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport type { SearchHit, SearchResult } from '../src/search'\nimport { articles } from './articles'\n\n/** A Workers AI embedding model; its dimensions (768) are the Vectorize index's. */\nconst EMBEDDING_MODEL = '@cf/baai/bge-base-en-v1.5'\n\n// \\`vite dev\\` has neither Workers AI nor Vectorize, so it searches with SQLite's full-text\n// index instead: by words, not meaning. \\`VITE_REAL_AI=1 vite dev\\` uses the real ones.\nconst keywordOnly = import.meta.env.DEV && import.meta.env['VITE_REAL_AI'] !== '1'\n\n/** What search needs of the Vectorize binding. */\nexport interface VectorIndex {\n query(\n vector: number[],\n options: { topK: number },\n ): Promise<{ matches: { id: string; score: number }[] }>\n upsert(vectors: { id: string; values: number[] }[]): Promise<unknown>\n describe(): Promise<{ vectorCount: number }>\n}\n\n/** What search needs of the Workers AI binding. */\nexport interface Embedder {\n run(model: typeof EMBEDDING_MODEL, input: { text: string[] }): Promise<unknown>\n}\n\nexport interface SearchEnv {\n DB: Database\n AI: Embedder\n ARTICLES_INDEX: VectorIndex\n}\n\nconst migrations = [\n {\n id: '0001_articles',\n statements: [\n 'CREATE TABLE articles (id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL)',\n \"CREATE VIRTUAL TABLE articles_fts USING fts5(title, body, content='articles', content_rowid='rowid')\",\n ],\n },\n]\n\n/** The schema, then the articles (once: a second isolate's inserts are ignored). */\nasync function ready(db: Database): Promise<void> {\n await migrate(db, migrations)\n const [seeded] = await queryRows(db, 'SELECT COUNT(*) AS n FROM articles', [], (row) =>\n typeof row === 'object' && row !== null ? Number((row as Record<string, unknown>)['n']) : 0,\n )\n if (seeded) return\n await db.batch([\n ...articles.map((a) =>\n db\n .prepare('INSERT OR IGNORE INTO articles (id, title, body) VALUES (?, ?, ?)')\n .bind(a.id, a.title, a.body),\n ),\n // The full-text index reads the articles table; rebuild it from what is there now.\n db.prepare(\"INSERT INTO articles_fts (articles_fts) VALUES ('rebuild')\"),\n ])\n}\n\nfunction parseRow(raw: unknown): SearchHit {\n if (typeof raw === 'object' && raw !== null) {\n const { id, title, body, score } = raw as Record<string, unknown>\n if (typeof id === 'string' && typeof title === 'string' && typeof body === 'string') {\n return { id, title, body, score: typeof score === 'number' ? score : 0 }\n }\n }\n throw new Error('Malformed article row')\n}\n\n/** Embeddings from Workers AI, checked: a model's reply is data like any other. */\nasync function embed(ai: Embedder, texts: string[]): Promise<number[][]> {\n const reply = await ai.run(EMBEDDING_MODEL, { text: texts })\n const data =\n typeof reply === 'object' && reply !== null ? (reply as Record<string, unknown>)['data'] : null\n if (\n !Array.isArray(data) ||\n data.length !== texts.length ||\n !data.every((v) => Array.isArray(v) && v.every((n) => typeof n === 'number'))\n ) {\n throw new Error('Workers AI returned no embeddings')\n }\n return data as number[][]\n}\n\n/** Every word as a quoted FTS5 term, OR-ed: user input never reaches FTS5's query syntax. */\nfunction keywordQuery(q: string): string | null {\n const words = q.toLowerCase().match(/[\\\\p{L}\\\\p{N}]+/gu) ?? []\n return words.length > 0 ? words.map((w) => \\`\"\\${w}\"\\`).join(' OR ') : null\n}\n\nexport async function search(env: SearchEnv, q: string): Promise<SearchResult> {\n await ready(env.DB)\n if (keywordOnly) {\n const match = keywordQuery(q)\n const hits = match\n ? await queryRows(\n env.DB,\n \\`SELECT a.id, a.title, a.body, -bm25(articles_fts) AS score\n FROM articles_fts JOIN articles a ON a.rowid = articles_fts.rowid\n WHERE articles_fts MATCH ? ORDER BY score DESC LIMIT 5\\`,\n [match],\n parseRow,\n )\n : []\n return { mode: 'keyword', hits, indexed: null }\n }\n const { vectorCount } = await env.ARTICLES_INDEX.describe()\n const [vector] = await embed(env.AI, [q])\n const { matches } = await env.ARTICLES_INDEX.query(vector!, { topK: 5 })\n if (matches.length === 0) return { mode: 'semantic', hits: [], indexed: vectorCount }\n const rows = await queryRows(\n env.DB,\n \\`SELECT id, title, body FROM articles WHERE id IN (\\${matches.map(() => '?').join(', ')})\\`,\n matches.map((m) => m.id),\n parseRow,\n )\n const byId = new Map(rows.map((row) => [row.id, row]))\n const hits = matches.flatMap((m) => {\n const row = byId.get(m.id)\n return row ? [{ ...row, score: m.score }] : []\n })\n return { mode: 'semantic', hits, indexed: vectorCount }\n}\n\n/**\n * Embeds every article and upserts it into the Vectorize index. Vectorize applies writes a few\n * seconds later, so \\`indexed\\` counts what it had already applied when this finished.\n */\nexport async function indexArticles(env: SearchEnv): Promise<{ indexed: number }> {\n if (keywordOnly)\n throw new HttpError(400, 'vite dev searches by keyword: there is nothing to index')\n await ready(env.DB)\n const rows = await queryRows(env.DB, 'SELECT id, title, body FROM articles', [], parseRow)\n for (let i = 0; i < rows.length; i += 50) {\n const batch = rows.slice(i, i + 50)\n const vectors = await embed(\n env.AI,\n batch.map((row) => \\`\\${row.title}\\\\n\\${row.body}\\`),\n )\n await env.ARTICLES_INDEX.upsert(batch.map((row, j) => ({ id: row.id, values: vectors[j]! })))\n }\n return { indexed: (await env.ARTICLES_INDEX.describe()).vectorCount }\n}\n`\n}\n\nfunction cfArticlesTs(): string {\n return `/**\n * The help articles /search looks through, seeded into D1 on the first query. Replace them with\n * your own content: anything with an id, a title and a body can be indexed the same way.\n */\nexport const articles: { id: string; title: string; body: string }[] = [\n {\n id: 'refunds',\n title: 'Refunds',\n body: 'We refund any charge within 30 days of the invoice. Open Billing, pick the invoice and choose Request refund; the amount returns to the card that paid it within five business days.',\n },\n {\n id: 'cancel',\n title: 'Cancelling a subscription',\n body: 'Cancel from Billing at any time. The plan stays active until the end of the period you paid for, and nothing is charged after that.',\n },\n {\n id: 'invoices',\n title: 'Invoices and receipts',\n body: 'Every charge produces an invoice in Billing. Add a tax number and a billing address there, and they appear on every invoice from then on.',\n },\n {\n id: 'change-plan',\n title: 'Changing plans',\n body: 'Upgrade or downgrade from Billing. An upgrade is prorated and charged at once; a downgrade takes effect at the next renewal.',\n },\n {\n id: 'payment-failed',\n title: 'When a payment fails',\n body: 'If a card is declined we retry three times over a week and email the account owner each time. Update the card in Billing to settle the balance.',\n },\n {\n id: 'sso',\n title: 'Single sign-on with SAML',\n body: 'Enterprise workspaces can require sign-in through an identity provider such as Okta or Entra ID. Upload the provider metadata under Security, then test with one account before enforcing it.',\n },\n {\n id: 'two-factor',\n title: 'Two-step verification',\n body: 'Turn on an authenticator app under Profile, Security. Keep the recovery codes somewhere safe: they are the only way back in if the phone is lost.',\n },\n {\n id: 'password-reset',\n title: 'Resetting a password',\n body: 'Choose Forgot password on the sign-in page. The link we email works once and expires after an hour.',\n },\n {\n id: 'invite',\n title: 'Inviting teammates',\n body: 'Admins invite people from Members by email. Each invitation expires after seven days and can be resent.',\n },\n {\n id: 'roles',\n title: 'Roles and permissions',\n body: 'Owners manage billing and security, admins manage members and projects, and members work inside the projects they are added to.',\n },\n {\n id: 'remove-member',\n title: 'Removing someone from the workspace',\n body: 'An admin removes a member from Members. Their projects stay, reassigned to the admin, and their access ends immediately.',\n },\n {\n id: 'export-data',\n title: 'Exporting your data',\n body: 'Download every project as CSV or JSON from Settings, Data. Large workspaces receive an email with a download link when the archive is ready.',\n },\n {\n id: 'delete-account',\n title: 'Deleting a workspace',\n body: 'The owner deletes the workspace under Settings. Data is kept for 30 days in case of a mistake, then erased permanently.',\n },\n {\n id: 'api-keys',\n title: 'API keys',\n body: 'Create keys under Settings, API. A key is shown once; store it as a secret, and rotate it by creating a new key before revoking the old one.',\n },\n {\n id: 'rate-limits',\n title: 'API rate limits',\n body: 'Each key may make 600 requests a minute. A request over the limit receives status 429 with a Retry-After header saying when to try again.',\n },\n {\n id: 'webhooks',\n title: 'Webhooks',\n body: 'Subscribe a URL to events under Settings, Webhooks. Each delivery is signed with your secret; verify the signature before trusting the body.',\n },\n {\n id: 'uptime',\n title: 'Status and incidents',\n body: 'Live service status and past incidents are on the status page. Subscribe there to hear about outages and maintenance by email.',\n },\n {\n id: 'data-region',\n title: 'Where data is stored',\n body: 'Choose the EU or US region when creating a workspace. Data at rest stays in that region, and it cannot be moved later.',\n },\n {\n id: 'gdpr',\n title: 'Privacy and GDPR requests',\n body: 'Request a copy of personal data, or its deletion, from Profile, Privacy. We answer within 30 days, as the regulation requires.',\n },\n {\n id: 'dark-mode',\n title: 'Dark mode and themes',\n body: 'Switch between light and dark themes under Profile, Appearance, or follow the operating system setting automatically.',\n },\n {\n id: 'keyboard',\n title: 'Keyboard shortcuts',\n body: 'Press the question mark anywhere to list shortcuts. Command K opens the command menu to jump to any page or action.',\n },\n {\n id: 'notifications',\n title: 'Email notifications',\n body: 'Choose which updates arrive by email under Profile, Notifications, or pause them all for a while.',\n },\n {\n id: 'mobile',\n title: 'Using the mobile app',\n body: 'The iOS and Android apps sign in with the same account and receive push notifications for mentions and assignments.',\n },\n {\n id: 'import',\n title: 'Importing from a spreadsheet',\n body: 'Upload a CSV under Settings, Data, Import. Map each column to a field, preview the first rows, then run the import; errors are listed per row.',\n },\n]\n`\n}\n\nfunction cfSearchRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Search,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport type { SearchResult } from '../search'\n\nconst client = createClient(api)\nconst result = signal<SearchResult | null>(null)\nconst failure = signal<string | null>(null)\nconst indexing = signal(false)\n\nlet latest = 0\n\n/** Runs a search (Search debounces typing); an older answer never replaces a newer one. */\nfunction onQuery(q: string): void {\n const ticket = ++latest\n if (q.trim() === '') {\n result.value = null\n return\n }\n client\n .search({ body: { q } })\n .then((answer) => {\n if (ticket === latest) {\n result.value = answer\n failure.value = null\n }\n })\n .catch((error: unknown) => {\n if (ticket === latest)\n failure.value = error instanceof Error ? error.message : 'Search failed'\n })\n}\n\nasync function indexArticles(): Promise<void> {\n indexing.value = true\n failure.value = null\n try {\n const { indexed } = await client.indexArticles()\n result.value = result.value ? { ...result.value, indexed } : null\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Indexing failed'\n } finally {\n indexing.value = false\n }\n}\n\nexport default function SearchPage() {\n useSignals()\n const current = result.value\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Search</Heading>\n <Text muted>\n Help articles, searched by meaning once deployed: ask “how do I get my money back” and\n Refunds comes first, though it shares no words with the question. vite dev searches by\n keyword instead.\n </Text>\n </Flex>\n <Search label=\"Search the help articles\" placeholder=\"Ask a question\" onSearch={onQuery} />\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Search failed\">\n {failure.value}\n </Alert>\n ) : null}\n {current ? (\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={current.mode === 'semantic' ? 'success' : 'warning'}>\n {current.mode === 'semantic'\n ? 'By meaning (Vectorize)'\n : 'By keyword (vite dev stand-in)'}\n </Badge>\n {current.mode === 'semantic' ? (\n <>\n <Text size=\"sm\" muted>\n {current.indexed ?? 0} articles indexed\n </Text>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n loading={indexing.value}\n onClick={() => void indexArticles()}\n >\n Index articles\n </Button>\n </>\n ) : null}\n </Flex>\n ) : null}\n {current && current.hits.length === 0 ? (\n <EmptyState\n title=\"Nothing found\"\n description={\n current.mode === 'semantic' && !current.indexed\n ? 'The index is empty: press Index articles, then search again in a few seconds.'\n : 'Try other words.'\n }\n />\n ) : null}\n {current?.hits.map((hit) => (\n <Card key={hit.id}>\n <CardContent>\n <Flex gap={1}>\n <Text weight=\"semibold\">{hit.title}</Text>\n <Text size=\"sm\" muted>\n {hit.body}\n </Text>\n </Flex>\n </CardContent>\n </Card>\n ))}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example digest`: the report, rendered and emailed on a Cron Trigger --- */\n\nfunction cfDigestTs(): string {\n return `/**\n * The weekly digest's runs, shared by the Worker (which records each one) and the /digest page\n * (which lists them). A run happens on the Cron Trigger in wrangler.jsonc, or from \"Send now\".\n */\nexport interface DigestRun {\n id: string\n startedAt: string\n /** \\`sent\\`, \\`skipped\\` (not configured yet: see \\`detail\\`) or \\`failed\\`. */\n status: 'sent' | 'skipped' | 'failed'\n /** Who it went to, why it was skipped, or what failed. */\n detail: string\n /** \\`cron\\` or \\`manual\\`. */\n trigger: 'cron' | 'manual'\n}\n\nconst STATUSES = ['sent', 'skipped', 'failed']\nconst TRIGGERS = ['cron', 'manual']\n\nexport function parseDigestRun(raw: unknown): DigestRun {\n if (typeof raw === 'object' && raw !== null) {\n const { id, startedAt, status, detail, trigger } = raw as Record<string, unknown>\n if (\n typeof id === 'string' &&\n typeof startedAt === 'string' &&\n typeof status === 'string' &&\n STATUSES.includes(status) &&\n typeof detail === 'string' &&\n typeof trigger === 'string' &&\n TRIGGERS.includes(trigger)\n ) {\n // Both checked against their lists just above.\n return {\n id,\n startedAt,\n status: status as DigestRun['status'],\n detail,\n trigger: trigger as DigestRun['trigger'],\n }\n }\n }\n throw new Error('Malformed digest run')\n}\n\nexport function parseDigestRuns(raw: unknown): DigestRun[] {\n if (!Array.isArray(raw)) throw new Error('Expected a list of runs')\n return raw.map(parseDigestRun)\n}\n`\n}\n\nfunction cfDigestWorkerTs(): string {\n return `import { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { exportPage } from '@cascivo/app/export'\nimport type { ExportBrowser } from '@cascivo/app/export'\nimport { parseDigestRun } from '../src/digest'\nimport type { DigestRun } from '../src/digest'\n\nconst migrations = [\n {\n id: '0001_digest_runs',\n statements: [\n \\`CREATE TABLE digest_runs (\n id TEXT PRIMARY KEY,\n started_at TEXT NOT NULL,\n status TEXT NOT NULL,\n detail TEXT NOT NULL,\n trigger TEXT NOT NULL\n )\\`,\n ],\n },\n]\n\n/** What sending the digest needs of the Email Service binding (\\`send_email\\`). */\nexport interface DigestSender {\n send(message: {\n from: string\n to: string[]\n subject: string\n text: string\n html: string\n attachments: {\n filename: string\n type: string\n content: Uint8Array\n disposition: 'attachment'\n }[]\n }): Promise<unknown>\n}\n\nexport interface DigestEnv {\n DB: Database\n EMAIL: DigestSender\n /** Comma-separated recipients, set in wrangler.jsonc. */\n DIGEST_TO: string\n DIGEST_FROM: string\n /** The deployed app's origin, which the browser opens: a cron has no request to read it from. */\n APP_URL: string\n}\n\n/** Why the digest cannot be sent yet, or \\`null\\` when it can. */\nfunction missing(env: DigestEnv, origin: string): string | null {\n const unset = [\n ...(env.DIGEST_TO.trim() ? [] : ['DIGEST_TO']),\n ...(env.DIGEST_FROM.trim() ? [] : ['DIGEST_FROM']),\n ...(origin ? [] : ['APP_URL']),\n ]\n return unset.length > 0 ? \\`Set \\${unset.join(', ')} in wrangler.jsonc\\` : null\n}\n\n/**\n * Renders /report to a PDF with Browser Run and emails it to DIGEST_TO. Every run is recorded,\n * whatever happens, so the /digest page shows why a Monday's digest did not arrive.\n */\nexport async function runDigest(\n env: DigestEnv,\n launch: () => Promise<ExportBrowser>,\n trigger: DigestRun['trigger'],\n origin = env.APP_URL,\n): Promise<DigestRun> {\n const run = { id: crypto.randomUUID(), startedAt: new Date().toISOString(), trigger }\n let result: DigestRun\n const skip = missing(env, origin)\n if (skip) {\n result = { ...run, status: 'skipped', detail: skip }\n } else {\n try {\n const pdf = await exportPage(launch, new URL('/report', origin).href, { format: 'pdf' })\n const to = env.DIGEST_TO.split(',')\n .map((address) => address.trim())\n .filter(Boolean)\n const week = run.startedAt.slice(0, 10)\n await env.EMAIL.send({\n from: env.DIGEST_FROM,\n to,\n subject: \\`Weekly report, \\${week}\\`,\n text: 'This week’s report is attached as a PDF.',\n html: '<p>This week’s report is attached as a PDF.</p>',\n attachments: [\n {\n filename: \\`report-\\${week}.pdf\\`,\n type: 'application/pdf',\n content: pdf,\n disposition: 'attachment',\n },\n ],\n })\n result = {\n ...run,\n status: 'sent',\n detail: \\`\\${to.join(', ')} (\\${Math.round(pdf.byteLength / 1024)} KB)\\`,\n }\n } catch (error) {\n console.error('[digest] failed:', error)\n result = {\n ...run,\n status: 'failed',\n detail: error instanceof Error ? error.message : String(error),\n }\n }\n }\n await migrate(env.DB, migrations)\n await env.DB.prepare(\n 'INSERT INTO digest_runs (id, started_at, status, detail, trigger) VALUES (?, ?, ?, ?, ?)',\n )\n .bind(result.id, result.startedAt, result.status, result.detail.slice(0, 500), result.trigger)\n .run()\n return result\n}\n\nexport async function listRuns(db: Database): Promise<DigestRun[]> {\n await migrate(db, migrations)\n return queryRows(\n db,\n \\`SELECT id, started_at AS startedAt, status, detail, trigger FROM digest_runs\n ORDER BY started_at DESC LIMIT 20\\`,\n [],\n parseDigestRun,\n )\n}\n`\n}\n\nfunction cfDigestRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Badge,\n Button,\n EmptyState,\n Flex,\n Heading,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport type { DigestRun } from '../digest'\n\nconst client = createClient(api)\nconst runs = signal<DigestRun[]>([])\nconst sending = signal(false)\nconst failure = signal<string | null>(null)\n\nasync function load(): Promise<void> {\n runs.value = await client.digestRuns().catch(() => runs.peek())\n}\nvoid load()\n\nasync function sendNow(): Promise<void> {\n sending.value = true\n failure.value = null\n try {\n await client.runDigest()\n await load()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not send'\n } finally {\n sending.value = false\n }\n}\n\nconst VARIANT = { sent: 'success', skipped: 'warning', failed: 'destructive' } as const\n\nexport default function Digest() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Weekly digest</Heading>\n <Text muted>\n Every Monday at 08:00 UTC, the Worker renders /report to a PDF and emails it. Each run is\n listed here, including the ones that could not send.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\">\n <Button loading={sending.value} onClick={() => void sendNow()}>\n Send now\n </Button>\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not sent\">\n {failure.value}\n </Alert>\n ) : null}\n {runs.value.length === 0 ? (\n <EmptyState\n title=\"No runs yet\"\n description=\"The first runs on Monday, or press Send now.\"\n />\n ) : (\n <Flex gap={2}>\n {runs.value.map((run) => (\n <Flex key={run.id} direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={VARIANT[run.status]}>{run.status}</Badge>\n <Text>{run.detail}</Text>\n <Text size=\"sm\" muted>\n {new Date(run.startedAt).toLocaleString()} · {run.trigger}\n </Text>\n </Flex>\n ))}\n </Flex>\n )}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example webhooks`: signed deliveries, stored once, pushed live --- */\n\nfunction cfWebhooksTs(): string {\n return `/**\n * Webhook deliveries, shared by the Worker (which verifies and stores them) and the\n * /webhooks page (which lists them as they arrive).\n */\nexport interface Delivery {\n id: string\n /** The sender's event name, e.g. GitHub's \\`push\\` or \\`issues\\`. */\n event: string\n /** One line about what happened, taken from the payload. */\n summary: string\n receivedAt: string\n}\n\n/** The live room the page watches; the Worker writes each new delivery to \\`latest\\` in it. */\nexport const DELIVERIES_ROOM = 'webhooks'\n\nexport function parseDelivery(raw: unknown): Delivery {\n if (typeof raw === 'object' && raw !== null) {\n const { id, event, summary, receivedAt } = raw as Record<string, unknown>\n if (\n typeof id === 'string' &&\n typeof event === 'string' &&\n typeof summary === 'string' &&\n typeof receivedAt === 'string'\n ) {\n return { id, event, summary, receivedAt }\n }\n }\n throw new Error('Malformed delivery')\n}\n\nexport function parseDeliveries(raw: unknown): Delivery[] {\n if (!Array.isArray(raw)) throw new Error('Expected a list of deliveries')\n return raw.map(parseDelivery)\n}\n\nexport function parseTestResult(raw: unknown): { status: number } {\n if (typeof raw === 'object' && raw !== null) {\n const { status } = raw as Record<string, unknown>\n if (typeof status === 'number') return { status }\n }\n throw new Error('Malformed reply')\n}\n`\n}\n\nfunction cfWebhooksWorkerTs(): string {\n return `import { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { verifyWebhook } from '@cascivo/app/guard'\nimport { writeRoom } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\nimport { DELIVERIES_ROOM, parseDelivery } from '../src/webhooks'\nimport type { Delivery } from '../src/webhooks'\n\nconst migrations = [\n {\n id: '0001_webhook_deliveries',\n statements: [\n \\`CREATE TABLE webhook_deliveries (\n id TEXT PRIMARY KEY,\n event TEXT NOT NULL,\n summary TEXT NOT NULL,\n received_at TEXT NOT NULL\n )\\`,\n ],\n },\n]\n\n/** Where GitHub posts; set it as the Payload URL of the repository's webhook. */\nexport const GITHUB_WEBHOOK_PATH = '/api/webhooks/github'\n\n/** A field of an object, or \\`undefined\\` for anything else. */\nconst field = (value: unknown, key: string): unknown =>\n typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined\n\n/**\n * One line about a GitHub event. The payload is the sender's JSON, read field by field: a\n * signature proves who sent it, not what shape it has.\n */\nfunction summarize(event: string, payload: unknown): string {\n const repo = field(field(payload, 'repository'), 'full_name')\n const action = field(payload, 'action')\n const where = typeof repo === 'string' ? repo : 'a repository'\n return \\`\\${event}\\${typeof action === 'string' ? \\` \\${action}\\` : ''} in \\${where}\\`.slice(0, 200)\n}\n\n/**\n * Verifies a GitHub delivery, stores it once (a retry has the same X-GitHub-Delivery id and is\n * ignored), and pushes it to the /webhooks page. Answers 202 quickly, as GitHub expects.\n */\nexport async function receiveGithub(\n request: Request,\n env: { DB: Database; ROOMS: RoomNamespace<unknown>; WEBHOOK_SECRET: string },\n): Promise<Response> {\n const { body, id } = await verifyWebhook(request, {\n scheme: 'github',\n secret: env.WEBHOOK_SECRET,\n })\n const event = request.headers.get('x-github-event') ?? 'unknown'\n let payload: unknown\n try {\n payload = JSON.parse(body)\n } catch {\n return Response.json({ error: 'Expected a JSON payload' }, { status: 400 })\n }\n const delivery: Delivery = {\n id: id ?? crypto.randomUUID(),\n event: event.slice(0, 60),\n summary: summarize(event, payload),\n receivedAt: new Date().toISOString(),\n }\n await migrate(env.DB, migrations)\n const stored = await queryRows(\n env.DB,\n \\`INSERT INTO webhook_deliveries (id, event, summary, received_at) VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO NOTHING RETURNING id\\`,\n [delivery.id, delivery.event, delivery.summary, delivery.receivedAt],\n (row) => row,\n )\n // Only a first delivery reaches the page; a retry was stored already.\n if (stored.length > 0) await writeRoom(env.ROOMS, DELIVERIES_ROOM, 'latest', { ...delivery })\n return Response.json({ received: true }, { status: 202 })\n}\n\nexport async function listDeliveries(db: Database): Promise<Delivery[]> {\n await migrate(db, migrations)\n return queryRows(\n db,\n \\`SELECT id, event, summary, received_at AS receivedAt FROM webhook_deliveries\n ORDER BY received_at DESC LIMIT 50\\`,\n [],\n parseDelivery,\n )\n}\n\n/** A signed test delivery, run through the same path GitHub's would take. */\nexport async function sendTestDelivery(\n origin: string,\n env: { DB: Database; ROOMS: RoomNamespace<unknown>; WEBHOOK_SECRET: string },\n): Promise<{ status: number }> {\n const body = JSON.stringify({ zen: 'Keep it logically awesome.', hook_id: 1 })\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(env.WEBHOOK_SECRET),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)))\n const hex = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('')\n const response = await receiveGithub(\n new Request(new URL(GITHUB_WEBHOOK_PATH, origin), {\n method: 'POST',\n headers: {\n 'x-github-event': 'ping',\n 'x-github-delivery': crypto.randomUUID(),\n 'x-hub-signature-256': \\`sha256=\\${hex}\\`,\n },\n body,\n }),\n env,\n )\n return { status: response.status }\n}\n`\n}\n\nfunction cfWebhooksRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { connectRoom } from '@cascivo/app/sync'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Kbd,\n Text,\n computed,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { parseDelivery } from '../webhooks'\nimport type { Delivery } from '../webhooks'\n\nconst client = createClient(api)\nconst stored = signal<Delivery[]>([])\nconst failure = signal<string | null>(null)\n\n// The Worker writes each new delivery here; the browser may only watch.\nconst room = connectRoom('/api/webhooks/live')\nconst latest = room.signal<Delivery | null>('latest', null, (raw) =>\n raw === null ? null : parseDelivery(raw),\n)\n/** Stored deliveries, with the newest pushed one on top when it is not in the list yet. */\nconst deliveries = computed(() => {\n const pushed = latest.value\n return pushed && !stored.value.some((d) => d.id === pushed.id)\n ? [pushed, ...stored.value]\n : stored.value\n})\n\nasync function load(): Promise<void> {\n stored.value = await client.listDeliveries().catch(() => stored.peek())\n}\nvoid load()\n\nasync function sendTest(): Promise<void> {\n failure.value = null\n try {\n await client.sendTestDelivery()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'The test delivery failed'\n }\n}\n\nexport default function Webhooks() {\n useSignals()\n const url = \\`\\${location.origin}/api/webhooks/github\\`\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Webhooks</Heading>\n <Text muted>\n GitHub deliveries, checked against their signature, stored once each, and shown here as\n they arrive.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={2}>\n <Text>\n In the repository's settings, add a webhook with the payload URL <Kbd>{url}</Kbd>,\n content type application/json, and the secret you set as WEBHOOK_SECRET.\n </Text>\n <Flex direction=\"horizontal\">\n <Button variant=\"secondary\" onClick={() => void sendTest()}>\n Send a test delivery\n </Button>\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not delivered\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n </CardContent>\n </Card>\n {deliveries.value.length === 0 ? (\n <EmptyState\n title=\"No deliveries yet\"\n description=\"Send a test delivery, or push to the repository.\"\n />\n ) : (\n <Flex gap={2} role=\"log\" aria-label=\"Deliveries\">\n {deliveries.value.map((delivery) => (\n <Flex key={delivery.id} direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant=\"secondary\">{delivery.event}</Badge>\n <Text>{delivery.summary}</Text>\n <Text size=\"sm\" muted>\n {new Date(delivery.receivedAt).toLocaleTimeString()}\n </Text>\n </Flex>\n ))}\n </Flex>\n )}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example checkout`: Stripe Checkout, its webhook, an order pushed live, a receipt --- */\n\nfunction cfCheckoutTs(opts: ScaffoldOptions): string {\n return `/**\n * What /checkout sells and the orders it makes, shared by the Worker (which creates them and\n * hears from Stripe) and the pages (which show them).\n */\n\n/** The seller, as the receipt names it. */\nexport const SHOP_NAME = '${brandName(opts.name).replace(/'/g, \"\\\\'\")}'\n\n/**\n * What you sell. The Worker sends this to Stripe, so a browser cannot change the price; the\n * page only displays it.\n */\nexport const PRODUCT = {\n name: 'Sticker pack',\n description: 'Twelve vinyl stickers of your favourite components, shipped worldwide.',\n /** In the currency's smallest unit: 900 is €9.00. */\n amount: 900,\n /** Three-letter ISO code, lowercase. */\n currency: 'eur',\n}\n\n/**\n * \\`pending\\` until Stripe confirms the payment. \\`failed\\` and \\`expired\\` are final. A paid\n * order becomes \\`refunded\\` when all of it is refunded, and \\`disputed\\` while a chargeback is\n * open (back to \\`paid\\` if it is won).\n */\nexport type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'disputed'\n\nexport interface Order {\n id: string\n status: OrderStatus\n /** What Stripe charged, in the currency's smallest unit. */\n amount: number\n currency: string\n /** Refunded so far, in the same unit: part of a paid order, or all of a refunded one. */\n refundedAmount: number\n createdAt: string\n paidAt: string | null\n}\n\n/** An order id: a UUID the Worker made. */\nexport const ORDER_ID = /^[0-9a-f-]{36}$/\n\n/** The room the Worker pushes an order's changes to; its page watches it. */\nexport const orderRoom = (id: string) => \\`order-\\${id}\\`\n\n/** A price in the currency's smallest unit, for people: 900 eur is €9.00, 900 jpy is ¥900. */\nexport function formatPrice(amount: number, currency: string): string {\n const format = new Intl.NumberFormat(undefined, { style: 'currency', currency })\n const digits = format.resolvedOptions().maximumFractionDigits ?? 2\n return format.format(amount / 10 ** digits)\n}\n\nconst STATUSES = ['pending', 'paid', 'failed', 'expired', 'refunded', 'disputed']\n\nexport function parseOrder(raw: unknown): Order {\n if (typeof raw === 'object' && raw !== null) {\n const { id, status, amount, currency, refundedAmount, createdAt, paidAt } = raw as Record<\n string,\n unknown\n >\n if (\n typeof id === 'string' &&\n typeof status === 'string' &&\n STATUSES.includes(status) &&\n typeof amount === 'number' &&\n typeof currency === 'string' &&\n typeof refundedAmount === 'number' &&\n typeof createdAt === 'string' &&\n (paidAt === null || typeof paidAt === 'string')\n ) {\n return {\n id,\n // Checked against STATUSES just above.\n status: status as OrderStatus,\n amount,\n currency,\n refundedAmount,\n createdAt,\n paidAt,\n }\n }\n }\n throw new Error('Malformed order')\n}\n\nexport function parseCheckoutStarted(raw: unknown): { url: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { url } = raw as Record<string, unknown>\n if (typeof url === 'string' && url.startsWith('https://')) return { url }\n }\n throw new Error('Malformed checkout')\n}\n`\n}\n\nfunction cfCheckoutWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { verifyWebhook } from '@cascivo/app/guard'\nimport { StripeError, createStripe, parseStripeEvent } from '@cascivo/app/stripe'\nimport type {\n Charge,\n CheckoutEventType,\n CheckoutSession,\n Dispute,\n DisputeEventType,\n Invoice,\n Stripe,\n} from '@cascivo/app/stripe'\nimport { writeRoom } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\nimport { Receipt, receiptSubject, renderEmail } from '@cascivo/email'\nimport { createElement } from 'react'\nimport { ORDER_ID, PRODUCT, SHOP_NAME, formatPrice, orderRoom, parseOrder } from '../src/checkout'\nimport type { Order, OrderStatus } from '../src/checkout'\n\nconst migrations = [\n {\n id: '0001_orders',\n statements: [\n \\`CREATE TABLE orders (\n id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL UNIQUE,\n status TEXT NOT NULL,\n amount INTEGER NOT NULL,\n currency TEXT NOT NULL,\n email TEXT,\n created_at TEXT NOT NULL,\n paid_at TEXT,\n payment_intent_id TEXT,\n refunded_amount INTEGER NOT NULL DEFAULT 0,\n dispute_status TEXT\n )\\`,\n // Refund and dispute events name the payment, not the session.\n 'CREATE INDEX orders_payment_intent ON orders (payment_intent_id)',\n ],\n },\n]\n\n/** Where Stripe posts; add it as an endpoint in the Stripe dashboard (README). */\nexport const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'\n\n/** What sending a receipt needs of the Email Service binding (\\`send_email\\`). */\nexport interface ReceiptSender {\n send(message: {\n from: string\n to: string\n subject: string\n text: string\n html: string\n }): Promise<unknown>\n}\n\nexport interface CheckoutEnv {\n DB: Database\n ROOMS: RoomNamespace<unknown>\n EMAIL: ReceiptSender\n RECEIPT_FROM: string\n STRIPE_SECRET_KEY?: string\n STRIPE_WEBHOOK_SECRET?: string\n}\n\nconst COLUMNS =\n 'id, status, amount, currency, refunded_amount AS refundedAmount, created_at AS createdAt, paid_at AS paidAt'\n\nexport function stripeOf(env: { STRIPE_SECRET_KEY?: string }): Stripe {\n if (!env.STRIPE_SECRET_KEY) {\n throw new HttpError(\n 503,\n 'Set STRIPE_SECRET_KEY to a test key from the Stripe dashboard (README)',\n )\n }\n return createStripe(env.STRIPE_SECRET_KEY)\n}\n\n/** Stripe's refusal, for the page: its message in vite dev, a pointer to the log deployed. */\nexport function refused(error: unknown): never {\n if (!(error instanceof StripeError)) throw error\n console.error('[checkout] Stripe refused:', error.status, error.code, error.message)\n throw new HttpError(\n 502,\n import.meta.env.DEV\n ? \\`Stripe: \\${error.message}\\`\n : 'The payment provider refused the request (see the Worker log)',\n )\n}\n\n/**\n * Creates a Stripe Checkout Session for PRODUCT and records the order as pending. The order id\n * is also the idempotency key, so a retried request cannot open a second session.\n */\nexport async function startCheckout(env: CheckoutEnv, origin: string): Promise<{ url: string }> {\n const stripe = stripeOf(env)\n const id = crypto.randomUUID()\n let session: CheckoutSession\n try {\n session = await stripe.createCheckoutSession(\n {\n mode: 'payment',\n lineItems: [{ ...PRODUCT, quantity: 1 }],\n successUrl: \\`\\${origin}/checkout/\\${id}\\`,\n cancelUrl: \\`\\${origin}/checkout\\`,\n clientReferenceId: id,\n },\n { idempotencyKey: id },\n )\n } catch (error) {\n refused(error)\n }\n if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')\n await migrate(env.DB, migrations)\n await queryRows(\n env.DB,\n \\`INSERT INTO orders (id, session_id, status, amount, currency, created_at)\n VALUES (?, ?, 'pending', ?, ?, ?)\\`,\n [id, session.id, PRODUCT.amount, PRODUCT.currency, new Date().toISOString()],\n (row) => row,\n )\n return { url: session.url }\n}\n\n/** Where an event moves the order, or \\`null\\` when it does not move it. */\nfunction statusAfter(type: CheckoutEventType, session: CheckoutSession): OrderStatus | null {\n switch (type) {\n case 'checkout.session.completed':\n // \\`unpaid\\` here is a bank debit that has not settled: the async events decide it.\n return session.paymentStatus === 'unpaid' ? null : 'paid'\n case 'checkout.session.async_payment_succeeded':\n return 'paid'\n case 'checkout.session.async_payment_failed':\n return 'failed'\n case 'checkout.session.expired':\n return 'expired'\n }\n}\n\n/** The same decision from a session read back from Stripe, with no event to go by. */\nfunction statusOf(session: CheckoutSession): OrderStatus | null {\n if (session.status === 'complete' && session.paymentStatus !== 'unpaid') return 'paid'\n return session.status === 'expired' ? 'expired' : null\n}\n\n/**\n * Moves a pending order to its final status, once: a retried event, or the order page reading\n * the session before the webhook arrived, finds it settled and changes nothing. The order is\n * found by Stripe's session id, never by \\`client_reference_id\\`, which a buyer can set on a\n * Payment Link. Then the order's page hears about it, and a paid order gets its receipt.\n */\nasync function settle(\n env: CheckoutEnv,\n session: CheckoutSession,\n status: OrderStatus,\n origin: string,\n): Promise<void> {\n await migrate(env.DB, migrations)\n const [order] = await queryRows(\n env.DB,\n \\`UPDATE orders SET status = ?, paid_at = ?, amount = COALESCE(?, amount),\n currency = COALESCE(?, currency), email = ?, payment_intent_id = ?\n WHERE session_id = ? AND status = 'pending' RETURNING \\${COLUMNS}\\`,\n [\n status,\n status === 'paid' ? new Date().toISOString() : null,\n session.amountTotal,\n session.currency,\n session.customerEmail,\n session.paymentIntentId,\n session.id,\n ],\n parseOrder,\n )\n if (!order) return\n await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })\n if (order.status === 'paid' && session.customerEmail) {\n await sendReceipt(env, order, session.customerEmail, origin)\n }\n}\n\n/**\n * Emails a receipt rendered with @cascivo/email. \\`vite dev\\` renders it and logs it instead.\n * A receipt that cannot be sent is logged, not thrown: the payment is recorded either way, and\n * Stripe retrying the event would not send it again.\n */\nasync function sendReceipt(env: CheckoutEnv, order: Order, to: string, origin: string) {\n const total = formatPrice(order.amount, order.currency)\n const props = {\n productName: SHOP_NAME,\n orderId: order.id.slice(0, 8).toUpperCase(),\n items: [{ description: PRODUCT.name, amount: total }],\n total,\n invoiceHref: \\`\\${origin}/checkout/\\${order.id}\\`,\n }\n const message = renderEmail(createElement(Receipt, props), { subject: receiptSubject(props) })\n if (import.meta.env.DEV) {\n console.log(\\`[checkout] receipt for \\${to}: \"\\${message.subject}\" (\\${message.html.length} bytes)\\`)\n return\n }\n if (!env.RECEIPT_FROM) {\n console.warn('[checkout] no receipt sent: set RECEIPT_FROM in wrangler.jsonc')\n return\n }\n try {\n await env.EMAIL.send({\n from: env.RECEIPT_FROM,\n to,\n subject: message.subject,\n text: message.text,\n html: message.html,\n })\n } catch (error) {\n console.error('[checkout] receipt not sent:', error)\n }\n}\n\n/**\n * A refund, made in the Stripe dashboard or with \\`createRefund\\`. \\`charge.refunded\\` carries the\n * running total, so a retried or late event cannot count a refund twice. All of it refunded\n * makes the order \\`refunded\\`; part of it leaves it \\`paid\\`, with the amount shown.\n */\nasync function refundOrder(env: CheckoutEnv, charge: Charge): Promise<void> {\n if (!charge.paymentIntentId) return\n await migrate(env.DB, migrations)\n const [order] = await queryRows(\n env.DB,\n \\`UPDATE orders SET refunded_amount = MAX(refunded_amount, ?),\n status = CASE WHEN ? = 1 THEN 'refunded' ELSE status END\n WHERE payment_intent_id = ? AND status IN ('paid', 'refunded') RETURNING \\${COLUMNS}\\`,\n [charge.amountRefunded, charge.refunded ? 1 : 0, charge.paymentIntentId],\n parseOrder,\n )\n if (order) await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })\n}\n\n/**\n * A chargeback. While it is open the order is \\`disputed\\`: hold back anything not yet\n * delivered, and answer it with evidence in the Stripe dashboard before the deadline shown\n * there. Won, the order is \\`paid\\` again; lost, it stays \\`disputed\\`. The dispute's status is\n * stored, so a \\`created\\` event arriving after \\`closed\\` cannot reopen it.\n */\nasync function disputeOrder(\n env: CheckoutEnv,\n type: DisputeEventType,\n dispute: Dispute,\n): Promise<void> {\n if (!dispute.paymentIntentId) return\n await migrate(env.DB, migrations)\n const [order] =\n type === 'charge.dispute.created'\n ? await queryRows(\n env.DB,\n \\`UPDATE orders SET status = 'disputed', dispute_status = ?\n WHERE payment_intent_id = ? AND status = 'paid' AND dispute_status IS NULL\n RETURNING \\${COLUMNS}\\`,\n [dispute.status, dispute.paymentIntentId],\n parseOrder,\n )\n : await queryRows(\n env.DB,\n \\`UPDATE orders SET dispute_status = ?,\n status = CASE WHEN ? = 'won' THEN 'paid' ELSE 'disputed' END\n WHERE payment_intent_id = ? AND status IN ('paid', 'disputed') RETURNING \\${COLUMNS}\\`,\n [dispute.status, dispute.status, dispute.paymentIntentId],\n parseOrder,\n )\n if (!order) return\n if (type === 'charge.dispute.created') {\n console.warn(\n \\`[checkout] order \\${order.id} is disputed (\\${dispute.reason ?? 'no reason given'})\\`,\n )\n }\n await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })\n}\n\n/** What the webhook hands to worker/billing.ts, when the app bills subscriptions. */\nexport interface BillingHooks {\n /** A subscription changed: store its current state. */\n subscription(subscriptionId: string): Promise<void>\n /** A renewal could not be charged: tell the customer. */\n paymentFailed(invoice: Invoice, origin: string): Promise<void>\n}\n\n/**\n * Stripe's webhook: verified against STRIPE_WEBHOOK_SECRET before anything in it is read,\n * then each Checkout event settles its order, and refunds and disputes update it. Subscription\n * and invoice events, and completed subscription checkouts, go to \\`billing\\` when the app bills\n * subscriptions (worker/billing.ts). Other events are acknowledged, so Stripe stops sending them.\n */\nexport async function receiveStripe(\n request: Request,\n env: CheckoutEnv,\n billing?: BillingHooks,\n): Promise<Response> {\n if (!env.STRIPE_WEBHOOK_SECRET) throw new HttpError(503, 'Set STRIPE_WEBHOOK_SECRET (README)')\n const { body } = await verifyWebhook(request, {\n scheme: 'stripe',\n secret: env.STRIPE_WEBHOOK_SECRET,\n })\n const event = parseStripeEvent(body)\n const origin = new URL(request.url).origin\n if (event.kind === 'subscription') await billing?.subscription(event.subscription.id)\n if (event.kind === 'invoice' && event.type === 'invoice.payment_failed') {\n await billing?.paymentFailed(event.invoice, origin)\n }\n if (event.kind === 'refund') await refundOrder(env, event.charge)\n if (event.kind === 'dispute') await disputeOrder(env, event.type, event.dispute)\n if (event.kind === 'checkout' && event.session.mode === 'subscription') {\n if (event.session.subscriptionId) await billing?.subscription(event.session.subscriptionId)\n } else if (event.kind === 'checkout') {\n const status = statusAfter(event.type, event.session)\n if (status) await settle(env, event.session, status, origin)\n }\n return Response.json({ received: true })\n}\n\nfunction parseStored(raw: unknown): { order: Order; sessionId: string } {\n const sessionId = typeof raw === 'object' && raw !== null ? Reflect.get(raw, 'sessionId') : null\n if (typeof sessionId !== 'string') throw new Error('Malformed order row')\n return { order: parseOrder(raw), sessionId }\n}\n\nasync function readOrder(env: CheckoutEnv, id: string) {\n await migrate(env.DB, migrations)\n const [row] = await queryRows(\n env.DB,\n \\`SELECT \\${COLUMNS}, session_id AS sessionId FROM orders WHERE id = ?\\`,\n [id],\n parseStored,\n )\n if (!row) throw new HttpError(404, 'No such order')\n return row\n}\n\n/**\n * An order, for its page. A pending one is checked with Stripe first, so the page is right\n * even when the webhook is late or not set up yet (as in \\`vite dev\\` without \\`stripe listen\\`).\n */\nexport async function getOrder(env: CheckoutEnv, id: string, origin: string): Promise<Order> {\n if (!ORDER_ID.test(id)) throw new HttpError(404, 'No such order')\n const { order, sessionId } = await readOrder(env, id)\n if (order.status !== 'pending' || !env.STRIPE_SECRET_KEY) return order\n try {\n const session = await stripeOf(env).retrieveCheckoutSession(sessionId)\n const status = statusOf(session)\n if (!status) return order\n await settle(env, session, status, origin)\n } catch (error) {\n // Stripe unreachable: show what is known; the webhook settles the order later.\n console.error('[checkout] could not read the session back:', error)\n return order\n }\n return (await readOrder(env, id)).order\n}\n`\n}\n\nfunction cfCheckoutRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { PRODUCT, formatPrice } from '../checkout'\n\nconst client = createClient(api)\nconst starting = signal(false)\nconst failure = signal<string | null>(null)\n\n/** Asks the Worker for a Stripe Checkout page and goes there: Stripe takes the card, not us. */\nasync function buy(): Promise<void> {\n starting.value = true\n failure.value = null\n try {\n const { url } = await client.startCheckout()\n location.assign(url)\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not start the checkout'\n starting.value = false\n }\n}\n\nexport default function Checkout() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Checkout</Heading>\n <Text muted>\n Paid on Stripe's hosted page. Stripe tells the Worker when the payment succeeds; the\n Worker records the order, updates its page and emails a receipt.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Flex gap={1}>\n <Heading level={2}>{PRODUCT.name}</Heading>\n <Text muted>{PRODUCT.description}</Text>\n </Flex>\n <Text size=\"lg\">{formatPrice(PRODUCT.amount, PRODUCT.currency)}</Text>\n <Flex direction=\"horizontal\">\n <Button loading={starting.value} onClick={() => void buy()}>\n Buy now\n </Button>\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"The checkout did not start\">\n {failure.value}\n </Alert>\n ) : null}\n <Text size=\"sm\" muted>\n In test mode, pay with the card 4242 4242 4242 4242, any future date and any CVC.\n </Text>\n </Flex>\n )\n}\n`\n}\n\nfunction cfOrderRouteTsx(): string {\n return `import type { RouteProps } from '@cascivo/app'\nimport { createClient } from '@cascivo/app/api'\nimport { connectRoom } from '@cascivo/app/sync'\nimport {\n Alert,\n Badge,\n EmptyState,\n Flex,\n Heading,\n Link,\n Spinner,\n Text,\n signal,\n useEffectPropSignal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../../api'\nimport { PRODUCT, formatPrice, parseOrder } from '../../checkout'\nimport type { Order } from '../../checkout'\n\nconst client = createClient(api)\n/** Orders seen, by id; \\`null\\` when the id has none. */\nconst orders = signal<Readonly<Record<string, Order | null>>>({})\n\n/** Keeps the newest word on an order: a final status is never replaced by \\`pending\\`. */\nfunction remember(id: string, order: Order | null): void {\n const known = orders.peek()[id]\n if (known && known.status !== 'pending' && order?.status === 'pending') return\n orders.value = { ...orders.peek(), [id]: order }\n}\n\nasync function load(id: string): Promise<void> {\n try {\n remember(id, await client.getOrder({ params: { id } }))\n } catch {\n remember(id, null)\n }\n}\n\n/** Watches the order's room, where the Worker pushes what Stripe reports. Returns the cleanup. */\nfunction watch(id: string): () => void {\n const room = connectRoom(\\`/api/orders/\\${id}/live\\`)\n const pushed = room.signal<Order | null>('order', null, (raw) =>\n raw === null ? null : parseOrder(raw),\n )\n const stop = pushed.signal.subscribe((order) => {\n if (order) remember(id, order)\n })\n return () => {\n stop()\n room.close()\n }\n}\n\nconst STATUS = {\n pending: { variant: 'warning', label: 'Waiting for Stripe' },\n paid: { variant: 'success', label: 'Paid' },\n failed: { variant: 'destructive', label: 'Payment failed' },\n expired: { variant: 'secondary', label: 'Expired' },\n refunded: { variant: 'secondary', label: 'Refunded' },\n disputed: { variant: 'destructive', label: 'Disputed' },\n} as const\n\n/** \\`/checkout/:order\\` — where Stripe sends the buyer back. It updates when Stripe confirms. */\nexport default function OrderPage({ params }: RouteProps<'/checkout/:order'>) {\n useSignals()\n const id = useEffectPropSignal(params.order)\n useSignalEffect(() => {\n void load(id.value)\n return watch(id.value)\n })\n const order = orders.value[params.order]\n\n if (order === undefined) return <Spinner label=\"Loading\" />\n if (order === null) {\n return <EmptyState title=\"No such order\" description=\"Check the link in your receipt.\" />\n }\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Your order</Heading>\n <Text muted>\n {PRODUCT.name} · {formatPrice(order.amount, order.currency)}\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={STATUS[order.status].variant}>{STATUS[order.status].label}</Badge>\n <Text size=\"sm\" muted>\n Ordered {new Date(order.createdAt).toLocaleString()}\n </Text>\n </Flex>\n {order.status === 'pending' ? (\n <Alert variant=\"info\" title=\"Confirming your payment\">\n This page updates by itself when Stripe confirms. A bank payment can take a few days.\n </Alert>\n ) : null}\n {order.status === 'paid' && order.refundedAmount === 0 ? (\n <Alert variant=\"success\" title=\"Thank you\">\n Your payment went through. A receipt is on its way to your inbox.\n </Alert>\n ) : null}\n {order.status === 'paid' && order.refundedAmount > 0 ? (\n <Alert variant=\"info\" title=\"Partly refunded\">\n {formatPrice(order.refundedAmount, order.currency)} of this order was refunded to the card\n that paid it.\n </Alert>\n ) : null}\n {order.status === 'refunded' ? (\n <Alert variant=\"info\" title=\"Refunded\">\n The full amount went back to the card that paid it. It can take a few days to show.\n </Alert>\n ) : null}\n {order.status === 'disputed' ? (\n <Alert variant=\"warning\" title=\"This payment is disputed\">\n The card's bank is reviewing a chargeback. The order is on hold until it is decided.\n </Alert>\n ) : null}\n {order.status === 'failed' ? (\n <Alert variant=\"destructive\" title=\"The payment failed\">\n Nothing was charged. <Link href=\"/checkout\">Try again</Link>\n </Alert>\n ) : null}\n {order.status === 'expired' ? (\n <Alert variant=\"warning\" title=\"This checkout expired\">\n It was not paid in time. <Link href=\"/checkout\">Start again</Link>\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example newsletter`: double opt-in, sent through Amazon SES on a Queue --- */\n\nfunction cfNewsletterTs(): string {\n return `/**\n * The newsletter's wire types, shared by the Worker (worker/newsletter.ts) and its pages.\n * Every payload is parsed on arrival: the network is not trusted because the types match.\n */\n\n/** Longest subject and body the composer accepts. */\nexport const MAX_SUBJECT = 200\nexport const MAX_BODY = 50_000\n\nexport interface SubscriberCounts {\n /** Signed up, has not clicked the confirmation link yet. */\n pending: number\n subscribed: number\n unsubscribed: number\n /** Bounced for good or complained: never mailed again (worker/newsletter.ts). */\n suppressed: number\n}\n\n/** One sent issue and how far its sending has got. */\nexport interface Issue {\n id: string\n subject: string\n createdAt: string\n /** Subscribers it was queued for. */\n total: number\n sent: number\n failed: number\n}\n\nexport interface Overview {\n subscribers: SubscriberCounts\n issues: Issue[]\n}\n\n/** The room the Worker pushes an issue's progress to; the composer watches it. */\nexport const issueRoom = (id: string) => \\`issue-\\${id}\\`\n\n/** An issue id: a UUID the Worker made. */\nexport const ISSUE_ID = /^[0-9a-f-]{36}$/\n\nconst isRecord = (raw: unknown): raw is Record<string, unknown> =>\n typeof raw === 'object' && raw !== null\n\nfunction text(raw: Record<string, unknown>, key: string, max: number): string {\n const value = raw[key]\n if (typeof value !== 'string' || value.trim() === '' || value.length > max) {\n throw new Error(\\`Expected \\${key}: some text, at most \\${max} characters\\`)\n }\n return value\n}\n\nexport function parseEmailInput(raw: unknown): { email: string } {\n if (!isRecord(raw)) throw new Error('Expected { email }')\n return { email: text(raw, 'email', 254) }\n}\n\nexport function parseTokenInput(raw: unknown): { token: string } {\n if (!isRecord(raw)) throw new Error('Expected { token }')\n return { token: text(raw, 'token', 100) }\n}\n\n/** The composer's key: NEWSLETTER_KEY, which only the sender knows. */\nexport function parseKeyInput(raw: unknown): { key: string } {\n if (!isRecord(raw)) throw new Error('Expected { key }')\n return { key: text(raw, 'key', 200) }\n}\n\nexport interface IssueInput {\n key: string\n subject: string\n /** The body in Markdown, rendered by @cascivo/email's Markdown. */\n body: string\n}\n\nexport function parseIssueInput(raw: unknown): IssueInput {\n if (!isRecord(raw)) throw new Error('Expected { key, subject, body }')\n return {\n key: text(raw, 'key', 200),\n subject: text(raw, 'subject', MAX_SUBJECT),\n body: text(raw, 'body', MAX_BODY),\n }\n}\n\nexport function parseSubscribed(raw: unknown): { devLink: string | null } {\n if (isRecord(raw) && (raw['devLink'] === null || typeof raw['devLink'] === 'string')) {\n return { devLink: raw['devLink'] }\n }\n throw new Error('Malformed reply')\n}\n\nexport function parseConfirmed(raw: unknown): { email: string } {\n if (isRecord(raw) && typeof raw['email'] === 'string') return { email: raw['email'] }\n throw new Error('Malformed reply')\n}\n\nexport function parsePreview(raw: unknown): { html: string; bytes: number } {\n if (isRecord(raw) && typeof raw['html'] === 'string' && typeof raw['bytes'] === 'number') {\n return { html: raw['html'], bytes: raw['bytes'] }\n }\n throw new Error('Malformed preview')\n}\n\nconst count = (raw: Record<string, unknown>, key: string): number => {\n const value = raw[key]\n if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n throw new Error(\\`Expected a count for \\${key}\\`)\n }\n return value\n}\n\nexport function parseIssue(raw: unknown): Issue {\n if (isRecord(raw) && typeof raw['id'] === 'string' && typeof raw['createdAt'] === 'string') {\n return {\n id: raw['id'],\n subject: text(raw, 'subject', MAX_SUBJECT),\n createdAt: raw['createdAt'],\n total: count(raw, 'total'),\n sent: count(raw, 'sent'),\n failed: count(raw, 'failed'),\n }\n }\n throw new Error('Malformed issue')\n}\n\nexport function parseOverview(raw: unknown): Overview {\n if (isRecord(raw) && isRecord(raw['subscribers']) && Array.isArray(raw['issues'])) {\n const s = raw['subscribers']\n return {\n subscribers: {\n pending: count(s, 'pending'),\n subscribed: count(s, 'subscribed'),\n unsubscribed: count(s, 'unsubscribed'),\n suppressed: count(s, 'suppressed'),\n },\n issues: raw['issues'].map(parseIssue),\n }\n }\n throw new Error('Malformed overview')\n}\n`\n}\n\nfunction cfNewsletterEmailTs(opts: ScaffoldOptions): string {\n return `import {\n Body,\n Button,\n Container,\n Footer,\n Head,\n Heading,\n Html,\n Link,\n Markdown,\n Preview,\n Section,\n Text,\n renderEmail,\n} from '@cascivo/email'\nimport type { RenderResult } from '@cascivo/email'\nimport { createElement as h } from 'react'\n\n/** Who the newsletter is from, as its emails say. */\nexport const NEWSLETTER_NAME = '${brandName(opts.name).replace(/'/g, \"\\\\'\")}'\n\n/** Stands in for each reader's unsubscribe token; worker/newsletter.ts swaps it per message. */\nexport const TOKEN_SLOT = '__UNSUBSCRIBE_TOKEN__'\n\n/**\n * An issue, rendered once with @cascivo/email: the body is Markdown, drawn through the email\n * primitives (raw HTML in it stays literal text). The footer carries the unsubscribe link,\n * with TOKEN_SLOT where each reader's token goes.\n */\nexport function renderIssue(subject: string, body: string, origin: string): RenderResult {\n const unsubscribe = \\`\\${origin}/newsletter/unsubscribe?token=\\${TOKEN_SLOT}\\`\n return renderEmail(\n h(\n Html,\n null,\n h(Head, { title: subject }),\n h(\n Body,\n null,\n h(Preview, null, subject),\n h(\n Container,\n null,\n h(Section, { padding: 32 }, h(Heading, { level: 1 }, subject), h(Markdown, null, body)),\n h(\n Footer,\n null,\n \\`You get this because you subscribed to \\${NEWSLETTER_NAME}. \\`,\n h(Link, { href: unsubscribe }, 'Unsubscribe'),\n ),\n ),\n ),\n ),\n { subject },\n )\n}\n\n/** The double opt-in email: nobody is mailed an issue until they open this link. */\nexport function renderConfirmation(confirmUrl: string): RenderResult {\n const subject = \\`Confirm your subscription to \\${NEWSLETTER_NAME}\\`\n return renderEmail(\n h(\n Html,\n null,\n h(Head, { title: subject }),\n h(\n Body,\n null,\n h(Preview, null, 'One click and you are on the list.'),\n h(\n Container,\n null,\n h(\n Section,\n { padding: 32 },\n h(Heading, { level: 1 }, 'Confirm your subscription'),\n h(Text, null, \\`Someone, hopefully you, asked to get \\${NEWSLETTER_NAME} by email.\\`),\n h(Button, { href: confirmUrl }, 'Yes, subscribe me'),\n h(\n Text,\n { variant: 'muted', size: '14px' },\n 'The link works for 24 hours. If you did not ask, ignore this email: you will not hear from us again.',\n ),\n ),\n ),\n ),\n ),\n { subject },\n )\n}\n`\n}\n\nfunction cfNewsletterWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { normalizeEmail } from '@cascivo/app/auth-server'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { SesError, createSes, handleSns, parseSesNotification } from '@cascivo/app/ses'\nimport { writeRoom } from '@cascivo/app/sync-server'\nimport type { RoomNamespace } from '@cascivo/app/sync-server'\nimport { assertSendable, sendEmail } from '@cascivo/email'\nimport type { RenderResult } from '@cascivo/email'\nimport { ISSUE_ID, issueRoom, parseIssue } from '../src/newsletter'\nimport type { Issue, IssueInput, Overview, SubscriberCounts } from '../src/newsletter'\nimport { TOKEN_SLOT, renderConfirmation, renderIssue } from './newsletter-email'\n\nconst migrations = [\n {\n id: '0001_newsletter',\n statements: [\n \\`CREATE TABLE subscribers (\n email TEXT PRIMARY KEY,\n status TEXT NOT NULL,\n confirm_hash TEXT,\n confirm_expires INTEGER,\n unsubscribe_token TEXT NOT NULL UNIQUE,\n reason TEXT,\n created_at TEXT NOT NULL,\n confirmed_at TEXT\n )\\`,\n \\`CREATE TABLE issues (\n id TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n body TEXT NOT NULL,\n total INTEGER NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n // One row per reader per issue, written as each send finishes: a retried queue message\n // skips whoever already has one, so nobody gets an issue twice.\n \\`CREATE TABLE deliveries (\n issue_id TEXT NOT NULL,\n email TEXT NOT NULL,\n status TEXT NOT NULL,\n detail TEXT,\n at TEXT NOT NULL,\n PRIMARY KEY (issue_id, email)\n )\\`,\n ],\n },\n]\n\n/** Readers per queue message. Each message is sent in one go, one reader after another. */\nconst CHUNK = 25\n/** A confirmation link works for a day. */\nconst CONFIRM_TTL_MS = 24 * 60 * 60 * 1000\n/** A pending address gets another link at most this often, so the form cannot flood an inbox. */\nconst RESEND_AFTER_MS = 10 * 60 * 1000\n\nexport interface NewsletterMessage {\n issueId: string\n emails: string[]\n /** The app's origin, for the unsubscribe links: a queue consumer has no request. */\n origin: string\n}\n\n/** What sending needs of the NEWSLETTER queue binding. */\nexport interface NewsletterQueue {\n sendBatch(messages: Iterable<{ body: NewsletterMessage }>): Promise<void>\n}\n\n/** The slice of a Queue consumer's batch \\`deliver\\` reads. */\nexport interface NewsletterBatch {\n readonly messages: readonly { readonly body: unknown }[]\n}\n\nexport interface NewsletterEnv {\n DB: Database\n ROOMS: RoomNamespace<unknown>\n NEWSLETTER: NewsletterQueue\n /** Set in wrangler.jsonc: the SES region, the From address, the SNS topic for feedback. */\n AWS_REGION: string\n NEWSLETTER_FROM: string\n SNS_TOPIC_ARN: string\n /** Secrets: \\`wrangler secret put\\` (.dev.vars locally). Unset until you add them. */\n AWS_ACCESS_KEY_ID?: string\n AWS_SECRET_ACCESS_KEY?: string\n NEWSLETTER_KEY?: string\n}\n\nconst encoder = new TextEncoder()\n\nconst cell = (raw: unknown, key: string): unknown =>\n typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined\n\n/** A string column of a D1 row. Rows are read like any payload: checked, not cast. */\nfunction column(raw: unknown, key: string): string {\n const value = cell(raw, key)\n if (typeof value !== 'string') throw new Error(\\`Expected a string in column \\${key}\\`)\n return value\n}\n\nasync function sha256(value: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))\n return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')\n}\n\n/** 32 random bytes, URL-safe: a confirmation or unsubscribe token. */\nfunction token(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32))\n return btoa(String.fromCharCode(...bytes))\n .replace(/\\\\+/g, '-')\n .replace(/\\\\//g, '_')\n .replace(/=+$/, '')\n}\n\n/** Refuses a request without NEWSLETTER_KEY. Compares digests, so timing says nothing. */\nasync function requireKey(env: NewsletterEnv, key: string): Promise<void> {\n if (!env.NEWSLETTER_KEY) throw new HttpError(503, 'Set NEWSLETTER_KEY (README)')\n if ((await sha256(key)) !== (await sha256(env.NEWSLETTER_KEY))) {\n throw new HttpError(403, 'Wrong newsletter key')\n }\n}\n\ntype Send = (\n to: string,\n message: RenderResult,\n headers?: Record<string, string>,\n) => Promise<'sent' | 'logged'>\n\n/**\n * Sends one email through SES, or, in \\`vite dev\\` without AWS credentials, logs it instead.\n * Deployed without them, it refuses with what to set. The SES client is an \\`EmailSender\\`, so\n * \\`sendEmail\\` checks the message (subject, preheader, text part, size) and every header first.\n */\nfunction mailer(env: NewsletterEnv): Send {\n const configured =\n env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_REGION && env.NEWSLETTER_FROM\n if (!configured) {\n if (!import.meta.env.DEV) {\n throw new HttpError(\n 503,\n 'Set AWS_REGION and NEWSLETTER_FROM in wrangler.jsonc, and the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets (README)',\n )\n }\n return async (to, message) => {\n console.log(\\`[newsletter] not sent (no SES credentials) to \\${to}: \"\\${message.subject}\"\\`)\n return 'logged'\n }\n }\n const ses = createSes({\n region: env.AWS_REGION,\n accessKeyId: env.AWS_ACCESS_KEY_ID!,\n secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,\n })\n return async (to, message, headers) => {\n await sendEmail(ses, message, {\n from: env.NEWSLETTER_FROM,\n to,\n ...(headers ? { headers } : {}),\n })\n return 'sent'\n }\n}\n\n/**\n * Signs someone up: a pending subscriber and a confirmation email. The answer is the same\n * whoever asks, so the form does not tell strangers who is on the list. \\`vite dev\\` without\n * SES also returns the confirmation link, to open instead of an email.\n */\nexport async function subscribe(\n env: NewsletterEnv,\n rawEmail: string,\n origin: string,\n): Promise<{ devLink: string | null }> {\n const email = normalizeEmail(rawEmail)\n const send = mailer(env)\n await migrate(env.DB, migrations)\n const confirm = token()\n const now = new Date()\n const expires = now.getTime() + CONFIRM_TTL_MS\n // A new or unsubscribed address gets a link; a pending one gets a fresh link at most every\n // ten minutes. A subscribed or suppressed one is left alone, and nothing is sent.\n const [row] = await queryRows(\n env.DB,\n \\`INSERT INTO subscribers\n (email, status, confirm_hash, confirm_expires, unsubscribe_token, created_at)\n VALUES (?, 'pending', ?, ?, ?, ?)\n ON CONFLICT (email) DO UPDATE SET\n status = 'pending', reason = NULL,\n confirm_hash = excluded.confirm_hash, confirm_expires = excluded.confirm_expires\n WHERE subscribers.status = 'unsubscribed'\n OR (subscribers.status = 'pending' AND subscribers.confirm_expires < ?)\n RETURNING email\\`,\n [email, await sha256(confirm), expires, token(), now.toISOString(), expires - RESEND_AFTER_MS],\n (raw) => column(raw, 'email'),\n )\n if (!row) return { devLink: null }\n const link = \\`\\${origin}/newsletter/confirm?token=\\${confirm}\\`\n const message = renderConfirmation(link)\n let outcome: 'sent' | 'logged'\n try {\n outcome = await send(email, message)\n } catch (error) {\n if (!(error instanceof SesError)) throw error\n console.error('[newsletter] confirmation not sent:', error.code, error.message)\n // Let the reader try again at once rather than wait out the resend interval.\n await queryRows(\n env.DB,\n \"UPDATE subscribers SET confirm_expires = 0 WHERE email = ? AND status = 'pending' RETURNING email\",\n [email],\n (raw) => raw,\n )\n throw new HttpError(502, 'Could not send the confirmation email. Try again in a moment.')\n }\n return { devLink: outcome === 'logged' ? link : null }\n}\n\n/** Opens a confirmation link: the subscriber is on the list from now on. */\nexport async function confirm(env: NewsletterEnv, rawToken: string): Promise<{ email: string }> {\n await migrate(env.DB, migrations)\n const [row] = await queryRows(\n env.DB,\n \\`UPDATE subscribers SET status = 'subscribed', confirm_hash = NULL, confirmed_at = ?\n WHERE confirm_hash = ? AND confirm_expires > ? AND status = 'pending' RETURNING email\\`,\n [new Date().toISOString(), await sha256(rawToken), Date.now()],\n (raw) => ({ email: column(raw, 'email') }),\n )\n if (!row) throw new HttpError(400, 'This link has expired or was used already. Sign up again.')\n return row\n}\n\n/**\n * \\`POST /api/newsletter/unsubscribe?token=…\\`: the page's button, and the one-click\n * unsubscribe mail clients send (RFC 8058) from the List-Unsubscribe header. Always 200, so a\n * token says nothing about who it belongs to, and a second click is not an error.\n */\nexport async function unsubscribe(request: Request, env: NewsletterEnv): Promise<Response> {\n const unsubscribeToken = new URL(request.url).searchParams.get('token') ?? ''\n await migrate(env.DB, migrations)\n await queryRows(\n env.DB,\n \\`UPDATE subscribers SET status = 'unsubscribed', reason = 'unsubscribed'\n WHERE unsubscribe_token = ? AND status IN ('pending', 'subscribed') RETURNING email\\`,\n [unsubscribeToken],\n (raw) => raw,\n )\n return Response.json({ unsubscribed: true })\n}\n\nconst ISSUE_COLUMNS = \\`issues.id, issues.subject, issues.created_at AS createdAt, issues.total,\n (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status != 'failed') AS sent,\n (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status = 'failed') AS failed\\`\n\nasync function readIssue(db: Database, id: string): Promise<Issue> {\n const [issue] = await queryRows(\n db,\n \\`SELECT \\${ISSUE_COLUMNS} FROM issues WHERE id = ?\\`,\n [id],\n parseIssue,\n )\n if (!issue) throw new HttpError(404, 'No such issue')\n return issue\n}\n\n/** Subscriber counts and the last 20 issues, for the composer. */\nexport async function overview(env: NewsletterEnv, key: string): Promise<Overview> {\n await requireKey(env, key)\n await migrate(env.DB, migrations)\n const subscribers: SubscriberCounts = {\n pending: 0,\n subscribed: 0,\n unsubscribed: 0,\n suppressed: 0,\n }\n const counts = await queryRows(\n env.DB,\n 'SELECT status, COUNT(*) AS n FROM subscribers GROUP BY status',\n [],\n (raw) => ({ status: column(raw, 'status'), n: Number(cell(raw, 'n')) }),\n )\n for (const { status, n } of counts) {\n if (status === 'pending' || status === 'subscribed') subscribers[status] = n\n if (status === 'unsubscribed' || status === 'suppressed') subscribers[status] = n\n }\n const issues = await queryRows(\n env.DB,\n \\`SELECT \\${ISSUE_COLUMNS} FROM issues ORDER BY created_at DESC LIMIT 20\\`,\n [],\n parseIssue,\n )\n return { subscribers, issues }\n}\n\n/** The issue as readers will get it, for the composer's preview. */\nexport async function preview(\n env: NewsletterEnv,\n input: IssueInput,\n origin: string,\n): Promise<{ html: string; bytes: number }> {\n await requireKey(env, input.key)\n const { html, stats } = renderIssue(input.subject, input.body, origin)\n return { html, bytes: stats.bytes }\n}\n\n/**\n * Sends an issue to every confirmed subscriber: the issue is stored, and its readers go onto\n * the NEWSLETTER queue in chunks, which \\`deliver\\` sends at the queue's pace.\n */\nexport async function sendIssue(\n env: NewsletterEnv,\n input: IssueInput,\n origin: string,\n): Promise<Issue> {\n await requireKey(env, input.key)\n mailer(env) // Deployed without SES, refuse now rather than fail in the queue.\n try {\n // An issue too large to send (Gmail clips it) is refused here, not retried in the queue.\n assertSendable(renderIssue(input.subject, input.body, origin))\n } catch (error) {\n throw new HttpError(400, error instanceof Error ? error.message : String(error))\n }\n await migrate(env.DB, migrations)\n const readers = await queryRows(\n env.DB,\n \"SELECT email FROM subscribers WHERE status = 'subscribed' ORDER BY email\",\n [],\n (raw) => column(raw, 'email'),\n )\n const id = crypto.randomUUID()\n await queryRows(\n env.DB,\n 'INSERT INTO issues (id, subject, body, total, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id',\n [id, input.subject, input.body, readers.length, new Date().toISOString()],\n (raw) => raw,\n )\n const messages: { body: NewsletterMessage }[] = []\n for (let i = 0; i < readers.length; i += CHUNK) {\n messages.push({ body: { issueId: id, emails: readers.slice(i, i + CHUNK), origin } })\n }\n // sendBatch takes at most 100 messages a call.\n for (let i = 0; i < messages.length; i += 100) {\n await env.NEWSLETTER.sendBatch(messages.slice(i, i + 100))\n }\n const issue = await readIssue(env.DB, id)\n await writeRoom(env.ROOMS, issueRoom(id), 'issue', { ...issue })\n return issue\n}\n\nfunction parseMessage(raw: unknown): NewsletterMessage {\n if (typeof raw === 'object' && raw !== null) {\n const { issueId, emails, origin } = raw as Record<string, unknown>\n if (\n typeof issueId === 'string' &&\n ISSUE_ID.test(issueId) &&\n Array.isArray(emails) &&\n emails.every((e) => typeof e === 'string') &&\n typeof origin === 'string'\n ) {\n return { issueId, emails: emails as string[], origin }\n }\n }\n throw new Error('Malformed newsletter message')\n}\n\n/**\n * The NEWSLETTER queue's consumer: sends each reader in the message their copy, with their\n * own unsubscribe link and the one-click headers bulk senders need. A reader who left or was\n * suppressed since the issue was queued is skipped. SES throttling or a server error throws,\n * and the queue retries the message: whoever was sent already has a delivery row and is not\n * sent again. A refusal for one address (an invalid one, say) is recorded and the rest go on.\n */\nexport async function deliver(env: NewsletterEnv, batch: NewsletterBatch): Promise<void> {\n await migrate(env.DB, migrations)\n const send = mailer(env)\n for (const { body } of batch.messages) {\n const { issueId, emails, origin } = parseMessage(body)\n const [issue] = await queryRows(\n env.DB,\n 'SELECT subject, body FROM issues WHERE id = ?',\n [issueId],\n (raw) => ({ subject: column(raw, 'subject'), body: column(raw, 'body') }),\n )\n if (!issue) continue\n const rendered = renderIssue(issue.subject, issue.body, origin)\n const placeholders = emails.map(() => '?').join(', ')\n const readers = await queryRows(\n env.DB,\n \\`SELECT s.email, s.unsubscribe_token AS token FROM subscribers s\n WHERE s.email IN (\\${placeholders}) AND s.status = 'subscribed'\n AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.issue_id = ? AND d.email = s.email)\\`,\n [...emails, issueId],\n (raw) => ({ email: column(raw, 'email'), token: column(raw, 'token') }),\n )\n for (const { email, token: readerToken } of readers) {\n const unsubscribe = \\`\\${origin}/api/newsletter/unsubscribe?token=\\${readerToken}\\`\n let status: string\n let detail: string | null = null\n try {\n status = await send(\n email,\n {\n ...rendered,\n html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),\n text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),\n },\n {\n 'List-Unsubscribe': \\`<\\${unsubscribe}>\\`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n },\n )\n } catch (error) {\n if (!(error instanceof SesError) || error.retryable) throw error\n status = 'failed'\n detail = \\`\\${error.code ?? error.status}: \\${error.message}\\`.slice(0, 300)\n }\n await queryRows(\n env.DB,\n \\`INSERT INTO deliveries (issue_id, email, status, detail, at) VALUES (?, ?, ?, ?, ?)\n ON CONFLICT DO NOTHING RETURNING email\\`,\n [issueId, email, status, detail, new Date().toISOString()],\n (raw) => raw,\n )\n }\n const progress = await readIssue(env.DB, issueId)\n await writeRoom(env.ROOMS, issueRoom(issueId), 'issue', { ...progress })\n }\n}\n\n/**\n * SES's feedback, delivered by SNS: a permanent bounce or a complaint suppresses the address\n * for good. Sending to them again is what gets an SES account put under review.\n */\nexport async function receiveFeedback(request: Request, env: NewsletterEnv): Promise<Response> {\n if (!env.SNS_TOPIC_ARN) throw new HttpError(503, 'Set SNS_TOPIC_ARN in wrangler.jsonc (README)')\n return handleSns(request, {\n topicArn: env.SNS_TOPIC_ARN,\n // The one topic configured above: confirming it is the operator's own choice.\n confirmSubscriptions: true,\n onNotification: async ({ message }) => {\n const event = parseSesNotification(message)\n const suppress =\n event.kind === 'complaint' || (event.kind === 'bounce' && event.bounceType === 'Permanent')\n if (!suppress || event.recipients.length === 0) return\n await migrate(env.DB, migrations)\n // D1 binds at most 100 parameters; one bounce rarely names more than a few addresses.\n const recipients = event.recipients\n .slice(0, 90)\n .map((address) => address.trim().toLowerCase())\n await queryRows(\n env.DB,\n \\`UPDATE subscribers SET status = 'suppressed', reason = ?\n WHERE email IN (\\${recipients.map(() => '?').join(', ')}) RETURNING email\\`,\n [event.kind, ...recipients],\n (raw) => raw,\n )\n },\n })\n}\n`\n}\n\nfunction cfNewsletterRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Input,\n Link,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport type { FormEvent } from 'react'\nimport { api } from '../api'\n\nconst client = createClient(api)\nconst sentTo = signal<string | null>(null)\n/** Set only in \\`vite dev\\` without SES: the confirmation link, to open instead of an email. */\nconst devLink = signal<string | null>(null)\nconst failure = signal<string | null>(null)\nconst sending = signal(false)\n\nasync function signUp(event: FormEvent<HTMLFormElement>): Promise<void> {\n event.preventDefault()\n const email = new FormData(event.currentTarget).get('email')\n if (typeof email !== 'string') return\n failure.value = null\n sending.value = true\n try {\n const { devLink: link } = await client.subscribe({ body: { email } })\n sentTo.value = email\n devLink.value = link\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not sign you up'\n } finally {\n sending.value = false\n }\n}\n\nexport default function Newsletter() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Newsletter</Heading>\n <Text muted>\n An email now and then. We send a link first: you are on the list only once you open it,\n and every issue has a one-click unsubscribe.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <form onSubmit={(event) => void signUp(event)}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input name=\"email\" type=\"email\" label=\"Email\" autoComplete=\"email\" required />\n <Button type=\"submit\" loading={sending.value}>\n Subscribe\n </Button>\n </Flex>\n </form>\n </CardContent>\n </Card>\n {sentTo.value ? (\n <Alert variant=\"success\" title=\"Check your inbox\">\n If {sentTo.value} is not on the list yet, a confirmation link is on its way.\n </Alert>\n ) : null}\n {devLink.value ? (\n <Alert variant=\"info\" title=\"vite dev sends no email without SES\">\n <Link href={devLink.value}>Open the confirmation link</Link>\n </Alert>\n ) : null}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not signed up\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\nfunction cfNewsletterConfirmRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'\nimport { api } from '../../api'\nimport { router } from '../../router'\n\nconst client = createClient(api)\nconst confirmed = signal<string | null>(null)\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n/**\n * The page a confirmation link opens. It confirms only when you press the button: mail\n * scanners open every link in a message, and would otherwise subscribe whoever was typed in.\n */\nasync function confirm(): Promise<void> {\n const token = new URLSearchParams(router.search.value).get('token')\n if (!token) {\n failure.value = 'This link has no token. Sign up again.'\n return\n }\n busy.value = true\n failure.value = null\n try {\n confirmed.value = (await client.confirmSubscription({ body: { token } })).email\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not confirm'\n } finally {\n busy.value = false\n }\n}\n\nexport default function ConfirmSubscription() {\n useSignals()\n return (\n <Flex gap={4}>\n <Heading level={1}>Confirm your subscription</Heading>\n {confirmed.value ? (\n <Alert variant=\"success\" title=\"You are on the list\">\n The next issue goes to {confirmed.value}.\n </Alert>\n ) : (\n <Flex gap={2}>\n <Text muted>One click and you get the newsletter.</Text>\n <Flex direction=\"horizontal\">\n <Button loading={busy.value} onClick={() => void confirm()}>\n Yes, subscribe me\n </Button>\n </Flex>\n </Flex>\n )}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not confirmed\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\nfunction cfNewsletterUnsubscribeRouteTsx(): string {\n return `import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'\nimport { router } from '../../router'\n\nconst done = signal(false)\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n/**\n * The footer link of every issue. Like the confirmation, it acts on a button press, never on\n * opening the page. Mail clients that support one-click unsubscribe skip this page and POST\n * to the same endpoint from the List-Unsubscribe header.\n */\nasync function unsubscribe(): Promise<void> {\n const token = new URLSearchParams(router.search.value).get('token') ?? ''\n busy.value = true\n failure.value = null\n try {\n const response = await fetch(\\`/api/newsletter/unsubscribe?token=\\${encodeURIComponent(token)}\\`, {\n method: 'POST',\n })\n if (!response.ok) throw new Error(\\`Request failed with status \\${response.status}\\`)\n done.value = true\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not unsubscribe'\n } finally {\n busy.value = false\n }\n}\n\nexport default function Unsubscribe() {\n useSignals()\n return (\n <Flex gap={4}>\n <Heading level={1}>Unsubscribe</Heading>\n {done.value ? (\n <Alert variant=\"success\" title=\"You are unsubscribed\">\n You will get no more issues. Sign up again any time.\n </Alert>\n ) : (\n <Flex gap={2}>\n <Text muted>Stop getting the newsletter at this address.</Text>\n <Flex direction=\"horizontal\">\n <Button variant=\"destructive\" loading={busy.value} onClick={() => void unsubscribe()}>\n Unsubscribe\n </Button>\n </Flex>\n </Flex>\n )}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not unsubscribed\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\nfunction cfNewsletterSendRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { connectRoom } from '@cascivo/app/sync'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Input,\n Text,\n Textarea,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport type { MouseEvent } from 'react'\nimport { api } from '../../api'\nimport { MAX_BODY, MAX_SUBJECT, parseIssue } from '../../newsletter'\nimport type { Issue, Overview } from '../../newsletter'\n\nconst client = createClient(api)\nconst overview = signal<Overview | null>(null)\nconst previewHtml = signal<string | null>(null)\nconst busy = signal<'load' | 'preview' | 'send' | null>(null)\nconst failure = signal<string | null>(null)\nconst notice = signal<string | null>(null)\n\n/** The composer's fields, read from the form the clicked button belongs to. */\nfunction fields(event: MouseEvent<HTMLButtonElement>) {\n const form = event.currentTarget.form\n const data = form ? new FormData(form) : new FormData()\n const read = (name: string) => {\n const value = data.get(name)\n return typeof value === 'string' ? value : ''\n }\n return { key: read('key'), subject: read('subject'), body: read('body') }\n}\n\nasync function run(kind: 'load' | 'preview' | 'send', task: () => Promise<void>): Promise<void> {\n busy.value = kind\n failure.value = null\n notice.value = null\n try {\n await task()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Something went wrong'\n } finally {\n busy.value = null\n }\n}\n\n/** Shows an issue's progress as the Worker pushes it, until every reader has had their copy. */\nfunction watch(issue: Issue): void {\n const room = connectRoom(\\`/api/newsletter/issues/\\${issue.id}/live\\`)\n const pushed = room.signal<Issue | null>('issue', null, (raw) =>\n raw === null ? null : parseIssue(raw),\n )\n const stop = pushed.signal.subscribe((latest) => {\n const current = overview.peek()\n if (!latest || !current) return\n overview.value = {\n ...current,\n issues: current.issues.map((i) => (i.id === latest.id ? latest : i)),\n }\n if (latest.sent + latest.failed >= latest.total) {\n stop()\n room.close()\n }\n })\n}\n\nfunction load(event: MouseEvent<HTMLButtonElement>): void {\n const { key } = fields(event)\n void run('load', async () => {\n overview.value = await client.newsletterOverview({ body: { key } })\n })\n}\n\nfunction preview(event: MouseEvent<HTMLButtonElement>): void {\n const input = fields(event)\n void run('preview', async () => {\n previewHtml.value = (await client.previewIssue({ body: input })).html\n })\n}\n\nfunction send(event: MouseEvent<HTMLButtonElement>): void {\n const input = fields(event)\n const readers = overview.value?.subscribers.subscribed ?? 0\n if (!window.confirm(\\`Send \"\\${input.subject}\" to \\${readers} subscribers?\\`)) return\n void run('send', async () => {\n const issue = await client.sendIssue({ body: input })\n overview.value = await client.newsletterOverview({ body: { key: input.key } })\n notice.value = \\`Queued for \\${issue.total} subscribers.\\`\n watch(issue)\n })\n}\n\nexport default function SendNewsletter() {\n useSignals()\n const counts = overview.value?.subscribers\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Send the newsletter</Heading>\n <Text muted>\n Written in Markdown, rendered with @cascivo/email, sent through Amazon SES to every\n confirmed subscriber. Only someone with NEWSLETTER_KEY can send.\n </Text>\n </Flex>\n <form onSubmit={(event) => event.preventDefault()}>\n <Flex gap={3}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input name=\"key\" type=\"password\" label=\"Newsletter key\" autoComplete=\"off\" required />\n <Button variant=\"secondary\" loading={busy.value === 'load'} onClick={load}>\n Show subscribers\n </Button>\n </Flex>\n <Input name=\"subject\" label=\"Subject\" maxLength={MAX_SUBJECT} required />\n <Textarea\n name=\"body\"\n label=\"Body\"\n hint=\"Markdown: headings, **bold**, links, lists, quotes and images.\"\n rows={12}\n maxLength={MAX_BODY}\n required\n />\n <Flex direction=\"horizontal\" gap={2} wrap>\n <Button variant=\"secondary\" loading={busy.value === 'preview'} onClick={preview}>\n Preview\n </Button>\n <Button loading={busy.value === 'send'} disabled={!counts} onClick={send}>\n Send to {counts ? counts.subscribed : '…'} subscribers\n </Button>\n </Flex>\n </Flex>\n </form>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not done\">\n {failure.value}\n </Alert>\n ) : null}\n {notice.value ? (\n <Alert variant=\"success\" title=\"Sending\">\n {notice.value}\n </Alert>\n ) : null}\n {previewHtml.value ? (\n <Card>\n <CardContent>\n {/* sandbox: the preview runs no script and cannot reach this page. */}\n <iframe\n title=\"Preview\"\n sandbox=\"\"\n srcDoc={previewHtml.value}\n width=\"100%\"\n height=\"640\"\n />\n </CardContent>\n </Card>\n ) : null}\n {counts ? (\n <Flex direction=\"horizontal\" gap={2} wrap>\n <Badge variant=\"success\">{counts.subscribed} subscribed</Badge>\n <Badge variant=\"warning\">{counts.pending} not confirmed</Badge>\n <Badge variant=\"secondary\">{counts.unsubscribed} unsubscribed</Badge>\n <Badge variant=\"destructive\">{counts.suppressed} suppressed</Badge>\n </Flex>\n ) : null}\n {overview.value && overview.value.issues.length === 0 ? (\n <EmptyState title=\"No issues yet\" description=\"Write one above and send it.\" />\n ) : null}\n {overview.value && overview.value.issues.length > 0 ? (\n <Flex gap={2} role=\"list\" aria-label=\"Issues\">\n {overview.value.issues.map((issue) => (\n <Flex key={issue.id} role=\"listitem\" direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={issue.sent + issue.failed >= issue.total ? 'success' : 'warning'}>\n {issue.sent}/{issue.total} sent\n </Badge>\n {issue.failed > 0 ? <Badge variant=\"destructive\">{issue.failed} failed</Badge> : null}\n <Text>{issue.subject}</Text>\n <Text size=\"sm\" muted>\n {new Date(issue.createdAt).toLocaleString()}\n </Text>\n </Flex>\n ))}\n </Flex>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example checkout` with `--auth email`: a subscription plan and Stripe's portal --- */\n\nfunction cfBillingTs(): string {\n return `/**\n * The subscription plan /billing sells, shared by the Worker (worker/billing.ts) and the page.\n * A subscription belongs to a signed-in user, so this exists only with --auth email.\n */\n\n/** The plan. The Worker sends this to Stripe; the page only displays it. */\nexport const PLAN = {\n name: 'Pro',\n description: 'Everything in the app, billed monthly. Cancel any time from the billing portal.',\n /** In the currency's smallest unit, per interval: 900 is €9.00. */\n amount: 900,\n currency: 'eur',\n interval: 'month' as const,\n}\n\n/** \\`none\\` before the first subscription; otherwise Stripe's subscription status. */\nexport type BillingStatus =\n | 'none'\n | 'incomplete'\n | 'incomplete_expired'\n | 'trialing'\n | 'active'\n | 'past_due'\n | 'canceled'\n | 'unpaid'\n | 'paused'\n\nconst STATUSES: readonly BillingStatus[] = [\n 'none',\n 'incomplete',\n 'incomplete_expired',\n 'trialing',\n 'active',\n 'past_due',\n 'canceled',\n 'unpaid',\n 'paused',\n]\n\nexport interface Billing {\n status: BillingStatus\n /**\n * Whether the plan's features are on: the subscription is active or trialing, or past due\n * while Stripe retries the renewal (\\`isEntitled\\` from @cascivo/app/stripe, in the Worker).\n */\n active: boolean\n /** When the current period ends: the next charge, or the end of a cancelled plan. */\n currentPeriodEnd: string | null\n /** Cancelled, but running until currentPeriodEnd. */\n cancelAtPeriodEnd: boolean\n /** Has a Stripe customer, so the billing portal can open. */\n canManage: boolean\n}\n\nexport function parseBilling(raw: unknown): Billing {\n if (typeof raw === 'object' && raw !== null) {\n const { status, active, currentPeriodEnd, cancelAtPeriodEnd, canManage } = raw as Record<\n string,\n unknown\n >\n const known = STATUSES.find((s) => s === status)\n if (\n known &&\n typeof active === 'boolean' &&\n (currentPeriodEnd === null || typeof currentPeriodEnd === 'string') &&\n typeof cancelAtPeriodEnd === 'boolean' &&\n typeof canManage === 'boolean'\n ) {\n return { status: known, active, currentPeriodEnd, cancelAtPeriodEnd, canManage }\n }\n }\n throw new Error('Malformed billing')\n}\n\n/** Where to send the browser next: Stripe's checkout or its billing portal. */\nexport function parseRedirect(raw: unknown): { url: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { url } = raw as Record<string, unknown>\n if (typeof url === 'string' && url.startsWith('https://')) return { url }\n }\n throw new Error('Malformed redirect')\n}\n\nexport function parseSyncInput(raw: unknown): { sessionId: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { sessionId } = raw as Record<string, unknown>\n if (typeof sessionId === 'string' && /^cs_[\\\\w]{1,250}$/.test(sessionId)) return { sessionId }\n }\n throw new Error('Expected { sessionId }: a Checkout Session id')\n}\n`\n}\n\nfunction cfBillingWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { requireUser } from '@cascivo/app/auth-server'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { isEntitled, requireEntitlement } from '@cascivo/app/stripe'\nimport type { Invoice, Subscription } from '@cascivo/app/stripe'\nimport {\n Body,\n Button,\n Container,\n Head,\n Heading,\n Html,\n Preview,\n Section,\n Text,\n renderEmail,\n} from '@cascivo/email'\nimport { createElement as h } from 'react'\nimport { PLAN, parseBilling } from '../src/billing'\nimport type { Billing } from '../src/billing'\nimport { SHOP_NAME, formatPrice } from '../src/checkout'\nimport { refused, stripeOf } from './checkout'\nimport type { BillingHooks, ReceiptSender } from './checkout'\n\nconst migrations = [\n {\n id: '0001_billing',\n statements: [\n \\`CREATE TABLE billing (\n user_id TEXT PRIMARY KEY,\n customer_id TEXT,\n subscription_id TEXT,\n status TEXT NOT NULL,\n current_period_end INTEGER,\n cancel_at_period_end INTEGER NOT NULL DEFAULT 0,\n updated_at TEXT NOT NULL,\n reminded TEXT\n )\\`,\n ],\n },\n]\n\nexport interface BillingEnv {\n DB: Database\n EMAIL: ReceiptSender\n RECEIPT_FROM: string\n STRIPE_SECRET_KEY?: string\n}\n\ninterface Row {\n status: string\n customerId: string | null\n currentPeriodEnd: number | null\n cancelAtPeriodEnd: number\n}\n\nconst cell = (raw: unknown, key: string): unknown =>\n typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined\n\nfunction parseRow(raw: unknown): Row {\n const status = cell(raw, 'status')\n const customerId = cell(raw, 'customerId')\n const end = cell(raw, 'currentPeriodEnd')\n if (typeof status !== 'string') throw new Error('Malformed billing row')\n return {\n status,\n customerId: typeof customerId === 'string' ? customerId : null,\n currentPeriodEnd: typeof end === 'number' ? end : null,\n cancelAtPeriodEnd: Number(cell(raw, 'cancelAtPeriodEnd')),\n }\n}\n\nasync function readRow(db: Database, userId: string): Promise<Row | null> {\n await migrate(db, migrations)\n const [row] = await queryRows(\n db,\n \\`SELECT status, customer_id AS customerId, current_period_end AS currentPeriodEnd,\n cancel_at_period_end AS cancelAtPeriodEnd FROM billing WHERE user_id = ?\\`,\n [userId],\n parseRow,\n )\n return row ?? null\n}\n\n/** A stored row as the page sees it; the status is checked against the known ones. */\nfunction toBilling(row: Row | null): Billing {\n const billing = parseBilling({\n status: row?.status ?? 'none',\n active: false,\n currentPeriodEnd:\n row?.currentPeriodEnd != null ? new Date(row.currentPeriodEnd * 1000).toISOString() : null,\n cancelAtPeriodEnd: row?.cancelAtPeriodEnd === 1,\n canManage: row?.customerId != null,\n })\n return { ...billing, active: isEntitled(billing.status) }\n}\n\n/**\n * Stores a subscription's current state for the user its metadata names. The metadata is set\n * by startSubscription, server side: a subscription made any other way (a Payment Link, the\n * dashboard) names no user here and is ignored. A different subscription replaces the stored\n * one only when that one is over, so a late event about an old plan cannot end a new one.\n */\nasync function store(db: Database, subscription: Subscription): Promise<void> {\n const userId = subscription.metadata['user']\n if (!userId) return\n await migrate(db, migrations)\n await queryRows(\n db,\n \\`INSERT INTO billing\n (user_id, customer_id, subscription_id, status, current_period_end, cancel_at_period_end, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (user_id) DO UPDATE SET\n customer_id = excluded.customer_id, subscription_id = excluded.subscription_id,\n status = excluded.status, current_period_end = excluded.current_period_end,\n cancel_at_period_end = excluded.cancel_at_period_end, updated_at = excluded.updated_at\n WHERE billing.subscription_id IS excluded.subscription_id\n OR billing.status NOT IN ('active', 'trialing', 'past_due')\n RETURNING user_id\\`,\n [\n userId,\n subscription.customerId,\n subscription.id,\n subscription.status,\n subscription.currentPeriodEnd,\n subscription.cancelAtPeriodEnd ? 1 : 0,\n new Date().toISOString(),\n ],\n (raw) => raw,\n )\n}\n\n/**\n * The webhook's part (worker/checkout.ts passes subscription events here). The event's copy\n * may be stale, since events arrive out of order: the subscription is read back from Stripe.\n */\nexport async function syncSubscription(env: BillingEnv, subscriptionId: string): Promise<void> {\n await store(env.DB, await stripeOf(env).retrieveSubscription(subscriptionId))\n}\n\n/** The signed-in user's plan. */\nexport async function getBilling(env: BillingEnv, request: Request): Promise<Billing> {\n const user = await requireUser(env.DB, request)\n return toBilling(await readRow(env.DB, user.id))\n}\n\n/**\n * Refuses a user whose plan is not on: 401 signed out, 402 without the plan. Call it in the\n * Worker before serving a paid feature, never trusting what the page shows.\n */\nexport async function requirePlan(env: BillingEnv, request: Request): Promise<void> {\n const user = await requireUser(env.DB, request)\n requireEntitlement((await readRow(env.DB, user.id))?.status)\n}\n\n/** The email for a renewal that could not be charged, with where to pay it. */\nfunction renderPaymentFailed(invoice: Invoice, payHref: string) {\n const subject = \\`Your \\${PLAN.name} payment did not go through\\`\n const amount = formatPrice(invoice.amountDue, invoice.currency)\n return renderEmail(\n h(\n Html,\n null,\n h(Head, { title: subject }),\n h(\n Body,\n null,\n h(Preview, null, \\`We could not charge \\${amount}. Update your card to keep \\${PLAN.name}.\\`),\n h(\n Container,\n null,\n h(\n Section,\n { padding: 32 },\n h(Heading, { level: 1 }, 'Your payment did not go through'),\n h(\n Text,\n null,\n \\`We could not charge \\${amount} for \\${SHOP_NAME} \\${PLAN.name}. Your plan stays on while we try again; pay with another card to keep it.\\`,\n ),\n h(Button, { href: payHref }, 'Update payment'),\n ),\n ),\n ),\n ),\n { subject },\n )\n}\n\n/**\n * A renewal Stripe could not charge (\\`invoice.payment_failed\\`). The customer hears about it\n * once per attempt, with Stripe's page for paying the invoice with another card. The plan stays\n * on while Stripe retries (\\`past_due\\`); the Stripe dashboard's failed-payment settings decide\n * when it ends. Only customers this app bills are written to: a retried event, or an invoice\n * for something else on the same Stripe account, sends nothing.\n */\nexport async function remindPayment(\n env: BillingEnv,\n invoice: Invoice,\n origin: string,\n): Promise<void> {\n if (!invoice.customerId || !invoice.customerEmail) return\n await migrate(env.DB, migrations)\n const attempt = \\`\\${invoice.id}:\\${invoice.attemptCount}\\`\n const [row] = await queryRows(\n env.DB,\n 'UPDATE billing SET reminded = ? WHERE customer_id = ? AND reminded IS NOT ? RETURNING user_id',\n [attempt, invoice.customerId, attempt],\n (raw) => raw,\n )\n if (!row) return\n const message = renderPaymentFailed(invoice, invoice.hostedInvoiceUrl ?? \\`\\${origin}/billing\\`)\n if (import.meta.env.DEV) {\n console.log(\\`[billing] payment reminder for \\${invoice.customerEmail}: \"\\${message.subject}\"\\`)\n return\n }\n if (!env.RECEIPT_FROM) {\n console.warn('[billing] no payment reminder sent: set RECEIPT_FROM in wrangler.jsonc')\n return\n }\n try {\n await env.EMAIL.send({\n from: env.RECEIPT_FROM,\n to: invoice.customerEmail,\n subject: message.subject,\n text: message.text,\n html: message.html,\n })\n } catch (error) {\n // Logged, not thrown: Stripe retrying the event would not send it again.\n console.error('[billing] payment reminder not sent:', error)\n }\n}\n\n/** What the Stripe webhook (worker/checkout.ts) hands over to billing. */\nexport function billingHooks(env: BillingEnv): BillingHooks {\n return {\n subscription: (id) => syncSubscription(env, id),\n paymentFailed: (invoice, origin) => remindPayment(env, invoice, origin),\n }\n}\n\n/** Opens a subscription checkout for PLAN, naming the user in the subscription's metadata. */\nexport async function startSubscription(\n env: BillingEnv,\n request: Request,\n origin: string,\n): Promise<{ url: string }> {\n const user = await requireUser(env.DB, request)\n const row = await readRow(env.DB, user.id)\n if (row && toBilling(row).active) {\n throw new HttpError(409, 'You already have the plan. Manage it in the billing portal.')\n }\n const stripe = stripeOf(env)\n try {\n const session = await stripe.createCheckoutSession({\n mode: 'subscription',\n lineItems: [{ ...PLAN, quantity: 1 }],\n // Stripe fills in {CHECKOUT_SESSION_ID}, so the page can sync before any webhook.\n successUrl: \\`\\${origin}/billing?session={CHECKOUT_SESSION_ID}\\`,\n cancelUrl: \\`\\${origin}/billing\\`,\n // Signed in with a provider that shares no email: Stripe's checkout asks for one.\n ...(row?.customerId\n ? { customer: row.customerId }\n : user.email\n ? { customerEmail: user.email }\n : {}),\n clientReferenceId: user.id,\n subscriptionMetadata: { user: user.id },\n })\n if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')\n return { url: session.url }\n } catch (error) {\n if (error instanceof HttpError) throw error\n refused(error)\n }\n}\n\n/**\n * Back from Stripe's checkout: reads the session and its subscription, so the page is right\n * before the webhook arrives (or in \\`vite dev\\` without \\`stripe listen\\`). Only the user the\n * subscription names can sync it.\n */\nexport async function syncBilling(\n env: BillingEnv,\n request: Request,\n sessionId: string,\n): Promise<Billing> {\n const user = await requireUser(env.DB, request)\n const stripe = stripeOf(env)\n try {\n const session = await stripe.retrieveCheckoutSession(sessionId)\n if (session.mode === 'subscription' && session.subscriptionId) {\n const subscription = await stripe.retrieveSubscription(session.subscriptionId)\n if (subscription.metadata['user'] !== user.id) {\n throw new HttpError(403, 'This checkout belongs to another account')\n }\n await store(env.DB, subscription)\n }\n } catch (error) {\n if (error instanceof HttpError) throw error\n refused(error)\n }\n return toBilling(await readRow(env.DB, user.id))\n}\n\n/** Opens Stripe's Customer Portal: plan, card, invoices and cancellation, all hosted. */\nexport async function openPortal(\n env: BillingEnv,\n request: Request,\n origin: string,\n): Promise<{ url: string }> {\n const user = await requireUser(env.DB, request)\n const row = await readRow(env.DB, user.id)\n if (!row?.customerId) throw new HttpError(409, 'Subscribe first: there is nothing to manage yet')\n try {\n return await stripeOf(env).createPortalSession({\n customer: row.customerId,\n returnUrl: \\`\\${origin}/billing\\`,\n })\n } catch (error) {\n refused(error)\n }\n}\n`\n}\n\nfunction cfBillingRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,\n Link,\n Spinner,\n Text,\n signal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { auth } from '../auth'\nimport { PLAN } from '../billing'\nimport type { Billing, BillingStatus } from '../billing'\nimport { formatPrice } from '../checkout'\nimport { router } from '../router'\n\nconst client = createClient(api)\nconst billing = signal<Billing | null>(null)\nconst busy = signal<'subscribe' | 'portal' | null>(null)\nconst failure = signal<string | null>(null)\n\n/**\n * Loads the plan. Back from Stripe's checkout, the URL carries the session id: syncing it\n * shows the new subscription at once, before the webhook. Then the id leaves the URL.\n */\nasync function load(): Promise<void> {\n failure.value = null\n try {\n const sessionId = new URLSearchParams(router.search.peek()).get('session')\n if (sessionId) {\n billing.value = await client.syncBilling({ body: { sessionId } })\n router.navigate('/billing', { replace: true })\n } else {\n billing.value = await client.getBilling()\n }\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not load billing'\n }\n}\n\n/** Both buttons leave for a Stripe page: checkout, or the billing portal. */\nasync function leaveFor(kind: 'subscribe' | 'portal'): Promise<void> {\n busy.value = kind\n failure.value = null\n try {\n const { url } =\n kind === 'subscribe' ? await client.startSubscription() : await client.openBillingPortal()\n location.assign(url)\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Stripe did not open'\n busy.value = null\n }\n}\n\nconst LABEL: Record<BillingStatus, string> = {\n none: 'No plan',\n incomplete: 'Payment pending',\n incomplete_expired: 'Payment expired',\n trialing: 'Trial',\n active: 'Active',\n past_due: 'Payment failed: retrying',\n canceled: 'Cancelled',\n unpaid: 'Unpaid',\n paused: 'Paused',\n}\n\nexport default function BillingPage() {\n useSignals()\n useSignalEffect(() => {\n if (auth.user.value) void load()\n })\n const user = auth.user.value\n const plan = billing.value\n\n if (user === undefined) return <Spinner label=\"Loading\" />\n if (user === null) {\n return (\n <Flex gap={4}>\n <Heading level={1}>Billing</Heading>\n <Text>\n <Link href=\"/account\">Sign in</Link> to subscribe: a plan belongs to your account.\n </Text>\n </Flex>\n )\n }\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Billing</Heading>\n <Text muted>\n Paid on Stripe's hosted checkout; changed, paused or cancelled in Stripe's billing portal.\n The Worker keeps your plan in step through Stripe's webhook.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Heading level={2}>{PLAN.name}</Heading>\n {plan ? (\n <Badge variant={plan.active ? 'success' : 'secondary'}>{LABEL[plan.status]}</Badge>\n ) : null}\n </Flex>\n <Text muted>{PLAN.description}</Text>\n <Text size=\"lg\">\n {formatPrice(PLAN.amount, PLAN.currency)} a {PLAN.interval}\n </Text>\n {plan?.currentPeriodEnd ? (\n <Text size=\"sm\" muted>\n {plan.cancelAtPeriodEnd ? 'Ends' : 'Renews'} on{' '}\n {new Date(plan.currentPeriodEnd).toLocaleDateString()}\n </Text>\n ) : null}\n <Flex direction=\"horizontal\" gap={2} wrap>\n {plan && !plan.active ? (\n <Button\n loading={busy.value === 'subscribe'}\n onClick={() => void leaveFor('subscribe')}\n >\n Subscribe\n </Button>\n ) : null}\n {plan?.canManage ? (\n <Button\n variant=\"secondary\"\n loading={busy.value === 'portal'}\n onClick={() => void leaveFor('portal')}\n >\n Manage billing\n </Button>\n ) : null}\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not done\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example social`: scheduled posts to connected LinkedIn and Mastodon accounts --- */\n\n/** Daily, off the hour: renews Threads tokens in their last 30 days (\\`renewConnections\\`). */\nconst SOCIAL_CRON = '17 4 * * *'\n\nfunction cfSocialTs(): string {\n return `import {\n blueskyPublisher,\n bufferPublisher,\n linkedinPublisher,\n mastodonPublisher,\n threadsPublisher,\n} from '@cascivo/app/social'\nimport type { Publisher, SocialImage, SocialPost } from '@cascivo/app/social'\nimport { defineUploads } from '@cascivo/app/uploads'\n\n/**\n * Posts scheduled to the accounts a user connected, shared by the Worker (worker/social.ts)\n * and the /social page. The publishers run on both sides: the page shows what a network would\n * refuse while you type, and the Worker refuses the same post before scheduling it.\n */\n\nexport const NETWORKS = {\n bluesky: 'Bluesky',\n buffer: 'Buffer',\n linkedin: 'LinkedIn',\n mastodon: 'Mastodon',\n threads: 'Threads',\n} as const\nexport type Network = keyof typeof NETWORKS\n\nexport function isNetwork(value: string): value is Network {\n return Object.keys(NETWORKS).includes(value)\n}\n\n/**\n * Networks with no idempotency key: a request that timed out may have posted, so their posts\n * are never retried.\n */\nexport function postsOnce(network: Network): boolean {\n return network === 'buffer' || network === 'linkedin' || network === 'threads'\n}\n\n/**\n * Images attached to a post: uploaded through the Worker into R2 (worker/social.ts). 1 MB and\n * JPEG or PNG, which every network here takes (Bluesky's limit is 1 MB).\n */\nexport const IMAGES = defineUploads({\n path: '/api/social/images',\n maxBytes: 1_000_000,\n types: ['image/jpeg', 'image/png'],\n})\nexport const MAX_IMAGES = 4\n\n/**\n * Threads and Buffer fetch images by URL. The Worker passes one that signs a link to the image\n * (worker/social-post.ts); the page only checks posts, so this stands in there.\n */\nexport type ImageLink = (image: SocialImage) => Promise<string>\nconst checkOnly: ImageLink = async () => {\n throw new Error('Only the Worker links images')\n}\n\nconst publishers: Record<Exclude<Network, 'buffer' | 'threads'>, Publisher> = {\n bluesky: blueskyPublisher(),\n linkedin: linkedinPublisher(),\n mastodon: mastodonPublisher(),\n}\n\n/** A Mastodon server's own limits (\\`mastodonServerLimits\\`), which many set above 500. */\nexport interface ServerLimits {\n maxChars: number\n maxImages: number\n urlWeight: number\n}\n\n/**\n * The publisher for an account. A Buffer channel posts through Buffer, checked against the\n * limit of the network behind it (\\`service\\`); a Mastodon account, against its server's.\n */\nexport function publisherFor(\n account: { network: Network; service: string | null; limits?: ServerLimits | null },\n imageLink: ImageLink = checkOnly,\n): Publisher {\n if (account.network === 'buffer') {\n return bufferPublisher({\n uploadImage: imageLink,\n ...(account.service ? { service: account.service } : {}),\n })\n }\n if (account.network === 'threads') return threadsPublisher({ uploadImage: imageLink })\n if (account.network === 'mastodon' && account.limits) return mastodonPublisher(account.limits)\n return publishers[account.network]\n}\n\nexport const MAX_TEXT = 5000\nexport const MAX_ACCOUNTS = 10\n/** How far ahead a post can be scheduled. */\nexport const MAX_DAYS_AHEAD = 365\n/** Buffer's request budget: this many per 15 minutes, for every user of the app together. */\nexport const BUFFER_BUDGET = 100\n\nexport interface Account {\n id: string\n network: Network\n /** \\`@ada.bsky.social\\`, \\`@ada@hachyderm.io\\`, or the name on a LinkedIn profile. */\n label: string\n /** \\`expiring\\`: the token ends soon and cannot be renewed (LinkedIn): connect it again. */\n status: 'active' | 'expiring' | 'reconnect'\n /** For a Buffer channel, the network behind it (\\`instagram\\`, \\`twitter\\`, …); else \\`null\\`. */\n service: string | null\n /** For a Mastodon account, its server's limits; else \\`null\\`. */\n limits: ServerLimits | null\n}\n\n/** \\`queued\\`: handed to Buffer, which holds it until the time (it shows in Buffer's queue). */\nexport type TargetStatus = 'pending' | 'publishing' | 'queued' | 'posted' | 'failed'\n\nexport interface Target {\n accountId: string\n network: Network\n service: string | null\n label: string\n status: TargetStatus\n url: string | null\n error: string | null\n}\n\nexport type PostStatus = 'scheduled' | 'publishing' | 'done' | 'partial' | 'failed' | 'cancelled'\n\nexport interface Link {\n url: string\n /** LinkedIn and Bluesky show it on the card; Mastodon builds its own card from the page. */\n title: string\n}\n\n/** An image on a post: its key in the user's uploads, its type, and its description. */\nexport interface PostImage {\n key: string\n type: string\n alt: string\n}\n\nexport interface ScheduledPost {\n id: string\n text: string\n link: Link | null\n images: PostImage[]\n /** ISO time it goes out. */\n at: string\n /** Buffer accounts are handed to Buffer at once, to hold until \\`at\\`. */\n inBuffer: boolean\n status: PostStatus\n targets: Target[]\n}\n\nexport interface Social {\n /** The networks this app can connect (LinkedIn needs its client id and secret). */\n networks: Network[]\n accounts: Account[]\n posts: ScheduledPost[]\n /** Buffer requests this app made in the current 15 minutes, of 100; \\`null\\` without Buffer. */\n bufferUsed: number | null\n}\n\nexport interface PostInput {\n text: string\n link: Link | null\n images: PostImage[]\n accountIds: string[]\n /** ISO time, or \\`null\\` for now. */\n at: string | null\n /** Hand Buffer accounts to Buffer now, to hold until \\`at\\`. */\n inBuffer: boolean\n}\n\n/** What \\`publishers\\` check: the post as a network sees it. */\n/**\n * What \\`publishers\\` check and post: the post as a network sees it. Checking needs only each\n * image's type and description; posting passes the bytes (\\`data\\`, in the same order).\n */\nexport function asSocialPost(\n input: { text: string; link: Link | null; images: readonly PostImage[] },\n data: readonly Blob[] = [],\n): SocialPost {\n const images = input.images.map((image, i) => ({\n data: data[i] ?? new Blob([], { type: image.type }),\n alt: image.alt,\n }))\n return {\n text: input.text,\n ...(input.link ? { link: input.link } : {}),\n ...(images.length > 0 ? { images } : {}),\n }\n}\n\nconst record = (raw: unknown, what: string): Record<string, unknown> => {\n if (typeof raw !== 'object' || raw === null) throw new Error(\\`Malformed \\${what}\\`)\n return raw as Record<string, unknown>\n}\n\nconst text = (value: unknown, what: string): string => {\n if (typeof value !== 'string') throw new Error(\\`Malformed \\${what}\\`)\n return value\n}\n\nfunction parseLink(raw: unknown): Link | null {\n if (raw === null || raw === undefined) return null\n const { url, title } = record(raw, 'link')\n return { url: text(url, 'link url'), title: text(title, 'link title') }\n}\n\nconst IMAGE_KEY = /^[0-9a-f-]{36}\\\\/[\\\\w.-]{1,100}$/\n\nfunction parseImages(raw: unknown): PostImage[] {\n if (raw === undefined || raw === null) return []\n if (!Array.isArray(raw) || raw.length > MAX_IMAGES) {\n throw new Error(\\`Attach at most \\${MAX_IMAGES} images\\`)\n }\n return raw.map((item) => {\n const r = record(item, 'image')\n const key = text(r['key'], 'image key')\n const type = text(r['type'], 'image type')\n const alt = text(r['alt'], 'image description').trim()\n if (!IMAGE_KEY.test(key) || !IMAGES.types.includes(type)) throw new Error('Malformed image')\n if (!alt || alt.length > 1000) throw new Error('Describe each image (alt text), briefly')\n return { key, type, alt }\n })\n}\n\nexport function parsePostInput(raw: unknown): PostInput {\n const r = record(raw, 'post')\n const body = text(r['text'], 'text')\n if (body.length > MAX_TEXT) throw new Error(\\`Keep the text under \\${MAX_TEXT} characters\\`)\n const link = parseLink(r['link'])\n if (link && !/^https?:\\\\/\\\\//.test(link.url)) throw new Error('The link must be an http(s) URL')\n const ids = r['accountIds']\n if (\n !Array.isArray(ids) ||\n ids.length === 0 ||\n ids.length > MAX_ACCOUNTS ||\n !ids.every((id) => typeof id === 'string')\n ) {\n throw new Error(\\`Pick 1 to \\${MAX_ACCOUNTS} accounts\\`)\n }\n const at = r['at'] ?? null\n if (at !== null && (typeof at !== 'string' || Number.isNaN(Date.parse(at)))) {\n throw new Error('Send the time as an ISO date')\n }\n return {\n text: body,\n link,\n images: parseImages(r['images']),\n accountIds: [...new Set(ids)],\n at,\n inBuffer: r['inBuffer'] === true,\n }\n}\n\nfunction parseAccount(raw: unknown): Account {\n const r = record(raw, 'account')\n const network = text(r['network'], 'network')\n const status = text(r['status'], 'status')\n if (!isNetwork(network)) throw new Error('Malformed network')\n if (status !== 'active' && status !== 'expiring' && status !== 'reconnect') {\n throw new Error('Malformed account status')\n }\n return {\n id: text(r['id'], 'account id'),\n network,\n label: text(r['label'], 'label'),\n status,\n service: typeof r['service'] === 'string' ? r['service'] : null,\n limits: parseLimits(r['limits']),\n }\n}\n\nfunction parseLimits(raw: unknown): ServerLimits | null {\n if (raw === null || raw === undefined) return null\n const r = record(raw, 'limits')\n const count = (value: unknown) => {\n if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {\n throw new Error('Malformed limits')\n }\n return value\n }\n return {\n maxChars: count(r['maxChars']),\n maxImages: count(r['maxImages']),\n urlWeight: count(r['urlWeight']),\n }\n}\n\nconst TARGET_STATUSES: readonly TargetStatus[] = [\n 'pending',\n 'publishing',\n 'queued',\n 'posted',\n 'failed',\n]\nconst POST_STATUSES: readonly PostStatus[] = [\n 'scheduled',\n 'publishing',\n 'done',\n 'partial',\n 'failed',\n 'cancelled',\n]\n\nfunction parseTarget(raw: unknown): Target {\n const r = record(raw, 'target')\n const network = text(r['network'], 'network')\n const status = TARGET_STATUSES.find((s) => s === r['status'])\n if (!isNetwork(network) || !status) throw new Error('Malformed target')\n return {\n accountId: text(r['accountId'], 'account id'),\n network,\n service: typeof r['service'] === 'string' ? r['service'] : null,\n label: text(r['label'], 'label'),\n status,\n url: typeof r['url'] === 'string' ? r['url'] : null,\n error: typeof r['error'] === 'string' ? r['error'] : null,\n }\n}\n\nexport function parseScheduledPost(raw: unknown): ScheduledPost {\n const r = record(raw, 'post')\n const status = POST_STATUSES.find((s) => s === r['status'])\n if (!status || !Array.isArray(r['targets'])) throw new Error('Malformed post')\n return {\n id: text(r['id'], 'post id'),\n text: text(r['text'], 'text'),\n link: parseLink(r['link']),\n images: parseImages(r['images']),\n at: text(r['at'], 'time'),\n // D1 hands back 1 and 0.\n inBuffer: r['inBuffer'] === true || r['inBuffer'] === 1,\n status,\n targets: r['targets'].map(parseTarget),\n }\n}\n\nexport function parseSocial(raw: unknown): Social {\n const r = record(raw, 'social')\n const networks = r['networks']\n if (!Array.isArray(networks) || !Array.isArray(r['accounts']) || !Array.isArray(r['posts'])) {\n throw new Error('Malformed social')\n }\n return {\n networks: networks.filter((n): n is Network => typeof n === 'string' && isNetwork(n)),\n accounts: r['accounts'].map(parseAccount),\n posts: r['posts'].map(parseScheduledPost),\n bufferUsed: typeof r['bufferUsed'] === 'number' ? r['bufferUsed'] : null,\n }\n}\n`\n}\n\nfunction cfSocialWorkerTs(opts: ScaffoldOptions): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { currentUser, requireUser } from '@cascivo/app/auth-server'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport {\n bluesky,\n blueskyClientMetadata,\n buffer,\n blueskyJwks,\n linkedin,\n mastodon,\n parseBlueskyKey,\n threads,\n} from '@cascivo/app/oauth'\nimport type { OAuthProvider } from '@cascivo/app/oauth'\nimport {\n connectionTokens,\n expiringConnections,\n handleConnections,\n listConnections,\n mastodonRegistrations,\n refreshConnections,\n} from '@cascivo/app/oauth-server'\nimport type { Connection } from '@cascivo/app/oauth-server'\nimport { bufferChannels, mastodonServerLimits } from '@cascivo/app/social'\nimport { handleUploads } from '@cascivo/app/uploads-server'\nimport type { UploadBucket } from '@cascivo/app/uploads-server'\nimport {\n asSocialPost,\n BUFFER_BUDGET,\n IMAGES,\n isNetwork,\n MAX_DAYS_AHEAD,\n NETWORKS,\n parseScheduledPost,\n publisherFor,\n} from '../src/social'\nimport type {\n Account,\n PostImage,\n PostInput,\n PostStatus,\n ScheduledPost,\n ServerLimits,\n Social,\n} from '../src/social'\n\n/** What the Workflow behind a scheduled post (worker/social-post.ts) is started with. */\nexport interface SocialPostParams {\n postId: string\n userId: string\n}\n\n/** What sending a reminder needs of the Email Service binding (\\`send_email\\`). */\nexport interface ReminderSender {\n send(message: {\n from: string\n to: string\n subject: string\n text: string\n html: string\n }): Promise<unknown>\n}\n\n/** The R2 bucket images are uploaded to (\\`SOCIAL_MEDIA\\` in wrangler.jsonc). */\nexport type SocialMediaBucket = UploadBucket\n\n/** What scheduling needs of the Worker's env. */\nexport interface SocialEnv {\n DB: Database\n SOCIAL_MEDIA: SocialMediaBucket\n EMAIL: ReminderSender\n /** Who reconnect reminders come from, and the deployed app they link to (wrangler.jsonc). */\n REMINDER_FROM: string\n APP_URL: string\n /** Seals the connected accounts' tokens (and Mastodon's app registrations). */\n AUTH_SECRET?: string\n LINKEDIN_CLIENT_ID?: string\n LINKEDIN_CLIENT_SECRET?: string\n /** An ES256 private JWK with a kid: makes Bluesky sessions last (README). */\n BLUESKY_PRIVATE_JWK?: string\n /** A Buffer app client (README); Buffer is offered once its id is set. */\n BUFFER_CLIENT_ID?: string\n BUFFER_CLIENT_SECRET?: string\n /** A Meta app with the Threads use case (README); Threads is offered once both are set. */\n THREADS_APP_ID?: string\n THREADS_APP_SECRET?: string\n SOCIAL_POST: Workflow<SocialPostParams>\n}\n\nconst APP_NAME = '${opts.name.replace(/[\\\\\\\\']/g, '')}'\n\nconst migrations = [\n {\n id: '0001_social',\n statements: [\n \\`CREATE TABLE social_posts (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL,\n text TEXT NOT NULL,\n link_url TEXT,\n link_title TEXT,\n at TEXT NOT NULL,\n status TEXT NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n 'CREATE INDEX social_posts_user ON social_posts (user_id, at)',\n \\`CREATE TABLE social_targets (\n post_id TEXT NOT NULL REFERENCES social_posts (id),\n account_id TEXT NOT NULL,\n network TEXT NOT NULL,\n service TEXT,\n label TEXT NOT NULL,\n status TEXT NOT NULL,\n url TEXT,\n error TEXT,\n PRIMARY KEY (post_id, account_id)\n )\\`,\n \\`CREATE TABLE social_buffer_channels (\n connection_id TEXT NOT NULL,\n channel_id TEXT NOT NULL,\n service TEXT NOT NULL,\n name TEXT NOT NULL,\n fetched_at INTEGER NOT NULL,\n PRIMARY KEY (connection_id, channel_id)\n )\\`,\n ],\n },\n {\n id: '0002_social_server_limits',\n statements: [\n \\`CREATE TABLE social_server_limits (\n server TEXT PRIMARY KEY,\n max_chars INTEGER NOT NULL,\n max_images INTEGER NOT NULL,\n url_weight INTEGER NOT NULL,\n fetched_at INTEGER NOT NULL\n )\\`,\n ],\n },\n {\n id: '0003_social_in_buffer',\n statements: ['ALTER TABLE social_posts ADD COLUMN in_buffer INTEGER NOT NULL DEFAULT 0'],\n },\n {\n id: '0004_social_buffer_budget',\n statements: [\n 'CREATE TABLE social_buffer_budget (slot INTEGER PRIMARY KEY, used INTEGER NOT NULL)',\n ],\n },\n {\n id: '0005_social_reminders',\n statements: [\n \\`CREATE TABLE social_reminders (\n connection_id TEXT NOT NULL,\n expires_at INTEGER NOT NULL,\n sent_at TEXT NOT NULL,\n PRIMARY KEY (connection_id, expires_at)\n )\\`,\n ],\n },\n {\n id: '0006_social_images',\n statements: [\"ALTER TABLE social_posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'\"],\n },\n]\n\nexport const secretOf = (env: SocialEnv): string => env.AUTH_SECRET ?? ''\n\n/** Bluesky fetches this app's client metadata here: its URL is the client id. */\nconst BLUESKY_METADATA = '/api/bluesky/client-metadata.json'\nconst BLUESKY_JWKS = '/api/bluesky/jwks.json'\nconst BLUESKY_SCOPES = ['atproto', 'transition:generic']\n\nconst blueskyKey = (env: SocialEnv) =>\n env.BLUESKY_PRIVATE_JWK ? parseBlueskyKey(env.BLUESKY_PRIVATE_JWK) : undefined\n\n/**\n * The accounts a user can connect: Bluesky and Mastodon always (Bluesky reads this app's\n * client metadata; the app registers itself with each Mastodon server); Buffer, LinkedIn and\n * Threads once their app's values are set. Each asks for the scopes to post.\n */\nexport function socialProviders(env: SocialEnv): OAuthProvider[] {\n const key = blueskyKey(env)\n const providers: OAuthProvider[] = [\n bluesky({\n clientMetadataPath: BLUESKY_METADATA,\n scopes: BLUESKY_SCOPES,\n ...(key ? { privateKey: key } : {}),\n }),\n ]\n if (env.BUFFER_CLIENT_ID) {\n providers.push(\n buffer({\n clientId: env.BUFFER_CLIENT_ID,\n ...(env.BUFFER_CLIENT_SECRET ? { clientSecret: env.BUFFER_CLIENT_SECRET } : {}),\n }),\n )\n }\n if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) {\n providers.push(\n linkedin({\n clientId: env.LINKEDIN_CLIENT_ID,\n clientSecret: env.LINKEDIN_CLIENT_SECRET,\n scopes: ['openid', 'profile', 'w_member_social'],\n }),\n )\n }\n if (env.THREADS_APP_ID && env.THREADS_APP_SECRET) {\n providers.push(threads({ clientId: env.THREADS_APP_ID, clientSecret: env.THREADS_APP_SECRET }))\n }\n providers.push(\n mastodon({\n appName: APP_NAME,\n scopes: ['profile', 'write:statuses', 'write:media'],\n registrations: mastodonRegistrations(env.DB, secretOf(env)),\n }),\n )\n return providers\n}\n\n/**\n * /api/connections: list, connect (\\`/api/connections/mastodon?server=hachyderm.io\\`) and remove\n * the signed-in user's accounts. A failed connection comes back to /social with \\`?error=\\`.\n */\nexport function connections(env: SocialEnv): (request: Request) => Promise<Response | null> {\n return handleConnections(env.DB, {\n secret: secretOf(env),\n providers: socialProviders(env),\n errorPath: '/social',\n })\n}\n\n/**\n * The daily Cron Trigger: renews Threads tokens in their last 30 days. A Threads token renews\n * only while it still works, so one nobody posts with would otherwise lapse after 60 days.\n */\nexport async function renewConnections(env: SocialEnv): Promise<void> {\n const { renewed, failed } = await refreshConnections(env.DB, {\n secret: secretOf(env),\n providers: socialProviders(env),\n })\n if (renewed + failed > 0) console.log(\\`[social] renewed \\${renewed}, could not renew \\${failed}\\`)\n}\n\nconst escapeHtml = (value: string) =>\n value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"')\n\n/**\n * The daily Cron Trigger, after renewing: a token that cannot be renewed (LinkedIn's 60 days)\n * and ends within a week gets its owner one email with a link to connect it again. Once per\n * token: connecting again issues a new one, with a new expiry.\n */\nexport async function remindExpiring(env: SocialEnv): Promise<void> {\n if (!env.REMINDER_FROM || !env.APP_URL) {\n console.log('[social] set REMINDER_FROM and APP_URL in wrangler.jsonc to send reminders')\n return\n }\n await migrate(env.DB, migrations)\n for (const { connection, email } of await expiringConnections(env.DB)) {\n if (!email || !isNetwork(connection.provider) || connection.expiresAt === null) continue\n const [sent] = await queryRows(\n env.DB,\n 'SELECT sent_at FROM social_reminders WHERE connection_id = ? AND expires_at = ?',\n [connection.id, connection.expiresAt],\n (raw) => raw,\n )\n if (sent) continue\n const network = NETWORKS[connection.provider]\n const account = connection.handle ?? connection.name ?? network\n const path = \\`/api/connections/\\${connection.provider}?returnTo=/social\\`\n const url = new URL(path, env.APP_URL).href\n const ends = new Date(connection.expiresAt * 1000).toUTCString().slice(0, 16)\n await env.EMAIL.send({\n from: env.REMINDER_FROM,\n to: email,\n subject: \\`Connect \\${network} again before \\${ends}\\`,\n text: \\`\\${network} lets this app post as \\${account} only until \\${ends}. Connect it again to keep scheduled posts going: \\${url}\\`,\n html: \\`<p>\\${escapeHtml(network)} lets this app post as \\${escapeHtml(account)} only until \\${escapeHtml(ends)}.</p><p><a href=\"\\${escapeHtml(url)}\">Connect it again</a> to keep scheduled posts going.</p>\\`,\n })\n await env.DB.prepare(\n 'INSERT INTO social_reminders (connection_id, expires_at, sent_at) VALUES (?, ?, ?)',\n )\n .bind(connection.id, connection.expiresAt, new Date().toISOString())\n .run()\n }\n}\n\n/**\n * Bluesky's client metadata and public key, which its servers fetch (no session). Without\n * BLUESKY_PRIVATE_JWK the app is a public client and serves no key.\n */\nexport function blueskyClient(env: SocialEnv, request: Request): Response | null {\n const url = new URL(request.url)\n if (request.method !== 'GET') return null\n const key = blueskyKey(env)\n if (url.pathname === BLUESKY_METADATA) {\n return Response.json(\n blueskyClientMetadata({\n origin: url.origin,\n redirectPaths: ['/api/connections/bluesky/callback'],\n clientName: APP_NAME,\n clientMetadataPath: BLUESKY_METADATA,\n scopes: BLUESKY_SCOPES,\n jwksPath: BLUESKY_JWKS,\n ...(key ? { privateKey: key } : {}),\n }),\n )\n }\n if (url.pathname === BLUESKY_JWKS && key) return Response.json(blueskyJwks(key))\n return null\n}\n\n/** Where a user's image lives in the bucket: under their own prefix, so only they reach it. */\nexport const mediaKey = (userId: string, key: string) => \\`social/\\${userId}/\\${key}\\`\n\n/**\n * \\`/api/social/images\\`: the signed-in user's uploads (\\`@cascivo/app/uploads-server\\`), stored\n * under their own prefix, so they can attach, preview and post only their own.\n */\nexport async function images(env: SocialEnv, request: Request): Promise<Response | null> {\n const { pathname } = new URL(request.url)\n if (pathname !== IMAGES.path && !pathname.startsWith(\\`\\${IMAGES.path}/\\`)) return null\n const user = await currentUser(env.DB, request)\n if (!user) return Response.json({ error: 'Sign in first' }, { status: 401 })\n return handleUploads(IMAGES, env.SOCIAL_MEDIA, { prefix: mediaKey(user.id, '') })(request)\n}\n\nconst MEDIA_PATH = '/api/social/media/'\n\nasync function mediaSigningKey(env: SocialEnv): Promise<CryptoKey> {\n const secret = secretOf(env)\n if (secret.length < 32) throw new Error('Set AUTH_SECRET (32+ characters) to link images')\n return crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(\\`social-media:\\${secret}\\`),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign', 'verify'],\n )\n}\n\nconst toHex = (bytes: ArrayBuffer) =>\n [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('')\n\n/**\n * A link to an image that works without a session until \\`until\\` (ms): Threads and Buffer\n * fetch images by URL. Signed with AUTH_SECRET; the image itself stays private in R2.\n */\nexport async function signedMediaUrl(env: SocialEnv, objectKey: string, until: number) {\n if (!env.APP_URL) throw new Error('Set APP_URL in wrangler.jsonc so networks can fetch images')\n const expires = String(Math.ceil(until / 1000))\n const signature = await crypto.subtle.sign(\n 'HMAC',\n await mediaSigningKey(env),\n new TextEncoder().encode(\\`\\${objectKey}:\\${expires}\\`),\n )\n const path = MEDIA_PATH + objectKey.split('/').map(encodeURIComponent).join('/')\n const url = new URL(path, env.APP_URL)\n url.searchParams.set('expires', expires)\n url.searchParams.set('signature', toHex(signature))\n return url.href\n}\n\n/** \\`GET /api/social/media/<key>?expires&signature\\`: an image, for a link \\`signedMediaUrl\\` made. */\nexport async function media(env: SocialEnv, request: Request): Promise<Response | null> {\n const url = new URL(request.url)\n if (request.method !== 'GET' || !url.pathname.startsWith(MEDIA_PATH)) return null\n const refused = () => new Response('Not found', { status: 404 })\n let objectKey: string\n try {\n objectKey = decodeURIComponent(url.pathname.slice(MEDIA_PATH.length))\n } catch {\n return refused()\n }\n const expires = url.searchParams.get('expires') ?? ''\n const signature = url.searchParams.get('signature') ?? ''\n if (!/^\\\\d{1,12}$/.test(expires) || Number(expires) * 1000 < Date.now()) return refused()\n if (!/^[0-9a-f]{64}$/.test(signature)) return refused()\n const bytes = new Uint8Array(signature.match(/../g)!.map((pair) => parseInt(pair, 16)))\n const valid = await crypto.subtle.verify(\n 'HMAC',\n await mediaSigningKey(env),\n bytes,\n new TextEncoder().encode(\\`\\${objectKey}:\\${expires}\\`),\n )\n if (!valid) return refused()\n const object = await env.SOCIAL_MEDIA.get(objectKey)\n if (!object) return refused()\n return new Response(object.body, {\n headers: {\n 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',\n 'cache-control': 'private, max-age=300',\n 'x-content-type-options': 'nosniff',\n },\n })\n}\n\n/** A Buffer channel's account id: its connection, and the channel within it. */\nexport const BUFFER_SEPARATOR = '~'\n\n/** Buffer's request budget is small: its channel list is kept for an hour. */\nconst CHANNELS_TTL_MS = 60 * 60 * 1000\n\ninterface ChannelRow {\n id: string\n service: string\n name: string\n fetchedAt: number\n}\n\nfunction parseChannelRow(raw: unknown): ChannelRow {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed channel row')\n const { channel_id, service, name, fetched_at } = raw as Record<string, unknown>\n if (typeof channel_id !== 'string' || typeof service !== 'string' || typeof name !== 'string') {\n throw new Error('Malformed channel row')\n }\n return { id: channel_id, service, name, fetchedAt: Number(fetched_at) }\n}\n\n/**\n * Buffer's request budget (\\`BUFFER_BUDGET\\` per 15 minutes, for every user of this app\n * together), counted per window as the app spends it, so the page can show it and channel lists\n * can yield when it runs low. Buffer enforces the real one (a 429 with Retry-After).\n */\nconst BUFFER_WINDOW_MS = 15 * 60 * 1000\n/** Below this, channel lists are not refreshed: the rest is kept for posting. */\nconst BUFFER_RESERVE = 20\n\nconst bufferWindow = () => Math.floor(Date.now() / BUFFER_WINDOW_MS)\n\n/** Counts one Buffer request against the current window. */\nexport async function spendBuffer(env: SocialEnv): Promise<void> {\n await migrate(env.DB, migrations)\n await env.DB.batch([\n env.DB.prepare(\n \\`INSERT INTO social_buffer_budget (slot, used) VALUES (?, 1)\n ON CONFLICT (slot) DO UPDATE SET used = used + 1\\`,\n ).bind(bufferWindow()),\n env.DB.prepare('DELETE FROM social_buffer_budget WHERE slot < ?').bind(bufferWindow() - 1),\n ])\n}\n\n/** Buffer requests this app has made in the current window. */\nasync function bufferUsed(env: SocialEnv): Promise<number> {\n await migrate(env.DB, migrations)\n const [row] = await queryRows(\n env.DB,\n 'SELECT used FROM social_buffer_budget WHERE slot = ?',\n [bufferWindow()],\n (raw) =>\n typeof raw === 'object' && raw !== null ? Number((raw as { used?: unknown }).used) : 0,\n )\n return row ?? 0\n}\n\n/**\n * A Buffer connection's channels, from D1 when fresh, else from Buffer. If Buffer cannot be\n * asked, or its budget is running low, the last list known is used.\n */\nasync function channelsOf(env: SocialEnv, connection: Connection, userId: string) {\n const cached = await queryRows(\n env.DB,\n 'SELECT channel_id, service, name, fetched_at FROM social_buffer_channels WHERE connection_id = ?',\n [connection.id],\n parseChannelRow,\n )\n const fresh = cached.length > 0 && cached.every((c) => Date.now() - c.fetchedAt < CHANNELS_TTL_MS)\n if (fresh || (cached.length > 0 && (await bufferUsed(env)) > BUFFER_BUDGET - BUFFER_RESERVE)) {\n return cached\n }\n try {\n const { tokens } = await connectionTokens(\n env.DB,\n { secret: secretOf(env), providers: socialProviders(env) },\n { connectionId: connection.id, userId },\n )\n await spendBuffer(env)\n const listed = await bufferChannels(tokens, connection.subject)\n await env.DB.batch([\n env.DB.prepare('DELETE FROM social_buffer_channels WHERE connection_id = ?').bind(\n connection.id,\n ),\n ...listed.map((c) =>\n env.DB.prepare(\n \\`INSERT INTO social_buffer_channels (connection_id, channel_id, service, name, fetched_at)\n VALUES (?, ?, ?, ?, ?)\\`,\n ).bind(connection.id, c.id, c.service, c.name, Date.now()),\n ),\n ])\n return listed\n } catch (error) {\n console.warn('[social] could not list Buffer channels:', error)\n return cached\n }\n}\n\n/** A Mastodon server's limits change rarely: they are kept for a day. */\nconst LIMITS_TTL_MS = 24 * 60 * 60 * 1000\n\n/**\n * A Mastodon server's own limits, from D1 when fresh, else from the server. Shared by every\n * account on that server; a server that does not answer gets Mastodon's defaults for now.\n */\nexport async function serverLimits(env: SocialEnv, server: string): Promise<ServerLimits> {\n await migrate(env.DB, migrations)\n const [cached] = await queryRows(\n env.DB,\n \\`SELECT max_chars AS maxChars, max_images AS maxImages, url_weight AS urlWeight, fetched_at AS fetchedAt\n FROM social_server_limits WHERE server = ?\\`,\n [server],\n (raw) => {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed limits row')\n const { maxChars, maxImages, urlWeight, fetchedAt } = raw as Record<string, unknown>\n return {\n limits: {\n maxChars: Number(maxChars),\n maxImages: Number(maxImages),\n urlWeight: Number(urlWeight),\n },\n fetchedAt: Number(fetchedAt),\n }\n },\n )\n if (cached && Date.now() - cached.fetchedAt < LIMITS_TTL_MS) return cached.limits\n const limits = await mastodonServerLimits(server)\n await env.DB.prepare(\n \\`INSERT INTO social_server_limits (server, max_chars, max_images, url_weight, fetched_at)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT (server) DO UPDATE SET max_chars = excluded.max_chars,\n max_images = excluded.max_images, url_weight = excluded.url_weight,\n fetched_at = excluded.fetched_at\\`,\n )\n .bind(server, limits.maxChars, limits.maxImages, limits.urlWeight, Date.now())\n .run()\n return limits\n}\n\nasync function userAccounts(env: SocialEnv, userId: string): Promise<Account[]> {\n await migrate(env.DB, migrations)\n const accounts: Account[] = []\n for (const connection of await listConnections(env.DB, userId)) {\n if (!isNetwork(connection.provider)) continue\n if (connection.provider !== 'buffer') {\n accounts.push({\n id: connection.id,\n network: connection.provider,\n label: connection.handle ?? connection.name ?? connection.subject,\n status: connection.status,\n service: null,\n limits:\n connection.provider === 'mastodon' && connection.server\n ? await serverLimits(env, connection.server)\n : null,\n })\n continue\n }\n // Each channel in Buffer is an account of its own to post to.\n for (const channel of await channelsOf(env, connection, userId)) {\n accounts.push({\n id: \\`\\${connection.id}\\${BUFFER_SEPARATOR}\\${channel.id}\\`,\n network: 'buffer',\n label: \\`\\${channel.name} (\\${channel.service}, via Buffer)\\`,\n status: connection.status,\n service: channel.service,\n limits: null,\n })\n }\n }\n return accounts\n}\n\nexport async function readPosts(db: Database, where: string, params: unknown[]) {\n await migrate(db, migrations)\n const posts = await queryRows(\n db,\n \\`SELECT id, text, link_url AS linkUrl, link_title AS linkTitle, images, at, in_buffer AS inBuffer,\n status FROM social_posts WHERE \\${where} ORDER BY at DESC LIMIT 50\\`,\n params,\n (raw) => raw as Record<string, unknown>,\n )\n if (posts.length === 0) return []\n const ids = posts.map((p) => String(p['id']))\n const targets = await queryRows(\n db,\n \\`SELECT post_id AS postId, account_id AS accountId, network, service, label, status, url, error\n FROM social_targets WHERE post_id IN (\\${ids.map(() => '?').join(', ')})\\`,\n ids,\n (raw) => raw as Record<string, unknown>,\n )\n return posts.map((p) =>\n parseScheduledPost({\n id: p['id'],\n text: p['text'],\n link:\n typeof p['linkUrl'] === 'string'\n ? { url: p['linkUrl'], title: p['linkTitle'] ?? '' }\n : null,\n images: JSON.parse(String(p['images'] ?? '[]')) as unknown,\n at: p['at'],\n inBuffer: p['inBuffer'],\n status: p['status'],\n targets: targets.filter((t) => t['postId'] === p['id']),\n }),\n )\n}\n\n/** The signed-in user's networks, accounts and recent posts: everything /social shows. */\nexport async function getSocial(env: SocialEnv, request: Request): Promise<Social> {\n const user = await requireUser(env.DB, request)\n return {\n networks: socialProviders(env)\n .map((p) => p.id)\n .filter(isNetwork),\n accounts: await userAccounts(env, user.id),\n posts: await readPosts(env.DB, 'user_id = ?', [user.id]),\n bufferUsed: env.BUFFER_CLIENT_ID ? await bufferUsed(env) : null,\n }\n}\n\n/**\n * Schedules a post: each account must be the user's and connected, and each network's\n * publisher must accept the post, before anything is stored. A Workflow then waits until the\n * time and posts to each account (worker/social-post.ts).\n */\nexport async function schedulePost(\n env: SocialEnv,\n request: Request,\n input: PostInput,\n): Promise<ScheduledPost> {\n const user = await requireUser(env.DB, request)\n const accounts = new Map((await userAccounts(env, user.id)).map((a) => [a.id, a]))\n const chosen = input.accountIds.map((id) => {\n const account = accounts.get(id)\n if (!account) throw new HttpError(400, 'Pick accounts you have connected')\n if (account.status === 'reconnect') {\n throw new HttpError(400, \\`Connect \\${account.label} again first\\`)\n }\n return account\n })\n // Each image must be one this user uploaded; its stored type is the one that counts.\n const images: PostImage[] = []\n for (const image of input.images) {\n const stored = await env.SOCIAL_MEDIA.head(mediaKey(user.id, image.key))\n const type = stored?.httpMetadata?.contentType\n if (!stored || !type) throw new HttpError(400, 'An image is missing: attach it again')\n images.push({ ...image, type })\n }\n const post = asSocialPost({ ...input, images })\n for (const account of chosen) {\n const problems = publisherFor(account).check(post)\n if (problems.length > 0) {\n throw new HttpError(400, \\`\\${account.label}: \\${problems.map((p) => p.message).join('; ')}\\`)\n }\n }\n const now = Date.now()\n const at = input.at ? Date.parse(input.at) : now\n if (at > now + MAX_DAYS_AHEAD * 86_400_000) {\n throw new HttpError(400, \\`Schedule at most \\${MAX_DAYS_AHEAD} days ahead\\`)\n }\n const id = crypto.randomUUID()\n const status: PostStatus = 'scheduled'\n await migrate(env.DB, migrations)\n await env.DB.batch([\n env.DB.prepare(\n \\`INSERT INTO social_posts (id, user_id, text, link_url, link_title, images, at, in_buffer,\n status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\\`,\n ).bind(\n id,\n user.id,\n input.text,\n input.link?.url ?? null,\n input.link?.title ?? null,\n JSON.stringify(images),\n new Date(Math.max(at, now)).toISOString(),\n input.inBuffer ? 1 : 0,\n status,\n new Date(now).toISOString(),\n ),\n ...chosen.map((account) =>\n env.DB.prepare(\n \\`INSERT INTO social_targets (post_id, account_id, network, service, label, status)\n VALUES (?, ?, ?, ?, ?, 'pending')\\`,\n ).bind(id, account.id, account.network, account.service, account.label),\n ),\n ])\n await env.SOCIAL_POST.create({ id, params: { postId: id, userId: user.id } })\n const [stored] = await readPosts(env.DB, 'id = ?', [id])\n if (!stored) throw new Error('The post was not stored')\n return stored\n}\n\n/** Cancels a post that has not started going out. */\nexport async function cancelPost(\n env: SocialEnv,\n request: Request,\n id: string,\n): Promise<ScheduledPost> {\n const user = await requireUser(env.DB, request)\n await migrate(env.DB, migrations)\n const cancelled = await queryRows(\n env.DB,\n \\`UPDATE social_posts SET status = 'cancelled'\n WHERE id = ? AND user_id = ? AND status = 'scheduled' RETURNING id\\`,\n [id, user.id],\n (raw) => raw,\n )\n if (cancelled.length === 0) throw new HttpError(409, 'Only a scheduled post can be cancelled')\n try {\n await (await env.SOCIAL_POST.get(id)).terminate()\n } catch (error) {\n // The Workflow checks the status before it posts, so a missed terminate posts nothing.\n console.warn('[social] could not stop the workflow:', error)\n }\n const [post] = await readPosts(env.DB, 'id = ?', [id])\n if (!post) throw new Error('The post disappeared')\n return post\n}\n`\n}\n\nfunction cfSocialPostTs(): string {\n return `import { queryRows } from '@cascivo/app/db'\nimport { ConnectionError, connectionTokens, markReconnect } from '@cascivo/app/oauth-server'\nimport { PublishError } from '@cascivo/app/social'\nimport { WorkflowEntrypoint } from 'cloudflare:workers'\nimport type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'\nimport { asSocialPost, NETWORKS, postsOnce, publisherFor } from '../src/social'\nimport type { ImageLink, PostImage, ScheduledPost, Target } from '../src/social'\nimport type { Env } from './index'\nimport {\n BUFFER_SEPARATOR,\n mediaKey,\n readPosts,\n secretOf,\n serverLimits,\n signedMediaUrl,\n socialProviders,\n spendBuffer,\n} from './social'\nimport type { SocialPostParams } from './social'\n\ntype TargetResult = Pick<Target, 'status' | 'url' | 'error'>\n\n/**\n * One scheduled post, as a Workflow: it sleeps until the post is due, then posts to each\n * account in its own step, so one network failing does not hold up or repeat the others.\n *\n * Retrying is decided per network. Mastodon takes an idempotency key and Bluesky a fixed record\n * key, so a step that failed mid-request is retried and cannot post twice. Buffer, LinkedIn and\n * Threads take none (\\`postsOnce\\`): their steps are never retried, and an account found mid-post\n * (a crash between the request and the record) is reported for a person to check instead of\n * being posted again.\n */\nexport class SocialPost extends WorkflowEntrypoint<Env, SocialPostParams> {\n override async run(event: WorkflowEvent<SocialPostParams>, step: WorkflowStep) {\n const { postId, userId } = event.payload\n const post = await step.do('load', () => loadPost(this.env, postId))\n if (!post) return\n // A Workflow refuses to sleep until a past time (\"post now\"). Decided in a step, so a\n // replay after the sleep takes the same path.\n const due = await step.do('due later', async () => Date.parse(post.at) > Date.now())\n const postTo = async (target: Target) => {\n const name = \\`post to \\${target.accountId}\\`\n try {\n await step.do(\n name,\n postsOnce(target.network)\n ? { retries: { limit: 0, delay: '1 second' } }\n : { retries: { limit: 3, delay: '30 seconds', backoff: 'exponential' } },\n () => publishTo(this.env, post, target, userId),\n )\n } catch (error) {\n await step.do(\\`\\${name}: give up\\`, () =>\n record(this.env, postId, target.accountId, {\n status: 'failed',\n url: null,\n error: postsOnce(target.network)\n ? \\`\\${NETWORKS[target.network]} did not answer. Check before posting this again.\\`\n : \\`\\${NETWORKS[target.network]} did not take it: \\${String(error)}\\`,\n }),\n )\n }\n }\n // \"Let Buffer hold it\": Buffer accounts go to Buffer now, with the post's time, and wait in\n // Buffer's queue (editable there) instead of here. Cancelling here cannot withdraw them.\n const handedOver =\n due && post.inBuffer ? post.targets.filter((t) => t.network === 'buffer') : []\n for (const target of handedOver) await postTo(target)\n if (due) await step.sleepUntil('wait until due', new Date(post.at))\n const started = await step.do('start', () =>\n setStatus(this.env, postId, 'scheduled', 'publishing'),\n )\n // Cancelled while it waited.\n if (!started) return\n for (const target of post.targets) {\n if (!handedOver.includes(target)) await postTo(target)\n }\n await step.do('finish', () => finish(this.env, postId))\n }\n}\n\nasync function loadPost(env: Env, postId: string): Promise<ScheduledPost | null> {\n const [post] = await readPosts(env.DB, 'id = ?', [postId])\n return post ?? null\n}\n\nasync function setStatus(env: Env, postId: string, from: string, to: string): Promise<boolean> {\n const rows = await queryRows(\n env.DB,\n 'UPDATE social_posts SET status = ? WHERE id = ? AND status = ? RETURNING id',\n [to, postId, from],\n (raw) => raw,\n )\n return rows.length === 1\n}\n\nasync function record(env: Env, postId: string, accountId: string, result: TargetResult) {\n await env.DB.prepare(\n 'UPDATE social_targets SET status = ?, url = ?, error = ? WHERE post_id = ? AND account_id = ?',\n )\n .bind(result.status, result.url, result.error, postId, accountId)\n .run()\n}\n\nasync function publishTo(env: Env, post: ScheduledPost, target: Target, userId: string) {\n // Claim the account before calling the network, so a second attempt knows a first began.\n const claimed = await queryRows(\n env.DB,\n \\`UPDATE social_targets SET status = 'publishing'\n WHERE post_id = ? AND account_id = ? AND status = 'pending' RETURNING status\\`,\n [post.id, target.accountId],\n (raw) => raw,\n )\n if (claimed.length === 0) {\n const [current] = await readPosts(env.DB, 'id = ?', [post.id])\n const now = current?.targets.find((t) => t.accountId === target.accountId)\n if (!now || now.status !== 'publishing') return\n if (postsOnce(target.network)) {\n await record(env, post.id, target.accountId, {\n status: 'failed',\n url: null,\n error: \\`Interrupted while posting. Check \\${NETWORKS[target.network]} before posting this again.\\`,\n })\n return\n }\n }\n // A Buffer channel's id is its connection's, then the channel's.\n const [connectionId = '', channelId] = target.accountId.split(BUFFER_SEPARATOR)\n try {\n const { connection, tokens } = await connectionTokens(\n env.DB,\n { secret: secretOf(env), providers: socialProviders(env) },\n { connectionId, userId },\n )\n // Checked again as it goes out, against the same server limits the composer used.\n const limits =\n target.network === 'mastodon' && connection.server\n ? await serverLimits(env, connection.server)\n : null\n // The images' bytes, from R2. Threads and Buffer fetch them instead, by a signed link that\n // lasts until a day after the post is due (Buffer may fetch a queued one only then).\n const data = await Promise.all(post.images.map((image) => loadImage(env, userId, image)))\n const keys = new Map(data.map((blob, i) => [blob, mediaKey(userId, post.images[i]!.key)]))\n const until = Math.max(Date.parse(post.at), Date.now()) + 86_400_000\n const imageLink: ImageLink = (image) => signedMediaUrl(env, keys.get(image.data)!, until)\n if (target.network === 'buffer') await spendBuffer(env)\n const published = await publisherFor({ ...target, limits }, imageLink).publish(\n { tokens, subject: channelId ?? connection.subject, server: connection.server },\n asSocialPost(post, data),\n // Fixed for every attempt: Mastodon's key, and Bluesky's record key, make a retry safe.\n { idempotencyKey: \\`\\${post.id}:\\${target.accountId}\\`, createdAt: new Date(post.at) },\n )\n await record(env, post.id, target.accountId, {\n // Buffer, given a later time, holds the post in its queue.\n status:\n target.network === 'buffer' && Date.parse(post.at) > Date.now() + 60_000\n ? 'queued'\n : 'posted',\n url: published.url,\n error: null,\n })\n } catch (error) {\n if (\n error instanceof ConnectionError ||\n (error instanceof PublishError && error.kind === 'reconnect')\n ) {\n await markReconnect(env.DB, connectionId)\n await record(env, post.id, target.accountId, {\n status: 'failed',\n url: null,\n error: \\`\\${target.label} needs connecting again before it can post.\\`,\n })\n return\n }\n if (error instanceof PublishError && !error.retryable) {\n await record(env, post.id, target.accountId, {\n status: 'failed',\n url: null,\n error: error.message,\n })\n return\n }\n // Retryable, or the network did not answer: the step's retry policy decides.\n throw error\n }\n}\n\nasync function loadImage(env: Env, userId: string, image: PostImage): Promise<Blob> {\n const object = await env.SOCIAL_MEDIA.get(mediaKey(userId, image.key))\n if (!object) throw new PublishError('social', 'invalid', 'An image of this post is gone')\n return new Blob([await new Response(object.body).arrayBuffer()], { type: image.type })\n}\n\nasync function finish(env: Env, postId: string) {\n const [post] = await readPosts(env.DB, 'id = ?', [postId])\n if (!post) return\n const posted = post.targets.filter((t) => t.status === 'posted' || t.status === 'queued').length\n const status = posted === post.targets.length ? 'done' : posted > 0 ? 'partial' : 'failed'\n await setStatus(env, postId, 'publishing', status)\n}\n`\n}\n\nfunction cfSocialRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { startUpload } from '@cascivo/app/uploads'\nimport type { Upload } from '@cascivo/app/uploads'\nimport {\n Alert,\n Badge,\n Button,\n Card,\n CardContent,\n Checkbox,\n FileUploader,\n Flex,\n Heading,\n Input,\n Link,\n Spinner,\n Text,\n Textarea,\n signal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport type { UploaderFile } from '@cascivo/react'\nimport type { FormEvent } from 'react'\nimport { api } from '../api'\nimport { auth } from '../auth'\nimport { router } from '../router'\nimport { asSocialPost, BUFFER_BUDGET, IMAGES, MAX_IMAGES, NETWORKS, publisherFor } from '../social'\nimport type { Account, PostImage, PostStatus, Social, TargetStatus } from '../social'\n\nconst client = createClient(api)\nconst social = signal<Social | null>(null)\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n// The composer. Kept in signals so each network's check runs as you type.\nconst text = signal('')\nconst linkUrl = signal('')\nconst linkTitle = signal('')\nconst at = signal('')\nconst chosen = signal<string[]>([])\nconst inBuffer = signal(false)\n// Images go up to R2 as soon as they are picked; each needs a description before posting.\nconst uploads = signal<Upload[]>([])\nconst alts = signal<Record<string, string>>({})\n\nfunction addImages(files: File[]): void {\n const room = MAX_IMAGES - uploads.value.length\n uploads.value = [...uploads.value, ...files.slice(0, room).map((f) => startUpload(IMAGES, f))]\n}\n\nfunction removeImage(id: string): void {\n uploads.value.find((u) => u.id === id)?.abort()\n uploads.value = uploads.value.filter((u) => u.id !== id)\n}\n\n/** The uploaded images, in order, with their descriptions. */\nfunction attached(): PostImage[] {\n return uploads.value.flatMap((upload) => {\n const stored = upload.result.value\n return stored ? [{ key: stored.key, type: stored.type, alt: alts.value[upload.id] ?? '' }] : []\n })\n}\n\nasync function load(): Promise<void> {\n try {\n social.value = await client.getSocial()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not load your accounts'\n }\n}\n\n/** Why connecting an account failed: /api/connections sends it back as \\`?error=\\`. */\nconst REASONS: Record<string, string> = {\n denied: 'You did not allow access, so nothing was connected.',\n expired: 'Connecting took too long, or started in another browser. Try again.',\n state_mismatch: 'That did not match the connection this browser started. Try again.',\n provider_error: 'The network did not confirm the account. Try again.',\n bad_server: 'That server or handle could not be reached, or did not check out. Check the name.',\n signed_out: 'Sign in first, then connect an account.',\n}\n\n/** Badge's tones. */\ntype Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'\n\nconst ACCOUNT_TONE: Record<Account['status'], Tone> = {\n active: 'success',\n expiring: 'warning',\n reconnect: 'danger',\n}\nconst POST_TONE: Record<PostStatus, Tone> = {\n scheduled: 'info',\n publishing: 'info',\n done: 'success',\n partial: 'warning',\n failed: 'danger',\n cancelled: 'neutral',\n}\nconst TARGET_TONE: Record<TargetStatus, Tone> = {\n pending: 'neutral',\n publishing: 'info',\n queued: 'info',\n posted: 'success',\n failed: 'danger',\n}\n\nfunction draft() {\n const url = linkUrl.value.trim()\n return asSocialPost({\n text: text.value,\n link: url ? { url, title: linkTitle.value.trim() } : null,\n images: attached(),\n })\n}\n\n/** What each chosen network would refuse, from the same checks the Worker runs. */\nfunction problems(accounts: Account[]): string[] {\n return accounts\n .filter((a) => chosen.value.includes(a.id))\n .flatMap((account) =>\n publisherFor(account)\n .check(draft())\n .map((p) => \\`\\${account.label}: \\${p.message}\\`),\n )\n}\n\nasync function schedule(event: FormEvent<HTMLFormElement>): Promise<void> {\n event.preventDefault()\n busy.value = true\n failure.value = null\n try {\n const url = linkUrl.value.trim()\n await client.schedulePost({\n body: {\n text: text.value,\n link: url ? { url, title: linkTitle.value.trim() } : null,\n images: attached(),\n accountIds: chosen.value,\n // datetime-local is the browser's local time; the Worker keeps UTC.\n at: at.value ? new Date(at.value).toISOString() : null,\n inBuffer: inBuffer.value,\n },\n })\n text.value = ''\n linkUrl.value = ''\n linkTitle.value = ''\n at.value = ''\n inBuffer.value = false\n uploads.value = []\n alts.value = {}\n await load()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Not scheduled'\n } finally {\n busy.value = false\n }\n}\n\nasync function cancel(id: string): Promise<void> {\n try {\n await client.cancelPost({ params: { id } })\n await load()\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Not cancelled'\n }\n}\n\nasync function remove(id: string): Promise<void> {\n // A Buffer channel goes with its whole Buffer connection (the id before the ~).\n const connectionId = id.split('~')[0] ?? id\n const response = await fetch(\\`/api/connections/\\${encodeURIComponent(connectionId)}\\`, {\n method: 'DELETE',\n })\n if (!response.ok) failure.value = 'The account was not removed'\n chosen.value = chosen.value.filter((c) => c !== id)\n await load()\n}\n\nfunction toggle(id: string, on: boolean): void {\n chosen.value = on ? [...chosen.value, id] : chosen.value.filter((c) => c !== id)\n}\n\nexport default function SocialPage() {\n useSignals()\n useSignalEffect(() => {\n if (auth.user.value) void load()\n })\n const user = auth.user.value\n const data = social.value\n const reason = new URLSearchParams(router.search.value).get('error')\n\n if (user === undefined) return <Spinner label=\"Loading\" />\n if (user === null) {\n return (\n <Flex gap={4}>\n <Heading level={1}>Social</Heading>\n <Text>\n <Link href=\"/account\">Sign in</Link> to connect your accounts and schedule posts.\n </Text>\n </Flex>\n )\n }\n const usable = data?.accounts.filter((a) => a.status !== 'reconnect') ?? []\n const images: UploaderFile[] = uploads.value.map((upload) => ({\n id: upload.id,\n name: upload.name,\n size: upload.size,\n status: upload.status.value === 'done' ? 'complete' : upload.status.value,\n ...(upload.error.value ? { errorMessage: upload.error.value } : {}),\n }))\n const uploading = uploads.value.some((upload) => upload.status.value === 'uploading')\n const blocking = data ? problems(data.accounts) : []\n const viaBuffer = usable.some((a) => a.network === 'buffer' && chosen.value.includes(a.id))\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Social</Heading>\n <Text muted>\n Write once, post to Bluesky, LinkedIn, Mastodon and the channels in your Buffer, now or at\n a time you pick.\n </Text>\n </Flex>\n {reason ? (\n <Alert variant=\"destructive\" title=\"Not connected\">\n {REASONS[reason] ?? 'Connecting failed. Try again.'}\n </Alert>\n ) : null}\n {location.hostname === 'localhost' ? (\n <Alert variant=\"info\" title=\"Connecting Bluesky in development\">\n Bluesky's development sign-in only returns to 127.0.0.1: open{' '}\n <Link href={\\`http://127.0.0.1:\\${location.port}/social\\`}>this page on 127.0.0.1</Link>.\n </Alert>\n ) : null}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Something went wrong\">\n {failure.value}\n </Alert>\n ) : null}\n {data === null ? (\n <Spinner label=\"Loading\" />\n ) : (\n <>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Heading level={2}>Accounts</Heading>\n {data.accounts.length === 0 ? <Text muted>No accounts connected yet.</Text> : null}\n {data.accounts.map((account) => (\n <Flex key={account.id} direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Text>\n {NETWORKS[account.network]}: {account.label}\n </Text>\n <Badge variant={ACCOUNT_TONE[account.status]}>\n {account.status === 'expiring'\n ? 'expires soon: connect again'\n : account.status}\n </Badge>\n <Button size=\"sm\" variant=\"ghost\" onClick={() => void remove(account.id)}>\n Remove\n </Button>\n </Flex>\n ))}\n {data.bufferUsed !== null ? (\n <Text muted>\n Buffer requests in the last 15 minutes, for everyone on this app:{' '}\n {data.bufferUsed} of {BUFFER_BUDGET}.\n </Text>\n ) : null}\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n {data.networks.includes('buffer') ? (\n <Button asChild variant=\"secondary\">\n <a href=\"/api/connections/buffer?returnTo=/social\">Connect Buffer</a>\n </Button>\n ) : null}\n {data.networks.includes('linkedin') ? (\n <Button asChild variant=\"secondary\">\n <a href=\"/api/connections/linkedin?returnTo=/social\">Connect LinkedIn</a>\n </Button>\n ) : null}\n {data.networks.includes('threads') ? (\n <Button asChild variant=\"secondary\">\n <a href=\"/api/connections/threads?returnTo=/social\">Connect Threads</a>\n </Button>\n ) : null}\n {/* Plain GET forms: the Worker redirects to the account or server named. */}\n <form method=\"get\" action=\"/api/connections/bluesky\">\n <input type=\"hidden\" name=\"returnTo\" value=\"/social\" />\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input\n name=\"server\"\n label=\"Bluesky handle\"\n placeholder=\"you.bsky.social\"\n required\n />\n <Button type=\"submit\" variant=\"secondary\">\n Connect Bluesky\n </Button>\n </Flex>\n </form>\n <form method=\"get\" action=\"/api/connections/mastodon\">\n <input type=\"hidden\" name=\"returnTo\" value=\"/social\" />\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input\n name=\"server\"\n label=\"Mastodon server\"\n placeholder=\"mastodon.social\"\n required\n />\n <Button type=\"submit\" variant=\"secondary\">\n Connect Mastodon\n </Button>\n </Flex>\n </form>\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n <Card>\n <CardContent>\n <form onSubmit={(event) => void schedule(event)}>\n <Flex gap={3}>\n <Heading level={2}>New post</Heading>\n <Textarea\n label=\"Text\"\n value={text.value}\n onInput={(event) => (text.value = event.currentTarget.value)}\n rows={5}\n />\n <Flex direction=\"horizontal\" gap={2} wrap>\n <Input\n label=\"Link (optional)\"\n type=\"url\"\n value={linkUrl.value}\n onInput={(event) => (linkUrl.value = event.currentTarget.value)}\n />\n <Input\n label=\"Link title (LinkedIn shows it)\"\n value={linkTitle.value}\n onInput={(event) => (linkTitle.value = event.currentTarget.value)}\n />\n </Flex>\n <FileUploader\n multiple\n label={\\`Images (up to \\${MAX_IMAGES}, JPEG or PNG, 1 MB each)\\`}\n accept={IMAGES.types.join(',')}\n maxSize={IMAGES.maxBytes}\n files={images}\n onFilesAdded={addImages}\n onRemove={removeImage}\n />\n {uploads.value\n .filter((upload) => upload.status.value === 'done')\n .map((upload) => (\n <Input\n key={upload.id}\n label={\\`Describe \\${upload.name} (alt text)\\`}\n required\n value={alts.value[upload.id] ?? ''}\n onInput={(event) =>\n (alts.value = { ...alts.value, [upload.id]: event.currentTarget.value })\n }\n />\n ))}\n {usable.length === 0 ? (\n <Text muted>Connect an account to post.</Text>\n ) : (\n usable.map((account) => (\n <Checkbox\n key={account.id}\n label={\\`\\${NETWORKS[account.network]}: \\${account.label}\\`}\n checked={chosen.value.includes(account.id)}\n onChange={(event) => toggle(account.id, event.currentTarget.checked)}\n />\n ))\n )}\n <Input\n label=\"When (empty: now)\"\n type=\"datetime-local\"\n value={at.value}\n onInput={(event) => (at.value = event.currentTarget.value)}\n />\n {at.value && viaBuffer ? (\n <Checkbox\n label=\"Let Buffer hold it: Buffer accounts go to Buffer's queue now, where you can still edit them\"\n checked={inBuffer.value}\n onChange={(event) => (inBuffer.value = event.currentTarget.checked)}\n />\n ) : null}\n {blocking.length > 0 ? (\n <Alert variant=\"warning\" title=\"Not ready to post\">\n {blocking.join(' ')}\n </Alert>\n ) : null}\n <Flex direction=\"horizontal\">\n <Button\n type=\"submit\"\n loading={busy.value}\n disabled={chosen.value.length === 0 || blocking.length > 0 || uploading}\n >\n {at.value ? 'Schedule' : 'Post now'}\n </Button>\n </Flex>\n </Flex>\n </form>\n </CardContent>\n </Card>\n <Flex gap={3}>\n <Heading level={2}>Posts</Heading>\n {data.posts.length === 0 ? <Text muted>Nothing scheduled yet.</Text> : null}\n {data.posts.map((post) => (\n <Card key={post.id}>\n <CardContent>\n <Flex gap={2}>\n <Flex direction=\"horizontal\" align=\"center\" gap={2} wrap>\n <Badge variant={POST_TONE[post.status]}>{post.status}</Badge>\n <Text muted>{new Date(post.at).toLocaleString()}</Text>\n {post.status === 'scheduled' ? (\n <Button size=\"sm\" variant=\"ghost\" onClick={() => void cancel(post.id)}>\n Cancel\n </Button>\n ) : null}\n </Flex>\n <Text>{post.text}</Text>\n {post.targets.map((target) => (\n <Flex\n key={target.accountId}\n direction=\"horizontal\"\n align=\"center\"\n gap={2}\n wrap\n >\n {post.status === 'cancelled' && target.status === 'queued' ? (\n <Badge variant=\"warning\">still in Buffer's queue</Badge>\n ) : post.status === 'cancelled' ? (\n <Badge variant=\"neutral\">not sent</Badge>\n ) : (\n <Badge variant={TARGET_TONE[target.status]}>{target.status}</Badge>\n )}\n <Text>\n {NETWORKS[target.network]}: {target.label}\n </Text>\n {target.url ? <Link href={target.url}>View</Link> : null}\n {target.error ? <Text muted>{target.error}</Text> : null}\n </Flex>\n ))}\n </Flex>\n </CardContent>\n </Card>\n ))}\n </Flex>\n </>\n )}\n </Flex>\n )\n}\n`\n}\n\n/* --- `--auth email`: accounts with emailed sign-in links (@cascivo/app/auth) --- */\n\nfunction cfAuthTs(): string {\n return `import { createAuth } from '@cascivo/app/auth'\n\n/**\n * Who is signed in, shared by every page: \\`auth.user.value\\` is \\`undefined\\` while the first\n * check runs, then the user or \\`null\\`. The Worker side is \\`handleAuth\\` in worker/index.ts.\n */\nexport const auth = createAuth()\n`\n}\n\nfunction cfAuthWorkerTs(): string {\n return `/** What sending a sign-in link needs of the Email Service binding (\\`send_email\\`). */\nexport interface SignInSender {\n send(message: {\n from: string\n to: string\n subject: string\n text: string\n html: string\n }): Promise<unknown>\n}\n\nconst escape = (value: string) =>\n value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"')\n\n/**\n * Emails a sign-in link. \\`vite dev\\` sends nothing: the link is logged, and the Account page\n * shows it (\\`exposeLink\\` in worker/index.ts).\n */\nexport async function sendSignInLink(\n sender: SignInSender,\n from: string,\n email: string,\n url: string,\n): Promise<void> {\n if (import.meta.env.DEV) {\n console.log(\\`[auth] sign-in link for \\${email}: \\${url}\\`)\n return\n }\n if (!from) throw new Error('Set AUTH_FROM in wrangler.jsonc to an address on your domain')\n await sender.send({\n from,\n to: email,\n subject: 'Your sign-in link',\n text: \\`Sign in: \\${url}\\\\n\\\\nThe link works once, for 15 minutes. If you did not ask for it, ignore this email.\\`,\n html: \\`<p><a href=\"\\${escape(url)}\">Sign in</a></p><p>The link works once, for 15 minutes. If you did not ask for it, ignore this email.</p>\\`,\n })\n}\n`\n}\n\nfunction cfAccountRouteTsx(opts: ScaffoldOptions): string {\n const email = emailSignIn(opts)\n const oauth = oauthSignIn(opts)\n const lead = email\n ? oauth\n ? 'Continue with GitHub, Google or LinkedIn, or get a one-time link by email. No password.'\n : 'No password: we email you a link that signs you in once.'\n : 'Continue with GitHub, Google or LinkedIn. No password.'\n return `import {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Heading,${email ? '\\n Input,\\n Link,' : ''}\n Spinner,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\n${email ? \"import type { FormEvent } from 'react'\\n\" : ''}import { auth } from '../auth'${oauth ? \"\\nimport { router } from '../router'\" : ''}\n${\n email\n ? `\nconst sentTo = signal<string | null>(null)\n/** Set only in \\`vite dev\\`, where no email is sent: the link to open instead. */\nconst devLink = signal<string | null>(null)\nconst failure = signal<string | null>(null)\nconst sending = signal(false)\n\nasync function start(event: FormEvent<HTMLFormElement>): Promise<void> {\n event.preventDefault()\n const email = new FormData(event.currentTarget).get('email')\n if (typeof email !== 'string') return\n failure.value = null\n sending.value = true\n try {\n const { link } = await auth.start(email)\n sentTo.value = email\n devLink.value = link\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not send the link'\n } finally {\n sending.value = false\n }\n}\n`\n : ''\n}${\n oauth\n ? `\nconst LABELS: Record<string, string> = { github: 'GitHub', google: 'Google', linkedin: 'LinkedIn' }\n\n/** The providers the Worker offers: those with an id and a secret set (README). */\nconst providers = signal<string[] | null>(null)\nauth.providers().then(\n (list) => {\n providers.value = list\n },\n () => {\n providers.value = []\n },\n)\n\n/** Why the last sign-in with a provider failed: the Worker sends it back as \\`?error=\\`. */\nconst REASONS: Record<string, string> = {\n denied: 'You did not allow access, so you are not signed in.',\n expired: 'The sign-in took too long, or started in another browser. Try again.',\n state_mismatch: 'That sign-in did not match the one this browser started. Try again.',\n provider_error: 'The provider did not confirm who you are. Try again, or use another way in.',\n identity_in_use: 'That account is already linked to another user here.',\n}\n`\n : ''\n }\nexport default function Account() {\n useSignals()\n const user = auth.user.value${\n oauth\n ? `\n const reason = new URLSearchParams(router.search.value).get('error')`\n : ''\n }\n\n if (user === undefined) return <Spinner label=\"Loading\" />\n if (user) {\n return (\n <Flex gap={4}>\n <Heading level={1}>Account</Heading>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Text>{user.email ? \\`Signed in as \\${user.email}\\` : 'Signed in'}</Text>\n <Flex direction=\"horizontal\">\n <Button variant=\"secondary\" onClick={() => void auth.signOut()}>\n Sign out\n </Button>\n </Flex>\n </Flex>\n </CardContent>\n </Card>\n </Flex>\n )\n }\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Sign in</Heading>\n ${\n // Prettier's width: a lead too long for one line goes on its own.\n `<Text muted>${lead}</Text>`.length + 8 > 100\n ? `<Text muted>\\n ${lead}\\n </Text>`\n : `<Text muted>${lead}</Text>`\n }\n </Flex>${\n oauth\n ? `\n {reason ? (\n <Alert variant=\"destructive\" title=\"Not signed in\">\n {REASONS[reason] ?? 'Sign-in failed. Try again.'}\n </Alert>\n ) : null}\n {providers.value === null ? (\n <Spinner label=\"Loading\" />\n ) : providers.value.length === 0 ? (\n <Alert variant=\"info\" title=\"No provider is set up yet\">\n Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, or the GOOGLE_ pair, in .dev.vars (README).\n </Alert>\n ) : (\n <Flex direction=\"horizontal\" gap={2} wrap>\n {providers.value.map((id) => (\n // A plain link: the Worker redirects to the provider, and back here afterwards.\n <Button key={id} asChild variant=\"secondary\">\n <a href={auth.signInUrl(id, '/account')}>Continue with {LABELS[id] ?? id}</a>\n </Button>\n ))}\n </Flex>\n )}`\n : ''\n }${\n email\n ? `\n {sentTo.value ? (\n <Alert variant=\"success\" title=\"Check your email\">\n We sent a sign-in link to {sentTo.value}. It works once, for 15 minutes.\n </Alert>\n ) : null}\n {devLink.value ? (\n <Alert variant=\"info\" title=\"vite dev sends no email\">\n <Link href={devLink.value}>Open the sign-in link</Link>\n </Alert>\n ) : null}\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not sent\">\n {failure.value}\n </Alert>\n ) : null}\n <form onSubmit={(event) => void start(event)}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2} wrap>\n <Input name=\"email\" type=\"email\" label=\"Email\" autoComplete=\"email\" required />\n <Button type=\"submit\" loading={sending.value}>\n Email me a link\n </Button>\n </Flex>\n </form>`\n : ''\n }\n </Flex>\n )\n}\n`\n}\n\nfunction cfVerifyRouteTsx(): string {\n return `import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'\nimport { auth } from '../../auth'\nimport { router } from '../../router'\n\nconst failure = signal<string | null>(null)\nconst busy = signal(false)\n\n/**\n * The page a sign-in link opens. It signs in only when you press the button: mail scanners\n * open every link in a message, and a link that signed in on open would be used up by them.\n */\nasync function signIn(): Promise<void> {\n const token = new URLSearchParams(router.search.value).get('token')\n if (!token) {\n failure.value = 'This link has no token. Request a new one.'\n return\n }\n busy.value = true\n failure.value = null\n try {\n await auth.verify(token)\n router.navigate('/account', { replace: true })\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not sign in'\n } finally {\n busy.value = false\n }\n}\n\nexport default function VerifySignIn() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Sign in</Heading>\n <Text muted>Finish signing in on this device.</Text>\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not signed in\">\n {failure.value}\n </Alert>\n ) : null}\n <Flex direction=\"horizontal\">\n <Button loading={busy.value} onClick={() => void signIn()}>\n Sign in\n </Button>\n </Flex>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example publish`: views published as pages, stored in D1 --- */\n\nfunction cfPagesTs(): string {\n return `import type { ViewConfig } from '@cascivo/render'\nimport { validateView } from '@cascivo/render/validate'\n\n/**\n * A published page: a title and a view. Shared by the Worker (which stores pages) and the\n * app (which builds and shows them). A view is data, not code — it can only arrange cascivo\n * components the manifests describe — so publishing one needs no sandbox and no deploy.\n */\nexport interface PageInput {\n title: string\n view: ViewConfig\n}\n\nexport interface PageSummary {\n slug: string\n title: string\n createdAt: string\n}\n\nexport interface Page extends PageInput, PageSummary {}\n\n/** Largest view accepted, in characters of its JSON. */\nexport const MAX_VIEW_LENGTH = 32_000\n\nconst SLUG = /^[a-z0-9]{10}$/\n\nexport function isSlug(value: string): boolean {\n return SLUG.test(value)\n}\n\n/**\n * Checks a page against the component manifests (\\`validateView\\`: known components and\n * props, and no URL that could run script). The Worker runs it before storing a page, and\n * \\`<CascivoView>\\` runs the same check again before rendering one.\n */\nexport function parsePageInput(raw: unknown): PageInput {\n if (typeof raw !== 'object' || raw === null) throw new Error('Expected { title, view }')\n const { title, view } = raw as Record<string, unknown>\n if (typeof title !== 'string' || title.trim() === '' || title.length > 80) {\n throw new Error('title: 1–80 characters')\n }\n if (JSON.stringify(view ?? null).length > MAX_VIEW_LENGTH) {\n throw new Error(\\`view: at most \\${MAX_VIEW_LENGTH} characters of JSON\\`)\n }\n const result = validateView(view)\n if (!result.valid) {\n throw new Error(result.errors.map((e) => \\`\\${e.path}: \\${e.message}\\`).join('\\\\n'))\n }\n // validateView has checked the whole shape, so the cast states a proven fact.\n return { title: title.trim(), view: view as ViewConfig }\n}\n\nexport function parsePageSummary(raw: unknown): PageSummary {\n if (typeof raw === 'object' && raw !== null) {\n const { slug, title, createdAt } = raw as Record<string, unknown>\n if (\n typeof slug === 'string' &&\n isSlug(slug) &&\n typeof title === 'string' &&\n typeof createdAt === 'string'\n ) {\n return { slug, title, createdAt }\n }\n }\n throw new Error('Malformed page')\n}\n\nexport function parsePage(raw: unknown): Page {\n const summary = parsePageSummary(raw)\n const { view } = raw as Record<string, unknown>\n return { ...summary, ...parsePageInput({ title: summary.title, view }) }\n}\n`\n}\n\nfunction cfPagesWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { isSlug, parsePage } from '../src/pages'\nimport type { Page, PageInput, PageSummary } from '../src/pages'\n\nconst migrations = [\n {\n id: '0001_pages',\n statements: [\n \\`CREATE TABLE pages (\n slug TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n view TEXT NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n ],\n },\n]\n\nconst ready = async (db: Database) => {\n await migrate(db, migrations)\n return db\n}\n\n/** Ten random base-36 characters: not guessable, so an unlisted page stays unlisted. */\nfunction newSlug(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(10))\n return Array.from(bytes, (b) => (b % 36).toString(36)).join('')\n}\n\n/**\n * A stored row back into a page. The view is JSON text written by this Worker, but it is\n * parsed and validated again like anything read from storage: an older version of the app,\n * or a hand in the D1 console, may have written it.\n */\nfunction pageFromRow(raw: unknown): Page {\n if (typeof raw !== 'object' || raw === null) throw new Error('Malformed page row')\n const row = raw as Record<string, unknown>\n if (typeof row['view'] !== 'string') throw new Error('Malformed page row')\n return parsePage({ ...row, view: JSON.parse(row['view']) })\n}\n\nexport async function publishPage(db: Database, page: PageInput): Promise<PageSummary> {\n const summary = { slug: newSlug(), title: page.title, createdAt: new Date().toISOString() }\n await (\n await ready(db)\n )\n .prepare('INSERT INTO pages (slug, title, view, created_at) VALUES (?, ?, ?, ?)')\n .bind(summary.slug, summary.title, JSON.stringify(page.view), summary.createdAt)\n .run()\n return summary\n}\n\nexport async function getPage(db: Database, slug: string): Promise<Page> {\n if (!isSlug(slug)) throw new HttpError(404, 'No such page')\n const [page] = await queryRows(\n await ready(db),\n 'SELECT slug, title, view, created_at AS createdAt FROM pages WHERE slug = ?',\n [slug],\n pageFromRow,\n )\n if (!page) throw new HttpError(404, 'No such page')\n return page\n}\n`\n}\n\nfunction cfPageHtmlTs(): string {\n return `import { CascivoView } from '@cascivo/render'\nimport { Flex, Heading } from '@cascivo/react'\nimport { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport type { Page } from '../src/pages'\n\n/** The static-assets binding (\\`assets.binding\\` in wrangler.jsonc): the built index.html. */\nexport interface Assets {\n fetch(request: Request): Promise<Response>\n}\n\nconst escapeHtml = (value: string) =>\n value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n\nconst ENTITIES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n}\n\n/** Rendered markup back to its visible text, for a link preview's description. */\nfunction textOf(html: string): string {\n return html\n .replace(/<[^>]+>/g, ' ')\n .replace(/&(?:amp|lt|gt|quot|#39|#x27);/g, (entity) => ENTITIES[entity] ?? entity)\n .replace(/\\\\s+/g, ' ')\n .trim()\n}\n\n/** The route file whose stylesheets a published page needs, as the build manifest names it. */\nconst PAGE_ROUTE = 'src/routes/p/[slug].tsx'\n\n/**\n * The stylesheets the published-page route loads, from Vite's build manifest (vite.config.ts\n * writes it as asset-manifest.json). The route is loaded lazily, so its component CSS is\n * linked by JavaScript; a reader without JavaScript needs it linked in the HTML. \\`vite dev\\`\n * has no manifest, and there the CSS arrives with the JavaScript anyway.\n */\nasync function pageStylesheets(request: Request, assets: Assets): Promise<string[]> {\n const response = await assets.fetch(new Request(new URL('/asset-manifest.json', request.url)))\n if (!response.ok) return []\n const manifest: unknown = await response.json().catch(() => null)\n if (typeof manifest !== 'object' || manifest === null) return []\n const chunks = manifest as Record<string, unknown>\n const found = new Set<string>()\n const seen = new Set<string>()\n const walk = (key: string) => {\n if (seen.has(key)) return\n seen.add(key)\n const chunk = chunks[key]\n if (typeof chunk !== 'object' || chunk === null) return\n const { css, imports } = chunk as Record<string, unknown>\n if (Array.isArray(css)) for (const file of css) if (typeof file === 'string') found.add(file)\n if (Array.isArray(imports)) for (const next of imports) if (typeof next === 'string') walk(next)\n }\n walk(PAGE_ROUTE)\n return [...found]\n}\n\n/**\n * The app's index.html for a published page, with the page in it: a title and description\n * for search engines and link previews (which run no JavaScript), and the rendered view in\n * a \\`<noscript>\\` for readers without JavaScript. The app then starts as usual and renders\n * the page itself.\n */\nexport async function renderPageHtml(\n request: Request,\n page: Page,\n assets: Assets,\n imageUrl: string | null,\n): Promise<Response> {\n const shell = await assets.fetch(new Request(new URL('/', request.url)))\n const view = renderToStaticMarkup(createElement(CascivoView, { config: page.view }))\n // The same layout as src/routes/p/[slug].tsx, so it is styled by the same stylesheets.\n const body = renderToStaticMarkup(\n createElement(\n Flex,\n { gap: 4 },\n createElement(Heading, { level: 1 }, page.title),\n createElement(CascivoView, { config: page.view }),\n ),\n )\n const description = textOf(view).slice(0, 160)\n const url = new URL(request.url)\n url.search = ''\n const title = escapeHtml(page.title)\n const meta = [\n \\`<title>\\${title}</title>\\`,\n \\`<meta name=\"description\" content=\"\\${escapeHtml(description)}\" />\\`,\n \\`<link rel=\"canonical\" href=\"\\${escapeHtml(url.href)}\" />\\`,\n '<meta property=\"og:type\" content=\"article\" />',\n \\`<meta property=\"og:title\" content=\"\\${title}\" />\\`,\n \\`<meta property=\"og:description\" content=\"\\${escapeHtml(description)}\" />\\`,\n \\`<meta property=\"og:url\" content=\"\\${escapeHtml(url.href)}\" />\\`,\n ...(imageUrl\n ? [\n \\`<meta property=\"og:image\" content=\"\\${escapeHtml(imageUrl)}\" />\\`,\n '<meta property=\"og:image:width\" content=\"1200\" />',\n '<meta property=\"og:image:height\" content=\"630\" />',\n '<meta name=\"twitter:card\" content=\"summary_large_image\" />',\n ]\n : ['<meta name=\"twitter:card\" content=\"summary\" />']),\n ].join('\\\\n ')\n const stylesheets = (await pageStylesheets(request, assets))\n .map((file) => \\`<link rel=\"stylesheet\" href=\"/\\${escapeHtml(file)}\" />\\`)\n .join('\\\\n ')\n const html = (await shell.text())\n .replace(/<title>[\\\\s\\\\S]*?<\\\\/title>/, stylesheets ? \\`\\${meta}\\\\n \\${stylesheets}\\` : meta)\n .replace(\n '<div id=\"root\"></div>',\n // Before the app's root, which fills the viewport: after it, the page would start below\n // the fold.\n \\`<noscript><main style=\"padding: 1.5rem\">\\${body}</main></noscript>\\\\n <div id=\"root\"></div>\\`,\n )\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' },\n })\n}\n`\n}\n\nfunction cfPagePreviewTs(): string {\n return `import { migrate, queryRows } from '@cascivo/app/db'\nimport type { Database } from '@cascivo/app/db'\nimport { exportPage } from '@cascivo/app/export'\nimport type { ExportBrowser } from '@cascivo/app/export'\nimport { getPage } from './pages'\n\nconst migrations = [\n {\n id: '0001_page_previews',\n statements: ['CREATE TABLE page_previews (slug TEXT PRIMARY KEY, png BLOB NOT NULL)'],\n },\n]\n\n/** A stored BLOB as bytes: D1 returns one as an array of numbers. */\nfunction bytesOf(raw: unknown): Uint8Array | null {\n const png =\n typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>)['png'] : null\n if (png instanceof ArrayBuffer) return new Uint8Array(png)\n if (ArrayBuffer.isView(png)) return new Uint8Array(png.buffer, png.byteOffset, png.byteLength)\n if (Array.isArray(png) && png.every((b) => typeof b === 'number')) return Uint8Array.from(png)\n return null\n}\n\n/**\n * A published page's link-preview image, 1200 × 630: rendered from the page by Browser Run on\n * its first request and kept in D1, so a page shared a thousand times starts one browser.\n */\nexport async function pagePreview(\n db: Database,\n slug: string,\n origin: string,\n launch: () => Promise<ExportBrowser>,\n): Promise<Response> {\n await getPage(db, slug) // 404 for a page that does not exist\n await migrate(db, migrations)\n const [stored] = await queryRows(\n db,\n 'SELECT png FROM page_previews WHERE slug = ?',\n [slug],\n bytesOf,\n )\n let png = stored ?? null\n if (!png) {\n png = await exportPage(launch, new URL(\\`/p/\\${slug}\\`, origin).href, {\n format: 'png',\n viewport: { width: 1200, height: 630 },\n fullPage: false,\n })\n await db\n .prepare('INSERT OR REPLACE INTO page_previews (slug, png) VALUES (?, ?)')\n .bind(slug, png)\n .run()\n }\n return new Response(new Uint8Array(png), {\n headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' },\n })\n}\n`\n}\n\nfunction cfPublishRouteTsx(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport { CascivoView } from '@cascivo/render'\nimport type { ViewConfig } from '@cascivo/render'\nimport { validateView } from '@cascivo/render/validate'\nimport {\n Alert,\n Button,\n Card,\n CardContent,\n Flex,\n Grid,\n Heading,\n Input,\n Link,\n Text,\n Textarea,\n computed,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport type { ChangeEvent } from 'react'\nimport { api } from '../api'\nimport type { PageSummary } from '../pages'\n\nconst client = createClient(api)\n\nconst EXAMPLE: ViewConfig = {\n view: {\n regions: {\n main: [\n {\n component: 'Card',\n props: { padding: 'lg' },\n children: [\n {\n component: 'Flex',\n props: { gap: 3 },\n children: [\n { component: 'Badge', props: { variant: 'success' }, children: 'Live' },\n { component: 'ProgressBar', props: { value: 3, max: 5, label: 'Steps done' } },\n {\n component: 'Alert',\n props: { variant: 'info', title: 'Published from a view' },\n children: 'Edit the JSON on /publish and publish again for a new page.',\n },\n {\n component: 'Link',\n props: { href: 'https://developers.cloudflare.com/workers/' },\n children: 'Cloudflare Workers docs',\n },\n ],\n },\n ],\n },\n ],\n },\n },\n}\n\nconst title = signal('Launch checklist')\nconst draft = signal(JSON.stringify(EXAMPLE, null, 2))\nconst published = signal<PageSummary | null>(null)\nconst failure = signal<string | null>(null)\n\n/**\n * The draft as a view to preview, or what is wrong with it. The Worker checks a page again\n * before storing it: this check is for the preview, not a guard.\n */\nconst checked = computed((): { view: ViewConfig } | { errors: string[] } => {\n let raw: unknown\n try {\n raw = JSON.parse(draft.value)\n } catch (error) {\n return { errors: [error instanceof Error ? error.message : 'Not JSON'] }\n }\n const result = validateView(raw)\n if (!result.valid) return { errors: result.errors.map((e) => \\`\\${e.path}: \\${e.message}\\`) }\n // validateView has checked the whole shape, so the cast states a proven fact.\n return { view: raw as ViewConfig }\n})\n\nasync function publish(): Promise<void> {\n failure.value = null\n published.value = null\n const current = checked.value\n if ('errors' in current) {\n failure.value = current.errors.join('\\\\n')\n return\n }\n try {\n published.value = await client.publishPage({\n body: { title: title.value, view: current.view },\n })\n } catch (error) {\n failure.value = error instanceof Error ? error.message : 'Could not publish'\n }\n}\n\nexport default function Publish() {\n useSignals()\n const current = checked.value\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Publish</Heading>\n <Text muted>\n Write a view — or have an agent write one — and publish it as a page. A view only arranges\n this app's components, so a page needs no sandbox and no deploy.\n </Text>\n </Flex>\n <Grid cols={2} gap={4}>\n <Flex gap={3}>\n <Input\n label=\"Title\"\n value={title.value}\n onChange={(event: ChangeEvent<HTMLInputElement>) => {\n title.value = event.currentTarget.value\n }}\n />\n <Textarea\n label=\"View (JSON)\"\n rows={18}\n spellCheck={false}\n value={draft.value}\n onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {\n draft.value = event.currentTarget.value\n }}\n />\n <Flex direction=\"horizontal\" align=\"center\" gap={3} wrap>\n <Button onClick={() => void publish()}>Publish</Button>\n {published.value ? (\n <Link href={\\`/p/\\${published.value.slug}\\`}>Open /p/{published.value.slug}</Link>\n ) : null}\n </Flex>\n {failure.value ? (\n <Alert variant=\"destructive\" title=\"Not published\">\n {failure.value}\n </Alert>\n ) : null}\n </Flex>\n <Card>\n <CardContent>\n {'errors' in current ? (\n <Flex gap={1}>\n {current.errors.map((error) => (\n <Text key={error} size=\"sm\" muted>\n {error}\n </Text>\n ))}\n </Flex>\n ) : (\n <CascivoView config={current.view} />\n )}\n </CardContent>\n </Card>\n </Grid>\n </Flex>\n )\n}\n`\n}\n\nfunction cfPageRouteTsx(): string {\n return `import type { RouteProps } from '@cascivo/app'\nimport { createClient } from '@cascivo/app/api'\nimport { CascivoView } from '@cascivo/render'\nimport {\n EmptyState,\n Flex,\n Heading,\n Spinner,\n signal,\n useEffectPropSignal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../../api'\nimport type { Page } from '../../pages'\n\nconst client = createClient(api)\n/** Pages already fetched, by slug; \\`null\\` when the slug has no page. */\nconst pages = signal<Readonly<Record<string, Page | null>>>({})\n\nasync function load(slug: string): Promise<void> {\n if (slug in pages.peek()) return\n try {\n const page = await client.getPage({ params: { slug } })\n pages.value = { ...pages.peek(), [slug]: page }\n } catch {\n pages.value = { ...pages.peek(), [slug]: null }\n }\n}\n\n/** \\`/p/:slug\\` — a published page, rendered from its view with the app's own components. */\nexport default function PublishedPage({ params }: RouteProps<'/p/:slug'>) {\n useSignals()\n const slug = useEffectPropSignal(params.slug)\n useSignalEffect(() => {\n void load(slug.value)\n })\n const page = pages.value[params.slug]\n\n if (page === undefined) return <Spinner label=\"Loading\" />\n if (page === null) {\n return <EmptyState title=\"No such page\" description=\"It may never have been published.\" />\n }\n return (\n <Flex gap={4}>\n <Heading level={1}>{page.title}</Heading>\n <CascivoView config={page.view} onInvalid=\"render\" />\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example voice`: a voice assistant on the Agents SDK and Workers AI speech --- */\n\nfunction cfVoiceTs(): string {\n return `import { signal } from '@cascivo/react'\nimport { VoiceClient } from 'agents/voice/client'\nimport type { TranscriptMessage, VoiceStatus } from 'agents/voice/client'\n\n/**\n * The /voice page's connection to its agent (worker/voice.ts). \\`VoiceClient\\` does the audio:\n * the microphone, streaming it to the Worker, playing the replies, and noticing when you talk\n * over one. This file turns its events into signals, which the page reads.\n */\n\n/** The Durable Object class; the client connects to /agents/voice/<conversation>. */\nexport const VOICE_AGENT = 'Voice'\n\n/** One line of the conversation, from this call or an earlier one. */\nexport interface Line {\n role: 'user' | 'assistant'\n text: string\n}\n\nexport const status = signal<VoiceStatus>('idle')\nexport const connected = signal(false)\n/** The conversation as the agent stored it when this tab connected. */\nexport const history = signal<Line[]>([])\n/** Turns since then; \\`transcript\\` from VoiceClient, minus what \\`history\\` already holds. */\nexport const transcript = signal<TranscriptMessage[]>([])\n/** What the speech-to-text model has heard so far of the current sentence. */\nexport const interim = signal<string | null>(null)\n/** Microphone level, 0–1. */\nexport const level = signal(0)\nexport const muted = signal(false)\nexport const error = signal<string | null>(null)\n\nlet client: VoiceClient | null = null\n/** How much of VoiceClient's transcript the last history message already covered. */\nlet covered = 0\n\n/**\n * Checks the history the agent sends on connect (worker/voice.ts). It crossed the network, so\n * anything that is not a list of { role, content } lines is ignored rather than trusted.\n */\nexport function parseHistory(raw: unknown): Line[] | null {\n if (typeof raw !== 'object' || raw === null) return null\n const { type, messages } = raw as Record<string, unknown>\n if (type !== 'history' || !Array.isArray(messages)) return null\n const lines: Line[] = []\n for (const message of messages) {\n if (typeof message !== 'object' || message === null) continue\n const { role, content } = message as Record<string, unknown>\n if ((role === 'user' || role === 'assistant') && typeof content === 'string') {\n lines.push({ role, text: content })\n }\n }\n return lines\n}\n\n/** One conversation per tab: a reload keeps it, a new tab starts another. */\nfunction conversationId(): string {\n const key = 'voice-conversation'\n try {\n const saved = sessionStorage.getItem(key)\n if (saved && /^[\\\\w-]{1,64}$/.test(saved)) return saved\n const id = crypto.randomUUID()\n sessionStorage.setItem(key, id)\n return id\n } catch {\n return crypto.randomUUID()\n }\n}\n\n/** Connects once; later calls return the same client. */\nexport function voice(): VoiceClient {\n if (client) return client\n const c = new VoiceClient({ agent: VOICE_AGENT, name: conversationId() })\n c.addEventListener('statuschange', (value) => (status.value = value))\n c.addEventListener('connectionchange', (value) => (connected.value = value))\n c.addEventListener('transcriptchange', (value) => (transcript.value = value.slice(covered)))\n c.addEventListener('custommessage', (raw) => {\n const lines = parseHistory(raw)\n if (!lines) return\n history.value = lines\n covered = c.transcript.length\n transcript.value = []\n })\n c.addEventListener('interimtranscript', (value) => (interim.value = value))\n c.addEventListener('audiolevelchange', (value) => (level.value = value))\n c.addEventListener('mutechange', (value) => (muted.value = value))\n c.addEventListener('error', (value) => (error.value = value))\n c.connect()\n client = c\n return c\n}\n`\n}\n\nfunction cfVoiceWorkerTs(): string {\n return `import { Agent } from 'agents'\nimport type { Connection } from 'agents'\nimport { withVoice, WorkersAIFluxSTT, WorkersAITTS } from 'agents/voice'\nimport type { TextSource, VoiceTurnContext } from 'agents/voice'\nimport type { Env } from './index'\nimport { scriptedReply, ScriptedTranscriber, silentSpeech } from './scripted-voice'\n\n/** Any Workers AI text model; replies stream into speech sentence by sentence. */\nconst MODEL = '@cf/meta/llama-3.3-70b-instruct-fp8-fast'\n\n/** How many earlier messages a reload shows. */\nconst HISTORY_SHOWN = 50\n\nconst SYSTEM = \\`You are a voice assistant inside a web app. Your replies are spoken aloud, so\nanswer in one to three short sentences of plain speech: no lists, no markdown, no emoji.\\`\n\n// \\`vite dev\\` has no Workers AI, so there the voice is a stand-in (worker/scripted-voice.ts):\n// it \"hears\" a fixed question every few seconds of audio and answers without sound. A\n// deployed Worker uses Workers AI for all three steps, and so does \\`VITE_REAL_AI=1 vite dev\\`.\nconst scripted = import.meta.env.DEV && import.meta.env['VITE_REAL_AI'] !== '1'\n\nconst VoiceAgent = withVoice(Agent<Env>)\n\n/**\n * One Durable Object per conversation: speech in, text through a model, speech out. It keeps\n * the conversation in its SQLite database, so a reconnect carries on where it was.\n */\nexport class Voice extends VoiceAgent {\n transcriber = scripted ? new ScriptedTranscriber() : new WorkersAIFluxSTT(this.env.AI)\n tts = scripted ? silentSpeech : new WorkersAITTS(this.env.AI)\n\n /** VoiceClient starts empty: send a new connection the conversation so far (src/voice.ts). */\n onConnect(connection: Connection): void {\n connection.send(\n JSON.stringify({ type: 'history', messages: this.getConversationHistory(HISTORY_SHOWN) }),\n )\n }\n\n async onTurn(transcript: string, { messages, signal }: VoiceTurnContext): Promise<TextSource> {\n if (scripted) return scriptedReply(transcript)\n try {\n // A stream of server-sent events; the voice pipeline reads the text out of it.\n const stream = await this.env.AI.run(\n MODEL,\n {\n messages: [\n { role: 'system', content: SYSTEM },\n ...messages,\n { role: 'user', content: transcript },\n ],\n stream: true,\n },\n { signal },\n )\n if (!(stream instanceof ReadableStream)) throw new Error('Workers AI returned no stream')\n return stream\n } catch (error) {\n if (signal.aborted) throw error\n // Said aloud rather than left as silence; the cause is in the Worker's logs.\n console.error('[voice] the model call failed:', error)\n return 'Sorry, I could not reach the model just now.'\n }\n }\n}\n`\n}\n\nfunction cfScriptedVoiceTs(): string {\n return `import type {\n TTSProvider,\n Transcriber,\n TranscriberSession,\n TranscriberSessionOptions,\n} from 'agents/voice'\n\n/**\n * \\`vite dev\\` stand-ins for Workers AI's speech models, which have no local mode. They keep the\n * whole call working offline — microphone, streaming, turns, transcript — without a model:\n * the transcriber \"hears\" a fixed question for every few seconds of sound, and the replies\n * are text only. Production never uses them (see worker/voice.ts).\n */\n\n/** 16 kHz, 16-bit mono: this many bytes is three seconds of audio. */\nconst BYTES_PER_TURN = 16_000 * 2 * 3\nconst QUESTIONS = ['What can you do?', 'How do I deploy this app?', 'Thanks, that is all.']\n\nexport class ScriptedTranscriber implements Transcriber {\n createSession(options: TranscriberSessionOptions = {}): TranscriberSession {\n let bytes = 0\n let turn = 0\n return {\n feed(chunk) {\n bytes += chunk.byteLength\n if (bytes < BYTES_PER_TURN) return\n bytes = 0\n options.onUtterance?.(QUESTIONS[turn++ % QUESTIONS.length]!)\n },\n close() {},\n }\n }\n}\n\n/** No sound in \\`vite dev\\`: the reply shows in the transcript only. */\nexport const silentSpeech: TTSProvider = { synthesize: async () => null }\n\nconst REPLIES: Record<string, string> = {\n 'What can you do?':\n 'I am the development stand-in. Deploy, or set VITE_REAL_AI=1, for Workers AI.',\n 'How do I deploy this app?': 'Run the deploy script. It builds the page and the Worker together.',\n}\n\nexport function scriptedReply(transcript: string): string {\n return REPLIES[transcript] ?? \\`You said: \\${transcript}\\`\n}\n`\n}\n\nfunction cfVoiceRouteTsx(): string {\n return `import {\n Badge,\n Button,\n Card,\n CardContent,\n EmptyState,\n Flex,\n Heading,\n Input,\n ProgressBar,\n Text,\n useSignals,\n} from '@cascivo/react'\nimport type { FormEvent } from 'react'\nimport {\n connected,\n error,\n history,\n interim,\n level,\n muted,\n status,\n transcript,\n voice,\n} from '../voice'\nimport type { Line } from '../voice'\n\nconst STATUS_LABEL = {\n idle: 'Not in a call',\n listening: 'Listening',\n thinking: 'Thinking',\n speaking: 'Speaking',\n} as const\n\nfunction sendTyped(event: FormEvent<HTMLFormElement>): void {\n event.preventDefault()\n const form = event.currentTarget\n const text = new FormData(form).get('message')\n if (typeof text !== 'string' || text.trim() === '') return\n voice().sendText(text.trim())\n form.reset()\n}\n\nexport default function VoicePage() {\n useSignals()\n const client = voice()\n const inCall = status.value !== 'idle'\n const lines: Line[] = [\n ...history.value,\n ...transcript.value.map((m) => ({ role: m.role, text: m.text })),\n ]\n\n return (\n <Flex gap={4}>\n <Flex gap={1}>\n <Heading level={1}>Voice</Heading>\n <Text muted>\n Talk to an assistant on Workers AI: speech to text, a model, and text to speech, all in a\n Durable Object. Speak over a reply to interrupt it.\n </Text>\n </Flex>\n <Card>\n <CardContent>\n <Flex gap={3}>\n <Flex direction=\"horizontal\" align=\"center\" wrap gap={3}>\n {inCall ? (\n <Button variant=\"destructive\" onClick={() => client.endCall()}>\n End call\n </Button>\n ) : (\n <Button disabled={!connected.value} onClick={() => void client.startCall()}>\n Start call\n </Button>\n )}\n <Button variant=\"secondary\" disabled={!inCall} onClick={() => client.toggleMute()}>\n {muted.value ? 'Unmute' : 'Mute'}\n </Button>\n <Badge variant={status.value === 'idle' ? 'secondary' : 'success'}>\n {connected.value ? STATUS_LABEL[status.value] : 'Connecting'}\n </Badge>\n </Flex>\n {inCall ? (\n <ProgressBar label=\"Microphone\" value={Math.round(level.value * 100)} max={100} />\n ) : null}\n {error.value ? <Text muted>{error.value}</Text> : null}\n </Flex>\n </CardContent>\n </Card>\n <Flex gap={2} role=\"log\" aria-live=\"polite\" aria-label=\"Conversation\">\n {lines.length === 0 && !interim.value ? (\n <EmptyState\n title=\"Start a call and say something\"\n description=\"Or type below: a typed message gets a reply too, spoken if you are in a call.\"\n />\n ) : null}\n {lines.map((message, index) => (\n // The conversation only grows, so a line's position is a stable key.\n <Flex key={index} gap={1}>\n <Text size=\"sm\" muted>\n {message.role === 'user' ? 'You' : 'Assistant'}\n </Text>\n <Text>{message.text}</Text>\n </Flex>\n ))}\n {interim.value ? <Text muted>{interim.value}…</Text> : null}\n </Flex>\n <form onSubmit={sendTyped}>\n <Flex direction=\"horizontal\" align=\"end\" gap={2}>\n <Input name=\"message\" label=\"Or type\" placeholder=\"Ask something\" autoComplete=\"off\" />\n <Button type=\"submit\" variant=\"secondary\" disabled={!connected.value}>\n Send\n </Button>\n </Flex>\n </form>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example live`: an ops dashboard fed by a Queue (@cascivo/app/live) --- */\n\nfunction cfOpsTs(): string {\n return `import { defineLive } from '@cascivo/app/live'\n\n/**\n * The live dashboard's metrics, shared by the Worker (which records them) and the page (which\n * charts them). Each event adds to its second's totals, and the room keeps two minutes.\n */\nexport const ops = defineLive({ metrics: ['orders', 'revenue', 'errors'], window: 120 })\n\n/** The dashboard's room. Name one per team or tenant if each needs its own. */\nexport const OPS_ROOM = 'ops'\n\nexport interface Accepted {\n accepted: number\n}\n\nexport function parseAccepted(raw: unknown): Accepted {\n if (typeof raw === 'object' && raw !== null) {\n const { accepted } = raw as Record<string, unknown>\n if (typeof accepted === 'number') return { accepted }\n }\n throw new Error('Malformed reply')\n}\n`\n}\n\nfunction cfOpsRouteTsx(): string {\n return `import { Kpi, LineChart } from '@cascivo/charts'\nimport { createClient } from '@cascivo/app/api'\nimport { watchLive } from '@cascivo/app/live'\nimport type { LiveEvent, LivePoint } from '@cascivo/app/live'\nimport {\n Badge,\n Card,\n CardContent,\n Flex,\n Grid,\n Heading,\n Text,\n Toggle,\n computed,\n signal,\n useSignalEffect,\n useSignals,\n} from '@cascivo/react'\nimport { api } from '../api'\nimport { ops } from '../ops'\n\ntype Metric = (typeof ops.metrics)[number]\n\nconst client = createClient(api)\n// One connection for the app's lifetime. The room sends the whole window when it opens, so a\n// reload or a dropped connection comes back with the last two minutes.\nconst live = watchLive(ops, '/api/live')\nconst lastMinute = computed(() => live.points.value.slice(-60))\nconst total = (points: LivePoint<Metric>[], metric: Metric) =>\n points.reduce((sum, point) => sum + point.values[metric], 0)\n\nconst simulating = signal(true)\n\n/** Stand-in traffic: a few orders every half second, some of them failing. */\nfunction sendTraffic(): void {\n const events: LiveEvent<Metric>[] = Array.from(\n { length: 1 + Math.floor(Math.random() * 6) },\n () =>\n Math.random() < 0.05\n ? { values: { errors: 1 } }\n : { values: { orders: 1, revenue: Math.round(20 + Math.random() * 180) } },\n )\n client\n .sendEvents({ body: events })\n .catch((error: unknown) => console.warn('Could not send events', error))\n}\n\nexport default function Ops() {\n useSignals()\n useSignalEffect(() => {\n if (!simulating.value) return\n const timer = setInterval(sendTraffic, 500)\n return () => clearInterval(timer)\n })\n const points = live.points.value\n const minute = lastMinute.value\n\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Ops</Heading>\n <Text muted>\n Events go through a Queue into a Durable Object, which sends each second to every open\n dashboard.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={3}>\n <Badge variant={live.connection.value === 'open' ? 'success' : 'warning'}>\n {live.connection.value === 'open' ? 'Live' : 'Reconnecting'}\n </Badge>\n <Toggle\n label=\"Send simulated traffic\"\n checked={simulating.value}\n onValueChange={(on) => {\n simulating.value = on\n }}\n />\n </Flex>\n </Flex>\n <Grid cols={3} gap={3}>\n <Kpi\n label=\"Orders, last minute\"\n value={total(minute, 'orders').toLocaleString()}\n sparkline={minute.map((p) => p.values.orders)}\n />\n <Kpi\n label=\"Revenue, last minute\"\n value={\\`$\\${total(minute, 'revenue').toLocaleString()}\\`}\n sparkline={minute.map((p) => p.values.revenue)}\n />\n <Kpi\n label=\"Errors, last minute\"\n value={total(minute, 'errors').toLocaleString()}\n sparkline={minute.map((p) => p.values.errors)}\n />\n </Grid>\n <Card>\n <CardContent>\n <LineChart\n title=\"Orders and errors per second\"\n series={[\n { id: 'orders', label: 'Orders', data: points, y: (p) => p.values.orders },\n { id: 'errors', label: 'Errors', data: points, y: (p) => p.values.errors },\n ]}\n x={(p) => new Date(p.at)}\n y={(p) => p.values.orders}\n />\n </CardContent>\n </Card>\n <Card>\n <CardContent>\n <LineChart\n title=\"Revenue per second\"\n series={[{ id: 'revenue', label: 'Revenue', data: points }]}\n x={(p) => new Date(p.at)}\n y={(p) => p.values.revenue}\n />\n </CardContent>\n </Card>\n </Flex>\n )\n}\n`\n}\n\n/* --- `--example crud`: a D1 table behind DataTable's server mode (@cascivo/app/db) --- */\n\nfunction cfCustomersTs(): string {\n return `import { defineTable } from '@cascivo/app/db'\n\n/**\n * The customers table, shared by the Worker (which queries it) and the page (which shows\n * it). The columns say what the table may sort, search and filter by — anything else in a\n * query is refused before any SQL is built.\n */\nexport const customersTable = defineTable({\n table: 'customers',\n key: 'id',\n columns: {\n id: {},\n name: { sort: true, search: true, filter: 'text' },\n email: { sort: true, search: true },\n plan: { sort: true, filter: 'select' },\n seats: { sort: true, filter: 'range' },\n created_at: { sort: true },\n },\n})\n\nexport const PLANS = ['free', 'team', 'enterprise'] as const\nexport type Plan = (typeof PLANS)[number]\n\nexport interface Customer {\n id: string\n name: string\n email: string\n plan: Plan\n seats: number\n created_at: string\n}\n\n/** What the form sends: a customer without the fields the Worker sets. */\nexport type CustomerInput = Omit<Customer, 'id' | 'created_at'>\n\nconst EMAIL = /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/\n\nfunction isPlan(value: unknown): value is Plan {\n return typeof value === 'string' && (PLANS as readonly string[]).includes(value)\n}\n\n/** A form submission crosses the network: checked on the Worker before it is stored. */\nexport function parseCustomerInput(raw: unknown): CustomerInput {\n if (typeof raw !== 'object' || raw === null) throw new Error('Send a customer')\n const { name, email, plan, seats } = raw as Record<string, unknown>\n if (typeof name !== 'string' || name.trim().length === 0 || name.length > 120) {\n throw new Error('A name is 1 to 120 characters')\n }\n if (typeof email !== 'string' || !EMAIL.test(email) || email.length > 200) {\n throw new Error('That is not an email address')\n }\n if (!isPlan(plan)) throw new Error(\\`The plan is one of \\${PLANS.join(', ')}\\`)\n if (typeof seats !== 'number' || !Number.isInteger(seats) || seats < 1 || seats > 100_000) {\n throw new Error('Seats is a whole number from 1')\n }\n return { name: name.trim(), email: email.trim().toLowerCase(), plan, seats }\n}\n\n/** A row from D1, or from the Worker's response. */\nexport function parseCustomer(raw: unknown): Customer {\n if (typeof raw === 'object' && raw !== null) {\n const { id, created_at } = raw as Record<string, unknown>\n if (typeof id === 'string' && typeof created_at === 'string') {\n return { id, created_at, ...parseCustomerInput(raw) }\n }\n }\n throw new Error('Malformed customer')\n}\n\nexport function parseDeleted(raw: unknown): { id: string } {\n if (typeof raw === 'object' && raw !== null) {\n const { id } = raw as Record<string, unknown>\n if (typeof id === 'string') return { id }\n }\n throw new Error('Malformed delete result')\n}\n`\n}\n\nfunction cfCustomersPageTs(): string {\n return `import { createClient } from '@cascivo/app/api'\nimport type { TableQuery } from '@cascivo/app/db'\nimport { signal } from '@cascivo/react'\nimport { api } from './api'\nimport type { Customer, CustomerInput, Plan } from './customers'\n\nconst client = createClient(api)\n\nexport const PAGE_SIZE = 10\n\n/** The current page, as the Worker returned it for \\`query\\`. */\nexport const rows = signal<Customer[]>([])\nexport const total = signal(0)\nexport const loadError = signal<string | null>(null)\n\n/** The plan filter above the table; '' is every plan. */\nexport const plan = signal<Plan | ''>('')\n\nlet query: TableQuery = { sort: undefined, search: '', filters: {}, page: 1, pageSize: PAGE_SIZE }\nlet latest = 0\n\n/** Fetches the page for \\`next\\` (or the current query again); a slower, older answer is dropped. */\nexport async function load(next: TableQuery = query): Promise<void> {\n query = next\n const request = ++latest\n const filters = plan.value\n ? { ...next.filters, plan: { kind: 'select' as const, values: [plan.value] } }\n : next.filters\n try {\n const page = await client.customers({ body: { ...next, filters } })\n if (request !== latest) return\n rows.value = page.rows\n total.value = page.total\n loadError.value = null\n } catch (error) {\n if (request === latest)\n loadError.value = error instanceof Error ? error.message : 'Could not load'\n }\n}\n\nexport function setPlan(next: Plan | ''): void {\n plan.value = next\n void load({ ...query, page: 1 })\n}\n\nexport async function save(id: string | null, input: CustomerInput): Promise<void> {\n if (id) await client.updateCustomer({ params: { id }, body: input })\n else await client.createCustomer({ body: input })\n await load()\n}\n\nexport async function remove(id: string): Promise<void> {\n await client.deleteCustomer({ params: { id } })\n await load()\n}\n`\n}\n\nfunction cfCustomersWorkerTs(): string {\n return `import { HttpError } from '@cascivo/app/api'\nimport { migrate, queryTable } from '@cascivo/app/db'\nimport type { Database, TablePage, TableQuery } from '@cascivo/app/db'\nimport { customersTable, parseCustomer } from '../src/customers'\nimport type { Customer, CustomerInput } from '../src/customers'\nimport { migrations } from './migrations'\n\n/** Every handler starts here: the schema is applied once per isolate, before the first query. */\nasync function ready(db: Database): Promise<Database> {\n await migrate(db, migrations)\n return db\n}\n\n/** A second customer with the same email is a conflict the form can show, not a 500. */\nfunction uniqueEmail(error: unknown): never {\n if (/UNIQUE/i.test(String(error))) throw new HttpError(409, 'A customer with that email exists')\n throw error\n}\n\nexport async function listCustomers(db: Database, query: TableQuery): Promise<TablePage<Customer>> {\n return queryTable(await ready(db), customersTable, query, parseCustomer)\n}\n\nexport async function createCustomer(db: Database, input: CustomerInput): Promise<Customer> {\n const customer: Customer = {\n id: crypto.randomUUID(),\n created_at: new Date().toISOString(),\n ...input,\n }\n await (\n await ready(db)\n )\n .prepare(\n 'INSERT INTO customers (id, name, email, plan, seats, created_at) VALUES (?, ?, ?, ?, ?, ?)',\n )\n .bind(customer.id, input.name, input.email, input.plan, input.seats, customer.created_at)\n .run()\n .catch(uniqueEmail)\n return customer\n}\n\nexport async function updateCustomer(\n db: Database,\n id: string,\n input: CustomerInput,\n): Promise<Customer> {\n const row = await (\n await ready(db)\n )\n .prepare(\n 'UPDATE customers SET name = ?, email = ?, plan = ?, seats = ? WHERE id = ? RETURNING *',\n )\n .bind(input.name, input.email, input.plan, input.seats, id)\n .first()\n .catch(uniqueEmail)\n if (!row) throw new HttpError(404, 'No such customer')\n return parseCustomer(row)\n}\n\nexport async function deleteCustomer(db: Database, id: string): Promise<{ id: string }> {\n const row = await (\n await ready(db)\n )\n .prepare('DELETE FROM customers WHERE id = ? RETURNING id')\n .bind(id)\n .first()\n if (!row) throw new HttpError(404, 'No such customer')\n return { id }\n}\n`\n}\n\nfunction cfMigrationsTs(): string {\n return `import type { Migration } from '@cascivo/app/db'\n\nconst FIRST = [\n 'Ada',\n 'Grace',\n 'Alan',\n 'Edsger',\n 'Barbara',\n 'Donald',\n 'Margaret',\n 'Ken',\n 'Frances',\n 'Tim',\n]\nconst LAST = ['Labs', 'Systems', 'Works', 'Studio', 'Group', 'Cloud']\nconst PLANS = ['free', 'team', 'enterprise']\n\n/** 60 sample customers, so the table has pages to sort, search and filter from the start. */\nfunction seed(): string {\n const rows = Array.from({ length: 60 }, (_, i) => {\n const name = \\`\\${FIRST[i % FIRST.length]} \\${LAST[i % LAST.length]}\\`\n const email = \\`\\${name.toLowerCase().replace(' ', '.')}\\${i}@example.com\\`\n const day = String((i % 28) + 1).padStart(2, '0')\n return \\`('seed-\\${i}', '\\${name}', '\\${email}', '\\${PLANS[i % 3]}', \\${((i * 37) % 250) + 1}, '2026-0\\${(i % 9) + 1}-\\${day}')\\`\n })\n return \\`INSERT INTO customers (id, name, email, plan, seats, created_at) VALUES \\${rows.join(', ')}\\`\n}\n\n/**\n * The schema, applied by the Worker itself on its first query (\\`migrate\\` from\n * \\`@cascivo/app/db\\`): a fresh deploy, a Deploy button or a temporary account needs no step.\n * Append new migrations; never edit one that has shipped.\n */\nexport const migrations: Migration[] = [\n {\n id: '0001_customers',\n statements: [\n \\`CREATE TABLE customers (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n email TEXT NOT NULL UNIQUE,\n plan TEXT NOT NULL,\n seats INTEGER NOT NULL,\n created_at TEXT NOT NULL\n )\\`,\n 'CREATE INDEX customers_plan ON customers (plan)',\n seed(),\n ],\n },\n]\n`\n}\n\nfunction cfCustomersRouteTsx(): string {\n return `import type { Column } from '@cascivo/react'\nimport {\n Badge,\n Button,\n DataTable,\n Flex,\n Heading,\n Input,\n Modal,\n NumberInput,\n Select,\n Text,\n signal,\n useSignals,\n} from '@cascivo/react'\nimport { PLANS } from '../customers'\nimport type { Customer, CustomerInput, Plan } from '../customers'\nimport {\n PAGE_SIZE,\n load,\n loadError,\n plan,\n remove,\n rows,\n save,\n setPlan,\n total,\n} from '../customers-page'\n\n/** The customer in the form: \\`id\\` null for a new one. */\nconst editing = signal<{ id: string | null; draft: CustomerInput } | null>(null)\nconst formError = signal<string | null>(null)\nconst saving = signal(false)\n\nvoid load()\n\nconst COLUMNS: Column<Customer>[] = [\n { key: 'name', header: 'Name', sortable: true, filter: 'text' },\n { key: 'email', header: 'Email', sortable: true },\n {\n key: 'plan',\n header: 'Plan',\n sortable: true,\n render: (row) => (\n <Badge variant={row.plan === 'enterprise' ? 'success' : 'secondary'}>{row.plan}</Badge>\n ),\n },\n { key: 'seats', header: 'Seats', sortable: true, align: 'end', filter: 'range' },\n {\n key: 'created_at',\n header: 'Since',\n sortable: true,\n render: (row) => row.created_at.slice(0, 10),\n },\n]\n\nfunction edit(customer: Customer | null) {\n formError.value = null\n editing.value = customer\n ? {\n id: customer.id,\n draft: {\n name: customer.name,\n email: customer.email,\n plan: customer.plan,\n seats: customer.seats,\n },\n }\n : { id: null, draft: { name: '', email: '', plan: 'team', seats: 5 } }\n}\n\nfunction change(patch: Partial<CustomerInput>) {\n if (editing.value)\n editing.value = { ...editing.value, draft: { ...editing.value.draft, ...patch } }\n}\n\nasync function submit() {\n if (!editing.value) return\n saving.value = true\n formError.value = null\n try {\n await save(editing.value.id, editing.value.draft)\n editing.value = null\n } catch (error) {\n // The Worker's message: \"A customer with that email exists\", \"That is not an email address\"…\n formError.value = error instanceof Error ? error.message : 'Could not save'\n } finally {\n saving.value = false\n }\n}\n\nfunction CustomerForm() {\n useSignals()\n const current = editing.value\n if (!current) return null\n const { draft } = current\n return (\n <Modal\n open\n onClose={() => (editing.value = null)}\n title={current.id ? 'Edit customer' : 'New customer'}\n footer={\n <Flex direction=\"horizontal\" gap={2} justify=\"end\">\n <Button variant=\"ghost\" onClick={() => (editing.value = null)}>\n Cancel\n </Button>\n <Button onClick={() => void submit()} loading={saving.value}>\n Save\n </Button>\n </Flex>\n }\n >\n <Flex gap={3}>\n <Input label=\"Name\" value={draft.name} onChange={(e) => change({ name: e.target.value })} />\n <Input\n label=\"Email\"\n type=\"email\"\n value={draft.email}\n onChange={(e) => change({ email: e.target.value })}\n />\n <Select\n label=\"Plan\"\n value={draft.plan}\n options={PLANS.map((p) => ({ value: p, label: p }))}\n onChange={(e) => change({ plan: e.target.value as Plan })}\n />\n <NumberInput\n label=\"Seats\"\n min={1}\n value={draft.seats}\n onValueChange={(v) => change({ seats: v ?? 1 })}\n />\n {formError.value ? <Text muted>{formError.value}</Text> : null}\n </Flex>\n </Modal>\n )\n}\n\nexport default function Customers() {\n useSignals()\n return (\n <Flex gap={4}>\n <Flex direction=\"horizontal\" align=\"center\" justify=\"between\" wrap gap={3}>\n <Flex gap={1}>\n <Heading level={1}>Customers</Heading>\n <Text muted>\n A D1 table behind DataTable's server mode: sorting, search, filters and paging all run\n as SQL in the Worker.\n </Text>\n </Flex>\n <Flex direction=\"horizontal\" align=\"center\" gap={2}>\n <Select\n ariaLabel=\"Plan\"\n value={plan.value}\n options={[\n { value: '', label: 'Every plan' },\n ...PLANS.map((p) => ({ value: p, label: p })),\n ]}\n onChange={(e) => setPlan(e.target.value as Plan | '')}\n />\n <Button onClick={() => edit(null)}>New customer</Button>\n </Flex>\n </Flex>\n {loadError.value ? <Text muted>{loadError.value}</Text> : null}\n <DataTable\n columns={COLUMNS}\n rows={rows.value}\n getRowId={(row) => row.id}\n searchable\n pagination={{ pageSize: PAGE_SIZE }}\n server={{ totalItems: total.value, onQueryChange: (query) => void load(query) }}\n rowActions={(row) => [\n { id: 'edit', label: 'Edit', onSelect: () => edit(row) },\n { id: 'delete', label: 'Delete', destructive: true, onSelect: () => void remove(row.id) },\n ]}\n />\n <CustomerForm />\n </Flex>\n )\n}\n`\n}\n\nfunction cfPrettierIgnore(): string {\n return `${prettierIgnore()}# Rewritten by @cascivo/app/vite whenever a route file changes.\nsrc/routes.gen.ts\n`\n}\n\nfunction cfGitignore(): string {\n return `node_modules\ndist\n.wrangler\n.dev.vars*\n!.dev.vars.example\n*.local\n.DS_Store\n`\n}\n\nfunction cfReadme(opts: ScaffoldOptions): string {\n const pm = opts.pm ?? 'npm'\n const runtime = runtimeOf(opts)\n return `# ${opts.name}\n\nA [cascivo](https://cascivo.com) app on Cloudflare: a client-rendered\n${runtime === 'preact' ? 'Preact' : 'React'} app and its API, deployed as one Worker.\nThere is no server rendering; the browser gets the app, and \\`/api/*\\` reaches the Worker.\n\n## Develop\n\n\\`\\`\\`sh\n${installAllCommand(pm)}\n${runScriptCommand(pm, 'dev')}\n\\`\\`\\`\n\n\\`vite dev\\` runs \\`worker/index.ts\\` in workerd, the same runtime as production, via\n\\`@cloudflare/vite-plugin\\`.\n\n## Deploy\n\n\\`\\`\\`sh\nnpx wrangler login # once\n${runExplicitCommand(pm, 'deploy')}\n\\`\\`\\`\n\n## Share a preview (no account)\n\n\\`\\`\\`sh\n${runExplicitCommand(pm, 'deploy:preview')}\n\\`\\`\\`\n\nThis builds the app, then deploys it to a temporary Cloudflare account with\n\\`wrangler deploy --temporary\\`. You need no sign-up. It prints two URLs:\n\n- **A public \\`workers.dev\\` URL** for the app.\n- **A claim URL.** Open it and sign in within 60 minutes to keep the deployment; otherwise it is deleted.\n\nIt works only while wrangler is logged out. If you are logged in, use \\`deploy\\` instead.\nA temporary account supports Workers, static assets, KV, D1 and Durable Objects. It does not\nsupport Workers AI, R2, Workflows or Browser Run.${\n usesAgents(opts)\n ? ` So a preview serves the app, but its ${hasExample(opts, 'agent') ? 'assistant' : 'voice page'} cannot reach the model: deploy it\\nto your own account for that.`\n : ''\n }\n\n## Structure\n\n- \\`src/routes/\\` — one file per page. \\`index.tsx\\` is \\`/\\`, \\`settings.tsx\\` is\n \\`/settings\\`, \\`c/[id].tsx\\` is \\`/c/:id\\` (it receives \\`params.id\\`), \\`404.tsx\\` is\n everything else. \\`src/routes.gen.ts\\` is rewritten from this folder; do not edit it.\n- \\`src/api.ts\\` — the API contract both sides import: endpoints, their paths, and parsers\n for what crosses the network.\n- \\`worker/index.ts\\` — the API's handlers, typed from \\`src/api.ts\\`. Add bindings (KV,\n D1, R2, Durable Objects, Workers AI) in \\`wrangler.jsonc\\`; handlers receive them as \\`env\\`.\n- \\`src/live.ts\\` + \\`src/LiveCard.tsx\\` — a streaming endpoint read through the typed\n client into signals.\n- \\`src/App.tsx\\` — the nav (real \\`href\\`s) and the \\`RouterView\\`.\n- \\`src/Shell.tsx\\` — the app shell (header + side nav + content slot).\n\nPersist client state with \\`persistedSignal\\` from \\`@cascivo/storage\\` (localStorage or\nIndexedDB).${\n hasExample(opts, 'board')\n ? `\n\n## Board (multiplayer)\n\n\\`/board\\` is a shared board. Open it in two windows: notes, edits and cursors sync live.\n\n- \\`src/board.ts\\` — \\`connectRoom('/api/rooms/<name>')\\` from \\`@cascivo/app/sync\\`; \\`room.map('notes', parseNote)\\`\n is a signal every visitor shares, and \\`room.presence\\` carries cursors.\n- \\`worker/index.ts\\` — exports \\`SyncRoom\\`, one Durable Object per room, and routes\n \\`/api/rooms/:name\\` to it (bound as \\`ROOMS\\` in \\`wrangler.jsonc\\`).\n- \\`/board?room=team\\` is a separate board.\n\nEach path (one note) is last-writer-wins in the order the room receives writes, so two people\nediting the same note at once settle on one value; editing different notes never collides.\nDurable Objects work on a temporary account, so \\`deploy:preview\\` shares a live board with\nno Cloudflare account.`\n : ''\n }${\n hasExample(opts, 'notes')\n ? `\n\n## Notes (local-first)\n\n\\`/notes\\` keeps working when the connection drops. Edits are saved on this device and sync\nwhen the room is reachable again; a badge shows how many are still waiting.\n\n- \\`src/notes.ts\\` — \\`connectRoom(url, { storage: indexedDBDriver() })\\`: the room's last\n state and every unconfirmed edit are kept in IndexedDB, so offline edits survive a reload\n or a closed tab and go out on the next connection. \\`room.unsynced\\` counts them. (Opening\n the app with no network at all also needs its files cached, by a service worker.)\n- The list is \\`/notes?list=<name>\\`. Without one, each browser makes its own and remembers\n it; open the same \\`?list=\\` on another device to sync with it.\n- It uses the same \\`SyncRoom\\` Durable Object as any other room (\\`ROOMS\\` in\n \\`wrangler.jsonc\\`). To query notes across lists, override \\`SyncRoom.onWrite\\` and mirror\n each write into D1.\n\nA note is last-writer-wins: an edit made offline replaces whatever the note held when it\narrives. Edits to different notes never collide.`\n : ''\n }${\n hasExample(opts, 'import')\n ? `\n\n## Import (background job)\n\n\\`/import\\` runs a CSV import as a Cloudflare Workflow and shows its progress live.\n\n- \\`worker/import-job.ts\\` — \\`ImportJob\\`, the Workflow. Each \\`step.do\\` is retried on its own\n and its result kept, so a failure or a deploy resumes the job where it was. Put your writes\n in \\`importContacts\\`.\n- \\`src/import-job.ts\\` — the job (\\`defineJob\\` from \\`@cascivo/app/jobs\\`): its steps and the\n parser for its result, shared by both sides.\n- Progress is a read-only room: the Workflow reports with \\`jobReporter\\`\n (\\`@cascivo/app/jobs-server\\`), and the page watches \\`/api/jobs/:id\\` with \\`watchJob\\`. The job\n id is in the URL, so a reload or another tab picks the job back up.\n\nReport from inside a \\`step.do\\`: a Workflow replays \\`run()\\` from the top after each step, and\na report outside one would run again. Workflows run in \\`vite dev\\` locally, but not on a\ntemporary account, so \\`deploy:preview\\` serves the page without starting imports.`\n : ''\n }${\n hasExample(opts, 'files')\n ? `\n\n## Files (uploads)\n\n\\`/files\\` uploads into R2 through the Worker, with progress, and shows resized previews.\n\n- \\`src/upload-policy.ts\\` — what the app accepts (types, size), shared by both sides: the page\n checks a file before sending it, and the Worker enforces the same policy. SVG and HTML are\n refused by design; served from your origin they would run script.\n- \\`worker/index.ts\\` — \\`handleUploads\\` (\\`@cascivo/app/uploads-server\\`) stores uploads, in\n parts above 16 MiB, under keys the Worker chooses, and serves them back with headers that\n stop any file running script. \\`?w=320\\` returns a WebP preview from Cloudflare Images.\n- \\`src/files.ts\\` — \\`startUpload\\` gives each file progress and status signals.\n\nEach caller (by IP) may start 20 uploads a minute (\\`ratelimits\\` in \\`wrangler.jsonc\\`).${\n opts.auth\n ? ''\n : `\n**It has no auth.** Anyone who can reach the app can upload: put Cloudflare Access in front of\nit (\\`--auth access\\`), add accounts (\\`--auth email\\`), or check who is asking in\n\\`worker/index.ts\\` before \\`handleUploads\\` runs.`\n } Create the bucket once before deploying:\n\\`npx wrangler r2 bucket create ${packageName(opts.name)}-files\\`. R2 and Images do not run on a\ntemporary account.`\n : ''\n }${\n hasExample(opts, 'export')\n ? `\n\n## Report (PDF and PNG export)\n\n\\`/report\\` has \"Download PDF\" and \"Download PNG\" buttons. The Worker renders the page in\nCloudflare's Browser Run and returns the file.\n\n- \\`worker/index.ts\\` — \\`handleExport\\` (\\`@cascivo/app/export\\`) serves\n \\`/api/export?page=/report&format=pdf\\`. It opens the page at this app's own origin with\n \\`?export=1\\`, waits for the network to go quiet, and prints it. Only paths of this app\n outside \\`/api/\\` can be exported.\n- \\`src/App.tsx\\` — \\`isExporting()\\` drops the shell, so the file holds the page alone.\n- For a scheduled report, call \\`exportPage\\` from a Cron Trigger and attach the PDF to an\n email (\\`sendEmail\\` from \\`@cascivo/email\\` takes \\`attachments\\`).\n\nThe browser opens the page without the visitor's cookies, so a page that needs a session\nrenders signed out. Each export starts a browser session, which is billed, so each caller\n(by IP) may start 20 a minute (\\`ratelimits\\` in \\`wrangler.jsonc\\`). In \\`vite dev\\` Browser Run starts a local Chrome (downloaded on\nfirst use); it does not run on a temporary account.`\n : ''\n }${\n hasExample(opts, 'live')\n ? `\n\n## Ops (live dashboard)\n\n\\`/ops\\` charts orders, revenue and errors per second, and every open copy of the page moves\ntogether as events arrive.\n\n- \\`src/ops.ts\\` — \\`defineLive\\` (\\`@cascivo/app/live\\`): the metrics, and two minutes of\n per-second history.\n- \\`worker/index.ts\\` — \\`POST /api/events\\` puts events on the \\`EVENTS\\` queue. The \\`queue\\`\n handler adds each batch into a \\`LiveRoom\\` Durable Object with \\`recordLive\\`, which keeps\n per-second totals and sends each change to the browsers watching \\`/api/live\\` (read-only).\n- \\`src/routes/ops.tsx\\` — \\`watchLive\\` gives the window as a signal; the page sends simulated\n traffic while its switch is on. Real producers are anything with the queue binding: another\n Worker, a cron, a webhook handler.\n\nA new viewer, or one back from a dropped connection, starts with the whole window. Delivery is\nat least once: a batch retried after a lost reply counts twice. \\`/api/events\\` has no auth, so\nanyone can move the numbers: check who is sending before real use (\\`--auth access\\` puts\nCloudflare Access in front of the Worker). Create the queue once before deploying:\n\\`npx wrangler queues create ${packageName(opts.name)}-events\\`.`\n : ''\n }${\n hasExample(opts, 'usage')\n ? `\n\n## Usage (analytics)\n\nEvery API request is recorded in Workers Analytics Engine, and \\`/usage\\` charts the last\n24 hours: requests per hour, the busiest routes, errors and latency.\n\n- \\`src/usage.ts\\` — \\`defineMetrics\\` (\\`@cascivo/app/analytics\\`) names the columns once\n (\\`path\\`, \\`method\\`, \\`status\\`, \\`duration_ms\\`), so writes and queries never disagree about\n which blob is which.\n- \\`worker/index.ts\\` — \\`handleAndRecord\\` writes one data point per API request.\n- \\`worker/usage.ts\\` — the queries, over Analytics Engine's SQL API.\n\nWriting needs only the binding. **Reading needs two secrets**, because the SQL API is HTTP\nwith an account token:\n\n\\`\\`\\`sh\nnpx wrangler secret put CF_ACCOUNT_ID\nnpx wrangler secret put CF_API_TOKEN # a token with Account Analytics: Read\n\\`\\`\\`\n\nFor \\`vite dev\\`, put both in \\`.dev.vars\\`. Data written locally is not in your account's\ndataset, so in development the charts show what your deployed app recorded. Until the secrets\nexist, the page says what to set.`\n : ''\n }${\n hasExample(opts, 'search')\n ? `\n\n## Search (by meaning)\n\n\\`/search\\` finds help articles by what a question means: \"how do I get my money back\" finds\nRefunds, though they share no words. Each article is embedded with Workers AI\n(\\`@cf/baai/bge-base-en-v1.5\\`) into a Vectorize index; a question is embedded the same way\nand matched against it.\n\n- \\`worker/articles.ts\\` — the articles, seeded into D1. Replace them with your own content.\n- \\`worker/search.ts\\` — \\`search\\` and \\`indexArticles\\`. \"Index articles\" on the page embeds\n every article; Vectorize applies writes a few seconds later.\n\nCreate the index once before deploying, with the embedding model's dimensions:\n\\`npx wrangler vectorize create ${packageName(opts.name)}-articles --dimensions=768 --metric=cosine\\`.\nNeither Vectorize nor Workers AI runs locally, so \\`vite dev\\` searches by keyword with SQLite's\nfull-text search instead, and the page says so. \\`VITE_REAL_AI=1 ${runScriptCommand(pm, 'dev')}\\` uses the real ones\n(after \\`npx wrangler login\\`). Workers AI and Vectorize bill per use beyond their free\nallocations, and a temporary account has neither.`\n : ''\n }${\n hasExample(opts, 'digest')\n ? `\n\n## Weekly digest (Cron Trigger)\n\nEvery Monday at 08:00 UTC (\\`triggers.crons\\` in \\`wrangler.jsonc\\`), the Worker renders\n\\`/report\\` to a PDF with Browser Run and emails it. \\`/digest\\` lists each run — sent,\nskipped or failed, and why — and \"Send now\" runs one at once.\n\n- \\`worker/digest.ts\\` — \\`runDigest\\`: \\`exportPage\\` (\\`@cascivo/app/export\\`), then Email\n Service with the PDF attached. Every run is recorded in D1, so a digest that did not arrive\n says why.\n- \\`worker/index.ts\\` — the \\`scheduled\\` handler the Cron Trigger calls.\n\nSet \\`DIGEST_TO\\` (comma-separated), \\`DIGEST_FROM\\` (an address on a domain onboarded to Email\nService) and \\`APP_URL\\` (the deployed app's origin: a cron has no request to read it from) in\n\\`wrangler.jsonc\\`. Until they are set, runs are recorded as skipped. To fire the cron in\n\\`vite dev\\`, open \\`/cdn-cgi/handler/scheduled\\`; \"Send now\" uses the page's own origin, so it\nneeds no \\`APP_URL\\`. Browser Run starts a local Chrome in development.`\n : ''\n }${\n hasExample(opts, 'webhooks')\n ? `\n\n## Webhooks\n\n\\`/webhooks\\` lists GitHub webhook deliveries as they arrive. Point a repository's webhook at\n\\`https://<your app>/api/webhooks/github\\` (content type \\`application/json\\`) with a secret, and\ngive the Worker the same secret:\n\\`npx wrangler secret put WEBHOOK_SECRET\\`. Locally it is in \\`.dev.vars\\`, and \"Send a test\ndelivery\" signs one with it.\n\n- \\`worker/webhooks.ts\\` — \\`verifyWebhook\\` (\\`@cascivo/app/guard\\`) checks the\n \\`X-Hub-Signature-256\\` HMAC over the raw body before anything is parsed, and refuses a bad\n signature with a 401. Each delivery is stored in D1 under GitHub's delivery id, so a retry is ignored, and a\n new one is written to a read-only room the page watches.\n- \\`verifyWebhook\\` also checks Stripe (\\`scheme: 'stripe'\\`) and Standard Webhooks\n (\\`'standard'\\`: Svix, Clerk, Resend…), both with a five-minute timestamp window against\n replays.\n\nThe page answers within milliseconds, well inside GitHub's ten seconds. For slow work, send the\ndelivery to a Queue from \\`receiveGithub\\` and answer at once.`\n : ''\n }${\n hasExample(opts, 'checkout')\n ? `\n\n## Checkout (Stripe)\n\n\\`/checkout\\` sells one product on Stripe's hosted Checkout page: the card never touches this\napp. Stripe tells the Worker when the payment succeeds; the Worker marks the order paid, pushes\nit to the order page, and emails a receipt rendered with \\`@cascivo/email\\`.\n\n1. In the Stripe dashboard, in test mode, copy the secret key (\\`sk_test_…\\`) into \\`.dev.vars\\`\n as \\`STRIPE_SECRET_KEY\\`, then run the app and buy with the card \\`4242 4242 4242 4242\\`.\n The order page confirms the payment by reading the session back from Stripe, so this works\n before any webhook is set up.\n2. To receive the webhook locally, run\n \\`stripe listen --forward-to localhost:5173/api/stripe/webhook\\` (the Stripe CLI) and put the\n \\`whsec_…\\` secret it prints in \\`.dev.vars\\` as \\`STRIPE_WEBHOOK_SECRET\\`.\n3. Deployed: \\`npx wrangler secret put STRIPE_SECRET_KEY\\` and\n \\`npx wrangler secret put STRIPE_WEBHOOK_SECRET\\`. In the dashboard, add a webhook endpoint\n at \\`https://<your app>/api/stripe/webhook\\` for \\`checkout.session.completed\\`,\n \\`checkout.session.async_payment_succeeded\\`, \\`checkout.session.async_payment_failed\\`,\n \\`checkout.session.expired\\`, \\`charge.refunded\\`, \\`charge.dispute.created\\` and\n \\`charge.dispute.closed\\`; its signing secret is \\`STRIPE_WEBHOOK_SECRET\\`.\n4. Receipts: set \\`RECEIPT_FROM\\` in \\`wrangler.jsonc\\` to an address on a domain you have\n onboarded to Email Service. \\`vite dev\\` renders each receipt and logs it instead.\n\nWhat you sell is \\`PRODUCT\\` in \\`src/checkout.ts\\`. The Worker sends that price to Stripe, so a\nbrowser cannot change it.\n\n- \\`worker/checkout.ts\\` — \\`createStripe\\` (\\`@cascivo/app/stripe\\`) creates the session, with\n the order id as its idempotency key. The webhook is checked by \\`verifyWebhook\\` (scheme\n \\`stripe\\`: signature and a five-minute window) before \\`parseStripeEvent\\` reads it.\n- An order moves from \\`pending\\` to \\`paid\\`, \\`failed\\` or \\`expired\\` once. A retried event, or\n the page getting there before the webhook, changes nothing, so the receipt goes out once.\n Orders are found by Stripe's session id, never by \\`client_reference_id\\`, which a buyer can\n set on a Payment Link.\n- A bank debit completes the session as \\`unpaid\\`: the order stays pending until\n \\`async_payment_succeeded\\` or \\`async_payment_failed\\` arrives, possibly days later.\n- Refunds are made in the Stripe dashboard (or with \\`createRefund\\`); \\`charge.refunded\\` records\n the amount, and an order refunded in full becomes \\`refunded\\`. A chargeback makes it\n \\`disputed\\` until it is decided: answer it with evidence in the dashboard. Both find the\n order by the payment it stored when it was paid.\n- \\`src/routes/checkout/[order].tsx\\` — where Stripe sends the buyer back. It watches the\n order's read-only room, so it updates when the webhook arrives.\n\nEach caller (by IP) may start 20 checkouts a minute.${\n usesBilling(opts)\n ? `\n\n### Subscriptions (\\`/billing\\`)\n\nSigned-in users subscribe to \\`PLAN\\` (\\`src/billing.ts\\`) on Stripe's checkout and change,\npause or cancel it in Stripe's hosted billing portal, so the app has no billing screens to build.\n\n1. Add \\`customer.subscription.created\\`, \\`customer.subscription.updated\\`,\n \\`customer.subscription.deleted\\` and \\`invoice.payment_failed\\` to the webhook endpoint's\n events.\n2. In the Stripe dashboard, save the Customer Portal's settings once (test mode too): until\n then, Stripe refuses to open it.\n3. Gate a paid feature in the Worker with \\`await billingStore.requirePlan(env, request)\\` (402\n without the plan), never on what the page shows.\n\n- \\`worker/billing.ts\\` — the subscription names its user in its metadata, which only the\n Worker sets (\\`client_reference_id\\` can be set by a buyer on a Payment Link). Every\n subscription event is read back from Stripe before it is stored, because events arrive out of\n order, and a late event about an older subscription cannot end a live one.\n- Back from checkout, \\`/billing\\` reads the session and its subscription at once, so it is right\n before the webhook arrives, and only for the user the subscription names.\n- A renewal that cannot be charged leaves the plan on (\\`past_due\\`) while Stripe retries, and\n emails the customer once per attempt with Stripe's page to pay with another card. How long\n Stripe retries, and whether it then cancels, is set in the dashboard (Billing → Subscriptions\n and emails); turn off Stripe's own failed-payment emails there, or customers get two.`\n : `\n\nSubscriptions need an account to belong to: \\`cascivo create --framework cloudflare\n--example checkout --auth email\\` adds a \\`/billing\\` page with a monthly plan and Stripe's\nbilling portal.`\n }`\n : ''\n }${\n hasExample(opts, 'newsletter')\n ? `\n\n## Newsletter (Amazon SES)\n\n\\`/newsletter\\` signs readers up; \\`/newsletter/send\\` writes an issue in Markdown, previews it\nas the email it will be (rendered with \\`@cascivo/email\\`), and sends it to every confirmed\nreader through Amazon SES. It runs in \\`vite dev\\` with nothing set up: emails are logged instead\nof sent, and the sign-up page shows the confirmation link. The composer's key is in\n\\`.dev.vars\\` (\\`NEWSLETTER_KEY\\`).\n\nTo send for real:\n\n1. In SES, verify the domain you send from (SES gives DNS records for DKIM; add them in\n Cloudflare DNS, with an SPF and a DMARC record), and ask AWS to move the account out of the\n sandbox, where it can mail only verified addresses.\n2. Create an IAM user allowed \\`ses:SendEmail\\` and nothing else, and give its keys to the\n Worker: \\`npx wrangler secret put AWS_ACCESS_KEY_ID\\` and \\`AWS_SECRET_ACCESS_KEY\\` (in\n \\`.dev.vars\\` locally). Set \\`AWS_REGION\\` and \\`NEWSLETTER_FROM\\` in \\`wrangler.jsonc\\`, and\n \\`npx wrangler secret put NEWSLETTER_KEY\\` to a long random value.\n3. Bounces and complaints: create an SNS topic, set it as the SES identity's bounce and\n complaint notification topic, and subscribe \\`https://<your app>/api/sns/ses\\` to it (HTTPS).\n Put the topic's ARN in \\`SNS_TOPIC_ARN\\`. The Worker confirms the subscription itself.\n4. Create the queue once: \\`npx wrangler queues create ${newsletterQueue(opts)}\\`.\n\n- \\`worker/newsletter.ts\\` — double opt-in: a sign-up gets a confirmation link (valid a day,\n resent at most every ten minutes) and gets no issue until it is opened. Sending stores the\n issue and puts its readers on the \\`NEWSLETTER\\` queue, 25 per message; the consumer sends\n them one by one through \\`createSes\\` (\\`@cascivo/app/ses\\`), handed to \\`sendEmail\\` from\n \\`@cascivo/email\\`, which checks each message before it leaves, one message at a time. Each\n send is recorded, so a message retried after SES throttling skips whoever already has it.\n- Every issue carries \\`List-Unsubscribe\\` and \\`List-Unsubscribe-Post\\` (one-click\n unsubscribe, RFC 8058, which Gmail and Yahoo require of bulk senders) and a footer link to\n \\`/newsletter/unsubscribe\\`.\n- \\`handleSns\\` checks each SNS message's signature against SNS's certificate before\n \\`parseSesNotification\\` reads it. A permanent bounce or a complaint suppresses the address\n for good: mailing it again is what gets an SES account reviewed.\n- \\`worker/newsletter-email.ts\\` — the issue and confirmation emails. The body is Markdown drawn\n through the email primitives; raw HTML in it stays literal text.\n\nSign-ups and every use of the newsletter key count against the rate limit (20 a minute per\nIP). Sending reads every confirmed address into memory; past a few hundred thousand readers,\npage through them instead.`\n : ''\n }${\n hasExample(opts, 'social')\n ? `\n\n## Social posts\n\n\\`/social\\` connects Bluesky, Buffer, LinkedIn, Mastodon and Threads accounts and posts to them,\nnow or at a time you pick.\n\n- \\`worker/social.ts\\` — \\`handleConnections\\` (\\`@cascivo/app/oauth-server\\`) answers\n \\`/api/connections/*\\`: connect, list, remove. Tokens are sealed in D1 with \\`AUTH_SECRET\\`.\n Scheduling checks that each account is yours and connected, and that each network's\n publisher (\\`@cascivo/app/social\\`) accepts the post, before anything is stored.\n- \\`worker/social-post.ts\\` — \\`SocialPost\\`, a Workflow per post: it sleeps until the post\n is due, then posts to each account in its own step. Bluesky and Mastodon steps retry, safely:\n Mastodon takes an idempotency key, and a Bluesky post's record key is fixed by its time and\n id. Buffer, LinkedIn and Threads steps never do: they cannot deduplicate, so an interrupted\n post there is reported for you to check rather than sent twice.\n- \\`src/social.ts\\` — the shared types, and the publishers the page also runs, so what a\n network would refuse shows while you type.\n- Images: the composer uploads them into the \\`SOCIAL_MEDIA\\` R2 bucket through the Worker\n (\\`@cascivo/app/uploads\\`), each user under their own prefix; up to 4, JPEG or PNG, 1 MB each\n (Bluesky's limit), each with a description. Bluesky, LinkedIn and Mastodon get the bytes.\n Threads and Buffer fetch images by URL, so they get a link to \\`/api/social/media/…\\` signed\n with \\`AUTH_SECRET\\`. It is valid until a day after the post is due, and needs \\`APP_URL\\` set\n to the deployed app. Uploads stay in R2: add a lifecycle rule\n (\\`wrangler r2 bucket lifecycle add\\`) to expire old ones.\n\n**Bluesky** needs no set-up either: people type their handle. Bluesky's servers read this\napp's client metadata from \\`/api/bluesky/client-metadata.json\\`, so the deployed app must be\nreachable at its URL. In \\`vite dev\\` the app is Bluesky's development client, which may only\nreturn to \\`127.0.0.1\\`: open \\`http://127.0.0.1:5173\\`, not \\`localhost\\`. Without a key the app is a\npublic client and sessions end after two weeks; for sessions that last, make a key and set it\nas the \\`BLUESKY_PRIVATE_JWK\\` secret:\n\n\\`\\`\\`sh\nnode -e \"crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign']).then(k=>crypto.subtle.exportKey('jwk',k.privateKey)).then(j=>console.log(JSON.stringify({...j,kid:'k1'})))\"\n\\`\\`\\`\n\n**Mastodon** needs no set-up: people type their server, and the app registers itself there\non first use.\n\n**Buffer** reaches the networks connected there (X, Instagram, TikTok, …): register an app\nclient at https://publish.buffer.com/settings/api with \\`…/api/connections/buffer/callback\\` as\nits redirect URL, and set \\`BUFFER_CLIENT_ID\\` (and \\`BUFFER_CLIENT_SECRET\\` for a confidential\nclient). Each channel in a connected Buffer is an account in the composer, checked against\nits network's limit. Buffer counts every request against your plan's budget (100 per 15\nminutes on every plan), for all your users together, so the channel list is kept for an hour.\nBuffer cannot deduplicate, so its posts are never retried, like LinkedIn's. **LinkedIn** needs an app at https://www.linkedin.com/developers/apps with the\n\"Share on LinkedIn\" product${oauthSignIn(opts) ? ' (the same app as sign-in works, with both products)' : ''}; add\n\\`…/api/connections/linkedin/callback\\` as a redirect URL and set \\`LINKEDIN_CLIENT_ID\\` and\n\\`LINKEDIN_CLIENT_SECRET\\`. LinkedIn tokens last 60 days and cannot be renewed: the page shows\n\"expires soon\" a week ahead, and connecting again renews the account in place. The daily Cron\nTrigger also emails the owner once, a week ahead, with a link to connect again\n(\\`remindExpiring\\`): set \\`REMINDER_FROM\\` in \\`wrangler.jsonc\\` to an address on a domain you\nhave onboarded to Email Service, and \\`APP_URL\\` to the deployed app. People whose provider shared\nno email see the page's notice only.\n\n**Threads** needs a Meta app with the \"Access the Threads API\" use case\n(https://developers.facebook.com/apps), the \\`threads_basic\\` and \\`threads_content_publish\\`\npermissions, and \\`…/api/connections/threads/callback\\` as a redirect callback URL; set\n\\`THREADS_APP_ID\\` and \\`THREADS_APP_SECRET\\` (the Threads app id and secret, not the Meta\napp's). Until Meta's App Review and business verification pass, only people added as testers\non the app can connect. A Threads token lasts 60 days and renews itself only while it still\nworks, so a daily Cron Trigger (\\`${SOCIAL_CRON}\\`, \\`renewConnections\\`) renews those in their last\n30 days; try it in \\`vite dev\\` at \\`/cdn-cgi/handler/scheduled\\`. Threads takes 500 characters,\ncounting an emoji as several, and cannot deduplicate either.\n\nConnecting an account and posting count against the rate limit (20 a minute per IP).\nWorkflows need a real Cloudflare account to deploy.`\n : ''\n }${\n hasAccounts(opts)\n ? `\n\n## Accounts (${emailSignIn(opts) ? (oauthSignIn(opts) ? 'email, GitHub, Google and LinkedIn sign-in' : 'email sign-in') : 'GitHub, Google and LinkedIn sign-in'})\n\n${\n emailSignIn(opts) && oauthSignIn(opts)\n ? 'Anyone can create an account by continuing with GitHub, Google or LinkedIn, or with their email address, on `/account`.'\n : oauthSignIn(opts)\n ? 'Anyone can create an account by continuing with GitHub, Google or LinkedIn on `/account`.'\n : 'Anyone can create an account with their email address: `/account` emails a one-time link, and opening it signs them in with a session cookie.'\n} **Every API write needs a signed-in user; reads stay public.**\n\n- \\`worker/index.ts\\` — ${[emailSignIn(opts) ? '`handleAuth` (`@cascivo/app/auth-server`)' : '', oauthSignIn(opts) ? '`handleOAuth` (`@cascivo/app/oauth-server`)' : ''].filter(Boolean).join(' and ')} answer${emailSignIn(opts) && oauthSignIn(opts) ? '' : 's'} \\`/api/auth/*\\`, and\n \\`requireUser\\` refuses any other write without a session (401) or from another site (403).\n Call \\`requireUser(env.DB, request)\\` in a handler to know who is asking.${\n emailSignIn(opts)\n ? `\n- \\`worker/auth.ts\\` — sends the link through Email Service. Set \\`AUTH_FROM\\` in\n \\`wrangler.jsonc\\` to an address on a domain you have onboarded to Email Service.`\n : ''\n }\n- \\`src/auth.ts\\` — \\`auth.user\\`, a signal every page can read. \\`user.email\\` is \\`null\\` for\n someone whose provider shared no verified address.${\n emailSignIn(opts)\n ? `\n- \\`src/routes/signin/verify.tsx\\` — the page a link opens. It signs in on a button press,\n because mail scanners open every link in a message.`\n : ''\n }\n\nUsers${emailSignIn(opts) ? ', links' : ''} and sessions live in D1, stored as hashes. ${emailSignIn(opts) ? 'Links expire after 15 minutes and work once; sessions' : 'Sessions'} last 30 days.${emailSignIn(opts) ? ' Each caller (by IP) may request 20 links a minute. In `vite dev` no email is sent: the Account page shows the link instead.' : ''} WebSocket connections\n(rooms, agents) are not covered by the write rule; check \\`currentUser\\` before forwarding them\nif they need a user.${\n oauthSignIn(opts)\n ? `\n\n### GitHub, Google and LinkedIn\n\n1. **GitHub**: create an OAuth App at https://github.com/settings/developers with the\n callback URL \\`http://localhost:5173/api/auth/oauth/github/callback\\` for \\`vite dev\\`\n (an OAuth App takes one callback URL, so make a second app for the deployed one).\n2. **Google**: in the Google Cloud console, create an OAuth client of type \"Web application\"\n with the redirect URIs \\`http://localhost:5173/api/auth/oauth/google/callback\\` and\n \\`https://<your app>/api/auth/oauth/google/callback\\`.\n3. **LinkedIn**: create an app at https://www.linkedin.com/developers/apps, add the product\n \"Sign In with LinkedIn using OpenID Connect\", and under Auth add both redirect URLs\n (\\`…/api/auth/oauth/linkedin/callback\\` on localhost and on your app).\n4. Put the ids and secrets in \\`.dev.vars\\`. Deployed: \\`npx wrangler secret put\\` each\n \\`*_CLIENT_ID\\` and \\`*_CLIENT_SECRET\\` you use, and \\`AUTH_SECRET\\` (a random one:\n \\`openssl rand -base64 32\\`). A provider is offered once both its values are set.\n\nPeople are matched by their account at the provider, not by email. A new sign-in joins an\nexisting account only through an email the provider has verified (GitHub's primary verified\naddress, Google's and LinkedIn's \\`email_verified\\`). A signed-in user who follows another provider's link\nadds it to their account.`\n : ''\n }`\n : ''\n }${\n opts.auth === 'access'\n ? `\n\n## Access (who may use the app)\n\nThe Worker refuses every request Cloudflare Access did not let through: \\`requireAccess\\`\n(\\`@cascivo/app/guard\\`) at the top of \\`worker/index.ts\\` verifies the token Access signs.\n\n1. In Cloudflare One (Zero Trust), add a self-hosted Access application for this app's\n hostname, with a policy saying who may sign in.\n2. Put your team domain (\\`<team>.cloudflareaccess.com\\`) and the application's Audience (AUD)\n tag in \\`vars\\` in \\`wrangler.jsonc\\`, and deploy.\n\nUntil both are set, every API request answers 500 (\"Access is not configured\"). Access guards\nthe pages at the edge; the Worker's check also refuses API calls that reach it around Access,\nat \\`*.workers.dev\\` for example (set \\`\"workers_dev\": false\\` if the app should have no public\naddress at all). \\`vite dev\\` skips the check. A temporary account has no Access, so an app made\nwith \\`--auth access\\` cannot use \\`deploy:preview\\`.`\n : ''\n }${\n hasExample(opts, 'crud')\n ? `\n\n## Customers (D1)\n\n\\`/customers\\` is a D1 table behind \\`DataTable\\`'s server mode: sorting, search, the name and\nseats filters, and paging all run as SQL in the Worker, and the page shows one page at a time.\n\n- \\`src/customers.ts\\` — \\`defineTable\\` (\\`@cascivo/app/db\\`) lists what may be sorted, searched and\n filtered. \\`queryTable\\` builds SQL from that list only, with every value a bound parameter:\n a query for any other column is refused with a 400.\n- \\`worker/migrations.ts\\` — the schema and 60 sample rows. The Worker applies them itself on its\n first query (\\`migrate\\`), so a fresh deploy, the Deploy button and \\`deploy:preview\\` need no\n migration step. Append migrations; never edit one that has shipped.\n- \\`worker/customers.ts\\` — create, update and delete, each checked by \\`parseCustomerInput\\`.\n\nD1 works on a temporary account, so \\`deploy:preview\\` shares the table with no sign-up. If you\nprefer wrangler's own migrations (\\`wrangler d1 migrations apply\\`), move the SQL into\n\\`migrations/\\` and drop the \\`migrate\\` call.`\n : ''\n }${\n hasExample(opts, 'agent')\n ? `\n\n## Assistant (generative UI)\n\n\\`/assistant\\` is a chat whose answers can be UI. The model calls a \\`show_view\\` tool with a view\nconfig; the Worker checks it against the component manifests (\\`validateView\\` from\n\\`@cascivo/render/validate\\`) and returns any errors to the model, which fixes them and calls\nagain. The page renders the result with \\`<CascivoView>\\`: real components, no generated code.\n\n- \\`worker/assistant.ts\\` — \\`Assistant\\`, an \\`AIChatAgent\\` (Cloudflare's Agents SDK): one\n Durable Object per conversation, which stores the messages and streams replies to every open\n tab. The system prompt, the \\`show_view\\` tool and the model (\\`MODEL\\`, any Workers AI model\n with tool calling) are here.\n- \\`src/assistant.ts\\` — \\`checkView\\`, the one check both sides run on a view.\n- \\`src/routes/assistant.tsx\\` — \\`useAgent\\` + \\`useAgentChat\\`; one conversation per browser.\n- \\`worker/scripted-model.ts\\` — \\`vite dev\\` answers from this scripted model, so the page works\n offline and without an account. \\`VITE_REAL_AI=1 ${runScriptCommand(pm, 'dev')}\\` uses Workers AI instead\n (after \\`npx wrangler login\\`); a deployed Worker always does.\n\nWorkers AI bills per use beyond its free daily allocation.`\n : ''\n }${\n hasExample(opts, 'publish')\n ? `\n\n## Publish (views as pages)\n\n\\`/publish\\` turns a view — the JSON that \\`<CascivoView>\\` renders, which an agent can write\ntoo — into a page at \\`/p/<slug>\\`, with no deploy. A view is data, not code: it can only\narrange this app's components with props their manifests allow, so a published page runs no\ncode of its author's and needs no sandbox.\n\n- \\`src/pages.ts\\` — \\`parsePageInput\\` checks a page with \\`validateView\\`\n (\\`@cascivo/render/validate\\`): known components and props, and no link or image URL that\n could run script. The Worker runs it before storing a page; \\`<CascivoView>\\` checks again\n before rendering one.\n- \\`worker/pages.ts\\` — pages in D1, under a random ten-character slug.\n- \\`src/routes/publish.tsx\\` — the editor, with a live preview.\n- \\`src/routes/p/[slug].tsx\\` — a published page.\n- \\`worker/page-html.ts\\` — the Worker answers \\`/p/<slug>\\` with index.html carrying the\n page's title, description and Open Graph tags, so a shared link previews properly, and the\n rendered page in a \\`<noscript>\\` for readers without JavaScript.${\n hasExample(opts, 'export')\n ? `\n- \\`worker/page-preview.ts\\` — the preview image (\\`og:image\\`, 1200 × 630): Browser Run\n renders the page once, and D1 keeps the PNG.`\n : ''\n }\n\n${\n opts.auth\n ? ''\n : `**Anyone who can reach the app can publish**, and a page is served from your domain: put\nCloudflare Access in front (\\`--auth access\\`), add accounts (\\`--auth email\\`), or check who\nis publishing in \\`worker/index.ts\\`. `\n}Each caller (by IP) may publish 20 pages a minute (\\`ratelimits\\` in\n\\`wrangler.jsonc\\`). D1 works on a temporary account.`\n : ''\n }${\n hasExample(opts, 'voice')\n ? `\n\n## Voice\n\n\\`/voice\\` is a voice assistant: press \"Start call\", talk, and it answers aloud. Talk over a\nreply to interrupt it. Typed messages work too.\n\n- \\`worker/voice.ts\\` — \\`Voice\\`, an Agent with the Agents SDK's voice pipeline\n (\\`withVoice\\` from \\`agents/voice\\`): Workers AI speech to text (Flux, which also decides when\n you have finished speaking), a text model (\\`MODEL\\`) and text to speech. One Durable Object\n per conversation stores the transcript, which the model gets as context.\n- \\`src/voice.ts\\` — \\`VoiceClient\\` (\\`agents/voice/client\\`) captures the microphone, streams\n it, and plays the replies; its events become signals the page reads.\n- \\`worker/scripted-voice.ts\\` — \\`vite dev\\` stand-ins, because Workers AI has no local mode:\n the transcriber \"hears\" a fixed question for every three seconds of sound, and the replies\n are text only. \\`VITE_REAL_AI=1 ${runScriptCommand(pm, 'dev')}\\` uses Workers AI instead (after\n \\`npx wrangler login\\`); a deployed Worker always does.\n\nBrowsers allow the microphone only on a secure origin: \\`localhost\\` or HTTPS. Workers AI\nbills per use beyond its free daily allocation, and a temporary account has no Workers AI.`\n : ''\n }\n\nAdd more components with \\`npx cascivo add <component>\\`.\n\n## ${runtime === 'preact' ? 'Preact or React' : 'React or Preact'}\n\n${\n hasExample(opts, 'agent')\n ? `This app runs on React. The Agents SDK's hooks call React 19's \\`use()\\`, which\nPreact's compat layer does not implement, so the assistant page needs React. (The other pages\nwould run on Preact unchanged: the source is typed against React either way.)`\n : `The source is typed against React. The runtime is one plugin in \\`vite.config.ts\\`:\n\\`@preact/preset-vite\\` runs it on Preact, \\`@vitejs/plugin-react\\` on React. Switching\nneeds no source changes; swap the plugin and the matching dependencies. For this starter,\nPreact ships about a third of the client JS (~27 KB gzip against ~85 KB).`\n}\n`\n}\n\nfunction cfAgentsMd(opts: ScaffoldOptions): string {\n return `${agentsMd(opts)}\n## Server\n\nThe API contract is \\`src/api.ts\\` (\\`defineApi\\` from \\`@cascivo/app/api\\`); its handlers are\n\\`worker/index.ts\\` (\\`createHandler\\`), and the app calls it through \\`createClient\\`. Add an\nendpoint to the contract first, with a parser for its input and output: parse every payload\nthat crosses the network, never cast it. A streaming endpoint's handler is an async\ngenerator. \\`wrangler.jsonc\\` routes only \\`/api/*\\` to the Worker.\n\n## Pages\n\nA page is a file in \\`src/routes/\\` with a default export: \\`c/[id].tsx\\` serves \\`/c/:id\\` and\nreceives \\`{ params }: RouteProps<'/c/:id'>\\`. Never edit \\`src/routes.gen.ts\\`. Link with\n\\`href\\`s (SideNav items, \\`router.Link\\`); navigate from code with \\`router.navigate(path)\\`,\nand build a path with \\`buildPath('/c/:id', { id })\\`, both from \\`@cascivo/app\\`.\n\n## Sharing\n\nTo show the user a running version, run \\`${runExplicitCommand(opts.pm ?? 'npm', 'deploy:preview')}\\`.\nIt needs no Cloudflare account. Give the user both URLs it prints: the live one, and the\nclaim URL. The deployment is public, and it is deleted after 60 minutes unless the user\nopens the claim URL and signs in.\n`\n}\n\nfunction cfShellTsx(opts: ScaffoldOptions): string {\n const shell = shellTsx(opts)\n const start = shell.indexOf(' * Adding a router?')\n const end = shell.indexOf(' */\\nexport function Shell')\n // This app already routes, so the Vite scaffold's \"adding a router\" advice does not apply.\n return (\n shell.slice(0, start) +\n ' * Routing lives in src/router.ts; nav items carry `href`s, and main.tsx registers the\\n' +\n \" * router's Link so they navigate client-side.\\n\" +\n shell.slice(end)\n )\n}\n\n/** Local secrets for `vite dev` (.gitignore'd); deployed, each is a `wrangler secret`. */\nfunction cfDevVars(opts: ScaffoldOptions): string {\n return [\n ...(hasExample(opts, 'webhooks')\n ? [\n '# The secret vite dev signs and checks test webhook deliveries with.',\n 'WEBHOOK_SECRET=dev-only-webhook-secret',\n ]\n : []),\n ...(hasExample(opts, 'checkout')\n ? [\n '# Stripe, in test mode (README): the secret key from the dashboard, and the signing',\n '# secret `stripe listen` prints.',\n 'STRIPE_SECRET_KEY=',\n 'STRIPE_WEBHOOK_SECRET=',\n ]\n : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n '# The key /newsletter/send asks for. Deployed, choose a long random one.',\n 'NEWSLETTER_KEY=dev-only-newsletter-key',\n '# An IAM user allowed ses:SendEmail (README). Without them, vite dev logs each email.',\n 'AWS_ACCESS_KEY_ID=',\n 'AWS_SECRET_ACCESS_KEY=',\n ]\n : []),\n ...(oauthSignIn(opts)\n ? [\n '# Seals the sign-in state between the redirect and the callback. Deployed, a random one:',\n '# openssl rand -base64 32',\n 'AUTH_SECRET=dev-only-auth-secret-0123456789abcdef',\n '# A GitHub OAuth App, a Google OAuth client and a LinkedIn app (README).',\n '# Each provider is offered once both its id and secret are set.',\n 'GITHUB_CLIENT_ID=',\n 'GITHUB_CLIENT_SECRET=',\n 'GOOGLE_CLIENT_ID=',\n 'GOOGLE_CLIENT_SECRET=',\n 'LINKEDIN_CLIENT_ID=',\n 'LINKEDIN_CLIENT_SECRET=',\n ]\n : hasExample(opts, 'social')\n ? [\n \"# Seals connected accounts' tokens. Deployed, a random one: openssl rand -base64 32\",\n 'AUTH_SECRET=dev-only-auth-secret-0123456789abcdef',\n '# A LinkedIn app with \"Share on LinkedIn\" (README). Mastodon needs nothing.',\n 'LINKEDIN_CLIENT_ID=',\n 'LINKEDIN_CLIENT_SECRET=',\n ]\n : []),\n ...(hasExample(opts, 'social')\n ? [\n '# Optional: an ES256 private JWK with a kid, so Bluesky sessions last (README).',\n 'BLUESKY_PRIVATE_JWK=',\n '# Optional: a Buffer app client (publish.buffer.com/settings/api), to post to its channels.',\n 'BUFFER_CLIENT_ID=',\n 'BUFFER_CLIENT_SECRET=',\n '# Optional: a Meta app with the Threads use case (README), to post to Threads.',\n 'THREADS_APP_ID=',\n 'THREADS_APP_SECRET=',\n ]\n : []),\n ]\n .map((line) => `${line}\\n`)\n .join('')\n}\n\n/**\n * The secrets the app reads, with no values: committed, unlike .dev.vars. The \"Deploy to\n * Cloudflare\" button asks for each one it lists; `cp .dev.vars.example .dev.vars` starts a\n * fresh clone.\n */\nfunction cfDevVarsExample(opts: ScaffoldOptions): string {\n return cfDevVars(opts)\n .split('\\n')\n .map((line) => (line.startsWith('#') ? line : line.replace(/=.*$/, '=')))\n .join('\\n')\n}\n\n/**\n * What each setting is for, in package.json's \\`cloudflare.bindings\\`: the \"Deploy to Cloudflare\"\n * button shows it beside the field it asks the deployer to fill in.\n */\nfunction cfBindingDescriptions(opts: ScaffoldOptions): Record<string, { description: string }> {\n const describe = (entries: [string, string][]) =>\n Object.fromEntries(entries.map(([name, description]) => [name, { description }]))\n return {\n ...(hasExample(opts, 'webhooks')\n ? describe([\n [\n 'WEBHOOK_SECRET',\n 'The secret of the GitHub webhook that posts to `/api/webhooks/github`.',\n ],\n ])\n : {}),\n ...(hasExample(opts, 'checkout')\n ? describe([\n [\n 'STRIPE_SECRET_KEY',\n 'Your Stripe secret key, from the [API keys page](https://dashboard.stripe.com/test/apikeys). A test key (`sk_test_…`) takes test cards only.',\n ],\n [\n 'STRIPE_WEBHOOK_SECRET',\n 'The signing secret (`whsec_…`) of a Stripe webhook endpoint at `https://<this app>/api/stripe/webhook`. No endpoint yet? Enter `later`: orders are confirmed on their own page without it. Set the real one with `npx wrangler secret put STRIPE_WEBHOOK_SECRET`.',\n ],\n [\n 'RECEIPT_FROM',\n 'The From address of receipts, on a domain onboarded to Cloudflare Email Service. Leave it empty to send none.',\n ],\n ])\n : {}),\n ...(hasExample(opts, 'newsletter')\n ? describe([\n [\n 'NEWSLETTER_KEY',\n 'The key `/newsletter/send` asks for. Make a long random one: `openssl rand -hex 32`.',\n ],\n ['AWS_ACCESS_KEY_ID', 'An IAM user allowed `ses:SendEmail` and nothing else (README).'],\n ['AWS_SECRET_ACCESS_KEY', \"That IAM user's secret access key.\"],\n ['AWS_REGION', 'The SES region your sending domain is verified in, e.g. `eu-west-1`.'],\n ['NEWSLETTER_FROM', 'The From address of issues, on your SES-verified domain.'],\n [\n 'SNS_TOPIC_ARN',\n 'The SNS topic SES reports bounces and complaints to, subscribed to `https://<this app>/api/sns/ses`.',\n ],\n ])\n : {}),\n ...(oauthSignIn(opts)\n ? describe([\n [\n 'AUTH_SECRET',\n 'Seals the sign-in state between the redirect to GitHub, Google or LinkedIn and the way back. Make a random one: `openssl rand -base64 32`.',\n ],\n [\n 'GITHUB_CLIENT_ID',\n 'The client id of a [GitHub OAuth App](https://github.com/settings/developers) whose callback URL is `https://<this app>/api/auth/oauth/github/callback`. Leave it empty to offer no GitHub sign-in.',\n ],\n ['GITHUB_CLIENT_SECRET', \"That OAuth App's client secret.\"],\n [\n 'GOOGLE_CLIENT_ID',\n 'The client id of a Google OAuth client (Web application) with the redirect URI `https://<this app>/api/auth/oauth/google/callback`. Leave it empty to offer no Google sign-in.',\n ],\n ['GOOGLE_CLIENT_SECRET', \"That OAuth client's secret.\"],\n [\n 'LINKEDIN_CLIENT_ID',\n 'The client id of a LinkedIn app with the \"Sign In with LinkedIn using OpenID Connect\" product and the redirect URL `https://<this app>/api/auth/oauth/linkedin/callback`. Leave it empty to offer no LinkedIn sign-in.',\n ],\n ['LINKEDIN_CLIENT_SECRET', \"That LinkedIn app's primary client secret.\"],\n ])\n : hasExample(opts, 'social')\n ? describe([\n [\n 'AUTH_SECRET',\n 'Seals the tokens of the accounts people connect. Make a random one: `openssl rand -base64 32`.',\n ],\n [\n 'LINKEDIN_CLIENT_ID',\n 'The client id of a LinkedIn app with the \"Share on LinkedIn\" product and the redirect URL `https://<this app>/api/connections/linkedin/callback`. Leave it empty to offer no LinkedIn.',\n ],\n ['LINKEDIN_CLIENT_SECRET', \"That LinkedIn app's primary client secret.\"],\n ])\n : {}),\n ...(hasExample(opts, 'social')\n ? describe([\n [\n 'BLUESKY_PRIVATE_JWK',\n 'Optional. An ES256 private key as a JWK with a `kid` (README shows how to make one). With it, Bluesky sessions last until revoked; without it, two weeks.',\n ],\n [\n 'BUFFER_CLIENT_ID',\n 'Optional. A Buffer app client (https://publish.buffer.com/settings/api) with the redirect URL `https://<this app>/api/connections/buffer/callback`. Leave it empty to offer no Buffer.',\n ],\n ['BUFFER_CLIENT_SECRET', \"That Buffer app client's secret (none for a public client).\"],\n [\n 'THREADS_APP_ID',\n 'Optional. The Threads app id of a Meta app with the Threads use case, and `https://<this app>/api/connections/threads/callback` as its redirect callback URL. Leave it empty to offer no Threads.',\n ],\n ['THREADS_APP_SECRET', \"That Threads app's secret.\"],\n ])\n : {}),\n }\n}\n\nfunction buildCloudflareScaffold(opts: ScaffoldOptions, sections: Section[]): ScaffoldFile[] {\n const runtime = runtimeOf(opts)\n const routeFiles = [\n ...sections.map((s, i) => ({\n file: cfSectionFile(s, i),\n contents: i === 0 ? cfFirstRouteTsx(s) : cfRouteTsx(s, i),\n })),\n { file: '404.tsx', contents: cf404Tsx() },\n ...(hasExample(opts, 'agent')\n ? [{ file: 'assistant.tsx', contents: cfAssistantRouteTsx() }]\n : []),\n ...(hasExample(opts, 'board') ? [{ file: 'board.tsx', contents: cfBoardRouteTsx() }] : []),\n ...(hasExample(opts, 'notes') ? [{ file: 'notes.tsx', contents: cfNotesRouteTsx() }] : []),\n ...(hasExample(opts, 'import') ? [{ file: 'import.tsx', contents: cfImportRouteTsx() }] : []),\n ...(hasExample(opts, 'files') ? [{ file: 'files.tsx', contents: cfFilesRouteTsx() }] : []),\n ...(hasExample(opts, 'export') ? [{ file: 'report.tsx', contents: cfReportRouteTsx() }] : []),\n ...(hasExample(opts, 'usage') ? [{ file: 'usage.tsx', contents: cfUsageRouteTsx() }] : []),\n ...(hasExample(opts, 'crud')\n ? [{ file: 'customers.tsx', contents: cfCustomersRouteTsx() }]\n : []),\n ...(hasExample(opts, 'live') ? [{ file: 'ops.tsx', contents: cfOpsRouteTsx() }] : []),\n ...(hasExample(opts, 'voice') ? [{ file: 'voice.tsx', contents: cfVoiceRouteTsx() }] : []),\n ...(hasExample(opts, 'publish')\n ? [\n { file: 'publish.tsx', contents: cfPublishRouteTsx() },\n { file: 'p/[slug].tsx', contents: cfPageRouteTsx() },\n ]\n : []),\n ...(hasExample(opts, 'webhooks')\n ? [{ file: 'webhooks.tsx', contents: cfWebhooksRouteTsx() }]\n : []),\n ...(hasExample(opts, 'digest') ? [{ file: 'digest.tsx', contents: cfDigestRouteTsx() }] : []),\n ...(hasExample(opts, 'search') ? [{ file: 'search.tsx', contents: cfSearchRouteTsx() }] : []),\n ...(hasExample(opts, 'checkout')\n ? [\n { file: 'checkout.tsx', contents: cfCheckoutRouteTsx() },\n { file: 'checkout/[order].tsx', contents: cfOrderRouteTsx() },\n ]\n : []),\n ...(usesBilling(opts) ? [{ file: 'billing.tsx', contents: cfBillingRouteTsx() }] : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n { file: 'newsletter.tsx', contents: cfNewsletterRouteTsx() },\n { file: 'newsletter/confirm.tsx', contents: cfNewsletterConfirmRouteTsx() },\n { file: 'newsletter/unsubscribe.tsx', contents: cfNewsletterUnsubscribeRouteTsx() },\n { file: 'newsletter/send.tsx', contents: cfNewsletterSendRouteTsx() },\n ]\n : []),\n ...(hasExample(opts, 'social') ? [{ file: 'social.tsx', contents: cfSocialRouteTsx() }] : []),\n ...(hasAccounts(opts) ? [{ file: 'account.tsx', contents: cfAccountRouteTsx(opts) }] : []),\n ...(emailSignIn(opts) ? [{ file: 'signin/verify.tsx', contents: cfVerifyRouteTsx() }] : []),\n ]\n return [\n { path: 'package.json', contents: cfPackageJson(opts) },\n { path: 'tsconfig.json', contents: cfTsconfig(opts) },\n ...(needsWorkerTypes(opts)\n ? [{ path: 'tsconfig.worker.json', contents: cfWorkerTsconfig() }]\n : []),\n { path: 'vite.config.ts', contents: cfViteConfig(runtime, opts) },\n { path: 'wrangler.jsonc', contents: wranglerJsonc(opts) },\n { path: 'index.html', contents: indexHtml(opts) },\n { path: 'eslint.config.js', contents: eslintConfig() },\n { path: '.prettierrc', contents: prettierrc() },\n { path: '.prettierignore', contents: cfPrettierIgnore() },\n { path: '.gitignore', contents: cfGitignore() },\n { path: 'README.md', contents: cfReadme(opts) },\n { path: 'AGENTS.md', contents: cfAgentsMd(opts) },\n { path: 'worker/index.ts', contents: cfWorkerTs(opts) },\n { path: 'src/api.ts', contents: cfApiTs(opts) },\n { path: 'src/live.ts', contents: cfLiveTs() },\n { path: 'src/LiveCard.tsx', contents: cfLiveCardTsx() },\n { path: 'src/main.tsx', contents: cfMainTsx() },\n { path: 'src/vite-env.d.ts', contents: viteEnv() },\n { path: 'src/router.ts', contents: cfRouterTs() },\n { path: 'src/App.tsx', contents: cfAppTsx(sections, opts) },\n { path: 'src/Shell.tsx', contents: cfShellTsx(opts) },\n ...(hasExample(opts, 'agent')\n ? [\n { path: 'worker/assistant.ts', contents: cfAssistantWorkerTs() },\n { path: 'worker/scripted-model.ts', contents: cfScriptedModelTs() },\n { path: 'src/assistant.ts', contents: cfAssistantTs() },\n { path: 'src/assistant.module.css', contents: cfAssistantCss() },\n ]\n : []),\n ...(hasExample(opts, 'notes') ? [{ path: 'src/notes.ts', contents: cfNotesTs() }] : []),\n ...(hasExample(opts, 'import')\n ? [\n { path: 'src/import-job.ts', contents: cfImportJobTs() },\n { path: 'src/import-page.ts', contents: cfImportPageTs() },\n { path: 'worker/import-job.ts', contents: cfImportWorkflowTs() },\n ]\n : []),\n ...(hasExample(opts, 'crud')\n ? [\n { path: 'src/customers.ts', contents: cfCustomersTs() },\n { path: 'src/customers-page.ts', contents: cfCustomersPageTs() },\n { path: 'worker/customers.ts', contents: cfCustomersWorkerTs() },\n { path: 'worker/migrations.ts', contents: cfMigrationsTs() },\n ]\n : []),\n ...(hasExample(opts, 'live') ? [{ path: 'src/ops.ts', contents: cfOpsTs() }] : []),\n ...(hasExample(opts, 'search')\n ? [\n { path: 'src/search.ts', contents: cfSearchTs() },\n { path: 'worker/search.ts', contents: cfSearchWorkerTs() },\n { path: 'worker/articles.ts', contents: cfArticlesTs() },\n ]\n : []),\n ...(hasExample(opts, 'digest')\n ? [\n { path: 'src/digest.ts', contents: cfDigestTs() },\n { path: 'worker/digest.ts', contents: cfDigestWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'webhooks')\n ? [\n { path: 'src/webhooks.ts', contents: cfWebhooksTs() },\n { path: 'worker/webhooks.ts', contents: cfWebhooksWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'checkout')\n ? [\n { path: 'src/checkout.ts', contents: cfCheckoutTs(opts) },\n { path: 'worker/checkout.ts', contents: cfCheckoutWorkerTs() },\n ]\n : []),\n ...(usesBilling(opts)\n ? [\n { path: 'src/billing.ts', contents: cfBillingTs() },\n { path: 'worker/billing.ts', contents: cfBillingWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'newsletter')\n ? [\n { path: 'src/newsletter.ts', contents: cfNewsletterTs() },\n { path: 'worker/newsletter.ts', contents: cfNewsletterWorkerTs() },\n { path: 'worker/newsletter-email.ts', contents: cfNewsletterEmailTs(opts) },\n ]\n : []),\n ...(hasExample(opts, 'webhooks') ||\n hasExample(opts, 'checkout') ||\n hasExample(opts, 'newsletter') ||\n hasExample(opts, 'social') ||\n oauthSignIn(opts)\n ? [\n { path: '.dev.vars', contents: cfDevVars(opts) },\n { path: '.dev.vars.example', contents: cfDevVarsExample(opts) },\n ]\n : []),\n ...(hasAccounts(opts) ? [{ path: 'src/auth.ts', contents: cfAuthTs() }] : []),\n ...(hasExample(opts, 'social')\n ? [\n { path: 'src/social.ts', contents: cfSocialTs() },\n { path: 'worker/social.ts', contents: cfSocialWorkerTs(opts) },\n { path: 'worker/social-post.ts', contents: cfSocialPostTs() },\n ]\n : []),\n ...(emailSignIn(opts) ? [{ path: 'worker/auth.ts', contents: cfAuthWorkerTs() }] : []),\n ...(hasExample(opts, 'publish')\n ? [\n { path: 'src/pages.ts', contents: cfPagesTs() },\n { path: 'worker/pages.ts', contents: cfPagesWorkerTs() },\n { path: 'worker/page-html.ts', contents: cfPageHtmlTs() },\n ...(hasExample(opts, 'export')\n ? [{ path: 'worker/page-preview.ts', contents: cfPagePreviewTs() }]\n : []),\n ]\n : []),\n ...(hasExample(opts, 'voice')\n ? [\n { path: 'src/voice.ts', contents: cfVoiceTs() },\n { path: 'worker/voice.ts', contents: cfVoiceWorkerTs() },\n { path: 'worker/scripted-voice.ts', contents: cfScriptedVoiceTs() },\n ]\n : []),\n ...(hasExample(opts, 'usage')\n ? [\n {\n path: 'src/usage.ts',\n contents: cfUsageTs().replace(\n \"dataset: 'app_usage'\",\n `dataset: '${usageDataset(opts)}'`,\n ),\n },\n { path: 'worker/usage.ts', contents: cfUsageWorkerTs() },\n ]\n : []),\n ...(hasExample(opts, 'files')\n ? [\n { path: 'src/upload-policy.ts', contents: cfUploadPolicyTs() },\n { path: 'src/files.ts', contents: cfFilesTs() },\n { path: 'src/files.module.css', contents: cfFilesCss() },\n ]\n : []),\n ...(hasExample(opts, 'board')\n ? [\n { path: 'src/board.ts', contents: cfBoardTs() },\n { path: 'src/board.module.css', contents: cfBoardCss() },\n ]\n : []),\n ...routeFiles.map(({ file, contents }) => ({ path: `src/routes/${file}`, contents })),\n // Written now so \\`tsc\\` passes before the first \\`vite\\` run; the plugin keeps it current.\n {\n path: 'src/routes.gen.ts',\n contents: generateRoutes(\n routeFiles.map((r) => r.file),\n './routes',\n ),\n },\n ]\n}\n\nexport function buildScaffold(opts: ScaffoldOptions): ScaffoldFile[] {\n const sections = resolveSections(opts.sections)\n if (opts.framework === 'astro') return buildAstroScaffold(opts, sections)\n if (opts.framework === 'cloudflare') {\n // The digest emails the report page the export example adds, so it brings that along.\n const examples =\n opts.examples?.includes('digest') && !opts.examples.includes('export')\n ? [...opts.examples, 'export' as const]\n : opts.examples\n // Social posts belong to a user: without --auth, it brings sign-in with providers.\n const auth = opts.auth ?? (examples?.includes('social') ? 'oauth' : undefined)\n return buildCloudflareScaffold(\n { ...opts, ...(examples ? { examples } : {}), ...(auth ? { auth } : {}) },\n sections,\n )\n }\n return [\n { path: 'package.json', contents: packageJson(opts) },\n { path: 'tsconfig.json', contents: tsconfig() },\n { path: 'vite.config.ts', contents: viteConfig() },\n { path: 'index.html', contents: indexHtml(opts) },\n // No `cascivo.config.ts`. It configures where `cascivo add` copies source, which a\n // prebuilt-path app never does — and its presence was how `doctor` decided a project\n // was copy-paste, so the scaffolder's own output failed `doctor --ci` and was told to\n // install the two packages the docs forbid. `cascivo add` writes the config itself the\n // first time it is used.\n { path: 'eslint.config.js', contents: eslintConfig() },\n { path: '.prettierrc', contents: prettierrc() },\n { path: '.prettierignore', contents: prettierIgnore() },\n { path: '.gitignore', contents: gitignore() },\n { path: 'README.md', contents: readme(opts) },\n { path: 'AGENTS.md', contents: agentsMd(opts) },\n { path: 'src/main.tsx', contents: mainTsx() },\n { path: 'src/vite-env.d.ts', contents: viteEnv() },\n { path: 'src/App.tsx', contents: appTsx(sections) },\n { path: 'src/Shell.tsx', contents: shellTsx(opts) },\n ...sections.map((s) => ({\n path: `src/sections/${s.component}.tsx`,\n contents: sectionTsx(s),\n })),\n ]\n}\n\nfunction buildAstroScaffold(opts: ScaffoldOptions, sections: Section[]): ScaffoldFile[] {\n return [\n { path: 'package.json', contents: astroPackageJson(opts) },\n { path: 'tsconfig.json', contents: astroTsconfig() },\n { path: 'astro.config.mjs', contents: astroConfig() },\n { path: 'eslint.config.js', contents: eslintConfig() },\n { path: '.prettierrc', contents: prettierrc() },\n { path: '.prettierignore', contents: prettierIgnore() },\n { path: '.gitignore', contents: astroGitignore() },\n { path: 'README.md', contents: astroReadme(opts, sections) },\n { path: 'AGENTS.md', contents: agentsMd(opts) },\n { path: 'src/styles/layers.css', contents: astroLayersCss() },\n { path: 'src/layouts/Layout.astro', contents: astroLayout(opts) },\n { path: 'src/components/Shell.tsx', contents: astroShellTsx(opts, sections) },\n ...sections.map((s, i) => ({\n path: `src/pages/${sectionPageFile(s, i)}`,\n contents: astroPage(s, i),\n })),\n ...sections.map((s) => ({\n path: `src/components/${s.component}.tsx`,\n contents: astroSectionTsx(s),\n })),\n ]\n}\n\nconst DEFAULT_SECTIONS = ['Dashboard', 'Reports', 'Settings']\n\nexport async function create(args: string[], cwd: string = process.cwd()): Promise<void> {\n const yes = args.includes('--yes') || args.includes('-y')\n // Skip flag values (e.g. `bun` in `--pm bun`) so the project name is the first\n // real positional, not a flag's argument.\n const nameArg = positionalArgs(args, [\n 'pm',\n 'package-manager',\n 'theme',\n 'sections',\n 'template',\n 'framework',\n 'runtime',\n 'example',\n 'auth',\n ])[0]\n const themeArg = flagValue(args, 'theme')\n const sectionsArg = flagValue(args, 'sections')\n\n const frameworkArg = (flagValue(args, 'framework') ?? '').toLowerCase()\n if (frameworkArg && !(FRAMEWORKS as readonly string[]).includes(frameworkArg)) {\n console.error(`Unknown framework \"${frameworkArg}\". Expected one of: ${FRAMEWORKS.join(', ')}.`)\n process.exitCode = 1\n return\n }\n\n const runtimeArg = (flagValue(args, 'runtime') ?? '').toLowerCase()\n if (runtimeArg && !isRuntime(runtimeArg)) {\n console.error(`Unknown runtime \"${runtimeArg}\". Expected one of: ${RUNTIMES.join(', ')}.`)\n process.exitCode = 1\n return\n }\n\n const exampleArgs = (flagValue(args, 'example') ?? '')\n .split(',')\n .map((e) => e.trim().toLowerCase())\n .filter(Boolean)\n const badExample = exampleArgs.find((e) => !isExample(e))\n if (badExample) {\n console.error(`Unknown example \"${badExample}\". Expected one of: ${EXAMPLES.join(', ')}.`)\n process.exitCode = 1\n return\n }\n\n const authArg = parseAuth(flagValue(args, 'auth'))\n if (authArg === 'invalid') {\n console.error(\n `Unknown auth \"${flagValue(args, 'auth') ?? ''}\". Expected: access, email, oauth or email,oauth.`,\n )\n process.exitCode = 1\n return\n }\n\n const pmFlag = resolvePackageManagerFlag(args)\n if ('error' in pmFlag) {\n console.error(pmFlag.error)\n process.exitCode = 1\n return\n }\n // The new project has no lock file of its own, but the directory it lands IN usually does\n // — scaffolding into an existing workspace is the common case. `preferLockfileOverUserAgent`\n // makes that walk-up outrank `npm_config_user_agent`, which `npx` always reports as npm no\n // matter what the surrounding repo uses (2026-08-14 §11). With no lock file anywhere up the\n // tree, detection still falls back to the launcher.\n const pm = detectPackageManager(cwd, {\n preferLockfileOverUserAgent: true,\n ...(pmFlag.pm ? { override: pmFlag.pm } : {}),\n })\n\n const interactive = !yes && stdin.isTTY\n const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null\n\n try {\n let name = nameArg\n if (!name && rl) {\n name = (await rl.question('Project name? [my-cascivo-app]: ')).trim()\n }\n name = name || 'my-cascivo-app'\n\n let theme = (themeArg ?? '').toLowerCase()\n if (!(THEMES as readonly string[]).includes(theme) && rl) {\n theme = (await rl.question(`Theme? (${THEMES.join('/')}) [light]: `)).trim().toLowerCase()\n }\n const resolvedTheme: ThemeName = (THEMES as readonly string[]).includes(theme)\n ? (theme as ThemeName)\n : 'light'\n\n let sectionsInput = sectionsArg\n if (!sectionsInput && rl) {\n sectionsInput = (\n await rl.question(`Nav sections? (comma-separated) [${DEFAULT_SECTIONS.join(', ')}]: `)\n ).trim()\n }\n const sections = (sectionsInput ?? '')\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n\n let framework = frameworkArg\n if (!framework && rl) {\n framework = (await rl.question(`Framework? (${FRAMEWORKS.join('/')}) [react-vite]: `))\n .trim()\n .toLowerCase()\n }\n const resolvedFramework: Framework = (FRAMEWORKS as readonly string[]).includes(framework)\n ? (framework as Framework)\n : 'react-vite'\n\n if (exampleArgs.length > 0 && resolvedFramework !== 'cloudflare') {\n console.error('--example needs --framework cloudflare (it adds a Worker-backed page).')\n process.exitCode = 1\n return\n }\n if (exampleArgs.includes('social') && authArg === 'access') {\n console.error(\n '--example social needs accounts (posts belong to a user): use --auth email, oauth or ' +\n 'email,oauth, or leave --auth out to get oauth.',\n )\n process.exitCode = 1\n return\n }\n if (authArg && resolvedFramework !== 'cloudflare') {\n console.error('--auth needs --framework cloudflare (it guards the Worker).')\n process.exitCode = 1\n return\n }\n if (exampleArgs.includes('agent') && runtimeArg === 'preact') {\n console.error(\n \"--example agent needs --runtime react: the Agents SDK's hooks call React 19's use(), \" +\n 'which Preact does not implement.',\n )\n process.exitCode = 1\n return\n }\n\n const opts: ScaffoldOptions = {\n name,\n framework: resolvedFramework,\n theme: resolvedTheme,\n sections: sections.length > 0 ? sections : DEFAULT_SECTIONS,\n pm,\n ...(isRuntime(runtimeArg) ? { runtime: runtimeArg } : {}),\n ...(exampleArgs.length > 0 ? { examples: exampleArgs.filter(isExample) } : {}),\n ...(authArg ? { auth: authArg } : {}),\n }\n\n const targetDir = join(cwd, name)\n if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {\n console.error(`Target directory \"${name}\" already exists and is not empty.`)\n process.exitCode = 1\n return\n }\n\n const files = buildScaffold(opts)\n for (const file of files) {\n await writeFileSafe(join(targetDir, file.path), file.contents)\n }\n\n console.log(\n `\\nCreated ${name} (${resolvedFramework}) with the ${resolvedTheme} theme ` +\n `(${files.length} files).`,\n )\n\n const templateSpec = flagValue(args, 'template')\n if (templateSpec) {\n const { add } = await import('./add.js')\n const { loadConfig } = await import('../utils/config.js')\n console.log(`\\nInstalling template \"${templateSpec}\"…`)\n await add([templateSpec], await loadConfig(), { cwd: targetDir, pm })\n }\n\n console.log('\\nNext steps:')\n console.log(` cd ${name}`)\n console.log(` ${installAllCommand(pm)}`)\n console.log(` ${runScriptCommand(pm, 'dev')}`)\n // Two things the output used to leave the adopter to discover (2026-08-14 §11, §1).\n console.log('\\nGood to know:')\n console.log(' No cascivo.config.ts is written — this app uses the prebuilt @cascivo/react')\n console.log(' packages and never copies source. `cascivo add <component>` writes the')\n console.log(' config itself the first time you vendor a component.')\n if (resolvedFramework === 'cloudflare') {\n console.log('\\n worker/index.ts is the API (wrangler.jsonc routes /api/* to it); `dev`')\n console.log(' runs it in workerd. Deploy with `npx wrangler login` once, then the')\n console.log(` deploy script: ${runExplicitCommand(pm, 'deploy')}`)\n console.log('\\n No account yet? Share a 60-minute preview, claimable into a free account:')\n console.log(` ${runExplicitCommand(pm, 'deploy:preview')}`)\n } else if (resolvedFramework === 'astro') {\n console.log('\\n Pages are real Astro routes — no client router to add. Only src/')\n console.log(' components/Shell.tsx hydrates (client:load, for the mobile nav drawer);')\n console.log(' page content is server-rendered and ships no JS. See')\n console.log(' https://cascivo.com/docs/using-with-astro.md')\n } else {\n console.log('\\n Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,')\n console.log(' and register your Link once with setLinkComponent — see')\n console.log(' https://cascivo.com/docs/using-with-a-router.md')\n }\n } finally {\n rl?.close()\n }\n}\n"],"mappings":"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"}
|