cascivo 1.4.0 → 1.5.0

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,13 +1,13 @@
1
- import{a as e,r as t}from"./config-DT-Cs7bC.mjs";import{i as n}from"./fs-B7O-fNID.mjs";import{n as r,r as i,t as a}from"./args-CtmltI1S.mjs";import{stdin as o,stdout as s}from"node:process";import{existsSync as c,readdirSync as l}from"node:fs";import{join as u}from"node:path";import{createInterface as d}from"node:readline/promises";const f={"@cascivo/react":`1.4.0`,"@cascivo/themes":`1.0.0`,"@cascivo/charts":`1.4.0`,"@cascivo/icons":`1.1.0`,"@cascivo/eslint-config":`0.4.1`,"@cascivo/app":`1.4.0`,"@cascivo/render":`1.4.0`,"@cascivo/storage":`1.4.0`},p=`>=3.0.0`;function m(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\/+$/,``)||`/`:t}function h(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function g(e){let t=m(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+=`/${h(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function _(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}var v=/\.(tsx|jsx)$/;function y(e){if(!v.test(e)||/\.(test|spec)\.[jt]sx$/.test(e))return;let t=e.replace(v,``).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 b(e,t){let n=[],r=new Map;for(let t of e){let e=y(t);if(e===void 0)continue;let i=r.get(e);if(i)throw Error(`routes/${i} and routes/${t} both define ${e??`the 404 route`}`);r.set(e,t),n.push({file:t,path:e})}let i=e=>`${t}/${e.replace(v,``)}`,a=n.filter(e=>e.path!==null).map(e=>({...e,compiled:g(e.path)})).sort((e,t)=>_(e.compiled,t.compiled)||e.path.localeCompare(t.path)),o=n.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[] = [`,...a.map(e=>` lazyRoute('${e.path}', () => import('${i(e.file)}')),`),`]`,``,o?`export const notFound: Route | undefined = lazyRoute('*', () => import('${i(o.file)}'))`:`export const notFound: Route | undefined = undefined`,``,"/** Every route pattern. Fill one with `buildPath` for a typed link. */",`export type AppPath = ${a.length>0?a.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`
2
- `)}const x=f;function S(e){return e===`yarn`?`yarn`:`${e} install`}function C(e,t){return e===`npm`?`npm run ${t}`:`${e} ${t}`}function w(e,t){return`${e} run ${t}`}const T=[`react-vite`,`astro`,`cloudflare`],E=[`preact`,`react`],D=[`board`,`agent`,`notes`,`import`,`files`,`export`,`usage`,`crud`,`live`,`voice`,`publish`,`webhooks`,`digest`,`search`];function O(e){return D.includes(e)}function k(e){return E.includes(e)}function A(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``)||`section`}function j(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(``)||`Section`;return/^[0-9]/.test(t)?`Section${t}`:t}function M(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(e=>e!==``&&!/^\d+$/.test(e)&&!/^v\d+$/i.test(e)),n=[];for(let e of t){if(n.length>=3||n.length>0&&n.join(` `).length+1+e.length>24)break;n.push(e.charAt(0).toUpperCase()+e.slice(1))}return n.join(` `)||`App`}function N(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,`-`).replace(/^-+|-+$/g,``)||`cascivo-app`}function ee(e){let t=new Set,n=new Set,r=[];for(let i of e){let e=i.trim();if(!e)continue;let a=A(e),o=j(e),s=2;for(;t.has(a)||n.has(o);)a=`${A(e)}-${s}`,o=`${j(e)}${s}`,s++;t.add(a),n.add(o),r.push({key:a,label:e,component:o})}return r.length>0?r:[{key:`home`,label:`Home`,component:`Home`}]}function te(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:`tsc && vite build`,preview:`vite preview`,typecheck:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,"@vitejs/plugin-react":`^5.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^7.0.0`}};return JSON.stringify(t,null,2)+`
1
+ import{a as e,r as t}from"./config-DT-Cs7bC.mjs";import{i as n}from"./fs-B7O-fNID.mjs";import{n as r,r as i,t as a}from"./args-CtmltI1S.mjs";import{stdin as o,stdout as s}from"node:process";import{existsSync as c,readdirSync as l}from"node:fs";import{join as u}from"node:path";import{createInterface as d}from"node:readline/promises";const f={"@cascivo/react":`1.5.0`,"@cascivo/themes":`1.0.0`,"@cascivo/charts":`1.5.0`,"@cascivo/icons":`1.1.0`,"@cascivo/eslint-config":`0.4.1`,"@cascivo/app":`1.5.0`,"@cascivo/render":`1.5.0`,"@cascivo/storage":`1.5.0`,"@cascivo/email":`0.5.0`},p=`>=3.0.0`;function m(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\/+$/,``)||`/`:t}function h(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function g(e){let t=m(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+=`/${h(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function _(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}var v=/\.(tsx|jsx)$/;function y(e){if(!v.test(e)||/\.(test|spec)\.[jt]sx$/.test(e))return;let t=e.replace(v,``).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 b(e,t){let n=[],r=new Map;for(let t of e){let e=y(t);if(e===void 0)continue;let i=r.get(e);if(i)throw Error(`routes/${i} and routes/${t} both define ${e??`the 404 route`}`);r.set(e,t),n.push({file:t,path:e})}let i=e=>`${t}/${e.replace(v,``)}`,a=n.filter(e=>e.path!==null).map(e=>({...e,compiled:g(e.path)})).sort((e,t)=>_(e.compiled,t.compiled)||e.path.localeCompare(t.path)),o=n.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[] = [`,...a.map(e=>` lazyRoute('${e.path}', () => import('${i(e.file)}')),`),`]`,``,o?`export const notFound: Route | undefined = lazyRoute('*', () => import('${i(o.file)}'))`:`export const notFound: Route | undefined = undefined`,``,"/** Every route pattern. Fill one with `buildPath` for a typed link. */",`export type AppPath = ${a.length>0?a.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`
2
+ `)}const x=f;function S(e){return e===`yarn`?`yarn`:`${e} install`}function C(e,t){return e===`npm`?`npm run ${t}`:`${e} ${t}`}function w(e,t){return`${e} run ${t}`}const T=[`react-vite`,`astro`,`cloudflare`],E=[`preact`,`react`],D=[`board`,`agent`,`notes`,`import`,`files`,`export`,`usage`,`crud`,`live`,`voice`,`publish`,`webhooks`,`digest`,`search`,`checkout`,`newsletter`];function O(e){return D.includes(e)}function k(e){return E.includes(e)}function A(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``)||`section`}function j(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(``)||`Section`;return/^[0-9]/.test(t)?`Section${t}`:t}function M(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(e=>e!==``&&!/^\d+$/.test(e)&&!/^v\d+$/i.test(e)),n=[];for(let e of t){if(n.length>=3||n.length>0&&n.join(` `).length+1+e.length>24)break;n.push(e.charAt(0).toUpperCase()+e.slice(1))}return n.join(` `)||`App`}function N(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,`-`).replace(/^-+|-+$/g,``)||`cascivo-app`}function ee(e){let t=new Set,n=new Set,r=[];for(let i of e){let e=i.trim();if(!e)continue;let a=A(e),o=j(e),s=2;for(;t.has(a)||n.has(o);)a=`${A(e)}-${s}`,o=`${j(e)}${s}`,s++;t.add(a),n.add(o),r.push({key:a,label:e,component:o})}return r.length>0?r:[{key:`home`,label:`Home`,component:`Home`}]}function te(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:`tsc && vite build`,preview:`vite preview`,typecheck:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,"@vitejs/plugin-react":`^5.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^7.0.0`}};return JSON.stringify(t,null,2)+`
3
3
  `}function P(e){return JSON.stringify(e,null,2).replace(/\[\n\s+("[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s*\]/g,(e,t)=>`[${t.split(/,\n\s+/).join(`, `)}]`)+`
4
- `}function ne(){return P({compilerOptions:{target:`ES2022`,useDefineForClassFields:!0,lib:[`ES2022`,`DOM`,`DOM.Iterable`],module:`ESNext`,skipLibCheck:!0,moduleResolution:`bundler`,allowImportingTsExtensions:!0,resolveJsonModule:!0,isolatedModules:!0,moduleDetection:`force`,noEmit:!0,jsx:`react-jsx`,strict:!0,noUnusedLocals:!0,noUnusedParameters:!0,noFallthroughCasesInSwitch:!0},include:[`src`]})}function re(){return`import react from '@vitejs/plugin-react'
4
+ `}function F(){return P({compilerOptions:{target:`ES2022`,useDefineForClassFields:!0,lib:[`ES2022`,`DOM`,`DOM.Iterable`],module:`ESNext`,skipLibCheck:!0,moduleResolution:`bundler`,allowImportingTsExtensions:!0,resolveJsonModule:!0,isolatedModules:!0,moduleDetection:`force`,noEmit:!0,jsx:`react-jsx`,strict:!0,noUnusedLocals:!0,noUnusedParameters:!0,noFallthroughCasesInSwitch:!0},include:[`src`]})}function ne(){return`import react from '@vitejs/plugin-react'
5
5
  import { defineConfig } from 'vite'
6
6
 
7
7
  export default defineConfig({
8
8
  plugins: [react()],
9
9
  })
10
- `}function ie(e){return`<!doctype html>
10
+ `}function I(e){return`<!doctype html>
11
11
  <html lang="en" data-theme="${e.theme}">
12
12
  <head>
13
13
  <meta charset="UTF-8" />
@@ -46,7 +46,7 @@ export default defineConfig({
46
46
  <script type="module" src="/src/main.tsx"><\/script>
47
47
  </body>
48
48
  </html>
49
- `}function ae(){return`import React from 'react'
49
+ `}function re(){return`import React from 'react'
50
50
  import ReactDOM from 'react-dom/client'
51
51
  import App from './App'
52
52
 
@@ -58,8 +58,8 @@ if (root) {
58
58
  </React.StrictMode>,
59
59
  )
60
60
  }
61
- `}function F(){return`/// <reference types="vite/client" />
62
- `}function oe(e){let t=e.map(e=>`import { ${e.component} } from './sections/${e.component}'`).join(`
61
+ `}function ie(){return`/// <reference types="vite/client" />
62
+ `}function ae(e){let t=e.map(e=>`import { ${e.component} } from './sections/${e.component}'`).join(`
63
63
  `),n=e.map(e=>`'${e.key}'`).join(` | `),r=e.map(e=>` {
64
64
  label: '${e.label.replace(/'/g,`\\'`)}',
65
65
  active: section.value === '${e.key}',
@@ -91,7 +91,7 @@ ${i}
91
91
  </Shell>
92
92
  )
93
93
  }
94
- `}function I(e){return`'use client'
94
+ `}function oe(e){return`'use client'
95
95
  import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'
96
96
  import type { ReactNode } from 'react'
97
97
 
@@ -121,10 +121,7 @@ export interface ShellProps {
121
121
  */
122
122
  export function Shell({ navItems, children }: ShellProps) {
123
123
  return (
124
- <AppShell
125
- header={<ShellHeader brand={{ name: '${M(e.name).replace(/'/g,`\\'`)}' }} />}
126
- nav={<SideNav items={navItems} />}
127
- >
124
+ ${z(e,`navItems`)}
128
125
  {children}
129
126
  </AppShell>
130
127
  )
@@ -177,8 +174,8 @@ export default [
177
174
  // see https://cascivo.com/docs/using-with-strict-eslint.md
178
175
  ...cascivo,
179
176
  ]
180
- `}function z(){return JSON.stringify({semi:!1,singleQuote:!0,printWidth:100},null,2)+`
181
- `}function B(){return`dist
177
+ `}function z(e,t){let n=`header={<ShellHeader brand={{ name: '${M(e.name).replace(/'/g,`\\'`)}' }} />}`,r=`nav={<SideNav items={${t}} />}`,i=` <AppShell ${n} ${r}>`;return i.length<=100?i:` <AppShell\n ${n}\n ${r}\n >`}function B(){return JSON.stringify({semi:!1,singleQuote:!0,printWidth:100},null,2)+`
178
+ `}function V(){return`dist
182
179
  node_modules
183
180
  # Vendored cascivo source is formatted upstream — reformatting it makes every
184
181
  # \`cascivo add\` update a merge conflict.
@@ -187,17 +184,17 @@ src/components/ui/
187
184
  dist
188
185
  *.local
189
186
  .DS_Store
190
- `}function ce(e){return`
187
+ `}function H(e){return`
191
188
  ## Share a preview
192
189
 
193
190
  No Cloudflare account needed. Run \`${C(e.pm??`npm`,`build`)}\`, then either:
194
191
 
195
192
  - drag the \`dist/\` folder onto [Cloudflare Drop](https://www.cloudflare.com/drop/), or
196
- - from a terminal: \`npx wrangler deploy --temporary --assets dist --name ${N(e.name)} --compatibility-date ${W}\`
193
+ - from a terminal: \`npx wrangler deploy --temporary --assets dist --name ${N(e.name)} --compatibility-date ${K}\`
197
194
 
198
195
  Either way you get a public URL that lasts 60 minutes. Sign in to Cloudflare within that hour
199
196
  to keep it.
200
- `}function le(e){let t=e.pm??`npm`;return`# ${e.name}
197
+ `}function ce(e){let t=e.pm??`npm`;return`# ${e.name}
201
198
 
202
199
  A [cascivo](https://cascivo.com) app — Vite + React + TypeScript, pre-wired with
203
200
  the cascivo app shell, side navigation, and the \`${e.theme}\` theme.
@@ -239,7 +236,7 @@ setLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href ?? '#
239
236
  That one call makes \`SideNav\`, \`ShellHeader\` and \`Breadcrumb\` render real router links.
240
237
  Links you write in page content use \`<Link asChild>\` instead — two kinds of link, two
241
238
  mechanisms. Full recipe: https://cascivo.com/docs/using-with-a-router.md
242
- ${ce(e)}`}function V(e){return`# Agent instructions — ${e.name}
239
+ ${H(e)}`}function U(e){return`# Agent instructions — ${e.name}
243
240
 
244
241
  This is a [cascivo](https://cascivo.com) app. When generating or editing CSS, follow
245
242
  the **layer contract** — cascivo styles live in cascade layers, and layer order beats
@@ -306,8 +303,8 @@ The vocabulary types are on a subpath: \`import type { Tone } from '@cascivo/rea
306
303
  add \`@cascivo/core\` to this app's dependencies — it is transitive here.
307
304
 
308
305
  More: cascivo's machine-readable guide is at https://cascivo.com/llms.txt.
309
- `}function H(e,t){return t===0?`/`:`/${e.key}`}function U(e,t){return t===0?`index.astro`:`${e.key}.astro`}function ue(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`astro dev`,build:`astro build`,preview:`astro preview`,typecheck:`astro check`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@astrojs/react":`^5.0.0`,"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,astro:`^7.0.0`,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@astrojs/check":`^0.9.0`,"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`}};return JSON.stringify(t,null,2)+`
310
- `}function de(){return`// @ts-check
306
+ `}function W(e,t){return t===0?`/`:`/${e.key}`}function G(e,t){return t===0?`index.astro`:`${e.key}.astro`}function le(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`astro dev`,build:`astro build`,preview:`astro preview`,typecheck:`astro check`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@astrojs/react":`^5.0.0`,"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,astro:`^7.0.0`,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@astrojs/check":`^0.9.0`,"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`}};return JSON.stringify(t,null,2)+`
307
+ `}function ue(){return`// @ts-check
311
308
  import { defineConfig } from 'astro/config'
312
309
  import react from '@astrojs/react'
313
310
 
@@ -330,8 +327,8 @@ export default defineConfig({
330
327
  },
331
328
  },
332
329
  })
333
- `}function fe(){return JSON.stringify({extends:`astro/tsconfigs/strict`,include:[`.astro/types.d.ts`,`**/*`],exclude:[`dist`],compilerOptions:{jsx:`react-jsx`}},null,2)+`
334
- `}function pe(){return`/* Cascade layer order. Must load before any cascivo CSS — see the note in
330
+ `}function de(){return JSON.stringify({extends:`astro/tsconfigs/strict`,include:[`.astro/types.d.ts`,`**/*`],exclude:[`dist`],compilerOptions:{jsx:`react-jsx`}},null,2)+`
331
+ `}function fe(){return`/* Cascade layer order. Must load before any cascivo CSS — see the note in
335
332
  src/layouts/Layout.astro. Later layers beat earlier ones regardless of specificity.
336
333
 
337
334
  cascivo.example is this app's own slot: above the component and blocks layers so your
@@ -358,7 +355,7 @@ export default defineConfig({
358
355
  block-size: 100%;
359
356
  }
360
357
  }
361
- `}function me(e){return`---
358
+ `}function pe(e){return`---
362
359
  // Import order matters: the layer statement must be established before any cascivo CSS.
363
360
  import '../styles/layers.css'
364
361
  import '@cascivo/themes/${e.theme}.css'
@@ -385,11 +382,11 @@ const { title } = Astro.props
385
382
  <slot />
386
383
  </body>
387
384
  </html>
388
- `}function he(e,t){return`import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'
385
+ `}function me(e,t){return`import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'
389
386
  import type { ReactNode } from 'react'
390
387
 
391
388
  const NAV: { label: string; href: string }[] = [
392
- ${t.map((e,t)=>` { label: '${e.label.replace(/'/g,`\\'`)}', href: '${H(e,t)}' },`).join(`
389
+ ${t.map((e,t)=>` { label: '${e.label.replace(/'/g,`\\'`)}', href: '${W(e,t)}' },`).join(`
393
390
  `)}
394
391
  ]
395
392
 
@@ -412,15 +409,12 @@ export function Shell({ activePath, children }: ShellProps) {
412
409
  }))
413
410
 
414
411
  return (
415
- <AppShell
416
- header={<ShellHeader brand={{ name: '${M(e.name).replace(/'/g,`\\'`)}' }} />}
417
- nav={<SideNav items={items} />}
418
- >
412
+ ${z(e,`items`)}
419
413
  {children}
420
414
  </AppShell>
421
415
  )
422
416
  }
423
- `}function ge(e){return`import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'
417
+ `}function he(e){return`import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'
424
418
 
425
419
  export function ${e.component}() {
426
420
  return (
@@ -446,7 +440,7 @@ export function ${e.component}() {
446
440
  </Flex>
447
441
  )
448
442
  }
449
- `}function _e(e,t){return`---
443
+ `}function ge(e,t){return`---
450
444
  import Layout from '../layouts/Layout.astro'
451
445
  import { Shell } from '../components/Shell'
452
446
  import { ${e.component} } from '../components/${e.component}'
@@ -455,16 +449,16 @@ import { ${e.component} } from '../components/${e.component}'
455
449
  <Layout title="${e.label}">
456
450
  {/* client:load hydrates the shell for its mobile nav drawer. The section below stays
457
451
  server-rendered HTML — it is slotted in as children and ships no JS. */}
458
- <Shell client:load activePath="${H(e,t)}">
452
+ <Shell client:load activePath="${W(e,t)}">
459
453
  <${e.component} />
460
454
  </Shell>
461
455
  </Layout>
462
- `}function ve(){return`node_modules
456
+ `}function _e(){return`node_modules
463
457
  dist
464
458
  .astro
465
459
  *.local
466
460
  .DS_Store
467
- `}function ye(e,t){let n=e.pm??`npm`,r=t.map((e,t)=>`- \`${H(e,t)}\` — \`src/pages/${U(e,t)}\``).join(`
461
+ `}function ve(e,t){let n=e.pm??`npm`,r=t.map((e,t)=>`- \`${W(e,t)}\` — \`src/pages/${G(e,t)}\``).join(`
468
462
  `);return`# ${e.name}
469
463
 
470
464
  A [cascivo](https://cascivo.com) app — Astro + React islands + TypeScript, pre-wired with
@@ -507,8 +501,8 @@ and that order matters: layers take their position from first appearance, so a s
507
501
  loaded after the theme cannot reorder anything.
508
502
 
509
503
  Add more components with \`npx cascivo add <component>\`.
510
- ${ce(e)}`}const W=`2026-09-01`;function be(e){let t=e.pm??`npm`,n=K(e,`agent`),r=Q(e),i=$(e)===`preact`,a={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:r?`tsc && tsc -p tsconfig.worker.json && vite build`:`tsc && vite build`,preview:`vite preview`,deploy:`${C(t,`build`)} && wrangler deploy`,"deploy:preview":`${C(t,`build`)} && wrangler deploy --temporary`,typecheck:r?`tsc --noEmit && tsc --noEmit -p tsconfig.worker.json`:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/app":x[`@cascivo/app`],"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,...i?{preact:`^10.29.0`}:{react:`^19.0.0`,"react-dom":`^19.0.0`},...K(e,`usage`)||K(e,`live`)?{"@cascivo/charts":x[`@cascivo/charts`]}:{},...K(e,`export`)?{"@cloudflare/puppeteer":`^1.4.0`}:{},...K(e,`notes`)?{"@cascivo/storage":x[`@cascivo/storage`]}:{},...n?{"@ai-sdk/react":`^4.0.0`,"@cascivo/render":x[`@cascivo/render`],"@cloudflare/ai-chat":`^0.12.0`,agents:`^0.24.0`,ai:`^7.0.0`,"workers-ai-provider":`^4.0.0`,zod:`^4.0.0`}:{},...K(e,`voice`)&&!n?{agents:`^0.24.0`}:{},...K(e,`publish`)&&!n?{"@cascivo/render":x[`@cascivo/render`]}:{},...K(e,`publish`)&&i?{"preact-render-to-string":`^6.5.0`}:{}},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@cloudflare/vite-plugin":`^1.62.0`,...r?{"@cloudflare/workers-types":`^5.0.0`}:{},"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,...i?{"@babel/core":`^7.0.0`,"@preact/preset-vite":`^2.10.0`,react:`^19.0.0`,"react-dom":`^19.0.0`}:{"@vitejs/plugin-react":`^6.0.0`},eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^8.0.0`,wrangler:`^4.143.0`}};return JSON.stringify(a,null,2)+`
511
- `}function xe(e){let t=JSON.parse(ne());return t.include=Q(e)?[`src`]:[`src`,`worker`],P(t)}function Se(){return P({extends:`./tsconfig.json`,compilerOptions:{lib:[`ES2022`],types:[`@cloudflare/workers-types`,`vite/client`]},include:[`worker`]})}function Ce(e,t){let n=K(t,`agent`),r=X(t),i=e===`preact`?`import preact from '@preact/preset-vite'`:`import react from '@vitejs/plugin-react'`,a=e===`preact`?`preact()`:`react()`;return`${i}
504
+ ${H(e)}`}const K=`2026-09-01`;function ye(e){let t=e.pm??`npm`,n=X(e,`agent`),r=Q(e),i=$(e)===`preact`,a={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:r?`tsc && tsc -p tsconfig.worker.json && vite build`:`tsc && vite build`,preview:`vite preview`,deploy:`${C(t,`build`)} && wrangler deploy`,"deploy:preview":`${C(t,`build`)} && wrangler deploy --temporary`,typecheck:r?`tsc --noEmit && tsc --noEmit -p tsconfig.worker.json`:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/app":x[`@cascivo/app`],"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,...i?{preact:`^10.29.0`}:{react:`^19.0.0`,"react-dom":`^19.0.0`},...X(e,`usage`)||X(e,`live`)?{"@cascivo/charts":x[`@cascivo/charts`]}:{},...X(e,`export`)?{"@cloudflare/puppeteer":`^1.4.0`}:{},...X(e,`notes`)?{"@cascivo/storage":x[`@cascivo/storage`]}:{},...n?{"@ai-sdk/react":`^4.0.0`,"@cascivo/render":x[`@cascivo/render`],"@cloudflare/ai-chat":`^0.12.0`,agents:`^0.24.0`,ai:`^7.0.0`,"workers-ai-provider":`^4.0.0`,zod:`^4.0.0`}:{},...X(e,`voice`)&&!n?{agents:`^0.24.0`}:{},...X(e,`publish`)&&!n?{"@cascivo/render":x[`@cascivo/render`]}:{},...X(e,`checkout`)||X(e,`newsletter`)?{"@cascivo/email":x[`@cascivo/email`]}:{},...(X(e,`publish`)||X(e,`checkout`)||X(e,`newsletter`))&&i?{"preact-render-to-string":`^6.5.0`}:{}},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@cloudflare/vite-plugin":`^1.62.0`,...r?{"@cloudflare/workers-types":`^5.0.0`}:{},"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,...i?{"@babel/core":`^7.0.0`,"@preact/preset-vite":`^2.10.0`,react:`^19.0.0`,"react-dom":`^19.0.0`}:{"@vitejs/plugin-react":`^6.0.0`},eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^8.0.0`,wrangler:`^4.143.0`}},o=un(e);return JSON.stringify(Object.keys(o).length>0?{...a,cloudflare:{bindings:o}}:a,null,2)+`
505
+ `}function be(e){let t=JSON.parse(F());return t.include=Q(e)?[`src`]:[`src`,`worker`],P(t)}function xe(){return P({extends:`./tsconfig.json`,compilerOptions:{lib:[`ES2022`],types:[`@cloudflare/workers-types`,`vite/client`]},include:[`worker`]})}function Se(e,t){let n=X(t,`agent`),r=ke(t),i=e===`preact`?`import preact from '@preact/preset-vite'`:`import react from '@vitejs/plugin-react'`,a=e===`preact`?`preact()`:`react()`;return`${i}
512
506
  import { cascivoRoutes } from '@cascivo/app/vite'
513
507
  import { cloudflare } from '@cloudflare/vite-plugin'
514
508
  import { defineConfig } from 'vite'
@@ -526,7 +520,7 @@ ${e===`preact`?`// The source is written against React's types; @preact/preset-v
526
520
  // client, so dev and production execute the same runtime. Request routing is in wrangler.jsonc.${r?`
527
521
  //
528
522
  // Workers AI has no local mode: with remote bindings on, \`vite dev\` needs a Cloudflare login.
529
- // So they are off, ${K(t,`search`)?`and:${[n?`the assistant answers from worker/scripted-model.ts`:``,K(t,`voice`)?`the voice page uses the stand-ins in worker/scripted-voice.ts`:``,`search runs by keyword, on SQLite full-text search (worker/search.ts)`].filter(Boolean).map(e=>`\n// - ${e}`).join(``)}`:n&&K(t,`voice`)?`the assistant answers from worker/scripted-model.ts, and the voice
523
+ // So they are off, ${X(t,`search`)?`and:${[n?`the assistant answers from worker/scripted-model.ts`:``,X(t,`voice`)?`the voice page uses the stand-ins in worker/scripted-voice.ts`:``,`search runs by keyword, on SQLite full-text search (worker/search.ts)`].filter(Boolean).map(e=>`\n// - ${e}`).join(``)}`:n&&X(t,`voice`)?`the assistant answers from worker/scripted-model.ts, and the voice
530
524
  // page uses the stand-ins in worker/scripted-voice.ts`:n?`and the assistant answers from worker/scripted-model.ts`:`and the voice page uses the stand-ins in worker/scripted-voice.ts`}. Run
531
525
  // \`VITE_REAL_AI=1 vite dev\` (after \`wrangler login\`) to use Workers AI.`:``}
532
526
  export default defineConfig({
@@ -534,18 +528,18 @@ ${r?` plugins: [
534
528
  ${a},
535
529
  cascivoRoutes(),
536
530
  cloudflare({ remoteBindings: process.env['VITE_REAL_AI'] === '1' }),
537
- ],`:` plugins: [${a}, cascivoRoutes(), cloudflare()],`}${K(t,`publish`)?`
531
+ ],`:` plugins: [${a}, cascivoRoutes(), cloudflare()],`}${X(t,`publish`)?`
538
532
  // A published page's HTML links the stylesheets its route needs, found in this manifest
539
533
  // (worker/page-html.ts), so the page is styled for readers without JavaScript too.
540
534
  build: { manifest: 'asset-manifest.json' },`:``}
541
535
  })
542
- `}function G(e,t,n){let r=`${e}"${t}": [${n.join(`, `)}],`;return r.length<=100?r:`${e}"${t}": [\n${n.map(t=>`${e} ${t},`).join(`
543
- `)}\n${e}],`}function we(e){let t=K(e,`publish`),n=Ee(e),r=K(e,`agent`),i=Z(e),a=[...n?[{name:`ROOMS`,className:`SyncRoom`}]:[],...r?[{name:`Assistant`,className:`Assistant`}]:[],...K(e,`live`)?[{name:`LIVE`,className:`LiveRoom`}]:[],...K(e,`voice`)?[{name:`Voice`,className:`Voice`}]:[]],o=[...n?[`// ROOMS: one SyncRoom per room (@cascivo/app/sync-server).`]:[],...r?[`// Assistant: one AIChatAgent per conversation; it stores the messages in SQLite.`]:[],...K(e,`live`)?[`// LIVE: the /ops dashboard's per-second totals (@cascivo/app/live-server).`]:[],...K(e,`voice`)?[`// Voice: one voice agent per conversation (agents/voice); it stores the transcript.`]:[]];return`// Cloudflare deploy config. \`${w(e.pm??`npm`,`deploy`)}\` builds and ships the SPA and
536
+ `}function q(e,t,n){let r=`${e}"${t}": [${n.join(`, `)}],`;return r.length<=100?r:`${e}"${t}": [\n${n.map(t=>`${e} ${t},`).join(`
537
+ `)}\n${e}],`}function Ce(e){let t=X(e,`publish`),n=Ae(e),r=X(e,`agent`),i=Z(e),a=[...n?[{name:`ROOMS`,className:`SyncRoom`}]:[],...r?[{name:`Assistant`,className:`Assistant`}]:[],...X(e,`live`)?[{name:`LIVE`,className:`LiveRoom`}]:[],...X(e,`voice`)?[{name:`Voice`,className:`Voice`}]:[]],o=[...n?[`// ROOMS: one SyncRoom per room (@cascivo/app/sync-server).`]:[],...r?[`// Assistant: one AIChatAgent per conversation; it stores the messages in SQLite.`]:[],...X(e,`live`)?[`// LIVE: the /ops dashboard's per-second totals (@cascivo/app/live-server).`]:[],...X(e,`voice`)?[`// Voice: one voice agent per conversation (agents/voice); it stores the transcript.`]:[]];return`// Cloudflare deploy config. \`${w(e.pm??`npm`,`deploy`)}\` builds and ships the SPA and
544
538
  // the Worker together. https://developers.cloudflare.com/workers/wrangler/configuration/
545
539
  {
546
540
  "name": "${N(e.name)}",
547
541
  "main": "./worker/index.ts",
548
- "compatibility_date": "${W}",
542
+ "compatibility_date": "${K}",
549
543
  "assets": {
550
544
  // Client-side app: an unknown path serves index.html, and the client renders it.
551
545
  "not_found_handling": "single-page-application",
@@ -559,55 +553,56 @@ ${r?` plugins: [
559
553
  ${o.join(`
560
554
  `)}
561
555
  "durable_objects": {
562
- ${G(` `,`bindings`,a.map(e=>`{ "name": "${e.name}", "class_name": "${e.className}" }`))}
556
+ ${q(` `,`bindings`,a.map(e=>`{ "name": "${e.name}", "class_name": "${e.className}" }`))}
563
557
  },
564
- ${G(` `,`migrations`,[`{ "tag": "v1", "new_sqlite_classes": [${a.map(e=>`"${e.className}"`).join(`, `)}] }`])}`:``}${K(e,`files`)?`
558
+ ${q(` `,`migrations`,[`{ "tag": "v1", "new_sqlite_classes": [${a.map(e=>`"${e.className}"`).join(`, `)}] }`])}`:``}${X(e,`files`)?`
565
559
  // Uploaded files, and Cloudflare Images for their resized previews.
566
- ${G(` `,`r2_buckets`,[`{ "binding": "FILES", "bucket_name": "${N(e.name)}-files" }`])}
567
- "images": { "binding": "IMAGES" },`:``}${J(e)?`
568
- // ${[K(e,`files`)?`Uploads`:``,K(e,`export`)?`exports`:``,K(e,`publish`)?`published pages`:``,K(e,`digest`)?`digests sent now`:``,K(e,`search`)?`search indexing`:``,e.auth===`email`?`sign-in emails`:``].filter(Boolean).join(`, `).replace(/^./,e=>e.toUpperCase())} per caller: 20 a minute.
560
+ ${q(` `,`r2_buckets`,[`{ "binding": "FILES", "bucket_name": "${N(e.name)}-files" }`])}
561
+ "images": { "binding": "IMAGES" },`:``}${De(e)?`
562
+ // ${[X(e,`files`)?`Uploads`:``,X(e,`export`)?`exports`:``,X(e,`publish`)?`published pages`:``,X(e,`digest`)?`digests sent now`:``,X(e,`search`)?`search indexing`:``,X(e,`checkout`)?`checkouts started`:``,X(e,`newsletter`)?`newsletter sign-ups and composer requests`:``,e.auth===`email`?`sign-in emails`:``].filter(Boolean).join(`, `).replace(/^./,e=>e.toUpperCase())} per caller: 20 a minute.
569
563
  // namespace_id is any number unique within your account:
570
564
  // https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
571
- ${G(` `,`ratelimits`,[`{ "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }`])}`:``}${Te(e)}${K(e,`digest`)?`
565
+ ${q(` `,`ratelimits`,[`{ "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }`])}`:``}${we(e)}${X(e,`digest`)?`
572
566
  // The weekly digest: Mondays at 08:00 UTC (worker/digest.ts).
573
- "triggers": { "crons": ["0 8 * * 1"] },`:``}${Y(e)?`
574
- // Tables for ${[K(e,`crud`)?`customers`:``,K(e,`publish`)?`published pages`:``,K(e,`webhooks`)?`webhook deliveries`:``,K(e,`digest`)?`digest runs`:``,K(e,`search`)?`help articles`:``,e.auth===`email`?`accounts`:``].filter(Boolean).join(`, `)}.
567
+ "triggers": { "crons": ["0 8 * * 1"] },`:``}${Oe(e)?`
568
+ // Tables for ${[X(e,`crud`)?`customers`:``,X(e,`publish`)?`published pages`:``,X(e,`webhooks`)?`webhook deliveries`:``,X(e,`digest`)?`digest runs`:``,X(e,`search`)?`help articles`:``,X(e,`checkout`)?`orders`:``,X(e,`newsletter`)?`newsletter subscribers and issues`:``,e.auth===`email`?`accounts`:``].filter(Boolean).join(`, `)}.
575
569
  // No database_id: wrangler creates the database on first deploy, and the Worker applies its
576
570
  // own schema on its first query (\`migrate\`).
577
- ${G(` `,`d1_databases`,[`{ "binding": "DB", "database_name": "${N(e.name)}-db" }`])}`:``}${K(e,`usage`)?`
571
+ ${q(` `,`d1_databases`,[`{ "binding": "DB", "database_name": "${N(e.name)}-db" }`])}`:``}${X(e,`usage`)?`
578
572
  // Every API request is recorded here (worker/index.ts); /usage reads it back.
579
- "analytics_engine_datasets": [{ "binding": "USAGE", "dataset": "${q(e)}" }],`:``}${K(e,`live`)?`
580
- // Events for /ops: POST /api/events sends them, and the Worker's queue handler takes them in
581
- // batches of up to 100, or whatever arrived within a second.
582
- "queues": {
583
- ${G(` `,`producers`,[`{ "binding": "EVENTS", "queue": "${N(e.name)}-events" }`])}
584
- "consumers": [
585
- {
586
- "queue": "${N(e.name)}-events",
587
- "max_batch_size": 100,
588
- "max_batch_timeout": 1,
589
- },
590
- ],
591
- },`:``}${K(e,`import`)?`
573
+ "analytics_engine_datasets": [{ "binding": "USAGE", "dataset": "${Ee(e)}" }],`:``}${Te(e)}${X(e,`import`)?`
592
574
  // The CSV import runs as a Workflow (worker/import-job.ts); its progress is a room.
593
- "workflows": [{ "name": "import-job", "binding": "IMPORT_JOB", "class_name": "ImportJob" }],`:``}${X(e)?`
594
- // Workers AI, which the ${[r?`assistant`:``,K(e,`voice`)?`voice agent`:``,K(e,`search`)?`search page`:``].filter(Boolean).join(` and `)} call${[r,K(e,`voice`),K(e,`search`)].filter(Boolean).length>1?``:`s`}.
595
- "ai": { "binding": "AI" },`:``}${K(e,`search`)?`
575
+ "workflows": [{ "name": "import-job", "binding": "IMPORT_JOB", "class_name": "ImportJob" }],`:``}${ke(e)?`
576
+ // Workers AI, which the ${[r?`assistant`:``,X(e,`voice`)?`voice agent`:``,X(e,`search`)?`search page`:``].filter(Boolean).join(` and `)} call${[r,X(e,`voice`),X(e,`search`)].filter(Boolean).length>1?``:`s`}.
577
+ "ai": { "binding": "AI" },`:``}${X(e,`search`)?`
596
578
  // The help articles' embeddings (worker/search.ts). Create it once before deploying (README).
597
- ${G(` `,`vectorize`,[`{ "binding": "ARTICLES_INDEX", "index_name": "${N(e.name)}-articles" }`])}`:``}${K(e,`export`)?`
579
+ ${q(` `,`vectorize`,[`{ "binding": "ARTICLES_INDEX", "index_name": "${N(e.name)}-articles" }`])}`:``}${X(e,`export`)?`
598
580
  // Browser Run: renders pages to PDF/PNG for /api/export.
599
- "browser": { "binding": "BROWSER" },`:``}${i||K(e,`export`)?`
600
- // ${[i?`The Agents SDK`:``,K(e,`export`)?`Cloudflare's puppeteer`:``].filter(Boolean).join(` and `)} use${i&&K(e,`export`)?``:`s`} Node.js APIs.
581
+ "browser": { "binding": "BROWSER" },`:``}${i||X(e,`export`)?`
582
+ // ${[i?`The Agents SDK`:``,X(e,`export`)?`Cloudflare's puppeteer`:``].filter(Boolean).join(` and `)} use${i&&X(e,`export`)?``:`s`} Node.js APIs.
601
583
  "compatibility_flags": ["nodejs_compat"],`:``}
602
584
  // Add bindings here (KV, D1, R2, Durable Objects, Workers AI) and read them from the
603
585
  // \`env\` argument of the Worker's fetch handler.
604
586
  }
605
- `}function Te(e){let t=K(e,`digest`),n=e.auth===`email`,r=[...e.auth===`access`?[`Cloudflare Access (README). Until both are set, the Worker refuses every request.`]:[],...n?[`Sign-in links go out through Email Service (README). AUTH_FROM must be an address on a`,`domain you have onboarded; until it is set, sign-in fails with a clear error.`]:[],...t?[`The weekly digest (README): who gets it, who sends it, and the deployed app's URL,`,`which the browser opens. Until they are set, each run is recorded as skipped.`]:[]],i=[...e.auth===`access`?[`ACCESS_TEAM_DOMAIN`,`ACCESS_AUD`]:[],...n?[`AUTH_FROM`]:[],...t?[`DIGEST_TO`,`DIGEST_FROM`,`APP_URL`]:[]];if(i.length===0)return``;let a=i.map(e=>`"${e}": ""`),o=` "vars": { ${a.join(`, `)} },`,s=o.length<=100?o:` "vars": {\n${a.map(e=>` ${e},`).join(`
587
+ `}function we(e){let t=X(e,`digest`),n=X(e,`checkout`),r=X(e,`newsletter`),i=e.auth===`email`,a=[...e.auth===`access`?[`Cloudflare Access (README). Until both are set, the Worker refuses every request.`]:[],...i?[`Sign-in links go out through Email Service (README). AUTH_FROM must be an address on a`,`domain you have onboarded; until it is set, sign-in fails with a clear error.`]:[],...t?[`The weekly digest (README): who gets it, who sends it, and the deployed app's URL,`,`which the browser opens. Until they are set, each run is recorded as skipped.`]:[],...n?[`Receipts for paid orders go out through Email Service from RECEIPT_FROM, an address`,`on a domain you have onboarded (README). Until it is set, no receipt is sent.`]:[],...r?[`The newsletter sends through Amazon SES (README): the region your sending identity is`,`verified in, its From address, and the SNS topic SES reports bounces and complaints to.`]:[]],o=[...e.auth===`access`?[`ACCESS_TEAM_DOMAIN`,`ACCESS_AUD`]:[],...i?[`AUTH_FROM`]:[],...t?[`DIGEST_TO`,`DIGEST_FROM`,`APP_URL`]:[],...n?[`RECEIPT_FROM`]:[],...r?[`AWS_REGION`,`NEWSLETTER_FROM`,`SNS_TOPIC_ARN`]:[]];if(o.length===0)return``;let s=o.map(e=>`"${e}": ""`),c=` "vars": { ${s.join(`, `)} },`,l=c.length<=100?c:` "vars": {\n${s.map(e=>` ${e},`).join(`
606
588
  `)}\n },`;return`
607
- ${r.map(e=>` // ${e}`).join(`
608
- `)}${n||t?`
589
+ ${a.map(e=>` // ${e}`).join(`
590
+ `)}${i||t||n?`
609
591
  "send_email": [{ "name": "EMAIL" }],`:``}
610
- ${s}`}function K(e,t){return e.examples?.includes(t)??!1}function q(e){return`${N(e.name).replace(/[^a-z0-9_]/g,`_`).replace(/^[^a-z_]/,`_$&`)}_usage`}function J(e){return K(e,`files`)||K(e,`export`)||K(e,`publish`)||K(e,`digest`)||K(e,`search`)||e.auth===`email`}function Y(e){return K(e,`crud`)||K(e,`publish`)||K(e,`webhooks`)||K(e,`digest`)||K(e,`search`)||e.auth===`email`}function X(e){return Z(e)||K(e,`search`)}function Z(e){return K(e,`agent`)||K(e,`voice`)}function Ee(e){return K(e,`board`)||K(e,`notes`)||K(e,`import`)||K(e,`webhooks`)}function Q(e){return Z(e)||K(e,`import`)}function $(e){return e.runtime??(K(e,`agent`)?`react`:`preact`)}function De(e){let t=K(e,`import`),n=K(e,`files`),r=K(e,`usage`),i=K(e,`crud`),a=K(e,`live`),o=K(e,`publish`),s=K(e,`webhooks`),c=K(e,`digest`),l=K(e,`search`);return`import { defineApi, ${t||n||r||i||a||o||s||c||l?`endpoint, `:``}stream } from '@cascivo/app/api'
592
+ ${l}`}function Te(e){let t=[...X(e,`live`)?[{comment:[`Events for /ops: POST /api/events sends them, and the Worker's queue handler takes them in`,`batches of up to 100, or whatever arrived within a second.`],binding:`EVENTS`,queue:`${N(e.name)}-events`,consumer:[`"max_batch_size": 100`,`"max_batch_timeout": 1`]}]:[],...X(e,`newsletter`)?[{comment:[`Newsletter sends (worker/newsletter.ts): one message of 25 readers at a time, so SES`,`is called at a steady pace. A throttled message is retried after 30 seconds.`],binding:`NEWSLETTER`,queue:Y(e),consumer:[`"max_batch_size": 1`,`"max_concurrency": 1`,`"max_retries": 10`,`"retry_delay": 30`]}]:[]];return t.length===0?``:`
593
+ ${t.flatMap(e=>e.comment.map(e=>` // ${e}`)).join(`
594
+ `)}
595
+ "queues": {
596
+ ${q(` `,`producers`,t.map(e=>`{ "binding": "${e.binding}", "queue": "${e.queue}" }`))}
597
+ "consumers": [
598
+ ${t.map(e=>` {
599
+ "queue": "${e.queue}",
600
+ ${e.consumer.map(e=>` ${e},`).join(`
601
+ `)}
602
+ },`).join(`
603
+ `)}
604
+ ],
605
+ },`}function J(e){return X(e,`checkout`)&&e.auth===`email`}function Y(e){return`${N(e.name)}-newsletter`}function X(e,t){return e.examples?.includes(t)??!1}function Ee(e){return`${N(e.name).replace(/[^a-z0-9_]/g,`_`).replace(/^[^a-z_]/,`_$&`)}_usage`}function De(e){return X(e,`files`)||X(e,`export`)||X(e,`publish`)||X(e,`digest`)||X(e,`search`)||X(e,`checkout`)||X(e,`newsletter`)||e.auth===`email`}function Oe(e){return X(e,`crud`)||X(e,`publish`)||X(e,`webhooks`)||X(e,`digest`)||X(e,`search`)||X(e,`checkout`)||X(e,`newsletter`)||e.auth===`email`}function ke(e){return Z(e)||X(e,`search`)}function Z(e){return X(e,`agent`)||X(e,`voice`)}function Ae(e){return X(e,`board`)||X(e,`notes`)||X(e,`import`)||X(e,`webhooks`)||X(e,`checkout`)||X(e,`newsletter`)}function Q(e){return Z(e)||X(e,`import`)}function $(e){return e.runtime??(X(e,`agent`)?`react`:`preact`)}function je(e){let t=X(e,`import`),n=X(e,`files`),r=X(e,`usage`),i=X(e,`crud`),a=X(e,`live`),o=X(e,`publish`),s=X(e,`webhooks`),c=X(e,`digest`),l=X(e,`search`),u=X(e,`checkout`),d=X(e,`newsletter`),f=J(e);return`import { defineApi, ${t||n||r||i||a||o||s||c||l||u||d?`endpoint, `:``}stream } from '@cascivo/app/api'
611
606
  ${i?`import { parseTablePage, parseTableQuery } from '@cascivo/app/db'
612
607
  `:``}${n?`import { parseStoredFile } from '@cascivo/app/uploads'
613
608
  `:``}${t?`import { parseImportRequest, parseStarted } from './import-job'
@@ -618,6 +613,19 @@ ${i?`import { parseTablePage, parseTableQuery } from '@cascivo/app/db'
618
613
  `:``}${s?`import { parseDeliveries, parseTestResult } from './webhooks'
619
614
  `:``}${c?`import { parseDigestRun, parseDigestRuns } from './digest'
620
615
  `:``}${l?`import { parseIndexed, parseSearchQuery, parseSearchResult } from './search'
616
+ `:``}${f?`import { parseBilling, parseRedirect, parseSyncInput } from './billing'
617
+ `:``}${u?`import { parseCheckoutStarted, parseOrder } from './checkout'
618
+ `:``}${d?`import {
619
+ parseConfirmed,
620
+ parseEmailInput,
621
+ parseIssue,
622
+ parseIssueInput,
623
+ parseKeyInput,
624
+ parseOverview,
625
+ parsePreview,
626
+ parseSubscribed,
627
+ parseTokenInput,
628
+ } from './newsletter'
621
629
  `:``}
622
630
  /**
623
631
  * The contract between the browser and the Worker. Both import this file: the Worker serves
@@ -700,7 +708,61 @@ export const api = defineApi({
700
708
  output: parseSearchResult,
701
709
  }),
702
710
  // Embeds every article into the Vectorize index.
703
- indexArticles: endpoint({ method: 'POST', path: '/api/search/index', output: parseIndexed }),`:``}${i?`
711
+ indexArticles: endpoint({ method: 'POST', path: '/api/search/index', output: parseIndexed }),`:``}${u?`
712
+ // Opens a Stripe Checkout page for the product; the browser goes to its url.
713
+ startCheckout: endpoint({ method: 'POST', path: '/api/checkout', output: parseCheckoutStarted }),
714
+ // An order, checked with Stripe while it is pending (Stripe's webhook settles it too).
715
+ getOrder: endpoint({ method: 'GET', path: '/api/orders/:id', output: parseOrder }),`:``}${f?`
716
+ // The signed-in user's subscription (worker/billing.ts), and the ways to change it.
717
+ getBilling: endpoint({ method: 'GET', path: '/api/billing', output: parseBilling }),
718
+ startSubscription: endpoint({
719
+ method: 'POST',
720
+ path: '/api/billing/subscribe',
721
+ output: parseRedirect,
722
+ }),
723
+ syncBilling: endpoint({
724
+ method: 'POST',
725
+ path: '/api/billing/sync',
726
+ input: parseSyncInput,
727
+ output: parseBilling,
728
+ }),
729
+ openBillingPortal: endpoint({
730
+ method: 'POST',
731
+ path: '/api/billing/portal',
732
+ output: parseRedirect,
733
+ }),`:``}${d?`
734
+ // Signing up sends a confirmation link; only confirmed readers get issues.
735
+ subscribe: endpoint({
736
+ method: 'POST',
737
+ path: '/api/newsletter/subscribe',
738
+ input: parseEmailInput,
739
+ output: parseSubscribed,
740
+ }),
741
+ confirmSubscription: endpoint({
742
+ method: 'POST',
743
+ path: '/api/newsletter/confirm',
744
+ input: parseTokenInput,
745
+ output: parseConfirmed,
746
+ }),
747
+ // The composer's calls, each carrying NEWSLETTER_KEY.
748
+ newsletterOverview: endpoint({
749
+ method: 'POST',
750
+ path: '/api/newsletter/overview',
751
+ input: parseKeyInput,
752
+ output: parseOverview,
753
+ }),
754
+ previewIssue: endpoint({
755
+ method: 'POST',
756
+ path: '/api/newsletter/preview',
757
+ input: parseIssueInput,
758
+ output: parsePreview,
759
+ }),
760
+ sendIssue: endpoint({
761
+ method: 'POST',
762
+ path: '/api/newsletter/issues',
763
+ input: parseIssueInput,
764
+ output: parseIssue,
765
+ }),`:``}${i?`
704
766
  // One page of customers for DataTable's query (sort, search, filters, page).
705
767
  customers: endpoint({
706
768
  method: 'POST',
@@ -722,12 +784,12 @@ export const api = defineApi({
722
784
  }),
723
785
  deleteCustomer: endpoint({ method: 'DELETE', path: '/api/customers/:id', output: parseDeleted }),`:``}
724
786
  })
725
- `}function Oe(e){let t=Ee(e),n=K(e,`agent`),r=K(e,`import`),i=K(e,`files`),a=K(e,`export`),o=K(e,`usage`),s=K(e,`crud`),c=K(e,`live`),l=K(e,`voice`),u=K(e,`publish`),d=K(e,`webhooks`),f=K(e,`digest`),p=K(e,`search`),m=Y(e),h=Z(e),g=J(e),_=e.auth===`access`,v=e.auth===`email`,y=[..._?[`requireAccess`]:[],...g?[`clientIp`,`rateLimit`]:[]],b=t||h||i||a||_||c||g||u,x=h||i||a||_||g||d||u,S=[...r?[`job-`]:[],...d?[`webhooks$`]:[]];return`${o?`import type { AnalyticsDataset } from '@cascivo/app/analytics'
726
- `:``}${m?`import type { Database } from '@cascivo/app/db'
787
+ `}function Me(e){let t=Ae(e),n=X(e,`agent`),r=X(e,`import`),i=X(e,`files`),a=X(e,`export`),o=X(e,`usage`),s=X(e,`crud`),c=X(e,`live`),l=X(e,`voice`),u=X(e,`publish`),d=X(e,`webhooks`),f=X(e,`digest`),p=X(e,`search`),m=X(e,`checkout`),h=X(e,`newsletter`),g=J(e),_=Oe(e),v=Z(e),y=De(e),b=e.auth===`access`,x=e.auth===`email`,S=[...b?[`requireAccess`]:[],...y?[`clientIp`,`rateLimit`]:[]],C=t||v||i||a||b||c||y||u,w=v||i||a||b||y||d||u||m||h,T=[...r?[`job-`]:[],...d?[`webhooks$`]:[],...m?[`order-`]:[],...h?[`issue-`]:[]],E=[...d?[`/api/webhooks/`]:[],...m?[`/api/stripe/`]:[],...h?[`/api/sns/`]:[]],D=h?[`/api/newsletter/subscribe`,`/api/newsletter/confirm`,`/api/newsletter/unsubscribe`]:[],O=[...E,...D];return`${o?`import type { AnalyticsDataset } from '@cascivo/app/analytics'
788
+ `:``}${_?`import type { Database } from '@cascivo/app/db'
727
789
  `:``}${a?`import { handleExport } from '@cascivo/app/export'
728
790
  `:``}import { createHandler${u?`, HttpError`:``} } from '@cascivo/app/api'
729
- ${v?`import { handleAuth, requireUser } from '@cascivo/app/auth-server'
730
- `:``}${y.length>0||d?`import { ${[...y,`guardResponse`].sort().join(`, `)} } from '@cascivo/app/guard'\n${g?`import type { RateLimiter } from '@cascivo/app/guard'
791
+ ${x?`import { handleAuth, requireUser } from '@cascivo/app/auth-server'
792
+ `:``}${S.length>0||d?`import { ${[...S,`guardResponse`].sort().join(`, `)} } from '@cascivo/app/guard'\n${y?`import type { RateLimiter } from '@cascivo/app/guard'
731
793
  `:``}`:``}${r?`import { jobReporter } from '@cascivo/app/jobs-server'
732
794
  `:``}${i?`import { handleUploads, listUploads } from '@cascivo/app/uploads-server'
733
795
  import type { ImageResizer, UploadBucket } from '@cascivo/app/uploads-server'
@@ -737,7 +799,7 @@ import type { LiveBatch, LiveQueue } from '@cascivo/app/live-server'
737
799
  import type { RoomNamespace } from '@cascivo/app/sync-server'
738
800
  `:``}${a?`import puppeteer from '@cloudflare/puppeteer'
739
801
  import type { BrowserWorker } from '@cloudflare/puppeteer'
740
- `:``}${h?`import { routeAgentRequest } from 'agents'
802
+ `:``}${v?`import { routeAgentRequest } from 'agents'
741
803
  `:``}import { api, TICKS_PER_STREAM } from '../src/api'
742
804
  import type { Tick } from '../src/api'
743
805
  ${r?`import { importJob } from '../src/import-job'
@@ -745,7 +807,7 @@ ${r?`import { importJob } from '../src/import-job'
745
807
  `:``}${o?`import { usageMetrics } from '../src/usage'
746
808
  import { usageReport } from './usage'
747
809
  `:``}${c?`import { OPS_ROOM, ops } from '../src/ops'
748
- `:``}${v?`import { sendSignInLink } from './auth'
810
+ `:``}${x?`import { sendSignInLink } from './auth'
749
811
  import type { SignInSender } from './auth'
750
812
  `:``}${s?`import * as customerStore from './customers'
751
813
  `:``}${u?`import * as pageStore from './pages'\nimport { renderPageHtml } from './page-html'\nimport type { Assets } from './page-html'\n${a?`import { pagePreview } from './page-preview'
@@ -753,7 +815,14 @@ import type { SignInSender } from './auth'
753
815
  import { DELIVERIES_ROOM } from '../src/webhooks'
754
816
  `:``}${f?`import * as digestJob from './digest'
755
817
  import type { DigestSender } from './digest'
756
- `:``}${p?`import * as articleSearch from './search'\nimport type { ${h?``:`Embedder, `}VectorIndex } from './search'\n`:``}${t?`
818
+ `:``}${p?`import * as articleSearch from './search'\nimport type { ${v?``:`Embedder, `}VectorIndex } from './search'\n`:``}${g?`import * as billingStore from './billing'
819
+ `:``}${m?`import * as orderStore from './checkout'
820
+ import type { ReceiptSender } from './checkout'
821
+ import { ORDER_ID, orderRoom } from '../src/checkout'
822
+ `:``}${h?`import * as newsletterStore from './newsletter'
823
+ import type { NewsletterBatch, NewsletterQueue } from './newsletter'
824
+ import { ISSUE_ID, issueRoom } from '../src/newsletter'
825
+ `:``}${t?`
757
826
  // The Durable Object class behind every room. wrangler.jsonc binds it as ROOMS, and it must be
758
827
  // exported from the Worker's main module.
759
828
  export { SyncRoom } from '@cascivo/app/sync-server'
@@ -774,7 +843,7 @@ export { ImportJob } from './import-job'
774
843
  * Add bindings (KV, D1, R2, Durable Objects, Workers AI) in wrangler.jsonc and type them
775
844
  * here; every handler receives them as \`env\`.
776
845
  */
777
- ${t||h||i||a||o||m||_||c||v||d||f||p?`export interface Env {${h?`
846
+ ${t||v||i||a||o||_||b||c||x||d||f||p||m||h?`export interface Env {${v?`
778
847
  /** Workers AI, bound in wrangler.jsonc. */
779
848
  AI: Ai`:p?`
780
849
  /** Workers AI, bound in wrangler.jsonc. */
@@ -788,37 +857,58 @@ ${t||h||i||a||o||m||_||c||v||d||f||p?`export interface Env {${h?`
788
857
  USAGE: AnalyticsDataset
789
858
  /** Secrets for reading Analytics Engine back (see README). */
790
859
  CF_ACCOUNT_ID?: string
791
- CF_API_TOKEN?: string`:``}${m?`
860
+ CF_API_TOKEN?: string`:``}${_?`
792
861
  DB: Database`:``}${u?`
793
862
  ASSETS: Assets`:``}${c?`
794
863
  LIVE: RoomNamespace<unknown>
795
- EVENTS: LiveQueue`:``}${g?`
796
- LIMITER: RateLimiter`:``}${_?`
864
+ EVENTS: LiveQueue`:``}${y?`
865
+ LIMITER: RateLimiter`:``}${b?`
797
866
  /** Set in wrangler.jsonc (see README). */
798
867
  ACCESS_TEAM_DOMAIN: string
799
868
  ACCESS_AUD: string`:``}${d?`
800
869
  /** The webhook signing secret: \`wrangler secret put WEBHOOK_SECRET\` (.dev.vars locally). */
801
- WEBHOOK_SECRET: string`:``}${v||f?`\n EMAIL: ${[v?`SignInSender`:``,f?`DigestSender`:``].filter(Boolean).join(` & `)}`:``}${v?`
870
+ WEBHOOK_SECRET: string`:``}${x||f||m?`\n EMAIL: ${[x?`SignInSender`:``,f?`DigestSender`:``,m?`ReceiptSender`:``].filter(Boolean).join(` & `)}`:``}${x?`
802
871
  /** The From address of sign-in emails, set in wrangler.jsonc. */
803
872
  AUTH_FROM: string`:``}${f?`
804
873
  /** The weekly digest (worker/digest.ts), set in wrangler.jsonc. */
805
874
  DIGEST_TO: string
806
875
  DIGEST_FROM: string
807
- APP_URL: string`:``}
876
+ APP_URL: string`:``}${m?`
877
+ /** The From address of receipts, set in wrangler.jsonc. */
878
+ RECEIPT_FROM: string
879
+ /** Stripe secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
880
+ STRIPE_SECRET_KEY?: string
881
+ STRIPE_WEBHOOK_SECRET?: string`:``}${h?`
882
+ NEWSLETTER: NewsletterQueue
883
+ /** The newsletter (worker/newsletter.ts), set in wrangler.jsonc. */
884
+ AWS_REGION: string
885
+ NEWSLETTER_FROM: string
886
+ SNS_TOPIC_ARN: string
887
+ /** Secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
888
+ AWS_ACCESS_KEY_ID?: string
889
+ AWS_SECRET_ACCESS_KEY?: string
890
+ NEWSLETTER_KEY?: string`:``}
808
891
  }`:`// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- bindings are added as members
809
892
  export interface Env {}`}
810
893
 
811
894
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
812
- ${g?`
895
+ ${y?`
813
896
  /**
814
897
  * Requests that each count against LIMITER:
815
- ${[v?`a sign-in email`:``,f?`a digest sent now (it starts a browser)`:``,p?`indexing the articles (it embeds every one)`:``,i?`a new upload (not each part of one)`:``,a?`an export`:``,u?`a published page`:``].filter(Boolean).map(e=>` * - ${e}`).join(`
898
+ ${[x?`a sign-in email`:``,f?`a digest sent now (it starts a browser)`:``,p?`indexing the articles (it embeds every one)`:``,m?`a checkout started (it creates a Stripe session and an order)`:``,g?`a subscription checkout or billing portal opened`:``,h?`a newsletter sign-up (it sends an email), and each use of the newsletter key`:``,i?`a new upload (not each part of one)`:``,a?`an export`:``,u?`a published page`:``].filter(Boolean).map(e=>` * - ${e}`).join(`
816
899
  `)}
817
900
  */
818
901
  function countsAgainstLimit(request: Request): boolean {
819
902
  const url = new URL(request.url)${p?`
820
903
  if (url.pathname === '/api/search/index') return request.method === 'POST'`:``}${f?`
821
- if (url.pathname === '/api/digest/run') return request.method === 'POST'`:``}${v?`
904
+ if (url.pathname === '/api/digest/run') return request.method === 'POST'`:``}${m?`
905
+ if (url.pathname === '/api/checkout') return request.method === 'POST'`:``}${g?`
906
+ if (url.pathname === '/api/billing/subscribe' || url.pathname === '/api/billing/portal') {
907
+ return request.method === 'POST'
908
+ }`:``}${h?`
909
+ if (/^\\/api\\/newsletter\\/(subscribe|overview|preview|issues)$/.test(url.pathname)) {
910
+ return request.method === 'POST'
911
+ }`:``}${x?`
822
912
  if (url.pathname === '/api/auth/start') return request.method === 'POST'`:``}${u?`
823
913
  if (url.pathname === '/api/pages') return request.method === 'POST'`:``}${i?`
824
914
  if (url.pathname === uploads.path) {
@@ -865,7 +955,24 @@ const handleApi = createHandler<typeof api, Env>(api, {
865
955
  listDeliveries: ({ env }) => webhookStore.listDeliveries(env.DB),
866
956
  sendTestDelivery: ({ request, env }) => webhookStore.sendTestDelivery(request.url, env),`:``}${p?`
867
957
  search: ({ body, env }) => articleSearch.search(env, body.q),
868
- indexArticles: ({ env }) => articleSearch.indexArticles(env),`:``}${f?`
958
+ indexArticles: ({ env }) => articleSearch.indexArticles(env),`:``}${m?`
959
+ startCheckout: ({ request, env }) => orderStore.startCheckout(env, new URL(request.url).origin),
960
+ getOrder: ({ params, request, env }) =>
961
+ orderStore.getOrder(env, params.id, new URL(request.url).origin),`:``}${g?`
962
+ getBilling: ({ request, env }) => billingStore.getBilling(env, request),
963
+ startSubscription: ({ request, env }) =>
964
+ billingStore.startSubscription(env, request, new URL(request.url).origin),
965
+ syncBilling: ({ body, request, env }) => billingStore.syncBilling(env, request, body.sessionId),
966
+ openBillingPortal: ({ request, env }) =>
967
+ billingStore.openPortal(env, request, new URL(request.url).origin),`:``}${h?`
968
+ subscribe: ({ body, request, env }) =>
969
+ newsletterStore.subscribe(env, body.email, new URL(request.url).origin),
970
+ confirmSubscription: ({ body, env }) => newsletterStore.confirm(env, body.token),
971
+ newsletterOverview: ({ body, env }) => newsletterStore.overview(env, body.key),
972
+ previewIssue: ({ body, request, env }) =>
973
+ newsletterStore.preview(env, body, new URL(request.url).origin),
974
+ sendIssue: ({ body, request, env }) =>
975
+ newsletterStore.sendIssue(env, body, new URL(request.url).origin),`:``}${f?`
869
976
  digestRuns: ({ env }) => digestJob.listRuns(env.DB),
870
977
  runDigest: ({ request, env }) =>
871
978
  digestJob.runDigest(
@@ -897,9 +1004,9 @@ async function handleAndRecord(request: Request, env: Env): Promise<Response> {
897
1004
  return response
898
1005
  }
899
1006
  `:``}
900
- // wrangler.jsonc routes only ${h?`/api/* and /agents/*`:`/api/*`} here; everything else is a static asset or index.html.
1007
+ // wrangler.jsonc routes only ${v?`/api/* and /agents/*`:`/api/*`} here; everything else is a static asset or index.html.
901
1008
  export default {
902
- ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?``:` | Response`} {${_?`
1009
+ ${C?` ${w?`async `:``}fetch(request: Request, env: Env): Promise<Response>${w?``:` | Response`} {${b?`
903
1010
  // Cloudflare Access is in front of the app (README); this refuses a request that came
904
1011
  // around it (straight to *.workers.dev, say). \`vite dev\` has no Access, so not there.
905
1012
  if (!import.meta.env.DEV) {
@@ -911,37 +1018,37 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
911
1018
  } catch (error) {
912
1019
  return guardResponse(error)
913
1020
  }
914
- }`:``}${g?`
1021
+ }`:``}${y?`
915
1022
  if (countsAgainstLimit(request)) {
916
1023
  try {
917
1024
  await rateLimit(env.LIMITER, clientIp(request))
918
1025
  } catch (error) {
919
1026
  return guardResponse(error)
920
1027
  }
921
- }`:``}${v?`
1028
+ }`:``}${x?`
922
1029
  // Sign-in links and sessions: /api/auth/* is answered here (worker/auth.ts sends mail).
923
1030
  const signIn = await handleAuth(env.DB, {
924
1031
  sendLink: (email, url) => sendSignInLink(env.EMAIL, env.AUTH_FROM, email, url),
925
1032
  exposeLink: import.meta.env.DEV,
926
1033
  })(request)
927
1034
  if (signIn) return signIn
928
- // Every other API write needs a signed-in user; reads stay public.${d?`
929
- // Webhooks carry a signature instead of a session, and are checked by it.`:``}
930
- if (${d?`
1035
+ // Every other API write needs a signed-in user; reads stay public.${E.length>0?`
1036
+ // Webhooks carry a signature instead of a session, and are checked by it.`:``}${D.length>0?`
1037
+ // Newsletter readers sign up, confirm and unsubscribe without an account.`:``}
1038
+ if (${O.length>0?`
931
1039
  request.method !== 'GET' &&
932
- request.method !== 'HEAD' &&
933
- !new URL(request.url).pathname.startsWith('/api/webhooks/')
1040
+ request.method !== 'HEAD' &&${O.map(e=>`\n !new URL(request.url).pathname.startsWith('${e}')`).join(` &&`)}
934
1041
  `:`request.method !== 'GET' && request.method !== 'HEAD'`}) {
935
1042
  try {
936
1043
  await requireUser(env.DB, request)
937
1044
  } catch (error) {
938
1045
  return guardResponse(error)
939
1046
  }
940
- }`:``}${h?`
1047
+ }`:``}${v?`
941
1048
  // /agents/<agent>/<conversation>: the WebSocket ${[n?`useAgent()`:``,l?`VoiceClient`:``].filter(Boolean).join(` or `)} opens.
942
1049
  const agent = await routeAgentRequest(request, env)
943
1050
  if (agent) return agent`:``}${i?`
944
- // Uploads into R2 and the files they stored.${_?``:` Anyone can upload: see README.`}
1051
+ // Uploads into R2 and the files they stored.${b?``:` Anyone can upload: see README.`}
945
1052
  const upload = await handleUploads(uploads, env.FILES, { images: env.IMAGES })(request)
946
1053
  if (upload) return upload`:``}${a?`
947
1054
  // /api/export?page=/report&format=pdf — each export starts a browser.
@@ -988,11 +1095,45 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
988
1095
  // New deliveries, pushed to the /webhooks page: it may watch, never write.
989
1096
  if (path === '/api/webhooks/live') {
990
1097
  return roomResponse(request, env.ROOMS, DELIVERIES_ROOM, { readOnly: true })
991
- }`:``}${K(e,`board`)||K(e,`notes`)?`
992
- const room = /^\\/api\\/rooms\\/([^/]+)$/.exec(new URL(request.url).pathname)${S.length>0?`
1098
+ }`:``}${m?`
1099
+ const checkoutPath = new URL(request.url).pathname
1100
+ // Stripe's webhook (worker/checkout.ts); a bad signature is a 401.
1101
+ if (checkoutPath === orderStore.STRIPE_WEBHOOK_PATH && request.method === 'POST') {
1102
+ try {
1103
+ return await orderStore.receiveStripe(request, env${g?`, (id) =>
1104
+ billingStore.syncSubscription(env, id),
1105
+ `:``})
1106
+ } catch (error) {
1107
+ return guardResponse(error)
1108
+ }
1109
+ }
1110
+ // An order's page watches its room for what Stripe reports: it may watch, never write.
1111
+ const orderLive = /^\\/api\\/orders\\/([^/]+)\\/live$/.exec(checkoutPath)
1112
+ if (orderLive && ORDER_ID.test(orderLive[1]!)) {
1113
+ return roomResponse(request, env.ROOMS, orderRoom(orderLive[1]!), { readOnly: true })
1114
+ }`:``}${h?`
1115
+ const newsletterPath = new URL(request.url).pathname
1116
+ // One-click unsubscribe (RFC 8058) and the unsubscribe page's button.
1117
+ if (newsletterPath === '/api/newsletter/unsubscribe' && request.method === 'POST') {
1118
+ return newsletterStore.unsubscribe(request, env)
1119
+ }
1120
+ // SES bounces and complaints, delivered by SNS (worker/newsletter.ts); verified by signature.
1121
+ if (newsletterPath === '/api/sns/ses' && request.method === 'POST') {
1122
+ try {
1123
+ return await newsletterStore.receiveFeedback(request, env)
1124
+ } catch (error) {
1125
+ return guardResponse(error)
1126
+ }
1127
+ }
1128
+ // An issue's sending progress, for the composer: it may watch, never write.
1129
+ const issueLive = /^\\/api\\/newsletter\\/issues\\/([^/]+)\\/live$/.exec(newsletterPath)
1130
+ if (issueLive && ISSUE_ID.test(issueLive[1]!)) {
1131
+ return roomResponse(request, env.ROOMS, issueRoom(issueLive[1]!), { readOnly: true })
1132
+ }`:``}${X(e,`board`)||X(e,`notes`)?`
1133
+ const room = /^\\/api\\/rooms\\/([^/]+)$/.exec(new URL(request.url).pathname)${T.length>0?`
993
1134
  // Rooms only the server writes are watched read-only at their own routes, never opened
994
- // here: ${[r?`a job's progress`:``,d?`webhook deliveries`:``].filter(Boolean).join(`, `)}.
995
- if (room && !/^(${S.join(`|`)})/.test(room[1]!)) {
1135
+ // here: ${[r?`a job's progress`:``,d?`webhook deliveries`:``,m?`orders`:``,h?`newsletter issues`:``].filter(Boolean).join(`, `)}.
1136
+ if (room && !/^(${T.join(`|`)})/.test(room[1]!)) {
996
1137
  return roomResponse(request, env.ROOMS, room[1]!)
997
1138
  }`:`
998
1139
  if (room) return roomResponse(request, env.ROOMS, room[1]!)`}`:``}${r?`
@@ -1006,7 +1147,19 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
1006
1147
  // The Cron Trigger in wrangler.jsonc: the weekly digest, recorded whatever happens.
1007
1148
  async scheduled(_event: unknown, env: Env): Promise<void> {
1008
1149
  await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')
1009
- },`:``}${c?`
1150
+ },`:``}${c&&h?`
1151
+ // Two queues, one handler: each batch says which queue it came from. A throw retries it.
1152
+ async queue(batch: LiveBatch & NewsletterBatch & { queue: string }, env: Env): Promise<void> {
1153
+ // NEWSLETTER: one message of readers at a time, through SES (worker/newsletter.ts).
1154
+ if (batch.queue === '${Y(e)}') return newsletterStore.deliver(env, batch)
1155
+ // EVENTS: into the dashboard's room.
1156
+ await recordLive(
1157
+ ops,
1158
+ env.LIVE,
1159
+ OPS_ROOM,
1160
+ batch.messages.map((message) => message.body),
1161
+ )
1162
+ },`:c?`
1010
1163
  // The EVENTS queue, a batch at a time, into the dashboard's room. A throw retries the batch.
1011
1164
  async queue(batch: LiveBatch, env: Env): Promise<void> {
1012
1165
  await recordLive(
@@ -1015,9 +1168,14 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
1015
1168
  OPS_ROOM,
1016
1169
  batch.messages.map((message) => message.body),
1017
1170
  )
1171
+ },`:h?`
1172
+ // The NEWSLETTER queue: one message of readers at a time, through SES
1173
+ // (worker/newsletter.ts). A throw retries the message; readers already sent are skipped.
1174
+ async queue(batch: NewsletterBatch, env: Env): Promise<void> {
1175
+ await newsletterStore.deliver(env, batch)
1018
1176
  },`:``}
1019
1177
  }
1020
- `}function ke(){return`import { createClient } from '@cascivo/app/api'
1178
+ `}function Ne(){return`import { createClient } from '@cascivo/app/api'
1021
1179
  import { signal } from '@cascivo/react'
1022
1180
  import { api } from './api'
1023
1181
  import type { Tick } from './api'
@@ -1060,7 +1218,7 @@ export async function connect(): Promise<void> {
1060
1218
  export function disconnect(): void {
1061
1219
  controller?.abort()
1062
1220
  }
1063
- `}function Ae(){return`import {
1221
+ `}function Pe(){return`import {
1064
1222
  Badge,
1065
1223
  Button,
1066
1224
  Card,
@@ -1115,18 +1273,18 @@ export function LiveCard() {
1115
1273
  </Card>
1116
1274
  )
1117
1275
  }
1118
- `}function je(e,t){return t===0?`/`:`/${e.key}`}function Me(e,t){return t===0?`index.tsx`:`${e.key}.tsx`}function Ne(){return`import { createRouter } from '@cascivo/app'
1276
+ `}function Fe(e,t){return t===0?`/`:`/${e.key}`}function Ie(e,t){return t===0?`index.tsx`:`${e.key}.tsx`}function Le(){return`import { createRouter } from '@cascivo/app'
1119
1277
  import { notFound, routes } from './routes.gen'
1120
1278
 
1121
1279
  // \`routes.gen.ts\` is written by \`cascivoRoutes()\` (vite.config.ts) from \`src/routes/\`:
1122
1280
  // add, rename or delete a file there and the route table follows.
1123
1281
  export const router = createRouter({ routes, notFound })
1124
- `}function Pe(e,t){let n=e.map((e,t)=>({label:e.label,href:je(e,t)}));K(t,`agent`)&&n.push({label:`Assistant`,href:`/assistant`}),K(t,`board`)&&n.push({label:`Board`,href:`/board`}),K(t,`notes`)&&n.push({label:`Notes`,href:`/notes`}),K(t,`import`)&&n.push({label:`Import`,href:`/import`}),K(t,`files`)&&n.push({label:`Files`,href:`/files`}),K(t,`export`)&&n.push({label:`Report`,href:`/report`}),K(t,`usage`)&&n.push({label:`Usage`,href:`/usage`}),K(t,`crud`)&&n.push({label:`Customers`,href:`/customers`}),K(t,`live`)&&n.push({label:`Ops`,href:`/ops`}),K(t,`voice`)&&n.push({label:`Voice`,href:`/voice`}),K(t,`publish`)&&n.push({label:`Publish`,href:`/publish`}),K(t,`webhooks`)&&n.push({label:`Webhooks`,href:`/webhooks`}),K(t,`digest`)&&n.push({label:`Digest`,href:`/digest`}),K(t,`search`)&&n.push({label:`Search`,href:`/search`}),t.auth===`email`&&n.push({label:`Account`,href:`/account`});let r=n.map(e=>` {
1282
+ `}function Re(e,t){let n=e.map((e,t)=>({label:e.label,href:Fe(e,t)}));X(t,`agent`)&&n.push({label:`Assistant`,href:`/assistant`}),X(t,`board`)&&n.push({label:`Board`,href:`/board`}),X(t,`notes`)&&n.push({label:`Notes`,href:`/notes`}),X(t,`import`)&&n.push({label:`Import`,href:`/import`}),X(t,`files`)&&n.push({label:`Files`,href:`/files`}),X(t,`export`)&&n.push({label:`Report`,href:`/report`}),X(t,`usage`)&&n.push({label:`Usage`,href:`/usage`}),X(t,`crud`)&&n.push({label:`Customers`,href:`/customers`}),X(t,`live`)&&n.push({label:`Ops`,href:`/ops`}),X(t,`voice`)&&n.push({label:`Voice`,href:`/voice`}),X(t,`publish`)&&n.push({label:`Publish`,href:`/publish`}),X(t,`webhooks`)&&n.push({label:`Webhooks`,href:`/webhooks`}),X(t,`digest`)&&n.push({label:`Digest`,href:`/digest`}),X(t,`search`)&&n.push({label:`Search`,href:`/search`}),X(t,`checkout`)&&n.push({label:`Checkout`,href:`/checkout`}),J(t)&&n.push({label:`Billing`,href:`/billing`}),X(t,`newsletter`)&&(n.push({label:`Newsletter`,href:`/newsletter`}),n.push({label:`Send newsletter`,href:`/newsletter/send`})),t.auth===`email`&&n.push({label:`Account`,href:`/account`});let r=n.map(e=>` {
1125
1283
  label: '${e.label.replace(/'/g,`\\'`)}',
1126
1284
  href: '${e.href}',
1127
1285
  active: path === '${e.href}',
1128
1286
  },`).join(`
1129
- `),i=K(t,`export`);return`import { RouterView } from '@cascivo/app'
1287
+ `),i=X(t,`export`);return`import { RouterView } from '@cascivo/app'
1130
1288
  ${i?`import { isExporting } from '@cascivo/app/export'
1131
1289
  `:``}import { Spinner, useSignals, type SideNavItem } from '@cascivo/react'
1132
1290
  import { router } from './router'
@@ -1157,7 +1315,7 @@ ${i?`
1157
1315
  </Shell>
1158
1316
  )
1159
1317
  }
1160
- `}function Fe(){return`import React from 'react'
1318
+ `}function ze(){return`import React from 'react'
1161
1319
  import ReactDOM from 'react-dom/client'
1162
1320
  import { setLinkComponent } from '@cascivo/react'
1163
1321
  import App from './App'
@@ -1174,7 +1332,7 @@ if (root) {
1174
1332
  </React.StrictMode>,
1175
1333
  )
1176
1334
  }
1177
- `}function Ie(e){return`import { Flex, Heading, Text } from '@cascivo/react'
1335
+ `}function Be(e){return`import { Flex, Heading, Text } from '@cascivo/react'
1178
1336
  import { LiveCard } from '../LiveCard'
1179
1337
 
1180
1338
  export default function ${e.component}() {
@@ -1190,12 +1348,12 @@ export default function ${e.component}() {
1190
1348
  </Flex>
1191
1349
  )
1192
1350
  }
1193
- `}function Le(e,t){return L(e).replace(`export function ${e.component}()`,`export default function ${e.component}()`).replace(`src/sections/${e.component}.tsx`,`src/routes/${Me(e,t)}`)}function Re(){return`import { EmptyState } from '@cascivo/react'
1351
+ `}function Ve(e,t){return L(e).replace(`export function ${e.component}()`,`export default function ${e.component}()`).replace(`src/sections/${e.component}.tsx`,`src/routes/${Ie(e,t)}`)}function He(){return`import { EmptyState } from '@cascivo/react'
1194
1352
 
1195
1353
  export default function NotFound() {
1196
1354
  return <EmptyState title="Page not found" description="There is nothing at this address." />
1197
1355
  }
1198
- `}function ze(){return`import { connectRoom } from '@cascivo/app/sync'
1356
+ `}function Ue(){return`import { connectRoom } from '@cascivo/app/sync'
1199
1357
 
1200
1358
  export interface Note {
1201
1359
  text: string
@@ -1246,7 +1404,7 @@ export function addNote(): void {
1246
1404
  const offset = Object.keys(notes.value).length * 24
1247
1405
  notes.set(crypto.randomUUID(), { text: '', x: 24 + (offset % 240), y: 24 + (offset % 160) })
1248
1406
  }
1249
- `}function Be(){return`import type { PointerEvent } from 'react'
1407
+ `}function We(){return`import type { PointerEvent } from 'react'
1250
1408
  import {
1251
1409
  Badge,
1252
1410
  Button,
@@ -1370,7 +1528,7 @@ export default function Board() {
1370
1528
  </Flex>
1371
1529
  )
1372
1530
  }
1373
- `}function Ve(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1531
+ `}function Ge(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1374
1532
  @layer cascivo.example {
1375
1533
  .board {
1376
1534
  position: relative;
@@ -1410,7 +1568,7 @@ export default function Board() {
1410
1568
  pointer-events: none;
1411
1569
  }
1412
1570
  }
1413
- `}function He(){return`import type { ViewConfig } from '@cascivo/render'
1571
+ `}function Ke(){return`import type { ViewConfig } from '@cascivo/render'
1414
1572
  import { validateView } from '@cascivo/render/validate'
1415
1573
 
1416
1574
  /**
@@ -1436,7 +1594,7 @@ export function checkView(title: unknown, view: unknown): CheckedView {
1436
1594
  // validateView has checked the whole shape, so the cast states a proven fact.
1437
1595
  return { title, view: view as ViewConfig }
1438
1596
  }
1439
- `}function Ue(){return`import { AIChatAgent } from '@cloudflare/ai-chat'
1597
+ `}function qe(){return`import { AIChatAgent } from '@cloudflare/ai-chat'
1440
1598
  import { convertToModelMessages, stepCountIs, streamText, tool } from 'ai'
1441
1599
  import type { LanguageModel } from 'ai'
1442
1600
  import { createWorkersAI } from 'workers-ai-provider'
@@ -1491,7 +1649,7 @@ export class Assistant extends AIChatAgent<Env> {
1491
1649
  return result.toUIMessageStreamResponse()
1492
1650
  }
1493
1651
  }
1494
- `}function We(){return`import { simulateReadableStream } from 'ai'
1652
+ `}function Je(){return`import { simulateReadableStream } from 'ai'
1495
1653
  import { MockLanguageModelV4 } from 'ai/test'
1496
1654
 
1497
1655
  /**
@@ -1593,7 +1751,7 @@ export function scriptedModel(): MockLanguageModelV4 {
1593
1751
  },
1594
1752
  })
1595
1753
  }
1596
- `}function Ge(){return`import type { FormEvent } from 'react'
1754
+ `}function Ye(){return`import type { FormEvent } from 'react'
1597
1755
  import { CascivoView } from '@cascivo/render'
1598
1756
  import {
1599
1757
  Badge,
@@ -1723,7 +1881,7 @@ export default function Assistant() {
1723
1881
  </Flex>
1724
1882
  )
1725
1883
  }
1726
- `}function Ke(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1884
+ `}function Xe(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1727
1885
  @layer cascivo.example {
1728
1886
  .messages {
1729
1887
  display: flex;
@@ -1756,7 +1914,7 @@ export default function Assistant() {
1756
1914
  flex: 1;
1757
1915
  }
1758
1916
  }
1759
- `}function qe(){return`import { connectRoom } from '@cascivo/app/sync'
1917
+ `}function Ze(){return`import { connectRoom } from '@cascivo/app/sync'
1760
1918
  import { indexedDBDriver } from '@cascivo/storage'
1761
1919
 
1762
1920
  export interface Note {
@@ -1817,7 +1975,7 @@ export function addNote(): void {
1817
1975
  export function editNote(id: string, note: Note, change: Partial<Note>): void {
1818
1976
  notes.set(id, { ...note, ...change, updatedAt: Date.now() })
1819
1977
  }
1820
- `}function Je(){return`import {
1978
+ `}function Qe(){return`import {
1821
1979
  Badge,
1822
1980
  Button,
1823
1981
  Card,
@@ -1893,7 +2051,7 @@ export default function Notes() {
1893
2051
  </Flex>
1894
2052
  )
1895
2053
  }
1896
- `}function Ye(){return`import { defineJob } from '@cascivo/app/jobs'
2054
+ `}function $e(){return`import { defineJob } from '@cascivo/app/jobs'
1897
2055
 
1898
2056
  /**
1899
2057
  * The CSV import job, shared by the Worker (which runs it) and the page (which watches it).
@@ -1953,7 +2111,7 @@ export function parseStarted(raw: unknown): { id: string } {
1953
2111
  }
1954
2112
  throw new Error('Malformed job id')
1955
2113
  }
1956
- `}function Xe(){return`import { createClient } from '@cascivo/app/api'
2114
+ `}function et(){return`import { createClient } from '@cascivo/app/api'
1957
2115
  import { watchJob } from '@cascivo/app/jobs'
1958
2116
  import type { WatchedJob } from '@cascivo/app/jobs'
1959
2117
  import { signal } from '@cascivo/react'
@@ -1989,7 +2147,7 @@ export async function startImport(csv: string): Promise<void> {
1989
2147
  starting.value = false
1990
2148
  }
1991
2149
  }
1992
- `}function Ze(){return`import { jobReporter } from '@cascivo/app/jobs-server'
2150
+ `}function tt(){return`import { jobReporter } from '@cascivo/app/jobs-server'
1993
2151
  import { WorkflowEntrypoint } from 'cloudflare:workers'
1994
2152
  import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'
1995
2153
  import { importJob } from '../src/import-job'
@@ -2075,7 +2233,7 @@ export class ImportJob extends WorkflowEntrypoint<Env, { csv: string }> {
2075
2233
  }
2076
2234
  }
2077
2235
  }
2078
- `}function Qe(){return`import type { Step } from '@cascivo/react'
2236
+ `}function nt(){return`import type { Step } from '@cascivo/react'
2079
2237
  import {
2080
2238
  Alert,
2081
2239
  Button,
@@ -2187,7 +2345,7 @@ export default function Import() {
2187
2345
  </Flex>
2188
2346
  )
2189
2347
  }
2190
- `}function $e(){return`import { defineUploads } from '@cascivo/app/uploads'
2348
+ `}function rt(){return`import { defineUploads } from '@cascivo/app/uploads'
2191
2349
 
2192
2350
  /**
2193
2351
  * What the app accepts, shared by the page (which checks first) and the Worker (which
@@ -2199,7 +2357,7 @@ export const uploads = defineUploads({
2199
2357
  maxBytes: 50 * 1024 * 1024,
2200
2358
  types: ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf'],
2201
2359
  })
2202
- `}function et(){return`import { createClient } from '@cascivo/app/api'
2360
+ `}function it(){return`import { createClient } from '@cascivo/app/api'
2203
2361
  import { startUpload } from '@cascivo/app/uploads'
2204
2362
  import type { StoredFile, Upload } from '@cascivo/app/uploads'
2205
2363
  import { signal } from '@cascivo/react'
@@ -2236,7 +2394,7 @@ export function removeUpload(id: string): void {
2236
2394
  upload?.abort()
2237
2395
  inFlight.value = inFlight.value.filter((u) => u.id !== id)
2238
2396
  }
2239
- `}function tt(){return`import type { UploaderFile } from '@cascivo/react'
2397
+ `}function at(){return`import type { UploaderFile } from '@cascivo/react'
2240
2398
  import {
2241
2399
  Card,
2242
2400
  CardContent,
@@ -2317,7 +2475,7 @@ export default function Files() {
2317
2475
  </Flex>
2318
2476
  )
2319
2477
  }
2320
- `}function nt(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
2478
+ `}function ot(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
2321
2479
  @layer cascivo.example {
2322
2480
  .grid {
2323
2481
  display: grid;
@@ -2333,7 +2491,7 @@ export default function Files() {
2333
2491
  background: var(--cascivo-color-bg-subtle);
2334
2492
  }
2335
2493
  }
2336
- `}function rt(){return`import { exportUrl, isExporting } from '@cascivo/app/export'
2494
+ `}function st(){return`import { exportUrl, isExporting } from '@cascivo/app/export'
2337
2495
  import type { Column } from '@cascivo/react'
2338
2496
  import { Badge, Button, Card, CardContent, DataTable, Flex, Heading, Text } from '@cascivo/react'
2339
2497
 
@@ -2401,7 +2559,7 @@ export default function Report() {
2401
2559
  </Flex>
2402
2560
  )
2403
2561
  }
2404
- `}function it(){return`import { defineMetrics } from '@cascivo/app/analytics'
2562
+ `}function ct(){return`import { defineMetrics } from '@cascivo/app/analytics'
2405
2563
 
2406
2564
  /**
2407
2565
  * What the Worker records for every API request, in Analytics Engine (worker/index.ts writes
@@ -2462,7 +2620,7 @@ export function parseUsageReport(raw: unknown): UsageReport {
2462
2620
  totals: { requests: num(t['requests']), errors: num(t['errors']), avgMs: num(t['avgMs']) },
2463
2621
  }
2464
2622
  }
2465
- `}function at(){return`import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'
2623
+ `}function lt(){return`import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'
2466
2624
  import type { AnalyticsCredentials } from '@cascivo/app/analytics'
2467
2625
  import { usageMetrics } from '../src/usage'
2468
2626
  import type { UsageReport } from '../src/usage'
@@ -2537,7 +2695,7 @@ export async function usageReport(credentials: AnalyticsCredentials | null): Pro
2537
2695
  },
2538
2696
  }
2539
2697
  }
2540
- `}function ot(){return`import { BarChart, Kpi, LineChart } from '@cascivo/charts'
2698
+ `}function ut(){return`import { BarChart, Kpi, LineChart } from '@cascivo/charts'
2541
2699
  import { createClient } from '@cascivo/app/api'
2542
2700
  import {
2543
2701
  Button,
@@ -2625,7 +2783,7 @@ export default function Usage() {
2625
2783
  </Flex>
2626
2784
  )
2627
2785
  }
2628
- `}function st(){return`/**
2786
+ `}function dt(){return`/**
2629
2787
  * /search, shared by the Worker (which searches) and the page (which asks). \`semantic\` is
2630
2788
  * Vectorize over Workers AI embeddings; \`keyword\` is the \`vite dev\` stand-in, SQLite's
2631
2789
  * full-text search, because neither runs locally.
@@ -2689,7 +2847,7 @@ export function parseIndexed(raw: unknown): { indexed: number } {
2689
2847
  }
2690
2848
  throw new Error('Malformed reply')
2691
2849
  }
2692
- `}function ct(){return`import { HttpError } from '@cascivo/app/api'
2850
+ `}function ft(){return`import { HttpError } from '@cascivo/app/api'
2693
2851
  import { migrate, queryRows } from '@cascivo/app/db'
2694
2852
  import type { Database } from '@cascivo/app/db'
2695
2853
  import type { SearchHit, SearchResult } from '../src/search'
@@ -2835,7 +2993,7 @@ export async function indexArticles(env: SearchEnv): Promise<{ indexed: number }
2835
2993
  }
2836
2994
  return { indexed: (await env.ARTICLES_INDEX.describe()).vectorCount }
2837
2995
  }
2838
- `}function lt(){return`/**
2996
+ `}function pt(){return`/**
2839
2997
  * The help articles /search looks through, seeded into D1 on the first query. Replace them with
2840
2998
  * your own content: anything with an id, a title and a body can be indexed the same way.
2841
2999
  */
@@ -2961,7 +3119,7 @@ export const articles: { id: string; title: string; body: string }[] = [
2961
3119
  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.',
2962
3120
  },
2963
3121
  ]
2964
- `}function ut(){return`import { createClient } from '@cascivo/app/api'
3122
+ `}function mt(){return`import { createClient } from '@cascivo/app/api'
2965
3123
  import {
2966
3124
  Alert,
2967
3125
  Badge,
@@ -3089,7 +3247,7 @@ export default function SearchPage() {
3089
3247
  </Flex>
3090
3248
  )
3091
3249
  }
3092
- `}function dt(){return`/**
3250
+ `}function ht(){return`/**
3093
3251
  * The weekly digest's runs, shared by the Worker (which records each one) and the /digest page
3094
3252
  * (which lists them). A run happens on the Cron Trigger in wrangler.jsonc, or from "Send now".
3095
3253
  */
@@ -3136,7 +3294,7 @@ export function parseDigestRuns(raw: unknown): DigestRun[] {
3136
3294
  if (!Array.isArray(raw)) throw new Error('Expected a list of runs')
3137
3295
  return raw.map(parseDigestRun)
3138
3296
  }
3139
- `}function ft(){return`import { migrate, queryRows } from '@cascivo/app/db'
3297
+ `}function gt(){return`import { migrate, queryRows } from '@cascivo/app/db'
3140
3298
  import type { Database } from '@cascivo/app/db'
3141
3299
  import { exportPage } from '@cascivo/app/export'
3142
3300
  import type { ExportBrowser } from '@cascivo/app/export'
@@ -3265,7 +3423,7 @@ export async function listRuns(db: Database): Promise<DigestRun[]> {
3265
3423
  parseDigestRun,
3266
3424
  )
3267
3425
  }
3268
- `}function pt(){return`import { createClient } from '@cascivo/app/api'
3426
+ `}function _t(){return`import { createClient } from '@cascivo/app/api'
3269
3427
  import {
3270
3428
  Alert,
3271
3429
  Badge,
@@ -3347,7 +3505,7 @@ export default function Digest() {
3347
3505
  </Flex>
3348
3506
  )
3349
3507
  }
3350
- `}function mt(){return`/**
3508
+ `}function vt(){return`/**
3351
3509
  * Webhook deliveries, shared by the Worker (which verifies and stores them) and the
3352
3510
  * /webhooks page (which lists them as they arrive).
3353
3511
  */
@@ -3390,7 +3548,7 @@ export function parseTestResult(raw: unknown): { status: number } {
3390
3548
  }
3391
3549
  throw new Error('Malformed reply')
3392
3550
  }
3393
- `}function ht(){return`import { migrate, queryRows } from '@cascivo/app/db'
3551
+ `}function yt(){return`import { migrate, queryRows } from '@cascivo/app/db'
3394
3552
  import type { Database } from '@cascivo/app/db'
3395
3553
  import { verifyWebhook } from '@cascivo/app/guard'
3396
3554
  import { writeRoom } from '@cascivo/app/sync-server'
@@ -3508,7 +3666,7 @@ export async function sendTestDelivery(
3508
3666
  )
3509
3667
  return { status: response.status }
3510
3668
  }
3511
- `}function gt(){return`import { createClient } from '@cascivo/app/api'
3669
+ `}function bt(){return`import { createClient } from '@cascivo/app/api'
3512
3670
  import { connectRoom } from '@cascivo/app/sync'
3513
3671
  import {
3514
3672
  Alert,
@@ -3614,8 +3772,117 @@ export default function Webhooks() {
3614
3772
  </Flex>
3615
3773
  )
3616
3774
  }
3617
- `}function _t(){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"}function vt(){return`/** What sending a sign-in link needs of the Email Service binding (\`send_email\`). */
3618
- export interface SignInSender {
3775
+ `}function xt(e){return`/**
3776
+ * What /checkout sells and the orders it makes, shared by the Worker (which creates them and
3777
+ * hears from Stripe) and the pages (which show them).
3778
+ */
3779
+
3780
+ /** The seller, as the receipt names it. */
3781
+ export const SHOP_NAME = '${M(e.name).replace(/'/g,`\\'`)}'
3782
+
3783
+ /**
3784
+ * What you sell. The Worker sends this to Stripe, so a browser cannot change the price; the
3785
+ * page only displays it.
3786
+ */
3787
+ export const PRODUCT = {
3788
+ name: 'Sticker pack',
3789
+ description: 'Twelve vinyl stickers of your favourite components, shipped worldwide.',
3790
+ /** In the currency's smallest unit: 900 is €9.00. */
3791
+ amount: 900,
3792
+ /** Three-letter ISO code, lowercase. */
3793
+ currency: 'eur',
3794
+ }
3795
+
3796
+ /** \`pending\` until Stripe confirms the payment; the other three are final. */
3797
+ export type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired'
3798
+
3799
+ export interface Order {
3800
+ id: string
3801
+ status: OrderStatus
3802
+ /** What Stripe charged, in the currency's smallest unit. */
3803
+ amount: number
3804
+ currency: string
3805
+ createdAt: string
3806
+ paidAt: string | null
3807
+ }
3808
+
3809
+ /** An order id: a UUID the Worker made. */
3810
+ export const ORDER_ID = /^[0-9a-f-]{36}$/
3811
+
3812
+ /** The room the Worker pushes an order's changes to; its page watches it. */
3813
+ export const orderRoom = (id: string) => \`order-\${id}\`
3814
+
3815
+ /** A price in the currency's smallest unit, for people: 900 eur is €9.00, 900 jpy is ¥900. */
3816
+ export function formatPrice(amount: number, currency: string): string {
3817
+ const format = new Intl.NumberFormat(undefined, { style: 'currency', currency })
3818
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2
3819
+ return format.format(amount / 10 ** digits)
3820
+ }
3821
+
3822
+ const STATUSES = ['pending', 'paid', 'failed', 'expired']
3823
+
3824
+ export function parseOrder(raw: unknown): Order {
3825
+ if (typeof raw === 'object' && raw !== null) {
3826
+ const { id, status, amount, currency, createdAt, paidAt } = raw as Record<string, unknown>
3827
+ if (
3828
+ typeof id === 'string' &&
3829
+ typeof status === 'string' &&
3830
+ STATUSES.includes(status) &&
3831
+ typeof amount === 'number' &&
3832
+ typeof currency === 'string' &&
3833
+ typeof createdAt === 'string' &&
3834
+ (paidAt === null || typeof paidAt === 'string')
3835
+ ) {
3836
+ // Checked against STATUSES just above.
3837
+ return { id, status: status as OrderStatus, amount, currency, createdAt, paidAt }
3838
+ }
3839
+ }
3840
+ throw new Error('Malformed order')
3841
+ }
3842
+
3843
+ export function parseCheckoutStarted(raw: unknown): { url: string } {
3844
+ if (typeof raw === 'object' && raw !== null) {
3845
+ const { url } = raw as Record<string, unknown>
3846
+ if (typeof url === 'string' && url.startsWith('https://')) return { url }
3847
+ }
3848
+ throw new Error('Malformed checkout')
3849
+ }
3850
+ `}function St(){return`import { HttpError } from '@cascivo/app/api'
3851
+ import { migrate, queryRows } from '@cascivo/app/db'
3852
+ import type { Database } from '@cascivo/app/db'
3853
+ import { verifyWebhook } from '@cascivo/app/guard'
3854
+ import { StripeError, createStripe, parseStripeEvent } from '@cascivo/app/stripe'
3855
+ import type { CheckoutEventType, CheckoutSession, Stripe } from '@cascivo/app/stripe'
3856
+ import { writeRoom } from '@cascivo/app/sync-server'
3857
+ import type { RoomNamespace } from '@cascivo/app/sync-server'
3858
+ import { Receipt, receiptSubject, renderEmail } from '@cascivo/email'
3859
+ import { createElement } from 'react'
3860
+ import { ORDER_ID, PRODUCT, SHOP_NAME, formatPrice, orderRoom, parseOrder } from '../src/checkout'
3861
+ import type { Order, OrderStatus } from '../src/checkout'
3862
+
3863
+ const migrations = [
3864
+ {
3865
+ id: '0001_orders',
3866
+ statements: [
3867
+ \`CREATE TABLE orders (
3868
+ id TEXT PRIMARY KEY,
3869
+ session_id TEXT NOT NULL UNIQUE,
3870
+ status TEXT NOT NULL,
3871
+ amount INTEGER NOT NULL,
3872
+ currency TEXT NOT NULL,
3873
+ email TEXT,
3874
+ created_at TEXT NOT NULL,
3875
+ paid_at TEXT
3876
+ )\`,
3877
+ ],
3878
+ },
3879
+ ]
3880
+
3881
+ /** Where Stripe posts; add it as an endpoint in the Stripe dashboard (README). */
3882
+ export const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'
3883
+
3884
+ /** What sending a receipt needs of the Email Service binding (\`send_email\`). */
3885
+ export interface ReceiptSender {
3619
3886
  send(message: {
3620
3887
  from: string
3621
3888
  to: string
@@ -3625,114 +3892,2038 @@ export interface SignInSender {
3625
3892
  }): Promise<unknown>
3626
3893
  }
3627
3894
 
3628
- const escape = (value: string) =>
3629
- value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
3895
+ export interface CheckoutEnv {
3896
+ DB: Database
3897
+ ROOMS: RoomNamespace<unknown>
3898
+ EMAIL: ReceiptSender
3899
+ RECEIPT_FROM: string
3900
+ STRIPE_SECRET_KEY?: string
3901
+ STRIPE_WEBHOOK_SECRET?: string
3902
+ }
3903
+
3904
+ const COLUMNS = 'id, status, amount, currency, created_at AS createdAt, paid_at AS paidAt'
3905
+
3906
+ export function stripeOf(env: { STRIPE_SECRET_KEY?: string }): Stripe {
3907
+ if (!env.STRIPE_SECRET_KEY) {
3908
+ throw new HttpError(
3909
+ 503,
3910
+ 'Set STRIPE_SECRET_KEY to a test key from the Stripe dashboard (README)',
3911
+ )
3912
+ }
3913
+ return createStripe(env.STRIPE_SECRET_KEY)
3914
+ }
3915
+
3916
+ /** Stripe's refusal, for the page: its message in vite dev, a pointer to the log deployed. */
3917
+ export function refused(error: unknown): never {
3918
+ if (!(error instanceof StripeError)) throw error
3919
+ console.error('[checkout] Stripe refused:', error.status, error.code, error.message)
3920
+ throw new HttpError(
3921
+ 502,
3922
+ import.meta.env.DEV
3923
+ ? \`Stripe: \${error.message}\`
3924
+ : 'The payment provider refused the request (see the Worker log)',
3925
+ )
3926
+ }
3630
3927
 
3631
3928
  /**
3632
- * Emails a sign-in link. \`vite dev\` sends nothing: the link is logged, and the Account page
3633
- * shows it (\`exposeLink\` in worker/index.ts).
3929
+ * Creates a Stripe Checkout Session for PRODUCT and records the order as pending. The order id
3930
+ * is also the idempotency key, so a retried request cannot open a second session.
3634
3931
  */
3635
- export async function sendSignInLink(
3636
- sender: SignInSender,
3637
- from: string,
3638
- email: string,
3639
- url: string,
3932
+ export async function startCheckout(env: CheckoutEnv, origin: string): Promise<{ url: string }> {
3933
+ const stripe = stripeOf(env)
3934
+ const id = crypto.randomUUID()
3935
+ let session: CheckoutSession
3936
+ try {
3937
+ session = await stripe.createCheckoutSession(
3938
+ {
3939
+ mode: 'payment',
3940
+ lineItems: [{ ...PRODUCT, quantity: 1 }],
3941
+ successUrl: \`\${origin}/checkout/\${id}\`,
3942
+ cancelUrl: \`\${origin}/checkout\`,
3943
+ clientReferenceId: id,
3944
+ },
3945
+ { idempotencyKey: id },
3946
+ )
3947
+ } catch (error) {
3948
+ refused(error)
3949
+ }
3950
+ if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')
3951
+ await migrate(env.DB, migrations)
3952
+ await queryRows(
3953
+ env.DB,
3954
+ \`INSERT INTO orders (id, session_id, status, amount, currency, created_at)
3955
+ VALUES (?, ?, 'pending', ?, ?, ?)\`,
3956
+ [id, session.id, PRODUCT.amount, PRODUCT.currency, new Date().toISOString()],
3957
+ (row) => row,
3958
+ )
3959
+ return { url: session.url }
3960
+ }
3961
+
3962
+ /** Where an event moves the order, or \`null\` when it does not move it. */
3963
+ function statusAfter(type: CheckoutEventType, session: CheckoutSession): OrderStatus | null {
3964
+ switch (type) {
3965
+ case 'checkout.session.completed':
3966
+ // \`unpaid\` here is a bank debit that has not settled: the async events decide it.
3967
+ return session.paymentStatus === 'unpaid' ? null : 'paid'
3968
+ case 'checkout.session.async_payment_succeeded':
3969
+ return 'paid'
3970
+ case 'checkout.session.async_payment_failed':
3971
+ return 'failed'
3972
+ case 'checkout.session.expired':
3973
+ return 'expired'
3974
+ }
3975
+ }
3976
+
3977
+ /** The same decision from a session read back from Stripe, with no event to go by. */
3978
+ function statusOf(session: CheckoutSession): OrderStatus | null {
3979
+ if (session.status === 'complete' && session.paymentStatus !== 'unpaid') return 'paid'
3980
+ return session.status === 'expired' ? 'expired' : null
3981
+ }
3982
+
3983
+ /**
3984
+ * Moves a pending order to its final status, once: a retried event, or the order page reading
3985
+ * the session before the webhook arrived, finds it settled and changes nothing. The order is
3986
+ * found by Stripe's session id, never by \`client_reference_id\`, which a buyer can set on a
3987
+ * Payment Link. Then the order's page hears about it, and a paid order gets its receipt.
3988
+ */
3989
+ async function settle(
3990
+ env: CheckoutEnv,
3991
+ session: CheckoutSession,
3992
+ status: OrderStatus,
3993
+ origin: string,
3640
3994
  ): Promise<void> {
3995
+ await migrate(env.DB, migrations)
3996
+ const [order] = await queryRows(
3997
+ env.DB,
3998
+ \`UPDATE orders SET status = ?, paid_at = ?, amount = COALESCE(?, amount),
3999
+ currency = COALESCE(?, currency), email = ?
4000
+ WHERE session_id = ? AND status = 'pending' RETURNING \${COLUMNS}\`,
4001
+ [
4002
+ status,
4003
+ status === 'paid' ? new Date().toISOString() : null,
4004
+ session.amountTotal,
4005
+ session.currency,
4006
+ session.customerEmail,
4007
+ session.id,
4008
+ ],
4009
+ parseOrder,
4010
+ )
4011
+ if (!order) return
4012
+ await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
4013
+ if (order.status === 'paid' && session.customerEmail) {
4014
+ await sendReceipt(env, order, session.customerEmail, origin)
4015
+ }
4016
+ }
4017
+
4018
+ /**
4019
+ * Emails a receipt rendered with @cascivo/email. \`vite dev\` renders it and logs it instead.
4020
+ * A receipt that cannot be sent is logged, not thrown: the payment is recorded either way, and
4021
+ * Stripe retrying the event would not send it again.
4022
+ */
4023
+ async function sendReceipt(env: CheckoutEnv, order: Order, to: string, origin: string) {
4024
+ const total = formatPrice(order.amount, order.currency)
4025
+ const props = {
4026
+ productName: SHOP_NAME,
4027
+ orderId: order.id.slice(0, 8).toUpperCase(),
4028
+ items: [{ description: PRODUCT.name, amount: total }],
4029
+ total,
4030
+ invoiceHref: \`\${origin}/checkout/\${order.id}\`,
4031
+ }
4032
+ const message = renderEmail(createElement(Receipt, props), { subject: receiptSubject(props) })
3641
4033
  if (import.meta.env.DEV) {
3642
- console.log(\`[auth] sign-in link for \${email}: \${url}\`)
4034
+ console.log(\`[checkout] receipt for \${to}: "\${message.subject}" (\${message.html.length} bytes)\`)
3643
4035
  return
3644
4036
  }
3645
- if (!from) throw new Error('Set AUTH_FROM in wrangler.jsonc to an address on your domain')
3646
- await sender.send({
3647
- from,
3648
- to: email,
3649
- subject: 'Your sign-in link',
3650
- text: \`Sign in: \${url}\\n\\nThe link works once, for 15 minutes. If you did not ask for it, ignore this email.\`,
3651
- 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>\`,
4037
+ if (!env.RECEIPT_FROM) {
4038
+ console.warn('[checkout] no receipt sent: set RECEIPT_FROM in wrangler.jsonc')
4039
+ return
4040
+ }
4041
+ try {
4042
+ await env.EMAIL.send({
4043
+ from: env.RECEIPT_FROM,
4044
+ to,
4045
+ subject: message.subject,
4046
+ text: message.text,
4047
+ html: message.html,
4048
+ })
4049
+ } catch (error) {
4050
+ console.error('[checkout] receipt not sent:', error)
4051
+ }
4052
+ }
4053
+
4054
+ /**
4055
+ * Stripe's webhook: verified against STRIPE_WEBHOOK_SECRET before anything in it is read,
4056
+ * then each Checkout event settles its order. Subscription events, and completed subscription
4057
+ * checkouts, go to \`onSubscription\` when the app bills subscriptions (worker/billing.ts).
4058
+ * Other events are acknowledged, so Stripe stops sending them.
4059
+ */
4060
+ export async function receiveStripe(
4061
+ request: Request,
4062
+ env: CheckoutEnv,
4063
+ onSubscription?: (subscriptionId: string) => Promise<void>,
4064
+ ): Promise<Response> {
4065
+ if (!env.STRIPE_WEBHOOK_SECRET) throw new HttpError(503, 'Set STRIPE_WEBHOOK_SECRET (README)')
4066
+ const { body } = await verifyWebhook(request, {
4067
+ scheme: 'stripe',
4068
+ secret: env.STRIPE_WEBHOOK_SECRET,
3652
4069
  })
4070
+ const event = parseStripeEvent(body)
4071
+ if (event.kind === 'subscription') await onSubscription?.(event.subscription.id)
4072
+ if (event.kind === 'checkout' && event.session.mode === 'subscription') {
4073
+ if (event.session.subscriptionId) await onSubscription?.(event.session.subscriptionId)
4074
+ } else if (event.kind === 'checkout') {
4075
+ const status = statusAfter(event.type, event.session)
4076
+ if (status) await settle(env, event.session, status, new URL(request.url).origin)
4077
+ }
4078
+ return Response.json({ received: true })
4079
+ }
4080
+
4081
+ function parseStored(raw: unknown): { order: Order; sessionId: string } {
4082
+ const sessionId = typeof raw === 'object' && raw !== null ? Reflect.get(raw, 'sessionId') : null
4083
+ if (typeof sessionId !== 'string') throw new Error('Malformed order row')
4084
+ return { order: parseOrder(raw), sessionId }
4085
+ }
4086
+
4087
+ async function readOrder(env: CheckoutEnv, id: string) {
4088
+ await migrate(env.DB, migrations)
4089
+ const [row] = await queryRows(
4090
+ env.DB,
4091
+ \`SELECT \${COLUMNS}, session_id AS sessionId FROM orders WHERE id = ?\`,
4092
+ [id],
4093
+ parseStored,
4094
+ )
4095
+ if (!row) throw new HttpError(404, 'No such order')
4096
+ return row
4097
+ }
4098
+
4099
+ /**
4100
+ * An order, for its page. A pending one is checked with Stripe first, so the page is right
4101
+ * even when the webhook is late or not set up yet (as in \`vite dev\` without \`stripe listen\`).
4102
+ */
4103
+ export async function getOrder(env: CheckoutEnv, id: string, origin: string): Promise<Order> {
4104
+ if (!ORDER_ID.test(id)) throw new HttpError(404, 'No such order')
4105
+ const { order, sessionId } = await readOrder(env, id)
4106
+ if (order.status !== 'pending' || !env.STRIPE_SECRET_KEY) return order
4107
+ try {
4108
+ const session = await stripeOf(env).retrieveCheckoutSession(sessionId)
4109
+ const status = statusOf(session)
4110
+ if (!status) return order
4111
+ await settle(env, session, status, origin)
4112
+ } catch (error) {
4113
+ // Stripe unreachable: show what is known; the webhook settles the order later.
4114
+ console.error('[checkout] could not read the session back:', error)
4115
+ return order
4116
+ }
4117
+ return (await readOrder(env, id)).order
3653
4118
  }
3654
- `}function yt(){return`import {
4119
+ `}function Ct(){return`import { createClient } from '@cascivo/app/api'
4120
+ import {
3655
4121
  Alert,
3656
4122
  Button,
3657
4123
  Card,
3658
4124
  CardContent,
3659
4125
  Flex,
3660
4126
  Heading,
3661
- Input,
3662
- Link,
3663
- Spinner,
3664
4127
  Text,
3665
4128
  signal,
3666
4129
  useSignals,
3667
4130
  } from '@cascivo/react'
3668
- import type { FormEvent } from 'react'
3669
- import { auth } from '../auth'
4131
+ import { api } from '../api'
4132
+ import { PRODUCT, formatPrice } from '../checkout'
3670
4133
 
3671
- const sentTo = signal<string | null>(null)
3672
- /** Set only in \`vite dev\`, where no email is sent: the link to open instead. */
3673
- const devLink = signal<string | null>(null)
4134
+ const client = createClient(api)
4135
+ const starting = signal(false)
3674
4136
  const failure = signal<string | null>(null)
3675
- const sending = signal(false)
3676
4137
 
3677
- async function start(event: FormEvent<HTMLFormElement>): Promise<void> {
3678
- event.preventDefault()
3679
- const email = new FormData(event.currentTarget).get('email')
3680
- if (typeof email !== 'string') return
4138
+ /** Asks the Worker for a Stripe Checkout page and goes there: Stripe takes the card, not us. */
4139
+ async function buy(): Promise<void> {
4140
+ starting.value = true
3681
4141
  failure.value = null
3682
- sending.value = true
3683
4142
  try {
3684
- const { link } = await auth.start(email)
3685
- sentTo.value = email
3686
- devLink.value = link
4143
+ const { url } = await client.startCheckout()
4144
+ location.assign(url)
3687
4145
  } catch (error) {
3688
- failure.value = error instanceof Error ? error.message : 'Could not send the link'
3689
- } finally {
3690
- sending.value = false
4146
+ failure.value = error instanceof Error ? error.message : 'Could not start the checkout'
4147
+ starting.value = false
3691
4148
  }
3692
4149
  }
3693
4150
 
3694
- export default function Account() {
4151
+ export default function Checkout() {
3695
4152
  useSignals()
3696
- const user = auth.user.value
3697
-
3698
- if (user === undefined) return <Spinner label="Loading" />
3699
- if (user) {
3700
- return (
3701
- <Flex gap={4}>
3702
- <Heading level={1}>Account</Heading>
3703
- <Card>
3704
- <CardContent>
3705
- <Flex gap={3}>
3706
- <Text>Signed in as {user.email}</Text>
3707
- <Flex direction="horizontal">
3708
- <Button variant="secondary" onClick={() => void auth.signOut()}>
3709
- Sign out
3710
- </Button>
3711
- </Flex>
3712
- </Flex>
3713
- </CardContent>
3714
- </Card>
3715
- </Flex>
3716
- )
3717
- }
3718
4153
  return (
3719
4154
  <Flex gap={4}>
3720
4155
  <Flex gap={1}>
3721
- <Heading level={1}>Sign in</Heading>
3722
- <Text muted>No password: we email you a link that signs you in once.</Text>
4156
+ <Heading level={1}>Checkout</Heading>
4157
+ <Text muted>
4158
+ Paid on Stripe's hosted page. Stripe tells the Worker when the payment succeeds; the
4159
+ Worker records the order, updates its page and emails a receipt.
4160
+ </Text>
3723
4161
  </Flex>
3724
- {sentTo.value ? (
3725
- <Alert variant="success" title="Check your email">
3726
- We sent a sign-in link to {sentTo.value}. It works once, for 15 minutes.
3727
- </Alert>
3728
- ) : null}
3729
- {devLink.value ? (
3730
- <Alert variant="info" title="vite dev sends no email">
3731
- <Link href={devLink.value}>Open the sign-in link</Link>
3732
- </Alert>
3733
- ) : null}
4162
+ <Card>
4163
+ <CardContent>
4164
+ <Flex gap={3}>
4165
+ <Flex gap={1}>
4166
+ <Heading level={2}>{PRODUCT.name}</Heading>
4167
+ <Text muted>{PRODUCT.description}</Text>
4168
+ </Flex>
4169
+ <Text size="lg">{formatPrice(PRODUCT.amount, PRODUCT.currency)}</Text>
4170
+ <Flex direction="horizontal">
4171
+ <Button loading={starting.value} onClick={() => void buy()}>
4172
+ Buy now
4173
+ </Button>
4174
+ </Flex>
4175
+ </Flex>
4176
+ </CardContent>
4177
+ </Card>
3734
4178
  {failure.value ? (
3735
- <Alert variant="destructive" title="Not sent">
4179
+ <Alert variant="destructive" title="The checkout did not start">
4180
+ {failure.value}
4181
+ </Alert>
4182
+ ) : null}
4183
+ <Text size="sm" muted>
4184
+ In test mode, pay with the card 4242 4242 4242 4242, any future date and any CVC.
4185
+ </Text>
4186
+ </Flex>
4187
+ )
4188
+ }
4189
+ `}function wt(){return`import type { RouteProps } from '@cascivo/app'
4190
+ import { createClient } from '@cascivo/app/api'
4191
+ import { connectRoom } from '@cascivo/app/sync'
4192
+ import {
4193
+ Alert,
4194
+ Badge,
4195
+ EmptyState,
4196
+ Flex,
4197
+ Heading,
4198
+ Link,
4199
+ Spinner,
4200
+ Text,
4201
+ signal,
4202
+ useEffectPropSignal,
4203
+ useSignalEffect,
4204
+ useSignals,
4205
+ } from '@cascivo/react'
4206
+ import { api } from '../../api'
4207
+ import { PRODUCT, formatPrice, parseOrder } from '../../checkout'
4208
+ import type { Order } from '../../checkout'
4209
+
4210
+ const client = createClient(api)
4211
+ /** Orders seen, by id; \`null\` when the id has none. */
4212
+ const orders = signal<Readonly<Record<string, Order | null>>>({})
4213
+
4214
+ /** Keeps the newest word on an order: a final status is never replaced by \`pending\`. */
4215
+ function remember(id: string, order: Order | null): void {
4216
+ const known = orders.peek()[id]
4217
+ if (known && known.status !== 'pending' && order?.status === 'pending') return
4218
+ orders.value = { ...orders.peek(), [id]: order }
4219
+ }
4220
+
4221
+ async function load(id: string): Promise<void> {
4222
+ try {
4223
+ remember(id, await client.getOrder({ params: { id } }))
4224
+ } catch {
4225
+ remember(id, null)
4226
+ }
4227
+ }
4228
+
4229
+ /** Watches the order's room, where the Worker pushes what Stripe reports. Returns the cleanup. */
4230
+ function watch(id: string): () => void {
4231
+ const room = connectRoom(\`/api/orders/\${id}/live\`)
4232
+ const pushed = room.signal<Order | null>('order', null, (raw) =>
4233
+ raw === null ? null : parseOrder(raw),
4234
+ )
4235
+ const stop = pushed.signal.subscribe((order) => {
4236
+ if (order) remember(id, order)
4237
+ })
4238
+ return () => {
4239
+ stop()
4240
+ room.close()
4241
+ }
4242
+ }
4243
+
4244
+ const STATUS = {
4245
+ pending: { variant: 'warning', label: 'Waiting for Stripe' },
4246
+ paid: { variant: 'success', label: 'Paid' },
4247
+ failed: { variant: 'destructive', label: 'Payment failed' },
4248
+ expired: { variant: 'secondary', label: 'Expired' },
4249
+ } as const
4250
+
4251
+ /** \`/checkout/:order\` — where Stripe sends the buyer back. It updates when Stripe confirms. */
4252
+ export default function OrderPage({ params }: RouteProps<'/checkout/:order'>) {
4253
+ useSignals()
4254
+ const id = useEffectPropSignal(params.order)
4255
+ useSignalEffect(() => {
4256
+ void load(id.value)
4257
+ return watch(id.value)
4258
+ })
4259
+ const order = orders.value[params.order]
4260
+
4261
+ if (order === undefined) return <Spinner label="Loading" />
4262
+ if (order === null) {
4263
+ return <EmptyState title="No such order" description="Check the link in your receipt." />
4264
+ }
4265
+ return (
4266
+ <Flex gap={4}>
4267
+ <Flex gap={1}>
4268
+ <Heading level={1}>Your order</Heading>
4269
+ <Text muted>
4270
+ {PRODUCT.name} · {formatPrice(order.amount, order.currency)}
4271
+ </Text>
4272
+ </Flex>
4273
+ <Flex direction="horizontal" align="center" gap={2} wrap>
4274
+ <Badge variant={STATUS[order.status].variant}>{STATUS[order.status].label}</Badge>
4275
+ <Text size="sm" muted>
4276
+ Ordered {new Date(order.createdAt).toLocaleString()}
4277
+ </Text>
4278
+ </Flex>
4279
+ {order.status === 'pending' ? (
4280
+ <Alert variant="info" title="Confirming your payment">
4281
+ This page updates by itself when Stripe confirms. A bank payment can take a few days.
4282
+ </Alert>
4283
+ ) : null}
4284
+ {order.status === 'paid' ? (
4285
+ <Alert variant="success" title="Thank you">
4286
+ Your payment went through. A receipt is on its way to your inbox.
4287
+ </Alert>
4288
+ ) : null}
4289
+ {order.status === 'failed' ? (
4290
+ <Alert variant="destructive" title="The payment failed">
4291
+ Nothing was charged. <Link href="/checkout">Try again</Link>
4292
+ </Alert>
4293
+ ) : null}
4294
+ {order.status === 'expired' ? (
4295
+ <Alert variant="warning" title="This checkout expired">
4296
+ It was not paid in time. <Link href="/checkout">Start again</Link>
4297
+ </Alert>
4298
+ ) : null}
4299
+ </Flex>
4300
+ )
4301
+ }
4302
+ `}function Tt(){return`/**
4303
+ * The newsletter's wire types, shared by the Worker (worker/newsletter.ts) and its pages.
4304
+ * Every payload is parsed on arrival: the network is not trusted because the types match.
4305
+ */
4306
+
4307
+ /** Longest subject and body the composer accepts. */
4308
+ export const MAX_SUBJECT = 200
4309
+ export const MAX_BODY = 50_000
4310
+
4311
+ export interface SubscriberCounts {
4312
+ /** Signed up, has not clicked the confirmation link yet. */
4313
+ pending: number
4314
+ subscribed: number
4315
+ unsubscribed: number
4316
+ /** Bounced for good or complained: never mailed again (worker/newsletter.ts). */
4317
+ suppressed: number
4318
+ }
4319
+
4320
+ /** One sent issue and how far its sending has got. */
4321
+ export interface Issue {
4322
+ id: string
4323
+ subject: string
4324
+ createdAt: string
4325
+ /** Subscribers it was queued for. */
4326
+ total: number
4327
+ sent: number
4328
+ failed: number
4329
+ }
4330
+
4331
+ export interface Overview {
4332
+ subscribers: SubscriberCounts
4333
+ issues: Issue[]
4334
+ }
4335
+
4336
+ /** The room the Worker pushes an issue's progress to; the composer watches it. */
4337
+ export const issueRoom = (id: string) => \`issue-\${id}\`
4338
+
4339
+ /** An issue id: a UUID the Worker made. */
4340
+ export const ISSUE_ID = /^[0-9a-f-]{36}$/
4341
+
4342
+ const isRecord = (raw: unknown): raw is Record<string, unknown> =>
4343
+ typeof raw === 'object' && raw !== null
4344
+
4345
+ function text(raw: Record<string, unknown>, key: string, max: number): string {
4346
+ const value = raw[key]
4347
+ if (typeof value !== 'string' || value.trim() === '' || value.length > max) {
4348
+ throw new Error(\`Expected \${key}: some text, at most \${max} characters\`)
4349
+ }
4350
+ return value
4351
+ }
4352
+
4353
+ export function parseEmailInput(raw: unknown): { email: string } {
4354
+ if (!isRecord(raw)) throw new Error('Expected { email }')
4355
+ return { email: text(raw, 'email', 254) }
4356
+ }
4357
+
4358
+ export function parseTokenInput(raw: unknown): { token: string } {
4359
+ if (!isRecord(raw)) throw new Error('Expected { token }')
4360
+ return { token: text(raw, 'token', 100) }
4361
+ }
4362
+
4363
+ /** The composer's key: NEWSLETTER_KEY, which only the sender knows. */
4364
+ export function parseKeyInput(raw: unknown): { key: string } {
4365
+ if (!isRecord(raw)) throw new Error('Expected { key }')
4366
+ return { key: text(raw, 'key', 200) }
4367
+ }
4368
+
4369
+ export interface IssueInput {
4370
+ key: string
4371
+ subject: string
4372
+ /** The body in Markdown, rendered by @cascivo/email's Markdown. */
4373
+ body: string
4374
+ }
4375
+
4376
+ export function parseIssueInput(raw: unknown): IssueInput {
4377
+ if (!isRecord(raw)) throw new Error('Expected { key, subject, body }')
4378
+ return {
4379
+ key: text(raw, 'key', 200),
4380
+ subject: text(raw, 'subject', MAX_SUBJECT),
4381
+ body: text(raw, 'body', MAX_BODY),
4382
+ }
4383
+ }
4384
+
4385
+ export function parseSubscribed(raw: unknown): { devLink: string | null } {
4386
+ if (isRecord(raw) && (raw['devLink'] === null || typeof raw['devLink'] === 'string')) {
4387
+ return { devLink: raw['devLink'] }
4388
+ }
4389
+ throw new Error('Malformed reply')
4390
+ }
4391
+
4392
+ export function parseConfirmed(raw: unknown): { email: string } {
4393
+ if (isRecord(raw) && typeof raw['email'] === 'string') return { email: raw['email'] }
4394
+ throw new Error('Malformed reply')
4395
+ }
4396
+
4397
+ export function parsePreview(raw: unknown): { html: string; bytes: number } {
4398
+ if (isRecord(raw) && typeof raw['html'] === 'string' && typeof raw['bytes'] === 'number') {
4399
+ return { html: raw['html'], bytes: raw['bytes'] }
4400
+ }
4401
+ throw new Error('Malformed preview')
4402
+ }
4403
+
4404
+ const count = (raw: Record<string, unknown>, key: string): number => {
4405
+ const value = raw[key]
4406
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
4407
+ throw new Error(\`Expected a count for \${key}\`)
4408
+ }
4409
+ return value
4410
+ }
4411
+
4412
+ export function parseIssue(raw: unknown): Issue {
4413
+ if (isRecord(raw) && typeof raw['id'] === 'string' && typeof raw['createdAt'] === 'string') {
4414
+ return {
4415
+ id: raw['id'],
4416
+ subject: text(raw, 'subject', MAX_SUBJECT),
4417
+ createdAt: raw['createdAt'],
4418
+ total: count(raw, 'total'),
4419
+ sent: count(raw, 'sent'),
4420
+ failed: count(raw, 'failed'),
4421
+ }
4422
+ }
4423
+ throw new Error('Malformed issue')
4424
+ }
4425
+
4426
+ export function parseOverview(raw: unknown): Overview {
4427
+ if (isRecord(raw) && isRecord(raw['subscribers']) && Array.isArray(raw['issues'])) {
4428
+ const s = raw['subscribers']
4429
+ return {
4430
+ subscribers: {
4431
+ pending: count(s, 'pending'),
4432
+ subscribed: count(s, 'subscribed'),
4433
+ unsubscribed: count(s, 'unsubscribed'),
4434
+ suppressed: count(s, 'suppressed'),
4435
+ },
4436
+ issues: raw['issues'].map(parseIssue),
4437
+ }
4438
+ }
4439
+ throw new Error('Malformed overview')
4440
+ }
4441
+ `}function Et(e){return`import {
4442
+ Body,
4443
+ Button,
4444
+ Container,
4445
+ Footer,
4446
+ Head,
4447
+ Heading,
4448
+ Html,
4449
+ Link,
4450
+ Markdown,
4451
+ Preview,
4452
+ Section,
4453
+ Text,
4454
+ renderEmail,
4455
+ } from '@cascivo/email'
4456
+ import type { RenderResult } from '@cascivo/email'
4457
+ import { createElement as h } from 'react'
4458
+
4459
+ /** Who the newsletter is from, as its emails say. */
4460
+ export const NEWSLETTER_NAME = '${M(e.name).replace(/'/g,`\\'`)}'
4461
+
4462
+ /** Stands in for each reader's unsubscribe token; worker/newsletter.ts swaps it per message. */
4463
+ export const TOKEN_SLOT = '__UNSUBSCRIBE_TOKEN__'
4464
+
4465
+ /**
4466
+ * An issue, rendered once with @cascivo/email: the body is Markdown, drawn through the email
4467
+ * primitives (raw HTML in it stays literal text). The footer carries the unsubscribe link,
4468
+ * with TOKEN_SLOT where each reader's token goes.
4469
+ */
4470
+ export function renderIssue(subject: string, body: string, origin: string): RenderResult {
4471
+ const unsubscribe = \`\${origin}/newsletter/unsubscribe?token=\${TOKEN_SLOT}\`
4472
+ return renderEmail(
4473
+ h(
4474
+ Html,
4475
+ null,
4476
+ h(Head, { title: subject }),
4477
+ h(
4478
+ Body,
4479
+ null,
4480
+ h(Preview, null, subject),
4481
+ h(
4482
+ Container,
4483
+ null,
4484
+ h(Section, { padding: 32 }, h(Heading, { level: 1 }, subject), h(Markdown, null, body)),
4485
+ h(
4486
+ Footer,
4487
+ null,
4488
+ \`You get this because you subscribed to \${NEWSLETTER_NAME}. \`,
4489
+ h(Link, { href: unsubscribe }, 'Unsubscribe'),
4490
+ ),
4491
+ ),
4492
+ ),
4493
+ ),
4494
+ { subject },
4495
+ )
4496
+ }
4497
+
4498
+ /** The double opt-in email: nobody is mailed an issue until they open this link. */
4499
+ export function renderConfirmation(confirmUrl: string): RenderResult {
4500
+ const subject = \`Confirm your subscription to \${NEWSLETTER_NAME}\`
4501
+ return renderEmail(
4502
+ h(
4503
+ Html,
4504
+ null,
4505
+ h(Head, { title: subject }),
4506
+ h(
4507
+ Body,
4508
+ null,
4509
+ h(Preview, null, 'One click and you are on the list.'),
4510
+ h(
4511
+ Container,
4512
+ null,
4513
+ h(
4514
+ Section,
4515
+ { padding: 32 },
4516
+ h(Heading, { level: 1 }, 'Confirm your subscription'),
4517
+ h(Text, null, \`Someone, hopefully you, asked to get \${NEWSLETTER_NAME} by email.\`),
4518
+ h(Button, { href: confirmUrl }, 'Yes, subscribe me'),
4519
+ h(
4520
+ Text,
4521
+ { variant: 'muted', size: '14px' },
4522
+ 'The link works for 24 hours. If you did not ask, ignore this email: you will not hear from us again.',
4523
+ ),
4524
+ ),
4525
+ ),
4526
+ ),
4527
+ ),
4528
+ { subject },
4529
+ )
4530
+ }
4531
+ `}function Dt(){return`import { HttpError } from '@cascivo/app/api'
4532
+ import { normalizeEmail } from '@cascivo/app/auth-server'
4533
+ import { migrate, queryRows } from '@cascivo/app/db'
4534
+ import type { Database } from '@cascivo/app/db'
4535
+ import { SesError, createSes, handleSns, parseSesNotification } from '@cascivo/app/ses'
4536
+ import { writeRoom } from '@cascivo/app/sync-server'
4537
+ import type { RoomNamespace } from '@cascivo/app/sync-server'
4538
+ import { ISSUE_ID, issueRoom, parseIssue } from '../src/newsletter'
4539
+ import type { Issue, IssueInput, Overview, SubscriberCounts } from '../src/newsletter'
4540
+ import { TOKEN_SLOT, renderConfirmation, renderIssue } from './newsletter-email'
4541
+
4542
+ const migrations = [
4543
+ {
4544
+ id: '0001_newsletter',
4545
+ statements: [
4546
+ \`CREATE TABLE subscribers (
4547
+ email TEXT PRIMARY KEY,
4548
+ status TEXT NOT NULL,
4549
+ confirm_hash TEXT,
4550
+ confirm_expires INTEGER,
4551
+ unsubscribe_token TEXT NOT NULL UNIQUE,
4552
+ reason TEXT,
4553
+ created_at TEXT NOT NULL,
4554
+ confirmed_at TEXT
4555
+ )\`,
4556
+ \`CREATE TABLE issues (
4557
+ id TEXT PRIMARY KEY,
4558
+ subject TEXT NOT NULL,
4559
+ body TEXT NOT NULL,
4560
+ total INTEGER NOT NULL,
4561
+ created_at TEXT NOT NULL
4562
+ )\`,
4563
+ // One row per reader per issue, written as each send finishes: a retried queue message
4564
+ // skips whoever already has one, so nobody gets an issue twice.
4565
+ \`CREATE TABLE deliveries (
4566
+ issue_id TEXT NOT NULL,
4567
+ email TEXT NOT NULL,
4568
+ status TEXT NOT NULL,
4569
+ detail TEXT,
4570
+ at TEXT NOT NULL,
4571
+ PRIMARY KEY (issue_id, email)
4572
+ )\`,
4573
+ ],
4574
+ },
4575
+ ]
4576
+
4577
+ /** Readers per queue message. Each message is sent in one go, one reader after another. */
4578
+ const CHUNK = 25
4579
+ /** A confirmation link works for a day. */
4580
+ const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000
4581
+ /** A pending address gets another link at most this often, so the form cannot flood an inbox. */
4582
+ const RESEND_AFTER_MS = 10 * 60 * 1000
4583
+
4584
+ export interface NewsletterMessage {
4585
+ issueId: string
4586
+ emails: string[]
4587
+ /** The app's origin, for the unsubscribe links: a queue consumer has no request. */
4588
+ origin: string
4589
+ }
4590
+
4591
+ /** What sending needs of the NEWSLETTER queue binding. */
4592
+ export interface NewsletterQueue {
4593
+ sendBatch(messages: Iterable<{ body: NewsletterMessage }>): Promise<void>
4594
+ }
4595
+
4596
+ /** The slice of a Queue consumer's batch \`deliver\` reads. */
4597
+ export interface NewsletterBatch {
4598
+ readonly messages: readonly { readonly body: unknown }[]
4599
+ }
4600
+
4601
+ export interface NewsletterEnv {
4602
+ DB: Database
4603
+ ROOMS: RoomNamespace<unknown>
4604
+ NEWSLETTER: NewsletterQueue
4605
+ /** Set in wrangler.jsonc: the SES region, the From address, the SNS topic for feedback. */
4606
+ AWS_REGION: string
4607
+ NEWSLETTER_FROM: string
4608
+ SNS_TOPIC_ARN: string
4609
+ /** Secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
4610
+ AWS_ACCESS_KEY_ID?: string
4611
+ AWS_SECRET_ACCESS_KEY?: string
4612
+ NEWSLETTER_KEY?: string
4613
+ }
4614
+
4615
+ const encoder = new TextEncoder()
4616
+
4617
+ const cell = (raw: unknown, key: string): unknown =>
4618
+ typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined
4619
+
4620
+ /** A string column of a D1 row. Rows are read like any payload: checked, not cast. */
4621
+ function column(raw: unknown, key: string): string {
4622
+ const value = cell(raw, key)
4623
+ if (typeof value !== 'string') throw new Error(\`Expected a string in column \${key}\`)
4624
+ return value
4625
+ }
4626
+
4627
+ async function sha256(value: string): Promise<string> {
4628
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))
4629
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
4630
+ }
4631
+
4632
+ /** 32 random bytes, URL-safe: a confirmation or unsubscribe token. */
4633
+ function token(): string {
4634
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
4635
+ return btoa(String.fromCharCode(...bytes))
4636
+ .replace(/\\+/g, '-')
4637
+ .replace(/\\//g, '_')
4638
+ .replace(/=+$/, '')
4639
+ }
4640
+
4641
+ /** Refuses a request without NEWSLETTER_KEY. Compares digests, so timing says nothing. */
4642
+ async function requireKey(env: NewsletterEnv, key: string): Promise<void> {
4643
+ if (!env.NEWSLETTER_KEY) throw new HttpError(503, 'Set NEWSLETTER_KEY (README)')
4644
+ if ((await sha256(key)) !== (await sha256(env.NEWSLETTER_KEY))) {
4645
+ throw new HttpError(403, 'Wrong newsletter key')
4646
+ }
4647
+ }
4648
+
4649
+ interface Mail {
4650
+ subject: string
4651
+ html: string
4652
+ text: string
4653
+ headers?: Record<string, string>
4654
+ }
4655
+
4656
+ type Send = (to: string, mail: Mail) => Promise<'sent' | 'logged'>
4657
+
4658
+ /**
4659
+ * Sends one email through SES, or, in \`vite dev\` without AWS credentials, logs it instead.
4660
+ * Deployed without them, it refuses with what to set.
4661
+ */
4662
+ function mailer(env: NewsletterEnv): Send {
4663
+ const configured =
4664
+ env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_REGION && env.NEWSLETTER_FROM
4665
+ if (!configured) {
4666
+ if (!import.meta.env.DEV) {
4667
+ throw new HttpError(
4668
+ 503,
4669
+ 'Set AWS_REGION and NEWSLETTER_FROM in wrangler.jsonc, and the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets (README)',
4670
+ )
4671
+ }
4672
+ return async (to, message) => {
4673
+ console.log(\`[newsletter] not sent (no SES credentials) to \${to}: "\${message.subject}"\`)
4674
+ return 'logged'
4675
+ }
4676
+ }
4677
+ const ses = createSes({
4678
+ region: env.AWS_REGION,
4679
+ accessKeyId: env.AWS_ACCESS_KEY_ID!,
4680
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,
4681
+ })
4682
+ return async (to, message) => {
4683
+ await ses.sendEmail({ from: env.NEWSLETTER_FROM, to, ...message })
4684
+ return 'sent'
4685
+ }
4686
+ }
4687
+
4688
+ /**
4689
+ * Signs someone up: a pending subscriber and a confirmation email. The answer is the same
4690
+ * whoever asks, so the form does not tell strangers who is on the list. \`vite dev\` without
4691
+ * SES also returns the confirmation link, to open instead of an email.
4692
+ */
4693
+ export async function subscribe(
4694
+ env: NewsletterEnv,
4695
+ rawEmail: string,
4696
+ origin: string,
4697
+ ): Promise<{ devLink: string | null }> {
4698
+ const email = normalizeEmail(rawEmail)
4699
+ const send = mailer(env)
4700
+ await migrate(env.DB, migrations)
4701
+ const confirm = token()
4702
+ const now = new Date()
4703
+ const expires = now.getTime() + CONFIRM_TTL_MS
4704
+ // A new or unsubscribed address gets a link; a pending one gets a fresh link at most every
4705
+ // ten minutes. A subscribed or suppressed one is left alone, and nothing is sent.
4706
+ const [row] = await queryRows(
4707
+ env.DB,
4708
+ \`INSERT INTO subscribers
4709
+ (email, status, confirm_hash, confirm_expires, unsubscribe_token, created_at)
4710
+ VALUES (?, 'pending', ?, ?, ?, ?)
4711
+ ON CONFLICT (email) DO UPDATE SET
4712
+ status = 'pending', reason = NULL,
4713
+ confirm_hash = excluded.confirm_hash, confirm_expires = excluded.confirm_expires
4714
+ WHERE subscribers.status = 'unsubscribed'
4715
+ OR (subscribers.status = 'pending' AND subscribers.confirm_expires < ?)
4716
+ RETURNING email\`,
4717
+ [email, await sha256(confirm), expires, token(), now.toISOString(), expires - RESEND_AFTER_MS],
4718
+ (raw) => column(raw, 'email'),
4719
+ )
4720
+ if (!row) return { devLink: null }
4721
+ const link = \`\${origin}/newsletter/confirm?token=\${confirm}\`
4722
+ const message = renderConfirmation(link)
4723
+ let outcome: 'sent' | 'logged'
4724
+ try {
4725
+ outcome = await send(email, {
4726
+ subject: message.subject,
4727
+ html: message.html,
4728
+ text: message.text,
4729
+ })
4730
+ } catch (error) {
4731
+ if (!(error instanceof SesError)) throw error
4732
+ console.error('[newsletter] confirmation not sent:', error.code, error.message)
4733
+ // Let the reader try again at once rather than wait out the resend interval.
4734
+ await queryRows(
4735
+ env.DB,
4736
+ "UPDATE subscribers SET confirm_expires = 0 WHERE email = ? AND status = 'pending' RETURNING email",
4737
+ [email],
4738
+ (raw) => raw,
4739
+ )
4740
+ throw new HttpError(502, 'Could not send the confirmation email. Try again in a moment.')
4741
+ }
4742
+ return { devLink: outcome === 'logged' ? link : null }
4743
+ }
4744
+
4745
+ /** Opens a confirmation link: the subscriber is on the list from now on. */
4746
+ export async function confirm(env: NewsletterEnv, rawToken: string): Promise<{ email: string }> {
4747
+ await migrate(env.DB, migrations)
4748
+ const [row] = await queryRows(
4749
+ env.DB,
4750
+ \`UPDATE subscribers SET status = 'subscribed', confirm_hash = NULL, confirmed_at = ?
4751
+ WHERE confirm_hash = ? AND confirm_expires > ? AND status = 'pending' RETURNING email\`,
4752
+ [new Date().toISOString(), await sha256(rawToken), Date.now()],
4753
+ (raw) => ({ email: column(raw, 'email') }),
4754
+ )
4755
+ if (!row) throw new HttpError(400, 'This link has expired or was used already. Sign up again.')
4756
+ return row
4757
+ }
4758
+
4759
+ /**
4760
+ * \`POST /api/newsletter/unsubscribe?token=…\`: the page's button, and the one-click
4761
+ * unsubscribe mail clients send (RFC 8058) from the List-Unsubscribe header. Always 200, so a
4762
+ * token says nothing about who it belongs to, and a second click is not an error.
4763
+ */
4764
+ export async function unsubscribe(request: Request, env: NewsletterEnv): Promise<Response> {
4765
+ const unsubscribeToken = new URL(request.url).searchParams.get('token') ?? ''
4766
+ await migrate(env.DB, migrations)
4767
+ await queryRows(
4768
+ env.DB,
4769
+ \`UPDATE subscribers SET status = 'unsubscribed', reason = 'unsubscribed'
4770
+ WHERE unsubscribe_token = ? AND status IN ('pending', 'subscribed') RETURNING email\`,
4771
+ [unsubscribeToken],
4772
+ (raw) => raw,
4773
+ )
4774
+ return Response.json({ unsubscribed: true })
4775
+ }
4776
+
4777
+ const ISSUE_COLUMNS = \`issues.id, issues.subject, issues.created_at AS createdAt, issues.total,
4778
+ (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status != 'failed') AS sent,
4779
+ (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status = 'failed') AS failed\`
4780
+
4781
+ async function readIssue(db: Database, id: string): Promise<Issue> {
4782
+ const [issue] = await queryRows(
4783
+ db,
4784
+ \`SELECT \${ISSUE_COLUMNS} FROM issues WHERE id = ?\`,
4785
+ [id],
4786
+ parseIssue,
4787
+ )
4788
+ if (!issue) throw new HttpError(404, 'No such issue')
4789
+ return issue
4790
+ }
4791
+
4792
+ /** Subscriber counts and the last 20 issues, for the composer. */
4793
+ export async function overview(env: NewsletterEnv, key: string): Promise<Overview> {
4794
+ await requireKey(env, key)
4795
+ await migrate(env.DB, migrations)
4796
+ const subscribers: SubscriberCounts = {
4797
+ pending: 0,
4798
+ subscribed: 0,
4799
+ unsubscribed: 0,
4800
+ suppressed: 0,
4801
+ }
4802
+ const counts = await queryRows(
4803
+ env.DB,
4804
+ 'SELECT status, COUNT(*) AS n FROM subscribers GROUP BY status',
4805
+ [],
4806
+ (raw) => ({ status: column(raw, 'status'), n: Number(cell(raw, 'n')) }),
4807
+ )
4808
+ for (const { status, n } of counts) {
4809
+ if (status === 'pending' || status === 'subscribed') subscribers[status] = n
4810
+ if (status === 'unsubscribed' || status === 'suppressed') subscribers[status] = n
4811
+ }
4812
+ const issues = await queryRows(
4813
+ env.DB,
4814
+ \`SELECT \${ISSUE_COLUMNS} FROM issues ORDER BY created_at DESC LIMIT 20\`,
4815
+ [],
4816
+ parseIssue,
4817
+ )
4818
+ return { subscribers, issues }
4819
+ }
4820
+
4821
+ /** The issue as readers will get it, for the composer's preview. */
4822
+ export async function preview(
4823
+ env: NewsletterEnv,
4824
+ input: IssueInput,
4825
+ origin: string,
4826
+ ): Promise<{ html: string; bytes: number }> {
4827
+ await requireKey(env, input.key)
4828
+ const { html, stats } = renderIssue(input.subject, input.body, origin)
4829
+ return { html, bytes: stats.bytes }
4830
+ }
4831
+
4832
+ /**
4833
+ * Sends an issue to every confirmed subscriber: the issue is stored, and its readers go onto
4834
+ * the NEWSLETTER queue in chunks, which \`deliver\` sends at the queue's pace.
4835
+ */
4836
+ export async function sendIssue(
4837
+ env: NewsletterEnv,
4838
+ input: IssueInput,
4839
+ origin: string,
4840
+ ): Promise<Issue> {
4841
+ await requireKey(env, input.key)
4842
+ mailer(env) // Deployed without SES, refuse now rather than fail in the queue.
4843
+ await migrate(env.DB, migrations)
4844
+ const readers = await queryRows(
4845
+ env.DB,
4846
+ "SELECT email FROM subscribers WHERE status = 'subscribed' ORDER BY email",
4847
+ [],
4848
+ (raw) => column(raw, 'email'),
4849
+ )
4850
+ const id = crypto.randomUUID()
4851
+ await queryRows(
4852
+ env.DB,
4853
+ 'INSERT INTO issues (id, subject, body, total, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id',
4854
+ [id, input.subject, input.body, readers.length, new Date().toISOString()],
4855
+ (raw) => raw,
4856
+ )
4857
+ const messages: { body: NewsletterMessage }[] = []
4858
+ for (let i = 0; i < readers.length; i += CHUNK) {
4859
+ messages.push({ body: { issueId: id, emails: readers.slice(i, i + CHUNK), origin } })
4860
+ }
4861
+ // sendBatch takes at most 100 messages a call.
4862
+ for (let i = 0; i < messages.length; i += 100) {
4863
+ await env.NEWSLETTER.sendBatch(messages.slice(i, i + 100))
4864
+ }
4865
+ const issue = await readIssue(env.DB, id)
4866
+ await writeRoom(env.ROOMS, issueRoom(id), 'issue', { ...issue })
4867
+ return issue
4868
+ }
4869
+
4870
+ function parseMessage(raw: unknown): NewsletterMessage {
4871
+ if (typeof raw === 'object' && raw !== null) {
4872
+ const { issueId, emails, origin } = raw as Record<string, unknown>
4873
+ if (
4874
+ typeof issueId === 'string' &&
4875
+ ISSUE_ID.test(issueId) &&
4876
+ Array.isArray(emails) &&
4877
+ emails.every((e) => typeof e === 'string') &&
4878
+ typeof origin === 'string'
4879
+ ) {
4880
+ return { issueId, emails: emails as string[], origin }
4881
+ }
4882
+ }
4883
+ throw new Error('Malformed newsletter message')
4884
+ }
4885
+
4886
+ /**
4887
+ * The NEWSLETTER queue's consumer: sends each reader in the message their copy, with their
4888
+ * own unsubscribe link and the one-click headers bulk senders need. A reader who left or was
4889
+ * suppressed since the issue was queued is skipped. SES throttling or a server error throws,
4890
+ * and the queue retries the message: whoever was sent already has a delivery row and is not
4891
+ * sent again. A refusal for one address (an invalid one, say) is recorded and the rest go on.
4892
+ */
4893
+ export async function deliver(env: NewsletterEnv, batch: NewsletterBatch): Promise<void> {
4894
+ await migrate(env.DB, migrations)
4895
+ const send = mailer(env)
4896
+ for (const { body } of batch.messages) {
4897
+ const { issueId, emails, origin } = parseMessage(body)
4898
+ const [issue] = await queryRows(
4899
+ env.DB,
4900
+ 'SELECT subject, body FROM issues WHERE id = ?',
4901
+ [issueId],
4902
+ (raw) => ({ subject: column(raw, 'subject'), body: column(raw, 'body') }),
4903
+ )
4904
+ if (!issue) continue
4905
+ const rendered = renderIssue(issue.subject, issue.body, origin)
4906
+ const placeholders = emails.map(() => '?').join(', ')
4907
+ const readers = await queryRows(
4908
+ env.DB,
4909
+ \`SELECT s.email, s.unsubscribe_token AS token FROM subscribers s
4910
+ WHERE s.email IN (\${placeholders}) AND s.status = 'subscribed'
4911
+ AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.issue_id = ? AND d.email = s.email)\`,
4912
+ [...emails, issueId],
4913
+ (raw) => ({ email: column(raw, 'email'), token: column(raw, 'token') }),
4914
+ )
4915
+ for (const { email, token: readerToken } of readers) {
4916
+ const unsubscribe = \`\${origin}/api/newsletter/unsubscribe?token=\${readerToken}\`
4917
+ let status: string
4918
+ let detail: string | null = null
4919
+ try {
4920
+ status = await send(email, {
4921
+ subject: rendered.subject,
4922
+ html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),
4923
+ text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),
4924
+ headers: {
4925
+ 'List-Unsubscribe': \`<\${unsubscribe}>\`,
4926
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
4927
+ },
4928
+ })
4929
+ } catch (error) {
4930
+ if (!(error instanceof SesError) || error.retryable) throw error
4931
+ status = 'failed'
4932
+ detail = \`\${error.code ?? error.status}: \${error.message}\`.slice(0, 300)
4933
+ }
4934
+ await queryRows(
4935
+ env.DB,
4936
+ \`INSERT INTO deliveries (issue_id, email, status, detail, at) VALUES (?, ?, ?, ?, ?)
4937
+ ON CONFLICT DO NOTHING RETURNING email\`,
4938
+ [issueId, email, status, detail, new Date().toISOString()],
4939
+ (raw) => raw,
4940
+ )
4941
+ }
4942
+ const progress = await readIssue(env.DB, issueId)
4943
+ await writeRoom(env.ROOMS, issueRoom(issueId), 'issue', { ...progress })
4944
+ }
4945
+ }
4946
+
4947
+ /**
4948
+ * SES's feedback, delivered by SNS: a permanent bounce or a complaint suppresses the address
4949
+ * for good. Sending to them again is what gets an SES account put under review.
4950
+ */
4951
+ export async function receiveFeedback(request: Request, env: NewsletterEnv): Promise<Response> {
4952
+ if (!env.SNS_TOPIC_ARN) throw new HttpError(503, 'Set SNS_TOPIC_ARN in wrangler.jsonc (README)')
4953
+ return handleSns(request, {
4954
+ topicArn: env.SNS_TOPIC_ARN,
4955
+ onNotification: async ({ message }) => {
4956
+ const event = parseSesNotification(message)
4957
+ const suppress =
4958
+ event.kind === 'complaint' || (event.kind === 'bounce' && event.bounceType === 'Permanent')
4959
+ if (!suppress || event.recipients.length === 0) return
4960
+ await migrate(env.DB, migrations)
4961
+ // D1 binds at most 100 parameters; one bounce rarely names more than a few addresses.
4962
+ const recipients = event.recipients
4963
+ .slice(0, 90)
4964
+ .map((address) => address.trim().toLowerCase())
4965
+ await queryRows(
4966
+ env.DB,
4967
+ \`UPDATE subscribers SET status = 'suppressed', reason = ?
4968
+ WHERE email IN (\${recipients.map(() => '?').join(', ')}) RETURNING email\`,
4969
+ [event.kind, ...recipients],
4970
+ (raw) => raw,
4971
+ )
4972
+ },
4973
+ })
4974
+ }
4975
+ `}function Ot(){return`import { createClient } from '@cascivo/app/api'
4976
+ import {
4977
+ Alert,
4978
+ Button,
4979
+ Card,
4980
+ CardContent,
4981
+ Flex,
4982
+ Heading,
4983
+ Input,
4984
+ Link,
4985
+ Text,
4986
+ signal,
4987
+ useSignals,
4988
+ } from '@cascivo/react'
4989
+ import type { FormEvent } from 'react'
4990
+ import { api } from '../api'
4991
+
4992
+ const client = createClient(api)
4993
+ const sentTo = signal<string | null>(null)
4994
+ /** Set only in \`vite dev\` without SES: the confirmation link, to open instead of an email. */
4995
+ const devLink = signal<string | null>(null)
4996
+ const failure = signal<string | null>(null)
4997
+ const sending = signal(false)
4998
+
4999
+ async function signUp(event: FormEvent<HTMLFormElement>): Promise<void> {
5000
+ event.preventDefault()
5001
+ const email = new FormData(event.currentTarget).get('email')
5002
+ if (typeof email !== 'string') return
5003
+ failure.value = null
5004
+ sending.value = true
5005
+ try {
5006
+ const { devLink: link } = await client.subscribe({ body: { email } })
5007
+ sentTo.value = email
5008
+ devLink.value = link
5009
+ } catch (error) {
5010
+ failure.value = error instanceof Error ? error.message : 'Could not sign you up'
5011
+ } finally {
5012
+ sending.value = false
5013
+ }
5014
+ }
5015
+
5016
+ export default function Newsletter() {
5017
+ useSignals()
5018
+ return (
5019
+ <Flex gap={4}>
5020
+ <Flex gap={1}>
5021
+ <Heading level={1}>Newsletter</Heading>
5022
+ <Text muted>
5023
+ An email now and then. We send a link first: you are on the list only once you open it,
5024
+ and every issue has a one-click unsubscribe.
5025
+ </Text>
5026
+ </Flex>
5027
+ <Card>
5028
+ <CardContent>
5029
+ <form onSubmit={(event) => void signUp(event)}>
5030
+ <Flex direction="horizontal" align="end" gap={2} wrap>
5031
+ <Input name="email" type="email" label="Email" autoComplete="email" required />
5032
+ <Button type="submit" loading={sending.value}>
5033
+ Subscribe
5034
+ </Button>
5035
+ </Flex>
5036
+ </form>
5037
+ </CardContent>
5038
+ </Card>
5039
+ {sentTo.value ? (
5040
+ <Alert variant="success" title="Check your inbox">
5041
+ If {sentTo.value} is not on the list yet, a confirmation link is on its way.
5042
+ </Alert>
5043
+ ) : null}
5044
+ {devLink.value ? (
5045
+ <Alert variant="info" title="vite dev sends no email without SES">
5046
+ <Link href={devLink.value}>Open the confirmation link</Link>
5047
+ </Alert>
5048
+ ) : null}
5049
+ {failure.value ? (
5050
+ <Alert variant="destructive" title="Not signed up">
5051
+ {failure.value}
5052
+ </Alert>
5053
+ ) : null}
5054
+ </Flex>
5055
+ )
5056
+ }
5057
+ `}function kt(){return`import { createClient } from '@cascivo/app/api'
5058
+ import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
5059
+ import { api } from '../../api'
5060
+ import { router } from '../../router'
5061
+
5062
+ const client = createClient(api)
5063
+ const confirmed = signal<string | null>(null)
5064
+ const failure = signal<string | null>(null)
5065
+ const busy = signal(false)
5066
+
5067
+ /**
5068
+ * The page a confirmation link opens. It confirms only when you press the button: mail
5069
+ * scanners open every link in a message, and would otherwise subscribe whoever was typed in.
5070
+ */
5071
+ async function confirm(): Promise<void> {
5072
+ const token = new URLSearchParams(router.search.value).get('token')
5073
+ if (!token) {
5074
+ failure.value = 'This link has no token. Sign up again.'
5075
+ return
5076
+ }
5077
+ busy.value = true
5078
+ failure.value = null
5079
+ try {
5080
+ confirmed.value = (await client.confirmSubscription({ body: { token } })).email
5081
+ } catch (error) {
5082
+ failure.value = error instanceof Error ? error.message : 'Could not confirm'
5083
+ } finally {
5084
+ busy.value = false
5085
+ }
5086
+ }
5087
+
5088
+ export default function ConfirmSubscription() {
5089
+ useSignals()
5090
+ return (
5091
+ <Flex gap={4}>
5092
+ <Heading level={1}>Confirm your subscription</Heading>
5093
+ {confirmed.value ? (
5094
+ <Alert variant="success" title="You are on the list">
5095
+ The next issue goes to {confirmed.value}.
5096
+ </Alert>
5097
+ ) : (
5098
+ <Flex gap={2}>
5099
+ <Text muted>One click and you get the newsletter.</Text>
5100
+ <Flex direction="horizontal">
5101
+ <Button loading={busy.value} onClick={() => void confirm()}>
5102
+ Yes, subscribe me
5103
+ </Button>
5104
+ </Flex>
5105
+ </Flex>
5106
+ )}
5107
+ {failure.value ? (
5108
+ <Alert variant="destructive" title="Not confirmed">
5109
+ {failure.value}
5110
+ </Alert>
5111
+ ) : null}
5112
+ </Flex>
5113
+ )
5114
+ }
5115
+ `}function At(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
5116
+ import { router } from '../../router'
5117
+
5118
+ const done = signal(false)
5119
+ const failure = signal<string | null>(null)
5120
+ const busy = signal(false)
5121
+
5122
+ /**
5123
+ * The footer link of every issue. Like the confirmation, it acts on a button press, never on
5124
+ * opening the page. Mail clients that support one-click unsubscribe skip this page and POST
5125
+ * to the same endpoint from the List-Unsubscribe header.
5126
+ */
5127
+ async function unsubscribe(): Promise<void> {
5128
+ const token = new URLSearchParams(router.search.value).get('token') ?? ''
5129
+ busy.value = true
5130
+ failure.value = null
5131
+ try {
5132
+ const response = await fetch(\`/api/newsletter/unsubscribe?token=\${encodeURIComponent(token)}\`, {
5133
+ method: 'POST',
5134
+ })
5135
+ if (!response.ok) throw new Error(\`Request failed with status \${response.status}\`)
5136
+ done.value = true
5137
+ } catch (error) {
5138
+ failure.value = error instanceof Error ? error.message : 'Could not unsubscribe'
5139
+ } finally {
5140
+ busy.value = false
5141
+ }
5142
+ }
5143
+
5144
+ export default function Unsubscribe() {
5145
+ useSignals()
5146
+ return (
5147
+ <Flex gap={4}>
5148
+ <Heading level={1}>Unsubscribe</Heading>
5149
+ {done.value ? (
5150
+ <Alert variant="success" title="You are unsubscribed">
5151
+ You will get no more issues. Sign up again any time.
5152
+ </Alert>
5153
+ ) : (
5154
+ <Flex gap={2}>
5155
+ <Text muted>Stop getting the newsletter at this address.</Text>
5156
+ <Flex direction="horizontal">
5157
+ <Button variant="destructive" loading={busy.value} onClick={() => void unsubscribe()}>
5158
+ Unsubscribe
5159
+ </Button>
5160
+ </Flex>
5161
+ </Flex>
5162
+ )}
5163
+ {failure.value ? (
5164
+ <Alert variant="destructive" title="Not unsubscribed">
5165
+ {failure.value}
5166
+ </Alert>
5167
+ ) : null}
5168
+ </Flex>
5169
+ )
5170
+ }
5171
+ `}function jt(){return`import { createClient } from '@cascivo/app/api'
5172
+ import { connectRoom } from '@cascivo/app/sync'
5173
+ import {
5174
+ Alert,
5175
+ Badge,
5176
+ Button,
5177
+ Card,
5178
+ CardContent,
5179
+ EmptyState,
5180
+ Flex,
5181
+ Heading,
5182
+ Input,
5183
+ Text,
5184
+ Textarea,
5185
+ signal,
5186
+ useSignals,
5187
+ } from '@cascivo/react'
5188
+ import type { MouseEvent } from 'react'
5189
+ import { api } from '../../api'
5190
+ import { MAX_BODY, MAX_SUBJECT, parseIssue } from '../../newsletter'
5191
+ import type { Issue, Overview } from '../../newsletter'
5192
+
5193
+ const client = createClient(api)
5194
+ const overview = signal<Overview | null>(null)
5195
+ const previewHtml = signal<string | null>(null)
5196
+ const busy = signal<'load' | 'preview' | 'send' | null>(null)
5197
+ const failure = signal<string | null>(null)
5198
+ const notice = signal<string | null>(null)
5199
+
5200
+ /** The composer's fields, read from the form the clicked button belongs to. */
5201
+ function fields(event: MouseEvent<HTMLButtonElement>) {
5202
+ const form = event.currentTarget.form
5203
+ const data = form ? new FormData(form) : new FormData()
5204
+ const read = (name: string) => {
5205
+ const value = data.get(name)
5206
+ return typeof value === 'string' ? value : ''
5207
+ }
5208
+ return { key: read('key'), subject: read('subject'), body: read('body') }
5209
+ }
5210
+
5211
+ async function run(kind: 'load' | 'preview' | 'send', task: () => Promise<void>): Promise<void> {
5212
+ busy.value = kind
5213
+ failure.value = null
5214
+ notice.value = null
5215
+ try {
5216
+ await task()
5217
+ } catch (error) {
5218
+ failure.value = error instanceof Error ? error.message : 'Something went wrong'
5219
+ } finally {
5220
+ busy.value = null
5221
+ }
5222
+ }
5223
+
5224
+ /** Shows an issue's progress as the Worker pushes it, until every reader has had their copy. */
5225
+ function watch(issue: Issue): void {
5226
+ const room = connectRoom(\`/api/newsletter/issues/\${issue.id}/live\`)
5227
+ const pushed = room.signal<Issue | null>('issue', null, (raw) =>
5228
+ raw === null ? null : parseIssue(raw),
5229
+ )
5230
+ const stop = pushed.signal.subscribe((latest) => {
5231
+ const current = overview.peek()
5232
+ if (!latest || !current) return
5233
+ overview.value = {
5234
+ ...current,
5235
+ issues: current.issues.map((i) => (i.id === latest.id ? latest : i)),
5236
+ }
5237
+ if (latest.sent + latest.failed >= latest.total) {
5238
+ stop()
5239
+ room.close()
5240
+ }
5241
+ })
5242
+ }
5243
+
5244
+ function load(event: MouseEvent<HTMLButtonElement>): void {
5245
+ const { key } = fields(event)
5246
+ void run('load', async () => {
5247
+ overview.value = await client.newsletterOverview({ body: { key } })
5248
+ })
5249
+ }
5250
+
5251
+ function preview(event: MouseEvent<HTMLButtonElement>): void {
5252
+ const input = fields(event)
5253
+ void run('preview', async () => {
5254
+ previewHtml.value = (await client.previewIssue({ body: input })).html
5255
+ })
5256
+ }
5257
+
5258
+ function send(event: MouseEvent<HTMLButtonElement>): void {
5259
+ const input = fields(event)
5260
+ const readers = overview.value?.subscribers.subscribed ?? 0
5261
+ if (!window.confirm(\`Send "\${input.subject}" to \${readers} subscribers?\`)) return
5262
+ void run('send', async () => {
5263
+ const issue = await client.sendIssue({ body: input })
5264
+ overview.value = await client.newsletterOverview({ body: { key: input.key } })
5265
+ notice.value = \`Queued for \${issue.total} subscribers.\`
5266
+ watch(issue)
5267
+ })
5268
+ }
5269
+
5270
+ export default function SendNewsletter() {
5271
+ useSignals()
5272
+ const counts = overview.value?.subscribers
5273
+ return (
5274
+ <Flex gap={4}>
5275
+ <Flex gap={1}>
5276
+ <Heading level={1}>Send the newsletter</Heading>
5277
+ <Text muted>
5278
+ Written in Markdown, rendered with @cascivo/email, sent through Amazon SES to every
5279
+ confirmed subscriber. Only someone with NEWSLETTER_KEY can send.
5280
+ </Text>
5281
+ </Flex>
5282
+ <form onSubmit={(event) => event.preventDefault()}>
5283
+ <Flex gap={3}>
5284
+ <Flex direction="horizontal" align="end" gap={2} wrap>
5285
+ <Input name="key" type="password" label="Newsletter key" autoComplete="off" required />
5286
+ <Button variant="secondary" loading={busy.value === 'load'} onClick={load}>
5287
+ Show subscribers
5288
+ </Button>
5289
+ </Flex>
5290
+ <Input name="subject" label="Subject" maxLength={MAX_SUBJECT} required />
5291
+ <Textarea
5292
+ name="body"
5293
+ label="Body"
5294
+ hint="Markdown: headings, **bold**, links, lists, quotes and images."
5295
+ rows={12}
5296
+ maxLength={MAX_BODY}
5297
+ required
5298
+ />
5299
+ <Flex direction="horizontal" gap={2} wrap>
5300
+ <Button variant="secondary" loading={busy.value === 'preview'} onClick={preview}>
5301
+ Preview
5302
+ </Button>
5303
+ <Button loading={busy.value === 'send'} disabled={!counts} onClick={send}>
5304
+ Send to {counts ? counts.subscribed : '…'} subscribers
5305
+ </Button>
5306
+ </Flex>
5307
+ </Flex>
5308
+ </form>
5309
+ {failure.value ? (
5310
+ <Alert variant="destructive" title="Not done">
5311
+ {failure.value}
5312
+ </Alert>
5313
+ ) : null}
5314
+ {notice.value ? (
5315
+ <Alert variant="success" title="Sending">
5316
+ {notice.value}
5317
+ </Alert>
5318
+ ) : null}
5319
+ {previewHtml.value ? (
5320
+ <Card>
5321
+ <CardContent>
5322
+ {/* sandbox: the preview runs no script and cannot reach this page. */}
5323
+ <iframe
5324
+ title="Preview"
5325
+ sandbox=""
5326
+ srcDoc={previewHtml.value}
5327
+ width="100%"
5328
+ height="640"
5329
+ />
5330
+ </CardContent>
5331
+ </Card>
5332
+ ) : null}
5333
+ {counts ? (
5334
+ <Flex direction="horizontal" gap={2} wrap>
5335
+ <Badge variant="success">{counts.subscribed} subscribed</Badge>
5336
+ <Badge variant="warning">{counts.pending} not confirmed</Badge>
5337
+ <Badge variant="secondary">{counts.unsubscribed} unsubscribed</Badge>
5338
+ <Badge variant="destructive">{counts.suppressed} suppressed</Badge>
5339
+ </Flex>
5340
+ ) : null}
5341
+ {overview.value && overview.value.issues.length === 0 ? (
5342
+ <EmptyState title="No issues yet" description="Write one above and send it." />
5343
+ ) : null}
5344
+ {overview.value && overview.value.issues.length > 0 ? (
5345
+ <Flex gap={2} role="list" aria-label="Issues">
5346
+ {overview.value.issues.map((issue) => (
5347
+ <Flex key={issue.id} role="listitem" direction="horizontal" align="center" gap={2} wrap>
5348
+ <Badge variant={issue.sent + issue.failed >= issue.total ? 'success' : 'warning'}>
5349
+ {issue.sent}/{issue.total} sent
5350
+ </Badge>
5351
+ {issue.failed > 0 ? <Badge variant="destructive">{issue.failed} failed</Badge> : null}
5352
+ <Text>{issue.subject}</Text>
5353
+ <Text size="sm" muted>
5354
+ {new Date(issue.createdAt).toLocaleString()}
5355
+ </Text>
5356
+ </Flex>
5357
+ ))}
5358
+ </Flex>
5359
+ ) : null}
5360
+ </Flex>
5361
+ )
5362
+ }
5363
+ `}function Mt(){return`/**
5364
+ * The subscription plan /billing sells, shared by the Worker (worker/billing.ts) and the page.
5365
+ * A subscription belongs to a signed-in user, so this exists only with --auth email.
5366
+ */
5367
+
5368
+ /** The plan. The Worker sends this to Stripe; the page only displays it. */
5369
+ export const PLAN = {
5370
+ name: 'Pro',
5371
+ description: 'Everything in the app, billed monthly. Cancel any time from the billing portal.',
5372
+ /** In the currency's smallest unit, per interval: 900 is €9.00. */
5373
+ amount: 900,
5374
+ currency: 'eur',
5375
+ interval: 'month' as const,
5376
+ }
5377
+
5378
+ /** \`none\` before the first subscription; otherwise Stripe's subscription status. */
5379
+ export type BillingStatus =
5380
+ | 'none'
5381
+ | 'incomplete'
5382
+ | 'incomplete_expired'
5383
+ | 'trialing'
5384
+ | 'active'
5385
+ | 'past_due'
5386
+ | 'canceled'
5387
+ | 'unpaid'
5388
+ | 'paused'
5389
+
5390
+ const STATUSES: readonly BillingStatus[] = [
5391
+ 'none',
5392
+ 'incomplete',
5393
+ 'incomplete_expired',
5394
+ 'trialing',
5395
+ 'active',
5396
+ 'past_due',
5397
+ 'canceled',
5398
+ 'unpaid',
5399
+ 'paused',
5400
+ ]
5401
+
5402
+ export interface Billing {
5403
+ status: BillingStatus
5404
+ /** Whether the plan's features are on: the subscription is active or trialing. */
5405
+ active: boolean
5406
+ /** When the current period ends: the next charge, or the end of a cancelled plan. */
5407
+ currentPeriodEnd: string | null
5408
+ /** Cancelled, but running until currentPeriodEnd. */
5409
+ cancelAtPeriodEnd: boolean
5410
+ /** Has a Stripe customer, so the billing portal can open. */
5411
+ canManage: boolean
5412
+ }
5413
+
5414
+ /** The statuses that unlock the plan. Check this in the Worker before serving a paid feature. */
5415
+ export const isActive = (status: BillingStatus) => status === 'active' || status === 'trialing'
5416
+
5417
+ export function parseBilling(raw: unknown): Billing {
5418
+ if (typeof raw === 'object' && raw !== null) {
5419
+ const { status, active, currentPeriodEnd, cancelAtPeriodEnd, canManage } = raw as Record<
5420
+ string,
5421
+ unknown
5422
+ >
5423
+ const known = STATUSES.find((s) => s === status)
5424
+ if (
5425
+ known &&
5426
+ typeof active === 'boolean' &&
5427
+ (currentPeriodEnd === null || typeof currentPeriodEnd === 'string') &&
5428
+ typeof cancelAtPeriodEnd === 'boolean' &&
5429
+ typeof canManage === 'boolean'
5430
+ ) {
5431
+ return { status: known, active, currentPeriodEnd, cancelAtPeriodEnd, canManage }
5432
+ }
5433
+ }
5434
+ throw new Error('Malformed billing')
5435
+ }
5436
+
5437
+ /** Where to send the browser next: Stripe's checkout or its billing portal. */
5438
+ export function parseRedirect(raw: unknown): { url: string } {
5439
+ if (typeof raw === 'object' && raw !== null) {
5440
+ const { url } = raw as Record<string, unknown>
5441
+ if (typeof url === 'string' && url.startsWith('https://')) return { url }
5442
+ }
5443
+ throw new Error('Malformed redirect')
5444
+ }
5445
+
5446
+ export function parseSyncInput(raw: unknown): { sessionId: string } {
5447
+ if (typeof raw === 'object' && raw !== null) {
5448
+ const { sessionId } = raw as Record<string, unknown>
5449
+ if (typeof sessionId === 'string' && /^cs_[\\w]{1,250}$/.test(sessionId)) return { sessionId }
5450
+ }
5451
+ throw new Error('Expected { sessionId }: a Checkout Session id')
5452
+ }
5453
+ `}function Nt(){return`import { HttpError } from '@cascivo/app/api'
5454
+ import { requireUser } from '@cascivo/app/auth-server'
5455
+ import { migrate, queryRows } from '@cascivo/app/db'
5456
+ import type { Database } from '@cascivo/app/db'
5457
+ import type { Subscription } from '@cascivo/app/stripe'
5458
+ import { PLAN, isActive, parseBilling } from '../src/billing'
5459
+ import type { Billing } from '../src/billing'
5460
+ import { refused, stripeOf } from './checkout'
5461
+
5462
+ const migrations = [
5463
+ {
5464
+ id: '0001_billing',
5465
+ statements: [
5466
+ \`CREATE TABLE billing (
5467
+ user_id TEXT PRIMARY KEY,
5468
+ customer_id TEXT,
5469
+ subscription_id TEXT,
5470
+ status TEXT NOT NULL,
5471
+ current_period_end INTEGER,
5472
+ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
5473
+ updated_at TEXT NOT NULL
5474
+ )\`,
5475
+ ],
5476
+ },
5477
+ ]
5478
+
5479
+ export interface BillingEnv {
5480
+ DB: Database
5481
+ STRIPE_SECRET_KEY?: string
5482
+ }
5483
+
5484
+ interface Row {
5485
+ status: string
5486
+ customerId: string | null
5487
+ currentPeriodEnd: number | null
5488
+ cancelAtPeriodEnd: number
5489
+ }
5490
+
5491
+ const cell = (raw: unknown, key: string): unknown =>
5492
+ typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined
5493
+
5494
+ function parseRow(raw: unknown): Row {
5495
+ const status = cell(raw, 'status')
5496
+ const customerId = cell(raw, 'customerId')
5497
+ const end = cell(raw, 'currentPeriodEnd')
5498
+ if (typeof status !== 'string') throw new Error('Malformed billing row')
5499
+ return {
5500
+ status,
5501
+ customerId: typeof customerId === 'string' ? customerId : null,
5502
+ currentPeriodEnd: typeof end === 'number' ? end : null,
5503
+ cancelAtPeriodEnd: Number(cell(raw, 'cancelAtPeriodEnd')),
5504
+ }
5505
+ }
5506
+
5507
+ async function readRow(db: Database, userId: string): Promise<Row | null> {
5508
+ await migrate(db, migrations)
5509
+ const [row] = await queryRows(
5510
+ db,
5511
+ \`SELECT status, customer_id AS customerId, current_period_end AS currentPeriodEnd,
5512
+ cancel_at_period_end AS cancelAtPeriodEnd FROM billing WHERE user_id = ?\`,
5513
+ [userId],
5514
+ parseRow,
5515
+ )
5516
+ return row ?? null
5517
+ }
5518
+
5519
+ /** A stored row as the page sees it; the status is checked against the known ones. */
5520
+ function toBilling(row: Row | null): Billing {
5521
+ const billing = parseBilling({
5522
+ status: row?.status ?? 'none',
5523
+ active: false,
5524
+ currentPeriodEnd:
5525
+ row?.currentPeriodEnd != null ? new Date(row.currentPeriodEnd * 1000).toISOString() : null,
5526
+ cancelAtPeriodEnd: row?.cancelAtPeriodEnd === 1,
5527
+ canManage: row?.customerId != null,
5528
+ })
5529
+ return { ...billing, active: isActive(billing.status) }
5530
+ }
5531
+
5532
+ /**
5533
+ * Stores a subscription's current state for the user its metadata names. The metadata is set
5534
+ * by startSubscription, server side: a subscription made any other way (a Payment Link, the
5535
+ * dashboard) names no user here and is ignored. A different subscription replaces the stored
5536
+ * one only when that one is over, so a late event about an old plan cannot end a new one.
5537
+ */
5538
+ async function store(db: Database, subscription: Subscription): Promise<void> {
5539
+ const userId = subscription.metadata['user']
5540
+ if (!userId) return
5541
+ await migrate(db, migrations)
5542
+ await queryRows(
5543
+ db,
5544
+ \`INSERT INTO billing
5545
+ (user_id, customer_id, subscription_id, status, current_period_end, cancel_at_period_end, updated_at)
5546
+ VALUES (?, ?, ?, ?, ?, ?, ?)
5547
+ ON CONFLICT (user_id) DO UPDATE SET
5548
+ customer_id = excluded.customer_id, subscription_id = excluded.subscription_id,
5549
+ status = excluded.status, current_period_end = excluded.current_period_end,
5550
+ cancel_at_period_end = excluded.cancel_at_period_end, updated_at = excluded.updated_at
5551
+ WHERE billing.subscription_id IS excluded.subscription_id
5552
+ OR billing.status NOT IN ('active', 'trialing', 'past_due')
5553
+ RETURNING user_id\`,
5554
+ [
5555
+ userId,
5556
+ subscription.customerId,
5557
+ subscription.id,
5558
+ subscription.status,
5559
+ subscription.currentPeriodEnd,
5560
+ subscription.cancelAtPeriodEnd ? 1 : 0,
5561
+ new Date().toISOString(),
5562
+ ],
5563
+ (raw) => raw,
5564
+ )
5565
+ }
5566
+
5567
+ /**
5568
+ * The webhook's part (worker/checkout.ts passes subscription events here). The event's copy
5569
+ * may be stale, since events arrive out of order: the subscription is read back from Stripe.
5570
+ */
5571
+ export async function syncSubscription(env: BillingEnv, subscriptionId: string): Promise<void> {
5572
+ await store(env.DB, await stripeOf(env).retrieveSubscription(subscriptionId))
5573
+ }
5574
+
5575
+ /** The signed-in user's plan. */
5576
+ export async function getBilling(env: BillingEnv, request: Request): Promise<Billing> {
5577
+ const user = await requireUser(env.DB, request)
5578
+ return toBilling(await readRow(env.DB, user.id))
5579
+ }
5580
+
5581
+ /** Opens a subscription checkout for PLAN, naming the user in the subscription's metadata. */
5582
+ export async function startSubscription(
5583
+ env: BillingEnv,
5584
+ request: Request,
5585
+ origin: string,
5586
+ ): Promise<{ url: string }> {
5587
+ const user = await requireUser(env.DB, request)
5588
+ const row = await readRow(env.DB, user.id)
5589
+ if (row && toBilling(row).active) {
5590
+ throw new HttpError(409, 'You already have the plan. Manage it in the billing portal.')
5591
+ }
5592
+ const stripe = stripeOf(env)
5593
+ try {
5594
+ const session = await stripe.createCheckoutSession({
5595
+ mode: 'subscription',
5596
+ lineItems: [{ ...PLAN, quantity: 1 }],
5597
+ // Stripe fills in {CHECKOUT_SESSION_ID}, so the page can sync before any webhook.
5598
+ successUrl: \`\${origin}/billing?session={CHECKOUT_SESSION_ID}\`,
5599
+ cancelUrl: \`\${origin}/billing\`,
5600
+ ...(row?.customerId ? { customer: row.customerId } : { customerEmail: user.email }),
5601
+ clientReferenceId: user.id,
5602
+ subscriptionMetadata: { user: user.id },
5603
+ })
5604
+ if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')
5605
+ return { url: session.url }
5606
+ } catch (error) {
5607
+ if (error instanceof HttpError) throw error
5608
+ refused(error)
5609
+ }
5610
+ }
5611
+
5612
+ /**
5613
+ * Back from Stripe's checkout: reads the session and its subscription, so the page is right
5614
+ * before the webhook arrives (or in \`vite dev\` without \`stripe listen\`). Only the user the
5615
+ * subscription names can sync it.
5616
+ */
5617
+ export async function syncBilling(
5618
+ env: BillingEnv,
5619
+ request: Request,
5620
+ sessionId: string,
5621
+ ): Promise<Billing> {
5622
+ const user = await requireUser(env.DB, request)
5623
+ const stripe = stripeOf(env)
5624
+ try {
5625
+ const session = await stripe.retrieveCheckoutSession(sessionId)
5626
+ if (session.mode === 'subscription' && session.subscriptionId) {
5627
+ const subscription = await stripe.retrieveSubscription(session.subscriptionId)
5628
+ if (subscription.metadata['user'] !== user.id) {
5629
+ throw new HttpError(403, 'This checkout belongs to another account')
5630
+ }
5631
+ await store(env.DB, subscription)
5632
+ }
5633
+ } catch (error) {
5634
+ if (error instanceof HttpError) throw error
5635
+ refused(error)
5636
+ }
5637
+ return toBilling(await readRow(env.DB, user.id))
5638
+ }
5639
+
5640
+ /** Opens Stripe's Customer Portal: plan, card, invoices and cancellation, all hosted. */
5641
+ export async function openPortal(
5642
+ env: BillingEnv,
5643
+ request: Request,
5644
+ origin: string,
5645
+ ): Promise<{ url: string }> {
5646
+ const user = await requireUser(env.DB, request)
5647
+ const row = await readRow(env.DB, user.id)
5648
+ if (!row?.customerId) throw new HttpError(409, 'Subscribe first: there is nothing to manage yet')
5649
+ try {
5650
+ return await stripeOf(env).createPortalSession({
5651
+ customer: row.customerId,
5652
+ returnUrl: \`\${origin}/billing\`,
5653
+ })
5654
+ } catch (error) {
5655
+ refused(error)
5656
+ }
5657
+ }
5658
+ `}function Pt(){return`import { createClient } from '@cascivo/app/api'
5659
+ import {
5660
+ Alert,
5661
+ Badge,
5662
+ Button,
5663
+ Card,
5664
+ CardContent,
5665
+ Flex,
5666
+ Heading,
5667
+ Link,
5668
+ Spinner,
5669
+ Text,
5670
+ signal,
5671
+ useSignalEffect,
5672
+ useSignals,
5673
+ } from '@cascivo/react'
5674
+ import { api } from '../api'
5675
+ import { auth } from '../auth'
5676
+ import { PLAN } from '../billing'
5677
+ import type { Billing, BillingStatus } from '../billing'
5678
+ import { formatPrice } from '../checkout'
5679
+ import { router } from '../router'
5680
+
5681
+ const client = createClient(api)
5682
+ const billing = signal<Billing | null>(null)
5683
+ const busy = signal<'subscribe' | 'portal' | null>(null)
5684
+ const failure = signal<string | null>(null)
5685
+
5686
+ /**
5687
+ * Loads the plan. Back from Stripe's checkout, the URL carries the session id: syncing it
5688
+ * shows the new subscription at once, before the webhook. Then the id leaves the URL.
5689
+ */
5690
+ async function load(): Promise<void> {
5691
+ failure.value = null
5692
+ try {
5693
+ const sessionId = new URLSearchParams(router.search.peek()).get('session')
5694
+ if (sessionId) {
5695
+ billing.value = await client.syncBilling({ body: { sessionId } })
5696
+ router.navigate('/billing', { replace: true })
5697
+ } else {
5698
+ billing.value = await client.getBilling()
5699
+ }
5700
+ } catch (error) {
5701
+ failure.value = error instanceof Error ? error.message : 'Could not load billing'
5702
+ }
5703
+ }
5704
+
5705
+ /** Both buttons leave for a Stripe page: checkout, or the billing portal. */
5706
+ async function leaveFor(kind: 'subscribe' | 'portal'): Promise<void> {
5707
+ busy.value = kind
5708
+ failure.value = null
5709
+ try {
5710
+ const { url } =
5711
+ kind === 'subscribe' ? await client.startSubscription() : await client.openBillingPortal()
5712
+ location.assign(url)
5713
+ } catch (error) {
5714
+ failure.value = error instanceof Error ? error.message : 'Stripe did not open'
5715
+ busy.value = null
5716
+ }
5717
+ }
5718
+
5719
+ const LABEL: Record<BillingStatus, string> = {
5720
+ none: 'No plan',
5721
+ incomplete: 'Payment pending',
5722
+ incomplete_expired: 'Payment expired',
5723
+ trialing: 'Trial',
5724
+ active: 'Active',
5725
+ past_due: 'Payment failed: retrying',
5726
+ canceled: 'Cancelled',
5727
+ unpaid: 'Unpaid',
5728
+ paused: 'Paused',
5729
+ }
5730
+
5731
+ export default function BillingPage() {
5732
+ useSignals()
5733
+ useSignalEffect(() => {
5734
+ if (auth.user.value) void load()
5735
+ })
5736
+ const user = auth.user.value
5737
+ const plan = billing.value
5738
+
5739
+ if (user === undefined) return <Spinner label="Loading" />
5740
+ if (user === null) {
5741
+ return (
5742
+ <Flex gap={4}>
5743
+ <Heading level={1}>Billing</Heading>
5744
+ <Text>
5745
+ <Link href="/account">Sign in</Link> to subscribe: a plan belongs to your account.
5746
+ </Text>
5747
+ </Flex>
5748
+ )
5749
+ }
5750
+ return (
5751
+ <Flex gap={4}>
5752
+ <Flex gap={1}>
5753
+ <Heading level={1}>Billing</Heading>
5754
+ <Text muted>
5755
+ Paid on Stripe's hosted checkout; changed, paused or cancelled in Stripe's billing portal.
5756
+ The Worker keeps your plan in step through Stripe's webhook.
5757
+ </Text>
5758
+ </Flex>
5759
+ <Card>
5760
+ <CardContent>
5761
+ <Flex gap={3}>
5762
+ <Flex direction="horizontal" align="center" gap={2} wrap>
5763
+ <Heading level={2}>{PLAN.name}</Heading>
5764
+ {plan ? (
5765
+ <Badge variant={plan.active ? 'success' : 'secondary'}>{LABEL[plan.status]}</Badge>
5766
+ ) : null}
5767
+ </Flex>
5768
+ <Text muted>{PLAN.description}</Text>
5769
+ <Text size="lg">
5770
+ {formatPrice(PLAN.amount, PLAN.currency)} a {PLAN.interval}
5771
+ </Text>
5772
+ {plan?.currentPeriodEnd ? (
5773
+ <Text size="sm" muted>
5774
+ {plan.cancelAtPeriodEnd ? 'Ends' : 'Renews'} on{' '}
5775
+ {new Date(plan.currentPeriodEnd).toLocaleDateString()}
5776
+ </Text>
5777
+ ) : null}
5778
+ <Flex direction="horizontal" gap={2} wrap>
5779
+ {plan && !plan.active ? (
5780
+ <Button
5781
+ loading={busy.value === 'subscribe'}
5782
+ onClick={() => void leaveFor('subscribe')}
5783
+ >
5784
+ Subscribe
5785
+ </Button>
5786
+ ) : null}
5787
+ {plan?.canManage ? (
5788
+ <Button
5789
+ variant="secondary"
5790
+ loading={busy.value === 'portal'}
5791
+ onClick={() => void leaveFor('portal')}
5792
+ >
5793
+ Manage billing
5794
+ </Button>
5795
+ ) : null}
5796
+ </Flex>
5797
+ </Flex>
5798
+ </CardContent>
5799
+ </Card>
5800
+ {failure.value ? (
5801
+ <Alert variant="destructive" title="Not done">
5802
+ {failure.value}
5803
+ </Alert>
5804
+ ) : null}
5805
+ </Flex>
5806
+ )
5807
+ }
5808
+ `}function Ft(){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"}function It(){return`/** What sending a sign-in link needs of the Email Service binding (\`send_email\`). */
5809
+ export interface SignInSender {
5810
+ send(message: {
5811
+ from: string
5812
+ to: string
5813
+ subject: string
5814
+ text: string
5815
+ html: string
5816
+ }): Promise<unknown>
5817
+ }
5818
+
5819
+ const escape = (value: string) =>
5820
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
5821
+
5822
+ /**
5823
+ * Emails a sign-in link. \`vite dev\` sends nothing: the link is logged, and the Account page
5824
+ * shows it (\`exposeLink\` in worker/index.ts).
5825
+ */
5826
+ export async function sendSignInLink(
5827
+ sender: SignInSender,
5828
+ from: string,
5829
+ email: string,
5830
+ url: string,
5831
+ ): Promise<void> {
5832
+ if (import.meta.env.DEV) {
5833
+ console.log(\`[auth] sign-in link for \${email}: \${url}\`)
5834
+ return
5835
+ }
5836
+ if (!from) throw new Error('Set AUTH_FROM in wrangler.jsonc to an address on your domain')
5837
+ await sender.send({
5838
+ from,
5839
+ to: email,
5840
+ subject: 'Your sign-in link',
5841
+ text: \`Sign in: \${url}\\n\\nThe link works once, for 15 minutes. If you did not ask for it, ignore this email.\`,
5842
+ 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>\`,
5843
+ })
5844
+ }
5845
+ `}function Lt(){return`import {
5846
+ Alert,
5847
+ Button,
5848
+ Card,
5849
+ CardContent,
5850
+ Flex,
5851
+ Heading,
5852
+ Input,
5853
+ Link,
5854
+ Spinner,
5855
+ Text,
5856
+ signal,
5857
+ useSignals,
5858
+ } from '@cascivo/react'
5859
+ import type { FormEvent } from 'react'
5860
+ import { auth } from '../auth'
5861
+
5862
+ const sentTo = signal<string | null>(null)
5863
+ /** Set only in \`vite dev\`, where no email is sent: the link to open instead. */
5864
+ const devLink = signal<string | null>(null)
5865
+ const failure = signal<string | null>(null)
5866
+ const sending = signal(false)
5867
+
5868
+ async function start(event: FormEvent<HTMLFormElement>): Promise<void> {
5869
+ event.preventDefault()
5870
+ const email = new FormData(event.currentTarget).get('email')
5871
+ if (typeof email !== 'string') return
5872
+ failure.value = null
5873
+ sending.value = true
5874
+ try {
5875
+ const { link } = await auth.start(email)
5876
+ sentTo.value = email
5877
+ devLink.value = link
5878
+ } catch (error) {
5879
+ failure.value = error instanceof Error ? error.message : 'Could not send the link'
5880
+ } finally {
5881
+ sending.value = false
5882
+ }
5883
+ }
5884
+
5885
+ export default function Account() {
5886
+ useSignals()
5887
+ const user = auth.user.value
5888
+
5889
+ if (user === undefined) return <Spinner label="Loading" />
5890
+ if (user) {
5891
+ return (
5892
+ <Flex gap={4}>
5893
+ <Heading level={1}>Account</Heading>
5894
+ <Card>
5895
+ <CardContent>
5896
+ <Flex gap={3}>
5897
+ <Text>Signed in as {user.email}</Text>
5898
+ <Flex direction="horizontal">
5899
+ <Button variant="secondary" onClick={() => void auth.signOut()}>
5900
+ Sign out
5901
+ </Button>
5902
+ </Flex>
5903
+ </Flex>
5904
+ </CardContent>
5905
+ </Card>
5906
+ </Flex>
5907
+ )
5908
+ }
5909
+ return (
5910
+ <Flex gap={4}>
5911
+ <Flex gap={1}>
5912
+ <Heading level={1}>Sign in</Heading>
5913
+ <Text muted>No password: we email you a link that signs you in once.</Text>
5914
+ </Flex>
5915
+ {sentTo.value ? (
5916
+ <Alert variant="success" title="Check your email">
5917
+ We sent a sign-in link to {sentTo.value}. It works once, for 15 minutes.
5918
+ </Alert>
5919
+ ) : null}
5920
+ {devLink.value ? (
5921
+ <Alert variant="info" title="vite dev sends no email">
5922
+ <Link href={devLink.value}>Open the sign-in link</Link>
5923
+ </Alert>
5924
+ ) : null}
5925
+ {failure.value ? (
5926
+ <Alert variant="destructive" title="Not sent">
3736
5927
  {failure.value}
3737
5928
  </Alert>
3738
5929
  ) : null}
@@ -3747,7 +5938,7 @@ export default function Account() {
3747
5938
  </Flex>
3748
5939
  )
3749
5940
  }
3750
- `}function bt(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
5941
+ `}function Rt(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
3751
5942
  import { auth } from '../../auth'
3752
5943
  import { router } from '../../router'
3753
5944
 
@@ -3797,7 +5988,7 @@ export default function VerifySignIn() {
3797
5988
  </Flex>
3798
5989
  )
3799
5990
  }
3800
- `}function xt(){return`import type { ViewConfig } from '@cascivo/render'
5991
+ `}function zt(){return`import type { ViewConfig } from '@cascivo/render'
3801
5992
  import { validateView } from '@cascivo/render/validate'
3802
5993
 
3803
5994
  /**
@@ -3869,7 +6060,7 @@ export function parsePage(raw: unknown): Page {
3869
6060
  const { view } = raw as Record<string, unknown>
3870
6061
  return { ...summary, ...parsePageInput({ title: summary.title, view }) }
3871
6062
  }
3872
- `}function St(){return`import { HttpError } from '@cascivo/app/api'
6063
+ `}function Bt(){return`import { HttpError } from '@cascivo/app/api'
3873
6064
  import { migrate, queryRows } from '@cascivo/app/db'
3874
6065
  import type { Database } from '@cascivo/app/db'
3875
6066
  import { isSlug, parsePage } from '../src/pages'
@@ -3934,7 +6125,7 @@ export async function getPage(db: Database, slug: string): Promise<Page> {
3934
6125
  if (!page) throw new HttpError(404, 'No such page')
3935
6126
  return page
3936
6127
  }
3937
- `}function Ct(){return`import { CascivoView } from '@cascivo/render'
6128
+ `}function Vt(){return`import { CascivoView } from '@cascivo/render'
3938
6129
  import { Flex, Heading } from '@cascivo/react'
3939
6130
  import { createElement } from 'react'
3940
6131
  import { renderToStaticMarkup } from 'react-dom/server'
@@ -4060,7 +6251,7 @@ export async function renderPageHtml(
4060
6251
  headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' },
4061
6252
  })
4062
6253
  }
4063
- `}function wt(){return`import { migrate, queryRows } from '@cascivo/app/db'
6254
+ `}function Ht(){return`import { migrate, queryRows } from '@cascivo/app/db'
4064
6255
  import type { Database } from '@cascivo/app/db'
4065
6256
  import { exportPage } from '@cascivo/app/export'
4066
6257
  import type { ExportBrowser } from '@cascivo/app/export'
@@ -4117,7 +6308,7 @@ export async function pagePreview(
4117
6308
  headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' },
4118
6309
  })
4119
6310
  }
4120
- `}function Tt(){return`import { createClient } from '@cascivo/app/api'
6311
+ `}function Ut(){return`import { createClient } from '@cascivo/app/api'
4121
6312
  import { CascivoView } from '@cascivo/render'
4122
6313
  import type { ViewConfig } from '@cascivo/render'
4123
6314
  import { validateView } from '@cascivo/render/validate'
@@ -4277,7 +6468,7 @@ export default function Publish() {
4277
6468
  </Flex>
4278
6469
  )
4279
6470
  }
4280
- `}function Et(){return`import type { RouteProps } from '@cascivo/app'
6471
+ `}function Wt(){return`import type { RouteProps } from '@cascivo/app'
4281
6472
  import { createClient } from '@cascivo/app/api'
4282
6473
  import { CascivoView } from '@cascivo/render'
4283
6474
  import {
@@ -4327,7 +6518,7 @@ export default function PublishedPage({ params }: RouteProps<'/p/:slug'>) {
4327
6518
  </Flex>
4328
6519
  )
4329
6520
  }
4330
- `}function Dt(){return`import { signal } from '@cascivo/react'
6521
+ `}function Gt(){return`import { signal } from '@cascivo/react'
4331
6522
  import { VoiceClient } from 'agents/voice/client'
4332
6523
  import type { TranscriptMessage, VoiceStatus } from 'agents/voice/client'
4333
6524
 
@@ -4418,7 +6609,7 @@ export function voice(): VoiceClient {
4418
6609
  client = c
4419
6610
  return c
4420
6611
  }
4421
- `}function Ot(){return`import { Agent } from 'agents'
6612
+ `}function Kt(){return`import { Agent } from 'agents'
4422
6613
  import type { Connection } from 'agents'
4423
6614
  import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from 'agents/voice'
4424
6615
  import type { TextSource, VoiceTurnContext } from 'agents/voice'
@@ -4482,7 +6673,7 @@ export class Voice extends VoiceAgent {
4482
6673
  }
4483
6674
  }
4484
6675
  }
4485
- `}function kt(){return`import type {
6676
+ `}function qt(){return`import type {
4486
6677
  TTSProvider,
4487
6678
  Transcriber,
4488
6679
  TranscriberSession,
@@ -4528,7 +6719,7 @@ const REPLIES: Record<string, string> = {
4528
6719
  export function scriptedReply(transcript: string): string {
4529
6720
  return REPLIES[transcript] ?? \`You said: \${transcript}\`
4530
6721
  }
4531
- `}function At(){return`import {
6722
+ `}function Jt(){return`import {
4532
6723
  Badge,
4533
6724
  Button,
4534
6725
  Card,
@@ -4645,7 +6836,7 @@ export default function VoicePage() {
4645
6836
  </Flex>
4646
6837
  )
4647
6838
  }
4648
- `}function jt(){return`import { defineLive } from '@cascivo/app/live'
6839
+ `}function Yt(){return`import { defineLive } from '@cascivo/app/live'
4649
6840
 
4650
6841
  /**
4651
6842
  * The live dashboard's metrics, shared by the Worker (which records them) and the page (which
@@ -4667,7 +6858,7 @@ export function parseAccepted(raw: unknown): Accepted {
4667
6858
  }
4668
6859
  throw new Error('Malformed reply')
4669
6860
  }
4670
- `}function Mt(){return`import { Kpi, LineChart } from '@cascivo/charts'
6861
+ `}function Xt(){return`import { Kpi, LineChart } from '@cascivo/charts'
4671
6862
  import { createClient } from '@cascivo/app/api'
4672
6863
  import { watchLive } from '@cascivo/app/live'
4673
6864
  import type { LiveEvent, LivePoint } from '@cascivo/app/live'
@@ -4790,7 +6981,7 @@ export default function Ops() {
4790
6981
  </Flex>
4791
6982
  )
4792
6983
  }
4793
- `}function Nt(){return`import { defineTable } from '@cascivo/app/db'
6984
+ `}function Zt(){return`import { defineTable } from '@cascivo/app/db'
4794
6985
 
4795
6986
  /**
4796
6987
  * The customers table, shared by the Worker (which queries it) and the page (which shows
@@ -4866,7 +7057,7 @@ export function parseDeleted(raw: unknown): { id: string } {
4866
7057
  }
4867
7058
  throw new Error('Malformed delete result')
4868
7059
  }
4869
- `}function Pt(){return`import { createClient } from '@cascivo/app/api'
7060
+ `}function Qt(){return`import { createClient } from '@cascivo/app/api'
4870
7061
  import type { TableQuery } from '@cascivo/app/db'
4871
7062
  import { signal } from '@cascivo/react'
4872
7063
  import { api } from './api'
@@ -4921,7 +7112,7 @@ export async function remove(id: string): Promise<void> {
4921
7112
  await client.deleteCustomer({ params: { id } })
4922
7113
  await load()
4923
7114
  }
4924
- `}function Ft(){return`import { HttpError } from '@cascivo/app/api'
7115
+ `}function $t(){return`import { HttpError } from '@cascivo/app/api'
4925
7116
  import { migrate, queryTable } from '@cascivo/app/db'
4926
7117
  import type { Database, TablePage, TableQuery } from '@cascivo/app/db'
4927
7118
  import { customersTable, parseCustomer } from '../src/customers'
@@ -4990,7 +7181,7 @@ export async function deleteCustomer(db: Database, id: string): Promise<{ id: st
4990
7181
  if (!row) throw new HttpError(404, 'No such customer')
4991
7182
  return { id }
4992
7183
  }
4993
- `}function It(){return`import type { Migration } from '@cascivo/app/db'
7184
+ `}function en(){return`import type { Migration } from '@cascivo/app/db'
4994
7185
 
4995
7186
  const FIRST = [
4996
7187
  'Ada',
@@ -5040,7 +7231,7 @@ export const migrations: Migration[] = [
5040
7231
  ],
5041
7232
  },
5042
7233
  ]
5043
- `}function Lt(){return`import type { Column } from '@cascivo/react'
7234
+ `}function tn(){return`import type { Column } from '@cascivo/react'
5044
7235
  import {
5045
7236
  Badge,
5046
7237
  Button,
@@ -5220,15 +7411,16 @@ export default function Customers() {
5220
7411
  </Flex>
5221
7412
  )
5222
7413
  }
5223
- `}function Rt(){return`${B()}# Rewritten by @cascivo/app/vite whenever a route file changes.
7414
+ `}function nn(){return`${V()}# Rewritten by @cascivo/app/vite whenever a route file changes.
5224
7415
  src/routes.gen.ts
5225
- `}function zt(){return`node_modules
7416
+ `}function rn(){return`node_modules
5226
7417
  dist
5227
7418
  .wrangler
5228
7419
  .dev.vars*
7420
+ !.dev.vars.example
5229
7421
  *.local
5230
7422
  .DS_Store
5231
- `}function Bt(e){let t=e.pm??`npm`,n=$(e);return`# ${e.name}
7423
+ `}function an(e){let t=e.pm??`npm`,n=$(e);return`# ${e.name}
5232
7424
 
5233
7425
  A [cascivo](https://cascivo.com) app on Cloudflare: a client-rendered
5234
7426
  ${n===`preact`?`Preact`:`React`} app and its API, deployed as one Worker.
@@ -5265,7 +7457,7 @@ This builds the app, then deploys it to a temporary Cloudflare account with
5265
7457
 
5266
7458
  It works only while wrangler is logged out. If you are logged in, use \`deploy\` instead.
5267
7459
  A temporary account supports Workers, static assets, KV, D1 and Durable Objects. It does not
5268
- support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves the app, but its ${K(e,`agent`)?`assistant`:`voice page`} cannot reach the model: deploy it\nto your own account for that.`:``}
7460
+ support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves the app, but its ${X(e,`agent`)?`assistant`:`voice page`} cannot reach the model: deploy it\nto your own account for that.`:``}
5269
7461
 
5270
7462
  ## Structure
5271
7463
 
@@ -5282,7 +7474,7 @@ support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves th
5282
7474
  - \`src/Shell.tsx\` — the app shell (header + side nav + content slot).
5283
7475
 
5284
7476
  Persist client state with \`persistedSignal\` from \`@cascivo/storage\` (localStorage or
5285
- IndexedDB).${K(e,`board`)?"\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.":``}${K(e,`notes`)?"\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.":``}${K(e,`import`)?"\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.":``}${K(e,`files`)?`
7477
+ IndexedDB).${X(e,`board`)?"\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.":``}${X(e,`notes`)?"\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.":``}${X(e,`import`)?"\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.":``}${X(e,`files`)?`
5286
7478
 
5287
7479
  ## Files (uploads)
5288
7480
 
@@ -5298,7 +7490,7 @@ IndexedDB).${K(e,`board`)?"\n\n## Board (multiplayer)\n\n`/board` is a shared bo
5298
7490
 
5299
7491
  Each caller (by IP) may start 20 uploads a minute (\`ratelimits\` in \`wrangler.jsonc\`).${e.auth?``:"\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."} Create the bucket once before deploying:
5300
7492
  \`npx wrangler r2 bucket create ${N(e.name)}-files\`. R2 and Images do not run on a
5301
- temporary account.`:``}${K(e,`export`)?'\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.':``}${K(e,`live`)?`
7493
+ temporary account.`:``}${X(e,`export`)?'\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.':``}${X(e,`live`)?`
5302
7494
 
5303
7495
  ## Ops (live dashboard)
5304
7496
 
@@ -5318,7 +7510,7 @@ A new viewer, or one back from a dropped connection, starts with the whole windo
5318
7510
  at least once: a batch retried after a lost reply counts twice. \`/api/events\` has no auth, so
5319
7511
  anyone can move the numbers: check who is sending before real use (\`--auth access\` puts
5320
7512
  Cloudflare Access in front of the Worker). Create the queue once before deploying:
5321
- \`npx wrangler queues create ${N(e.name)}-events\`.`:``}${K(e,`usage`)?"\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.":``}${K(e,`search`)?`
7513
+ \`npx wrangler queues create ${N(e.name)}-events\`.`:``}${X(e,`usage`)?"\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.":``}${X(e,`search`)?`
5322
7514
 
5323
7515
  ## Search (by meaning)
5324
7516
 
@@ -5336,7 +7528,89 @@ Create the index once before deploying, with the embedding model's dimensions:
5336
7528
  Neither Vectorize nor Workers AI runs locally, so \`vite dev\` searches by keyword with SQLite's
5337
7529
  full-text search instead, and the page says so. \`VITE_REAL_AI=1 ${C(t,`dev`)}\` uses the real ones
5338
7530
  (after \`npx wrangler login\`). Workers AI and Vectorize bill per use beyond their free
5339
- allocations, and a temporary account has neither.`:``}${K(e,`digest`)?'\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.':``}${K(e,`webhooks`)?"\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.":``}${e.auth===`email`?"\n\n## Accounts (email sign-in)\n\nAnyone can create an account with their email address: `/account` emails a one-time link,\nand opening it signs them in with a session cookie. **Every API write needs a signed-in\nuser; reads stay public.**\n\n- `worker/index.ts` — `handleAuth` (`@cascivo/app/auth-server`) answers `/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- `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- `src/auth.ts` — `auth.user`, a signal every page can read.\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\nUsers, links and sessions live in D1, stored as hashes. Links expire after 15 minutes and\nwork once; sessions last 30 days. Each caller (by IP) may request 20 links a minute. In\n`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.":``}${e.auth===`access`?'\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`.':``}${K(e,`crud`)?"\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.":``}${K(e,`agent`)?`
7531
+ allocations, and a temporary account has neither.`:``}${X(e,`digest`)?'\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.':``}${X(e,`webhooks`)?"\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.":``}${X(e,`checkout`)?`
7532
+
7533
+ ## Checkout (Stripe)
7534
+
7535
+ \`/checkout\` sells one product on Stripe's hosted Checkout page: the card never touches this
7536
+ app. Stripe tells the Worker when the payment succeeds; the Worker marks the order paid, pushes
7537
+ it to the order page, and emails a receipt rendered with \`@cascivo/email\`.
7538
+
7539
+ 1. In the Stripe dashboard, in test mode, copy the secret key (\`sk_test_…\`) into \`.dev.vars\`
7540
+ as \`STRIPE_SECRET_KEY\`, then run the app and buy with the card \`4242 4242 4242 4242\`.
7541
+ The order page confirms the payment by reading the session back from Stripe, so this works
7542
+ before any webhook is set up.
7543
+ 2. To receive the webhook locally, run
7544
+ \`stripe listen --forward-to localhost:5173/api/stripe/webhook\` (the Stripe CLI) and put the
7545
+ \`whsec_…\` secret it prints in \`.dev.vars\` as \`STRIPE_WEBHOOK_SECRET\`.
7546
+ 3. Deployed: \`npx wrangler secret put STRIPE_SECRET_KEY\` and
7547
+ \`npx wrangler secret put STRIPE_WEBHOOK_SECRET\`. In the dashboard, add a webhook endpoint
7548
+ at \`https://<your app>/api/stripe/webhook\` for \`checkout.session.completed\`,
7549
+ \`checkout.session.async_payment_succeeded\`, \`checkout.session.async_payment_failed\` and
7550
+ \`checkout.session.expired\`; its signing secret is \`STRIPE_WEBHOOK_SECRET\`.
7551
+ 4. Receipts: set \`RECEIPT_FROM\` in \`wrangler.jsonc\` to an address on a domain you have
7552
+ onboarded to Email Service. \`vite dev\` renders each receipt and logs it instead.
7553
+
7554
+ What you sell is \`PRODUCT\` in \`src/checkout.ts\`. The Worker sends that price to Stripe, so a
7555
+ browser cannot change it.
7556
+
7557
+ - \`worker/checkout.ts\` — \`createStripe\` (\`@cascivo/app/stripe\`) creates the session, with
7558
+ the order id as its idempotency key. The webhook is checked by \`verifyWebhook\` (scheme
7559
+ \`stripe\`: signature and a five-minute window) before \`parseStripeEvent\` reads it.
7560
+ - An order moves from \`pending\` to \`paid\`, \`failed\` or \`expired\` once. A retried event, or
7561
+ the page getting there before the webhook, changes nothing, so the receipt goes out once.
7562
+ Orders are found by Stripe's session id, never by \`client_reference_id\`, which a buyer can
7563
+ set on a Payment Link.
7564
+ - A bank debit completes the session as \`unpaid\`: the order stays pending until
7565
+ \`async_payment_succeeded\` or \`async_payment_failed\` arrives, possibly days later.
7566
+ - \`src/routes/checkout/[order].tsx\` — where Stripe sends the buyer back. It watches the
7567
+ order's read-only room, so it updates when the webhook arrives.
7568
+
7569
+ Each caller (by IP) may start 20 checkouts a minute.${J(e)?"\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` and\n `customer.subscription.deleted` to the webhook endpoint's 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 on `(await billingStore.getBilling(env, request)).active`,\n 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.":`
7570
+
7571
+ Subscriptions need an account to belong to: \`cascivo create --framework cloudflare
7572
+ --example checkout --auth email\` adds a \`/billing\` page with a monthly plan and Stripe's
7573
+ billing portal.`}`:``}${X(e,`newsletter`)?`
7574
+
7575
+ ## Newsletter (Amazon SES)
7576
+
7577
+ \`/newsletter\` signs readers up; \`/newsletter/send\` writes an issue in Markdown, previews it
7578
+ as the email it will be (rendered with \`@cascivo/email\`), and sends it to every confirmed
7579
+ reader through Amazon SES. It runs in \`vite dev\` with nothing set up: emails are logged instead
7580
+ of sent, and the sign-up page shows the confirmation link. The composer's key is in
7581
+ \`.dev.vars\` (\`NEWSLETTER_KEY\`).
7582
+
7583
+ To send for real:
7584
+
7585
+ 1. In SES, verify the domain you send from (SES gives DNS records for DKIM; add them in
7586
+ Cloudflare DNS, with an SPF and a DMARC record), and ask AWS to move the account out of the
7587
+ sandbox, where it can mail only verified addresses.
7588
+ 2. Create an IAM user allowed \`ses:SendEmail\` and nothing else, and give its keys to the
7589
+ Worker: \`npx wrangler secret put AWS_ACCESS_KEY_ID\` and \`AWS_SECRET_ACCESS_KEY\` (in
7590
+ \`.dev.vars\` locally). Set \`AWS_REGION\` and \`NEWSLETTER_FROM\` in \`wrangler.jsonc\`, and
7591
+ \`npx wrangler secret put NEWSLETTER_KEY\` to a long random value.
7592
+ 3. Bounces and complaints: create an SNS topic, set it as the SES identity's bounce and
7593
+ complaint notification topic, and subscribe \`https://<your app>/api/sns/ses\` to it (HTTPS).
7594
+ Put the topic's ARN in \`SNS_TOPIC_ARN\`. The Worker confirms the subscription itself.
7595
+ 4. Create the queue once: \`npx wrangler queues create ${Y(e)}\`.
7596
+
7597
+ - \`worker/newsletter.ts\` — double opt-in: a sign-up gets a confirmation link (valid a day,
7598
+ resent at most every ten minutes) and gets no issue until it is opened. Sending stores the
7599
+ issue and puts its readers on the \`NEWSLETTER\` queue, 25 per message; the consumer sends
7600
+ them one by one through \`createSes\` (\`@cascivo/app/ses\`), one message at a time. Each
7601
+ send is recorded, so a message retried after SES throttling skips whoever already has it.
7602
+ - Every issue carries \`List-Unsubscribe\` and \`List-Unsubscribe-Post\` (one-click
7603
+ unsubscribe, RFC 8058, which Gmail and Yahoo require of bulk senders) and a footer link to
7604
+ \`/newsletter/unsubscribe\`.
7605
+ - \`handleSns\` checks each SNS message's signature against SNS's certificate before
7606
+ \`parseSesNotification\` reads it. A permanent bounce or a complaint suppresses the address
7607
+ for good: mailing it again is what gets an SES account reviewed.
7608
+ - \`worker/newsletter-email.ts\` — the issue and confirmation emails. The body is Markdown drawn
7609
+ through the email primitives; raw HTML in it stays literal text.
7610
+
7611
+ Sign-ups and every use of the newsletter key count against the rate limit (20 a minute per
7612
+ IP). Sending reads every confirmed address into memory; past a few hundred thousand readers,
7613
+ page through them instead.`:``}${e.auth===`email`?"\n\n## Accounts (email sign-in)\n\nAnyone can create an account with their email address: `/account` emails a one-time link,\nand opening it signs them in with a session cookie. **Every API write needs a signed-in\nuser; reads stay public.**\n\n- `worker/index.ts` — `handleAuth` (`@cascivo/app/auth-server`) answers `/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- `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- `src/auth.ts` — `auth.user`, a signal every page can read.\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\nUsers, links and sessions live in D1, stored as hashes. Links expire after 15 minutes and\nwork once; sessions last 30 days. Each caller (by IP) may request 20 links a minute. In\n`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.":``}${e.auth===`access`?'\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`.':``}${X(e,`crud`)?"\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.":``}${X(e,`agent`)?`
5340
7614
 
5341
7615
  ## Assistant (generative UI)
5342
7616
 
@@ -5355,7 +7629,7 @@ again. The page renders the result with \`<CascivoView>\`: real components, no g
5355
7629
  offline and without an account. \`VITE_REAL_AI=1 ${C(t,`dev`)}\` uses Workers AI instead
5356
7630
  (after \`npx wrangler login\`); a deployed Worker always does.
5357
7631
 
5358
- Workers AI bills per use beyond its free daily allocation.`:``}${K(e,`publish`)?`
7632
+ Workers AI bills per use beyond its free daily allocation.`:``}${X(e,`publish`)?`
5359
7633
 
5360
7634
  ## Publish (views as pages)
5361
7635
 
@@ -5373,10 +7647,10 @@ code of its author's and needs no sandbox.
5373
7647
  - \`src/routes/p/[slug].tsx\` — a published page.
5374
7648
  - \`worker/page-html.ts\` — the Worker answers \`/p/<slug>\` with index.html carrying the
5375
7649
  page's title, description and Open Graph tags, so a shared link previews properly, and the
5376
- rendered page in a \`<noscript>\` for readers without JavaScript.${K(e,`export`)?"\n- `worker/page-preview.ts` — the preview image (`og:image`, 1200 × 630): Browser Run\n renders the page once, and D1 keeps the PNG.":``}
7650
+ rendered page in a \`<noscript>\` for readers without JavaScript.${X(e,`export`)?"\n- `worker/page-preview.ts` — the preview image (`og:image`, 1200 × 630): Browser Run\n renders the page once, and D1 keeps the PNG.":``}
5377
7651
 
5378
7652
  ${e.auth?``:"**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`. "}Each caller (by IP) may publish 20 pages a minute (\`ratelimits\` in
5379
- \`wrangler.jsonc\`). D1 works on a temporary account.`:``}${K(e,`voice`)?`
7653
+ \`wrangler.jsonc\`). D1 works on a temporary account.`:``}${X(e,`voice`)?`
5380
7654
 
5381
7655
  ## Voice
5382
7656
 
@@ -5401,10 +7675,10 @@ Add more components with \`npx cascivo add <component>\`.
5401
7675
 
5402
7676
  ## ${n===`preact`?`Preact or React`:`React or Preact`}
5403
7677
 
5404
- ${K(e,`agent`)?`This app runs on React. The Agents SDK's hooks call React 19's \`use()\`, which
7678
+ ${X(e,`agent`)?`This app runs on React. The Agents SDK's hooks call React 19's \`use()\`, which
5405
7679
  Preact's compat layer does not implement, so the assistant page needs React. (The other pages
5406
7680
  would run on Preact unchanged: the source is typed against React either way.)`:"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)."}
5407
- `}function Vt(e){return`${V(e)}
7681
+ `}function on(e){return`${U(e)}
5408
7682
  ## Server
5409
7683
 
5410
7684
  The API contract is \`src/api.ts\` (\`defineApi\` from \`@cascivo/app/api\`); its handlers are
@@ -5426,14 +7700,15 @@ To show the user a running version, run \`${w(e.pm??`npm`,`deploy:preview`)}\`.
5426
7700
  It needs no Cloudflare account. Give the user both URLs it prints: the live one, and the
5427
7701
  claim URL. The deployment is public, and it is deleted after 60 minutes unless the user
5428
7702
  opens the claim URL and signs in.
5429
- `}function Ht(e){let t=I(e),n=t.indexOf(` * Adding a router?`),r=t.indexOf(` */
7703
+ `}function sn(e){let t=oe(e),n=t.indexOf(` * Adding a router?`),r=t.indexOf(` */
5430
7704
  export function Shell`);return t.slice(0,n)+` * Routing lives in src/router.ts; nav items carry \`href\`s, and main.tsx registers the
5431
7705
  * router's Link so they navigate client-side.
5432
- `+t.slice(r)}function Ut(e,t){let n=$(e),r=[...t.map((e,t)=>({file:Me(e,t),contents:t===0?Ie(e):Le(e,t)})),{file:`404.tsx`,contents:Re()},...K(e,`agent`)?[{file:`assistant.tsx`,contents:Ge()}]:[],...K(e,`board`)?[{file:`board.tsx`,contents:Be()}]:[],...K(e,`notes`)?[{file:`notes.tsx`,contents:Je()}]:[],...K(e,`import`)?[{file:`import.tsx`,contents:Qe()}]:[],...K(e,`files`)?[{file:`files.tsx`,contents:tt()}]:[],...K(e,`export`)?[{file:`report.tsx`,contents:rt()}]:[],...K(e,`usage`)?[{file:`usage.tsx`,contents:ot()}]:[],...K(e,`crud`)?[{file:`customers.tsx`,contents:Lt()}]:[],...K(e,`live`)?[{file:`ops.tsx`,contents:Mt()}]:[],...K(e,`voice`)?[{file:`voice.tsx`,contents:At()}]:[],...K(e,`publish`)?[{file:`publish.tsx`,contents:Tt()},{file:`p/[slug].tsx`,contents:Et()}]:[],...K(e,`webhooks`)?[{file:`webhooks.tsx`,contents:gt()}]:[],...K(e,`digest`)?[{file:`digest.tsx`,contents:pt()}]:[],...K(e,`search`)?[{file:`search.tsx`,contents:ut()}]:[],...e.auth===`email`?[{file:`account.tsx`,contents:yt()},{file:`signin/verify.tsx`,contents:bt()}]:[]];return[{path:`package.json`,contents:be(e)},{path:`tsconfig.json`,contents:xe(e)},...Q(e)?[{path:`tsconfig.worker.json`,contents:Se()}]:[],{path:`vite.config.ts`,contents:Ce(n,e)},{path:`wrangler.jsonc`,contents:we(e)},{path:`index.html`,contents:ie(e)},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:z()},{path:`.prettierignore`,contents:Rt()},{path:`.gitignore`,contents:zt()},{path:`README.md`,contents:Bt(e)},{path:`AGENTS.md`,contents:Vt(e)},{path:`worker/index.ts`,contents:Oe(e)},{path:`src/api.ts`,contents:De(e)},{path:`src/live.ts`,contents:ke()},{path:`src/LiveCard.tsx`,contents:Ae()},{path:`src/main.tsx`,contents:Fe()},{path:`src/vite-env.d.ts`,contents:F()},{path:`src/router.ts`,contents:Ne()},{path:`src/App.tsx`,contents:Pe(t,e)},{path:`src/Shell.tsx`,contents:Ht(e)},...K(e,`agent`)?[{path:`worker/assistant.ts`,contents:Ue()},{path:`worker/scripted-model.ts`,contents:We()},{path:`src/assistant.ts`,contents:He()},{path:`src/assistant.module.css`,contents:Ke()}]:[],...K(e,`notes`)?[{path:`src/notes.ts`,contents:qe()}]:[],...K(e,`import`)?[{path:`src/import-job.ts`,contents:Ye()},{path:`src/import-page.ts`,contents:Xe()},{path:`worker/import-job.ts`,contents:Ze()}]:[],...K(e,`crud`)?[{path:`src/customers.ts`,contents:Nt()},{path:`src/customers-page.ts`,contents:Pt()},{path:`worker/customers.ts`,contents:Ft()},{path:`worker/migrations.ts`,contents:It()}]:[],...K(e,`live`)?[{path:`src/ops.ts`,contents:jt()}]:[],...K(e,`search`)?[{path:`src/search.ts`,contents:st()},{path:`worker/search.ts`,contents:ct()},{path:`worker/articles.ts`,contents:lt()}]:[],...K(e,`digest`)?[{path:`src/digest.ts`,contents:dt()},{path:`worker/digest.ts`,contents:ft()}]:[],...K(e,`webhooks`)?[{path:`src/webhooks.ts`,contents:mt()},{path:`worker/webhooks.ts`,contents:ht()},{path:`.dev.vars`,contents:`WEBHOOK_SECRET=dev-only-webhook-secret
5433
- `}]:[],...e.auth===`email`?[{path:`src/auth.ts`,contents:_t()},{path:`worker/auth.ts`,contents:vt()}]:[],...K(e,`publish`)?[{path:`src/pages.ts`,contents:xt()},{path:`worker/pages.ts`,contents:St()},{path:`worker/page-html.ts`,contents:Ct()},...K(e,`export`)?[{path:`worker/page-preview.ts`,contents:wt()}]:[]]:[],...K(e,`voice`)?[{path:`src/voice.ts`,contents:Dt()},{path:`worker/voice.ts`,contents:Ot()},{path:`worker/scripted-voice.ts`,contents:kt()}]:[],...K(e,`usage`)?[{path:`src/usage.ts`,contents:it().replace(`dataset: 'app_usage'`,`dataset: '${q(e)}'`)},{path:`worker/usage.ts`,contents:at()}]:[],...K(e,`files`)?[{path:`src/upload-policy.ts`,contents:$e()},{path:`src/files.ts`,contents:et()},{path:`src/files.module.css`,contents:nt()}]:[],...K(e,`board`)?[{path:`src/board.ts`,contents:ze()},{path:`src/board.module.css`,contents:Ve()}]:[],...r.map(({file:e,contents:t})=>({path:`src/routes/${e}`,contents:t})),{path:`src/routes.gen.ts`,contents:b(r.map(e=>e.file),`./routes`)}]}function Wt(e){let t=ee(e.sections);if(e.framework===`astro`)return Gt(e,t);if(e.framework===`cloudflare`){let n=e.examples?.includes(`digest`)&&!e.examples.includes(`export`)?[...e.examples,`export`]:e.examples;return Ut({...e,...n?{examples:n}:{}},t)}return[{path:`package.json`,contents:te(e)},{path:`tsconfig.json`,contents:ne()},{path:`vite.config.ts`,contents:re()},{path:`index.html`,contents:ie(e)},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:z()},{path:`.prettierignore`,contents:B()},{path:`.gitignore`,contents:se()},{path:`README.md`,contents:le(e)},{path:`AGENTS.md`,contents:V(e)},{path:`src/main.tsx`,contents:ae()},{path:`src/vite-env.d.ts`,contents:F()},{path:`src/App.tsx`,contents:oe(t)},{path:`src/Shell.tsx`,contents:I(e)},...t.map(e=>({path:`src/sections/${e.component}.tsx`,contents:L(e)}))]}function Gt(e,t){return[{path:`package.json`,contents:ue(e)},{path:`tsconfig.json`,contents:fe()},{path:`astro.config.mjs`,contents:de()},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:z()},{path:`.prettierignore`,contents:B()},{path:`.gitignore`,contents:ve()},{path:`README.md`,contents:ye(e,t)},{path:`AGENTS.md`,contents:V(e)},{path:`src/styles/layers.css`,contents:pe()},{path:`src/layouts/Layout.astro`,contents:me(e)},{path:`src/components/Shell.tsx`,contents:he(e,t)},...t.map((e,t)=>({path:`src/pages/${U(e,t)}`,contents:_e(e,t)})),...t.map(e=>({path:`src/components/${e.component}.tsx`,contents:ge(e)}))]}const Kt=[`Dashboard`,`Reports`,`Settings`];async function qt(f,p=process.cwd()){let m=f.includes(`--yes`)||f.includes(`-y`),h=r(f,[`pm`,`package-manager`,`theme`,`sections`,`template`,`framework`,`runtime`,`example`,`auth`])[0],g=a(f,`theme`),_=a(f,`sections`),v=(a(f,`framework`)??``).toLowerCase();if(v&&!T.includes(v)){console.error(`Unknown framework "${v}". Expected one of: ${T.join(`, `)}.`),process.exitCode=1;return}let y=(a(f,`runtime`)??``).toLowerCase();if(y&&!k(y)){console.error(`Unknown runtime "${y}". Expected one of: ${E.join(`, `)}.`),process.exitCode=1;return}let b=(a(f,`example`)??``).split(`,`).map(e=>e.trim().toLowerCase()).filter(Boolean),x=b.find(e=>!O(e));if(x){console.error(`Unknown example "${x}". Expected one of: ${D.join(`, `)}.`),process.exitCode=1;return}let A=(a(f,`auth`)??``).toLowerCase();if(A&&A!==`access`&&A!==`email`){console.error(`Unknown auth "${A}". Expected: access or email.`),process.exitCode=1;return}let j=i(f);if(`error`in j){console.error(j.error),process.exitCode=1;return}let M=e(p,{preferLockfileOverUserAgent:!0,...j.pm?{override:j.pm}:{}}),N=!m&&o.isTTY?d({input:o,output:s}):null;try{let e=h;!e&&N&&(e=(await N.question(`Project name? [my-cascivo-app]: `)).trim()),e||=`my-cascivo-app`;let r=(g??``).toLowerCase();!t.includes(r)&&N&&(r=(await N.question(`Theme? (${t.join(`/`)}) [light]: `)).trim().toLowerCase());let i=t.includes(r)?r:`light`,o=_;!o&&N&&(o=(await N.question(`Nav sections? (comma-separated) [${Kt.join(`, `)}]: `)).trim());let s=(o??``).split(`,`).map(e=>e.trim()).filter(Boolean),d=v;!d&&N&&(d=(await N.question(`Framework? (${T.join(`/`)}) [react-vite]: `)).trim().toLowerCase());let m=T.includes(d)?d:`react-vite`;if(b.length>0&&m!==`cloudflare`){console.error(`--example needs --framework cloudflare (it adds a Worker-backed page).`),process.exitCode=1;return}if(A&&m!==`cloudflare`){console.error(`--auth needs --framework cloudflare (it guards the Worker).`),process.exitCode=1;return}if(b.includes(`agent`)&&y===`preact`){console.error(`--example agent needs --runtime react: the Agents SDK's hooks call React 19's use(), which Preact does not implement.`),process.exitCode=1;return}let x={name:e,framework:m,theme:i,sections:s.length>0?s:Kt,pm:M,...k(y)?{runtime:y}:{},...b.length>0?{examples:b.filter(O)}:{},...A===`access`||A===`email`?{auth:A}:{}},E=u(p,e);if(c(E)&&l(E).length>0){console.error(`Target directory "${e}" already exists and is not empty.`),process.exitCode=1;return}let D=Wt(x);for(let e of D)await n(u(E,e.path),e.contents);console.log(`\nCreated ${e} (${m}) with the ${i} theme (${D.length} files).`);let j=a(f,`template`);if(j){let{add:e}=await import(`./index.mjs`).then(e=>e.t),{loadConfig:t}=await import(`./config-DT-Cs7bC.mjs`).then(e=>e.i);console.log(`\nInstalling template "${j}"…`),await e([j],await t(),{cwd:E,pm:M})}console.log(`
7706
+ `+t.slice(r)}function cn(e){return[...X(e,`webhooks`)?[`# The secret vite dev signs and checks test webhook deliveries with.`,`WEBHOOK_SECRET=dev-only-webhook-secret`]:[],...X(e,`checkout`)?[`# Stripe, in test mode (README): the secret key from the dashboard, and the signing`,"# secret `stripe listen` prints.",`STRIPE_SECRET_KEY=`,`STRIPE_WEBHOOK_SECRET=`]:[],...X(e,`newsletter`)?[`# The key /newsletter/send asks for. Deployed, choose a long random one.`,`NEWSLETTER_KEY=dev-only-newsletter-key`,`# An IAM user allowed ses:SendEmail (README). Without them, vite dev logs each email.`,`AWS_ACCESS_KEY_ID=`,`AWS_SECRET_ACCESS_KEY=`]:[]].map(e=>`${e}\n`).join(``)}function ln(e){return cn(e).split(`
7707
+ `).map(e=>e.startsWith(`#`)?e:e.replace(/=.*$/,`=`)).join(`
7708
+ `)}function un(e){let t=e=>Object.fromEntries(e.map(([e,t])=>[e,{description:t}]));return{...X(e,`webhooks`)?t([[`WEBHOOK_SECRET`,"The secret of the GitHub webhook that posts to `/api/webhooks/github`."]]):{},...X(e,`checkout`)?t([[`STRIPE_SECRET_KEY`,"Your Stripe secret key, from the [API keys page](https://dashboard.stripe.com/test/apikeys). A test key (`sk_test_…`) takes test cards only."],[`STRIPE_WEBHOOK_SECRET`,"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`."],[`RECEIPT_FROM`,`The From address of receipts, on a domain onboarded to Cloudflare Email Service. Leave it empty to send none.`]]):{},...X(e,`newsletter`)?t([[`NEWSLETTER_KEY`,"The key `/newsletter/send` asks for. Make a long random one: `openssl rand -hex 32`."],[`AWS_ACCESS_KEY_ID`,"An IAM user allowed `ses:SendEmail` and nothing else (README)."],[`AWS_SECRET_ACCESS_KEY`,`That IAM user's secret access key.`],[`AWS_REGION`,"The SES region your sending domain is verified in, e.g. `eu-west-1`."],[`NEWSLETTER_FROM`,`The From address of issues, on your SES-verified domain.`],[`SNS_TOPIC_ARN`,"The SNS topic SES reports bounces and complaints to, subscribed to `https://<this app>/api/sns/ses`."]]):{}}}function dn(e,t){let n=$(e),r=[...t.map((e,t)=>({file:Ie(e,t),contents:t===0?Be(e):Ve(e,t)})),{file:`404.tsx`,contents:He()},...X(e,`agent`)?[{file:`assistant.tsx`,contents:Ye()}]:[],...X(e,`board`)?[{file:`board.tsx`,contents:We()}]:[],...X(e,`notes`)?[{file:`notes.tsx`,contents:Qe()}]:[],...X(e,`import`)?[{file:`import.tsx`,contents:nt()}]:[],...X(e,`files`)?[{file:`files.tsx`,contents:at()}]:[],...X(e,`export`)?[{file:`report.tsx`,contents:st()}]:[],...X(e,`usage`)?[{file:`usage.tsx`,contents:ut()}]:[],...X(e,`crud`)?[{file:`customers.tsx`,contents:tn()}]:[],...X(e,`live`)?[{file:`ops.tsx`,contents:Xt()}]:[],...X(e,`voice`)?[{file:`voice.tsx`,contents:Jt()}]:[],...X(e,`publish`)?[{file:`publish.tsx`,contents:Ut()},{file:`p/[slug].tsx`,contents:Wt()}]:[],...X(e,`webhooks`)?[{file:`webhooks.tsx`,contents:bt()}]:[],...X(e,`digest`)?[{file:`digest.tsx`,contents:_t()}]:[],...X(e,`search`)?[{file:`search.tsx`,contents:mt()}]:[],...X(e,`checkout`)?[{file:`checkout.tsx`,contents:Ct()},{file:`checkout/[order].tsx`,contents:wt()}]:[],...J(e)?[{file:`billing.tsx`,contents:Pt()}]:[],...X(e,`newsletter`)?[{file:`newsletter.tsx`,contents:Ot()},{file:`newsletter/confirm.tsx`,contents:kt()},{file:`newsletter/unsubscribe.tsx`,contents:At()},{file:`newsletter/send.tsx`,contents:jt()}]:[],...e.auth===`email`?[{file:`account.tsx`,contents:Lt()},{file:`signin/verify.tsx`,contents:Rt()}]:[]];return[{path:`package.json`,contents:ye(e)},{path:`tsconfig.json`,contents:be(e)},...Q(e)?[{path:`tsconfig.worker.json`,contents:xe()}]:[],{path:`vite.config.ts`,contents:Se(n,e)},{path:`wrangler.jsonc`,contents:Ce(e)},{path:`index.html`,contents:I(e)},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:B()},{path:`.prettierignore`,contents:nn()},{path:`.gitignore`,contents:rn()},{path:`README.md`,contents:an(e)},{path:`AGENTS.md`,contents:on(e)},{path:`worker/index.ts`,contents:Me(e)},{path:`src/api.ts`,contents:je(e)},{path:`src/live.ts`,contents:Ne()},{path:`src/LiveCard.tsx`,contents:Pe()},{path:`src/main.tsx`,contents:ze()},{path:`src/vite-env.d.ts`,contents:ie()},{path:`src/router.ts`,contents:Le()},{path:`src/App.tsx`,contents:Re(t,e)},{path:`src/Shell.tsx`,contents:sn(e)},...X(e,`agent`)?[{path:`worker/assistant.ts`,contents:qe()},{path:`worker/scripted-model.ts`,contents:Je()},{path:`src/assistant.ts`,contents:Ke()},{path:`src/assistant.module.css`,contents:Xe()}]:[],...X(e,`notes`)?[{path:`src/notes.ts`,contents:Ze()}]:[],...X(e,`import`)?[{path:`src/import-job.ts`,contents:$e()},{path:`src/import-page.ts`,contents:et()},{path:`worker/import-job.ts`,contents:tt()}]:[],...X(e,`crud`)?[{path:`src/customers.ts`,contents:Zt()},{path:`src/customers-page.ts`,contents:Qt()},{path:`worker/customers.ts`,contents:$t()},{path:`worker/migrations.ts`,contents:en()}]:[],...X(e,`live`)?[{path:`src/ops.ts`,contents:Yt()}]:[],...X(e,`search`)?[{path:`src/search.ts`,contents:dt()},{path:`worker/search.ts`,contents:ft()},{path:`worker/articles.ts`,contents:pt()}]:[],...X(e,`digest`)?[{path:`src/digest.ts`,contents:ht()},{path:`worker/digest.ts`,contents:gt()}]:[],...X(e,`webhooks`)?[{path:`src/webhooks.ts`,contents:vt()},{path:`worker/webhooks.ts`,contents:yt()}]:[],...X(e,`checkout`)?[{path:`src/checkout.ts`,contents:xt(e)},{path:`worker/checkout.ts`,contents:St()}]:[],...J(e)?[{path:`src/billing.ts`,contents:Mt()},{path:`worker/billing.ts`,contents:Nt()}]:[],...X(e,`newsletter`)?[{path:`src/newsletter.ts`,contents:Tt()},{path:`worker/newsletter.ts`,contents:Dt()},{path:`worker/newsletter-email.ts`,contents:Et(e)}]:[],...X(e,`webhooks`)||X(e,`checkout`)||X(e,`newsletter`)?[{path:`.dev.vars`,contents:cn(e)},{path:`.dev.vars.example`,contents:ln(e)}]:[],...e.auth===`email`?[{path:`src/auth.ts`,contents:Ft()},{path:`worker/auth.ts`,contents:It()}]:[],...X(e,`publish`)?[{path:`src/pages.ts`,contents:zt()},{path:`worker/pages.ts`,contents:Bt()},{path:`worker/page-html.ts`,contents:Vt()},...X(e,`export`)?[{path:`worker/page-preview.ts`,contents:Ht()}]:[]]:[],...X(e,`voice`)?[{path:`src/voice.ts`,contents:Gt()},{path:`worker/voice.ts`,contents:Kt()},{path:`worker/scripted-voice.ts`,contents:qt()}]:[],...X(e,`usage`)?[{path:`src/usage.ts`,contents:ct().replace(`dataset: 'app_usage'`,`dataset: '${Ee(e)}'`)},{path:`worker/usage.ts`,contents:lt()}]:[],...X(e,`files`)?[{path:`src/upload-policy.ts`,contents:rt()},{path:`src/files.ts`,contents:it()},{path:`src/files.module.css`,contents:ot()}]:[],...X(e,`board`)?[{path:`src/board.ts`,contents:Ue()},{path:`src/board.module.css`,contents:Ge()}]:[],...r.map(({file:e,contents:t})=>({path:`src/routes/${e}`,contents:t})),{path:`src/routes.gen.ts`,contents:b(r.map(e=>e.file),`./routes`)}]}function fn(e){let t=ee(e.sections);if(e.framework===`astro`)return pn(e,t);if(e.framework===`cloudflare`){let n=e.examples?.includes(`digest`)&&!e.examples.includes(`export`)?[...e.examples,`export`]:e.examples;return dn({...e,...n?{examples:n}:{}},t)}return[{path:`package.json`,contents:te(e)},{path:`tsconfig.json`,contents:F()},{path:`vite.config.ts`,contents:ne()},{path:`index.html`,contents:I(e)},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:B()},{path:`.prettierignore`,contents:V()},{path:`.gitignore`,contents:se()},{path:`README.md`,contents:ce(e)},{path:`AGENTS.md`,contents:U(e)},{path:`src/main.tsx`,contents:re()},{path:`src/vite-env.d.ts`,contents:ie()},{path:`src/App.tsx`,contents:ae(t)},{path:`src/Shell.tsx`,contents:oe(e)},...t.map(e=>({path:`src/sections/${e.component}.tsx`,contents:L(e)}))]}function pn(e,t){return[{path:`package.json`,contents:le(e)},{path:`tsconfig.json`,contents:de()},{path:`astro.config.mjs`,contents:ue()},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:B()},{path:`.prettierignore`,contents:V()},{path:`.gitignore`,contents:_e()},{path:`README.md`,contents:ve(e,t)},{path:`AGENTS.md`,contents:U(e)},{path:`src/styles/layers.css`,contents:fe()},{path:`src/layouts/Layout.astro`,contents:pe(e)},{path:`src/components/Shell.tsx`,contents:me(e,t)},...t.map((e,t)=>({path:`src/pages/${G(e,t)}`,contents:ge(e,t)})),...t.map(e=>({path:`src/components/${e.component}.tsx`,contents:he(e)}))]}const mn=[`Dashboard`,`Reports`,`Settings`];async function hn(f,p=process.cwd()){let m=f.includes(`--yes`)||f.includes(`-y`),h=r(f,[`pm`,`package-manager`,`theme`,`sections`,`template`,`framework`,`runtime`,`example`,`auth`])[0],g=a(f,`theme`),_=a(f,`sections`),v=(a(f,`framework`)??``).toLowerCase();if(v&&!T.includes(v)){console.error(`Unknown framework "${v}". Expected one of: ${T.join(`, `)}.`),process.exitCode=1;return}let y=(a(f,`runtime`)??``).toLowerCase();if(y&&!k(y)){console.error(`Unknown runtime "${y}". Expected one of: ${E.join(`, `)}.`),process.exitCode=1;return}let b=(a(f,`example`)??``).split(`,`).map(e=>e.trim().toLowerCase()).filter(Boolean),x=b.find(e=>!O(e));if(x){console.error(`Unknown example "${x}". Expected one of: ${D.join(`, `)}.`),process.exitCode=1;return}let A=(a(f,`auth`)??``).toLowerCase();if(A&&A!==`access`&&A!==`email`){console.error(`Unknown auth "${A}". Expected: access or email.`),process.exitCode=1;return}let j=i(f);if(`error`in j){console.error(j.error),process.exitCode=1;return}let M=e(p,{preferLockfileOverUserAgent:!0,...j.pm?{override:j.pm}:{}}),N=!m&&o.isTTY?d({input:o,output:s}):null;try{let e=h;!e&&N&&(e=(await N.question(`Project name? [my-cascivo-app]: `)).trim()),e||=`my-cascivo-app`;let r=(g??``).toLowerCase();!t.includes(r)&&N&&(r=(await N.question(`Theme? (${t.join(`/`)}) [light]: `)).trim().toLowerCase());let i=t.includes(r)?r:`light`,o=_;!o&&N&&(o=(await N.question(`Nav sections? (comma-separated) [${mn.join(`, `)}]: `)).trim());let s=(o??``).split(`,`).map(e=>e.trim()).filter(Boolean),d=v;!d&&N&&(d=(await N.question(`Framework? (${T.join(`/`)}) [react-vite]: `)).trim().toLowerCase());let m=T.includes(d)?d:`react-vite`;if(b.length>0&&m!==`cloudflare`){console.error(`--example needs --framework cloudflare (it adds a Worker-backed page).`),process.exitCode=1;return}if(A&&m!==`cloudflare`){console.error(`--auth needs --framework cloudflare (it guards the Worker).`),process.exitCode=1;return}if(b.includes(`agent`)&&y===`preact`){console.error(`--example agent needs --runtime react: the Agents SDK's hooks call React 19's use(), which Preact does not implement.`),process.exitCode=1;return}let x={name:e,framework:m,theme:i,sections:s.length>0?s:mn,pm:M,...k(y)?{runtime:y}:{},...b.length>0?{examples:b.filter(O)}:{},...A===`access`||A===`email`?{auth:A}:{}},E=u(p,e);if(c(E)&&l(E).length>0){console.error(`Target directory "${e}" already exists and is not empty.`),process.exitCode=1;return}let D=fn(x);for(let e of D)await n(u(E,e.path),e.contents);console.log(`\nCreated ${e} (${m}) with the ${i} theme (${D.length} files).`);let j=a(f,`template`);if(j){let{add:e}=await import(`./index.mjs`).then(e=>e.t),{loadConfig:t}=await import(`./config-DT-Cs7bC.mjs`).then(e=>e.i);console.log(`\nInstalling template "${j}"…`),await e([j],await t(),{cwd:E,pm:M})}console.log(`
5434
7709
  Next steps:`),console.log(` cd ${e}`),console.log(` ${S(M)}`),console.log(` ${C(M,`dev`)}`),console.log(`
5435
7710
  Good to know:`),console.log(` No cascivo.config.ts is written — this app uses the prebuilt @cascivo/react`),console.log(" packages and never copies source. `cascivo add <component>` writes the"),console.log(` config itself the first time you vendor a component.`),m===`cloudflare`?(console.log("\n worker/index.ts is the API (wrangler.jsonc routes /api/* to it); `dev`"),console.log(" runs it in workerd. Deploy with `npx wrangler login` once, then the"),console.log(` deploy script: ${w(M,`deploy`)}`),console.log(`
5436
7711
  No account yet? Share a 60-minute preview, claimable into a free account:`),console.log(` ${w(M,`deploy:preview`)}`)):m===`astro`?(console.log(`
5437
7712
  Pages are real Astro routes — no client router to add. Only src/`),console.log(` components/Shell.tsx hydrates (client:load, for the mobile nav drawer);`),console.log(` page content is server-rendered and ships no JS. See`),console.log(` https://cascivo.com/docs/using-with-astro.md`)):(console.log(`
5438
- Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,`),console.log(` and register your Link once with setLinkComponent — see`),console.log(` https://cascivo.com/docs/using-with-a-router.md`))}finally{N?.close()}}export{qt as create};
5439
- //# sourceMappingURL=create-D_3pilHu.mjs.map
7713
+ Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,`),console.log(` and register your Link once with setLinkComponent — see`),console.log(` https://cascivo.com/docs/using-with-a-router.md`))}finally{N?.close()}}export{hn as create};
7714
+ //# sourceMappingURL=create-xLiwiyNv.mjs.map