cascivo 1.4.0 → 1.6.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.6.0`,"@cascivo/themes":`1.0.1`,"@cascivo/charts":`1.6.0`,"@cascivo/icons":`1.1.0`,"@cascivo/eslint-config":`0.4.1`,"@cascivo/app":`1.6.0`,"@cascivo/render":`1.6.0`,"@cascivo/storage":`1.6.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,43 @@ ${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?`, billingStore.billingHooks(env)`:``})
1104
+ } catch (error) {
1105
+ return guardResponse(error)
1106
+ }
1107
+ }
1108
+ // An order's page watches its room for what Stripe reports: it may watch, never write.
1109
+ const orderLive = /^\\/api\\/orders\\/([^/]+)\\/live$/.exec(checkoutPath)
1110
+ if (orderLive && ORDER_ID.test(orderLive[1]!)) {
1111
+ return roomResponse(request, env.ROOMS, orderRoom(orderLive[1]!), { readOnly: true })
1112
+ }`:``}${h?`
1113
+ const newsletterPath = new URL(request.url).pathname
1114
+ // One-click unsubscribe (RFC 8058) and the unsubscribe page's button.
1115
+ if (newsletterPath === '/api/newsletter/unsubscribe' && request.method === 'POST') {
1116
+ return newsletterStore.unsubscribe(request, env)
1117
+ }
1118
+ // SES bounces and complaints, delivered by SNS (worker/newsletter.ts); verified by signature.
1119
+ if (newsletterPath === '/api/sns/ses' && request.method === 'POST') {
1120
+ try {
1121
+ return await newsletterStore.receiveFeedback(request, env)
1122
+ } catch (error) {
1123
+ return guardResponse(error)
1124
+ }
1125
+ }
1126
+ // An issue's sending progress, for the composer: it may watch, never write.
1127
+ const issueLive = /^\\/api\\/newsletter\\/issues\\/([^/]+)\\/live$/.exec(newsletterPath)
1128
+ if (issueLive && ISSUE_ID.test(issueLive[1]!)) {
1129
+ return roomResponse(request, env.ROOMS, issueRoom(issueLive[1]!), { readOnly: true })
1130
+ }`:``}${X(e,`board`)||X(e,`notes`)?`
1131
+ const room = /^\\/api\\/rooms\\/([^/]+)$/.exec(new URL(request.url).pathname)${T.length>0?`
993
1132
  // 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]!)) {
1133
+ // here: ${[r?`a job's progress`:``,d?`webhook deliveries`:``,m?`orders`:``,h?`newsletter issues`:``].filter(Boolean).join(`, `)}.
1134
+ if (room && !/^(${T.join(`|`)})/.test(room[1]!)) {
996
1135
  return roomResponse(request, env.ROOMS, room[1]!)
997
1136
  }`:`
998
1137
  if (room) return roomResponse(request, env.ROOMS, room[1]!)`}`:``}${r?`
@@ -1006,7 +1145,19 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
1006
1145
  // The Cron Trigger in wrangler.jsonc: the weekly digest, recorded whatever happens.
1007
1146
  async scheduled(_event: unknown, env: Env): Promise<void> {
1008
1147
  await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')
1009
- },`:``}${c?`
1148
+ },`:``}${c&&h?`
1149
+ // Two queues, one handler: each batch says which queue it came from. A throw retries it.
1150
+ async queue(batch: LiveBatch & NewsletterBatch & { queue: string }, env: Env): Promise<void> {
1151
+ // NEWSLETTER: one message of readers at a time, through SES (worker/newsletter.ts).
1152
+ if (batch.queue === '${Y(e)}') return newsletterStore.deliver(env, batch)
1153
+ // EVENTS: into the dashboard's room.
1154
+ await recordLive(
1155
+ ops,
1156
+ env.LIVE,
1157
+ OPS_ROOM,
1158
+ batch.messages.map((message) => message.body),
1159
+ )
1160
+ },`:c?`
1010
1161
  // The EVENTS queue, a batch at a time, into the dashboard's room. A throw retries the batch.
1011
1162
  async queue(batch: LiveBatch, env: Env): Promise<void> {
1012
1163
  await recordLive(
@@ -1015,9 +1166,14 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
1015
1166
  OPS_ROOM,
1016
1167
  batch.messages.map((message) => message.body),
1017
1168
  )
1169
+ },`:h?`
1170
+ // The NEWSLETTER queue: one message of readers at a time, through SES
1171
+ // (worker/newsletter.ts). A throw retries the message; readers already sent are skipped.
1172
+ async queue(batch: NewsletterBatch, env: Env): Promise<void> {
1173
+ await newsletterStore.deliver(env, batch)
1018
1174
  },`:``}
1019
1175
  }
1020
- `}function ke(){return`import { createClient } from '@cascivo/app/api'
1176
+ `}function Ne(){return`import { createClient } from '@cascivo/app/api'
1021
1177
  import { signal } from '@cascivo/react'
1022
1178
  import { api } from './api'
1023
1179
  import type { Tick } from './api'
@@ -1060,7 +1216,7 @@ export async function connect(): Promise<void> {
1060
1216
  export function disconnect(): void {
1061
1217
  controller?.abort()
1062
1218
  }
1063
- `}function Ae(){return`import {
1219
+ `}function Pe(){return`import {
1064
1220
  Badge,
1065
1221
  Button,
1066
1222
  Card,
@@ -1115,18 +1271,18 @@ export function LiveCard() {
1115
1271
  </Card>
1116
1272
  )
1117
1273
  }
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'
1274
+ `}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
1275
  import { notFound, routes } from './routes.gen'
1120
1276
 
1121
1277
  // \`routes.gen.ts\` is written by \`cascivoRoutes()\` (vite.config.ts) from \`src/routes/\`:
1122
1278
  // add, rename or delete a file there and the route table follows.
1123
1279
  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=>` {
1280
+ `}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
1281
  label: '${e.label.replace(/'/g,`\\'`)}',
1126
1282
  href: '${e.href}',
1127
1283
  active: path === '${e.href}',
1128
1284
  },`).join(`
1129
- `),i=K(t,`export`);return`import { RouterView } from '@cascivo/app'
1285
+ `),i=X(t,`export`);return`import { RouterView } from '@cascivo/app'
1130
1286
  ${i?`import { isExporting } from '@cascivo/app/export'
1131
1287
  `:``}import { Spinner, useSignals, type SideNavItem } from '@cascivo/react'
1132
1288
  import { router } from './router'
@@ -1157,7 +1313,7 @@ ${i?`
1157
1313
  </Shell>
1158
1314
  )
1159
1315
  }
1160
- `}function Fe(){return`import React from 'react'
1316
+ `}function ze(){return`import React from 'react'
1161
1317
  import ReactDOM from 'react-dom/client'
1162
1318
  import { setLinkComponent } from '@cascivo/react'
1163
1319
  import App from './App'
@@ -1174,7 +1330,7 @@ if (root) {
1174
1330
  </React.StrictMode>,
1175
1331
  )
1176
1332
  }
1177
- `}function Ie(e){return`import { Flex, Heading, Text } from '@cascivo/react'
1333
+ `}function Be(e){return`import { Flex, Heading, Text } from '@cascivo/react'
1178
1334
  import { LiveCard } from '../LiveCard'
1179
1335
 
1180
1336
  export default function ${e.component}() {
@@ -1190,12 +1346,12 @@ export default function ${e.component}() {
1190
1346
  </Flex>
1191
1347
  )
1192
1348
  }
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'
1349
+ `}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
1350
 
1195
1351
  export default function NotFound() {
1196
1352
  return <EmptyState title="Page not found" description="There is nothing at this address." />
1197
1353
  }
1198
- `}function ze(){return`import { connectRoom } from '@cascivo/app/sync'
1354
+ `}function Ue(){return`import { connectRoom } from '@cascivo/app/sync'
1199
1355
 
1200
1356
  export interface Note {
1201
1357
  text: string
@@ -1246,7 +1402,7 @@ export function addNote(): void {
1246
1402
  const offset = Object.keys(notes.value).length * 24
1247
1403
  notes.set(crypto.randomUUID(), { text: '', x: 24 + (offset % 240), y: 24 + (offset % 160) })
1248
1404
  }
1249
- `}function Be(){return`import type { PointerEvent } from 'react'
1405
+ `}function We(){return`import type { PointerEvent } from 'react'
1250
1406
  import {
1251
1407
  Badge,
1252
1408
  Button,
@@ -1370,7 +1526,7 @@ export default function Board() {
1370
1526
  </Flex>
1371
1527
  )
1372
1528
  }
1373
- `}function Ve(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1529
+ `}function Ge(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1374
1530
  @layer cascivo.example {
1375
1531
  .board {
1376
1532
  position: relative;
@@ -1410,7 +1566,7 @@ export default function Board() {
1410
1566
  pointer-events: none;
1411
1567
  }
1412
1568
  }
1413
- `}function He(){return`import type { ViewConfig } from '@cascivo/render'
1569
+ `}function Ke(){return`import type { ViewConfig } from '@cascivo/render'
1414
1570
  import { validateView } from '@cascivo/render/validate'
1415
1571
 
1416
1572
  /**
@@ -1436,7 +1592,7 @@ export function checkView(title: unknown, view: unknown): CheckedView {
1436
1592
  // validateView has checked the whole shape, so the cast states a proven fact.
1437
1593
  return { title, view: view as ViewConfig }
1438
1594
  }
1439
- `}function Ue(){return`import { AIChatAgent } from '@cloudflare/ai-chat'
1595
+ `}function qe(){return`import { AIChatAgent } from '@cloudflare/ai-chat'
1440
1596
  import { convertToModelMessages, stepCountIs, streamText, tool } from 'ai'
1441
1597
  import type { LanguageModel } from 'ai'
1442
1598
  import { createWorkersAI } from 'workers-ai-provider'
@@ -1491,7 +1647,7 @@ export class Assistant extends AIChatAgent<Env> {
1491
1647
  return result.toUIMessageStreamResponse()
1492
1648
  }
1493
1649
  }
1494
- `}function We(){return`import { simulateReadableStream } from 'ai'
1650
+ `}function Je(){return`import { simulateReadableStream } from 'ai'
1495
1651
  import { MockLanguageModelV4 } from 'ai/test'
1496
1652
 
1497
1653
  /**
@@ -1593,7 +1749,7 @@ export function scriptedModel(): MockLanguageModelV4 {
1593
1749
  },
1594
1750
  })
1595
1751
  }
1596
- `}function Ge(){return`import type { FormEvent } from 'react'
1752
+ `}function Ye(){return`import type { FormEvent } from 'react'
1597
1753
  import { CascivoView } from '@cascivo/render'
1598
1754
  import {
1599
1755
  Badge,
@@ -1723,7 +1879,7 @@ export default function Assistant() {
1723
1879
  </Flex>
1724
1880
  )
1725
1881
  }
1726
- `}function Ke(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1882
+ `}function Xe(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
1727
1883
  @layer cascivo.example {
1728
1884
  .messages {
1729
1885
  display: flex;
@@ -1756,7 +1912,7 @@ export default function Assistant() {
1756
1912
  flex: 1;
1757
1913
  }
1758
1914
  }
1759
- `}function qe(){return`import { connectRoom } from '@cascivo/app/sync'
1915
+ `}function Ze(){return`import { connectRoom } from '@cascivo/app/sync'
1760
1916
  import { indexedDBDriver } from '@cascivo/storage'
1761
1917
 
1762
1918
  export interface Note {
@@ -1817,7 +1973,7 @@ export function addNote(): void {
1817
1973
  export function editNote(id: string, note: Note, change: Partial<Note>): void {
1818
1974
  notes.set(id, { ...note, ...change, updatedAt: Date.now() })
1819
1975
  }
1820
- `}function Je(){return`import {
1976
+ `}function Qe(){return`import {
1821
1977
  Badge,
1822
1978
  Button,
1823
1979
  Card,
@@ -1893,7 +2049,7 @@ export default function Notes() {
1893
2049
  </Flex>
1894
2050
  )
1895
2051
  }
1896
- `}function Ye(){return`import { defineJob } from '@cascivo/app/jobs'
2052
+ `}function $e(){return`import { defineJob } from '@cascivo/app/jobs'
1897
2053
 
1898
2054
  /**
1899
2055
  * The CSV import job, shared by the Worker (which runs it) and the page (which watches it).
@@ -1953,7 +2109,7 @@ export function parseStarted(raw: unknown): { id: string } {
1953
2109
  }
1954
2110
  throw new Error('Malformed job id')
1955
2111
  }
1956
- `}function Xe(){return`import { createClient } from '@cascivo/app/api'
2112
+ `}function et(){return`import { createClient } from '@cascivo/app/api'
1957
2113
  import { watchJob } from '@cascivo/app/jobs'
1958
2114
  import type { WatchedJob } from '@cascivo/app/jobs'
1959
2115
  import { signal } from '@cascivo/react'
@@ -1989,7 +2145,7 @@ export async function startImport(csv: string): Promise<void> {
1989
2145
  starting.value = false
1990
2146
  }
1991
2147
  }
1992
- `}function Ze(){return`import { jobReporter } from '@cascivo/app/jobs-server'
2148
+ `}function tt(){return`import { jobReporter } from '@cascivo/app/jobs-server'
1993
2149
  import { WorkflowEntrypoint } from 'cloudflare:workers'
1994
2150
  import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'
1995
2151
  import { importJob } from '../src/import-job'
@@ -2075,7 +2231,7 @@ export class ImportJob extends WorkflowEntrypoint<Env, { csv: string }> {
2075
2231
  }
2076
2232
  }
2077
2233
  }
2078
- `}function Qe(){return`import type { Step } from '@cascivo/react'
2234
+ `}function nt(){return`import type { Step } from '@cascivo/react'
2079
2235
  import {
2080
2236
  Alert,
2081
2237
  Button,
@@ -2187,7 +2343,7 @@ export default function Import() {
2187
2343
  </Flex>
2188
2344
  )
2189
2345
  }
2190
- `}function $e(){return`import { defineUploads } from '@cascivo/app/uploads'
2346
+ `}function rt(){return`import { defineUploads } from '@cascivo/app/uploads'
2191
2347
 
2192
2348
  /**
2193
2349
  * What the app accepts, shared by the page (which checks first) and the Worker (which
@@ -2199,7 +2355,7 @@ export const uploads = defineUploads({
2199
2355
  maxBytes: 50 * 1024 * 1024,
2200
2356
  types: ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf'],
2201
2357
  })
2202
- `}function et(){return`import { createClient } from '@cascivo/app/api'
2358
+ `}function it(){return`import { createClient } from '@cascivo/app/api'
2203
2359
  import { startUpload } from '@cascivo/app/uploads'
2204
2360
  import type { StoredFile, Upload } from '@cascivo/app/uploads'
2205
2361
  import { signal } from '@cascivo/react'
@@ -2236,7 +2392,7 @@ export function removeUpload(id: string): void {
2236
2392
  upload?.abort()
2237
2393
  inFlight.value = inFlight.value.filter((u) => u.id !== id)
2238
2394
  }
2239
- `}function tt(){return`import type { UploaderFile } from '@cascivo/react'
2395
+ `}function at(){return`import type { UploaderFile } from '@cascivo/react'
2240
2396
  import {
2241
2397
  Card,
2242
2398
  CardContent,
@@ -2317,7 +2473,7 @@ export default function Files() {
2317
2473
  </Flex>
2318
2474
  )
2319
2475
  }
2320
- `}function nt(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
2476
+ `}function ot(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
2321
2477
  @layer cascivo.example {
2322
2478
  .grid {
2323
2479
  display: grid;
@@ -2333,7 +2489,7 @@ export default function Files() {
2333
2489
  background: var(--cascivo-color-bg-subtle);
2334
2490
  }
2335
2491
  }
2336
- `}function rt(){return`import { exportUrl, isExporting } from '@cascivo/app/export'
2492
+ `}function st(){return`import { exportUrl, isExporting } from '@cascivo/app/export'
2337
2493
  import type { Column } from '@cascivo/react'
2338
2494
  import { Badge, Button, Card, CardContent, DataTable, Flex, Heading, Text } from '@cascivo/react'
2339
2495
 
@@ -2401,7 +2557,7 @@ export default function Report() {
2401
2557
  </Flex>
2402
2558
  )
2403
2559
  }
2404
- `}function it(){return`import { defineMetrics } from '@cascivo/app/analytics'
2560
+ `}function ct(){return`import { defineMetrics } from '@cascivo/app/analytics'
2405
2561
 
2406
2562
  /**
2407
2563
  * What the Worker records for every API request, in Analytics Engine (worker/index.ts writes
@@ -2462,7 +2618,7 @@ export function parseUsageReport(raw: unknown): UsageReport {
2462
2618
  totals: { requests: num(t['requests']), errors: num(t['errors']), avgMs: num(t['avgMs']) },
2463
2619
  }
2464
2620
  }
2465
- `}function at(){return`import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'
2621
+ `}function lt(){return`import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'
2466
2622
  import type { AnalyticsCredentials } from '@cascivo/app/analytics'
2467
2623
  import { usageMetrics } from '../src/usage'
2468
2624
  import type { UsageReport } from '../src/usage'
@@ -2537,7 +2693,7 @@ export async function usageReport(credentials: AnalyticsCredentials | null): Pro
2537
2693
  },
2538
2694
  }
2539
2695
  }
2540
- `}function ot(){return`import { BarChart, Kpi, LineChart } from '@cascivo/charts'
2696
+ `}function ut(){return`import { BarChart, Kpi, LineChart } from '@cascivo/charts'
2541
2697
  import { createClient } from '@cascivo/app/api'
2542
2698
  import {
2543
2699
  Button,
@@ -2625,7 +2781,7 @@ export default function Usage() {
2625
2781
  </Flex>
2626
2782
  )
2627
2783
  }
2628
- `}function st(){return`/**
2784
+ `}function dt(){return`/**
2629
2785
  * /search, shared by the Worker (which searches) and the page (which asks). \`semantic\` is
2630
2786
  * Vectorize over Workers AI embeddings; \`keyword\` is the \`vite dev\` stand-in, SQLite's
2631
2787
  * full-text search, because neither runs locally.
@@ -2689,7 +2845,7 @@ export function parseIndexed(raw: unknown): { indexed: number } {
2689
2845
  }
2690
2846
  throw new Error('Malformed reply')
2691
2847
  }
2692
- `}function ct(){return`import { HttpError } from '@cascivo/app/api'
2848
+ `}function ft(){return`import { HttpError } from '@cascivo/app/api'
2693
2849
  import { migrate, queryRows } from '@cascivo/app/db'
2694
2850
  import type { Database } from '@cascivo/app/db'
2695
2851
  import type { SearchHit, SearchResult } from '../src/search'
@@ -2835,7 +2991,7 @@ export async function indexArticles(env: SearchEnv): Promise<{ indexed: number }
2835
2991
  }
2836
2992
  return { indexed: (await env.ARTICLES_INDEX.describe()).vectorCount }
2837
2993
  }
2838
- `}function lt(){return`/**
2994
+ `}function pt(){return`/**
2839
2995
  * The help articles /search looks through, seeded into D1 on the first query. Replace them with
2840
2996
  * your own content: anything with an id, a title and a body can be indexed the same way.
2841
2997
  */
@@ -2961,7 +3117,7 @@ export const articles: { id: string; title: string; body: string }[] = [
2961
3117
  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
3118
  },
2963
3119
  ]
2964
- `}function ut(){return`import { createClient } from '@cascivo/app/api'
3120
+ `}function mt(){return`import { createClient } from '@cascivo/app/api'
2965
3121
  import {
2966
3122
  Alert,
2967
3123
  Badge,
@@ -3089,7 +3245,7 @@ export default function SearchPage() {
3089
3245
  </Flex>
3090
3246
  )
3091
3247
  }
3092
- `}function dt(){return`/**
3248
+ `}function ht(){return`/**
3093
3249
  * The weekly digest's runs, shared by the Worker (which records each one) and the /digest page
3094
3250
  * (which lists them). A run happens on the Cron Trigger in wrangler.jsonc, or from "Send now".
3095
3251
  */
@@ -3136,7 +3292,7 @@ export function parseDigestRuns(raw: unknown): DigestRun[] {
3136
3292
  if (!Array.isArray(raw)) throw new Error('Expected a list of runs')
3137
3293
  return raw.map(parseDigestRun)
3138
3294
  }
3139
- `}function ft(){return`import { migrate, queryRows } from '@cascivo/app/db'
3295
+ `}function gt(){return`import { migrate, queryRows } from '@cascivo/app/db'
3140
3296
  import type { Database } from '@cascivo/app/db'
3141
3297
  import { exportPage } from '@cascivo/app/export'
3142
3298
  import type { ExportBrowser } from '@cascivo/app/export'
@@ -3265,7 +3421,7 @@ export async function listRuns(db: Database): Promise<DigestRun[]> {
3265
3421
  parseDigestRun,
3266
3422
  )
3267
3423
  }
3268
- `}function pt(){return`import { createClient } from '@cascivo/app/api'
3424
+ `}function _t(){return`import { createClient } from '@cascivo/app/api'
3269
3425
  import {
3270
3426
  Alert,
3271
3427
  Badge,
@@ -3347,7 +3503,7 @@ export default function Digest() {
3347
3503
  </Flex>
3348
3504
  )
3349
3505
  }
3350
- `}function mt(){return`/**
3506
+ `}function vt(){return`/**
3351
3507
  * Webhook deliveries, shared by the Worker (which verifies and stores them) and the
3352
3508
  * /webhooks page (which lists them as they arrive).
3353
3509
  */
@@ -3390,7 +3546,7 @@ export function parseTestResult(raw: unknown): { status: number } {
3390
3546
  }
3391
3547
  throw new Error('Malformed reply')
3392
3548
  }
3393
- `}function ht(){return`import { migrate, queryRows } from '@cascivo/app/db'
3549
+ `}function yt(){return`import { migrate, queryRows } from '@cascivo/app/db'
3394
3550
  import type { Database } from '@cascivo/app/db'
3395
3551
  import { verifyWebhook } from '@cascivo/app/guard'
3396
3552
  import { writeRoom } from '@cascivo/app/sync-server'
@@ -3508,7 +3664,7 @@ export async function sendTestDelivery(
3508
3664
  )
3509
3665
  return { status: response.status }
3510
3666
  }
3511
- `}function gt(){return`import { createClient } from '@cascivo/app/api'
3667
+ `}function bt(){return`import { createClient } from '@cascivo/app/api'
3512
3668
  import { connectRoom } from '@cascivo/app/sync'
3513
3669
  import {
3514
3670
  Alert,
@@ -3614,8 +3770,148 @@ export default function Webhooks() {
3614
3770
  </Flex>
3615
3771
  )
3616
3772
  }
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 {
3773
+ `}function xt(e){return`/**
3774
+ * What /checkout sells and the orders it makes, shared by the Worker (which creates them and
3775
+ * hears from Stripe) and the pages (which show them).
3776
+ */
3777
+
3778
+ /** The seller, as the receipt names it. */
3779
+ export const SHOP_NAME = '${M(e.name).replace(/'/g,`\\'`)}'
3780
+
3781
+ /**
3782
+ * What you sell. The Worker sends this to Stripe, so a browser cannot change the price; the
3783
+ * page only displays it.
3784
+ */
3785
+ export const PRODUCT = {
3786
+ name: 'Sticker pack',
3787
+ description: 'Twelve vinyl stickers of your favourite components, shipped worldwide.',
3788
+ /** In the currency's smallest unit: 900 is €9.00. */
3789
+ amount: 900,
3790
+ /** Three-letter ISO code, lowercase. */
3791
+ currency: 'eur',
3792
+ }
3793
+
3794
+ /**
3795
+ * \`pending\` until Stripe confirms the payment. \`failed\` and \`expired\` are final. A paid
3796
+ * order becomes \`refunded\` when all of it is refunded, and \`disputed\` while a chargeback is
3797
+ * open (back to \`paid\` if it is won).
3798
+ */
3799
+ export type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'disputed'
3800
+
3801
+ export interface Order {
3802
+ id: string
3803
+ status: OrderStatus
3804
+ /** What Stripe charged, in the currency's smallest unit. */
3805
+ amount: number
3806
+ currency: string
3807
+ /** Refunded so far, in the same unit: part of a paid order, or all of a refunded one. */
3808
+ refundedAmount: number
3809
+ createdAt: string
3810
+ paidAt: string | null
3811
+ }
3812
+
3813
+ /** An order id: a UUID the Worker made. */
3814
+ export const ORDER_ID = /^[0-9a-f-]{36}$/
3815
+
3816
+ /** The room the Worker pushes an order's changes to; its page watches it. */
3817
+ export const orderRoom = (id: string) => \`order-\${id}\`
3818
+
3819
+ /** A price in the currency's smallest unit, for people: 900 eur is €9.00, 900 jpy is ¥900. */
3820
+ export function formatPrice(amount: number, currency: string): string {
3821
+ const format = new Intl.NumberFormat(undefined, { style: 'currency', currency })
3822
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2
3823
+ return format.format(amount / 10 ** digits)
3824
+ }
3825
+
3826
+ const STATUSES = ['pending', 'paid', 'failed', 'expired', 'refunded', 'disputed']
3827
+
3828
+ export function parseOrder(raw: unknown): Order {
3829
+ if (typeof raw === 'object' && raw !== null) {
3830
+ const { id, status, amount, currency, refundedAmount, createdAt, paidAt } = raw as Record<
3831
+ string,
3832
+ unknown
3833
+ >
3834
+ if (
3835
+ typeof id === 'string' &&
3836
+ typeof status === 'string' &&
3837
+ STATUSES.includes(status) &&
3838
+ typeof amount === 'number' &&
3839
+ typeof currency === 'string' &&
3840
+ typeof refundedAmount === 'number' &&
3841
+ typeof createdAt === 'string' &&
3842
+ (paidAt === null || typeof paidAt === 'string')
3843
+ ) {
3844
+ return {
3845
+ id,
3846
+ // Checked against STATUSES just above.
3847
+ status: status as OrderStatus,
3848
+ amount,
3849
+ currency,
3850
+ refundedAmount,
3851
+ createdAt,
3852
+ paidAt,
3853
+ }
3854
+ }
3855
+ }
3856
+ throw new Error('Malformed order')
3857
+ }
3858
+
3859
+ export function parseCheckoutStarted(raw: unknown): { url: string } {
3860
+ if (typeof raw === 'object' && raw !== null) {
3861
+ const { url } = raw as Record<string, unknown>
3862
+ if (typeof url === 'string' && url.startsWith('https://')) return { url }
3863
+ }
3864
+ throw new Error('Malformed checkout')
3865
+ }
3866
+ `}function St(){return`import { HttpError } from '@cascivo/app/api'
3867
+ import { migrate, queryRows } from '@cascivo/app/db'
3868
+ import type { Database } from '@cascivo/app/db'
3869
+ import { verifyWebhook } from '@cascivo/app/guard'
3870
+ import { StripeError, createStripe, parseStripeEvent } from '@cascivo/app/stripe'
3871
+ import type {
3872
+ Charge,
3873
+ CheckoutEventType,
3874
+ CheckoutSession,
3875
+ Dispute,
3876
+ DisputeEventType,
3877
+ Invoice,
3878
+ Stripe,
3879
+ } from '@cascivo/app/stripe'
3880
+ import { writeRoom } from '@cascivo/app/sync-server'
3881
+ import type { RoomNamespace } from '@cascivo/app/sync-server'
3882
+ import { Receipt, receiptSubject, renderEmail } from '@cascivo/email'
3883
+ import { createElement } from 'react'
3884
+ import { ORDER_ID, PRODUCT, SHOP_NAME, formatPrice, orderRoom, parseOrder } from '../src/checkout'
3885
+ import type { Order, OrderStatus } from '../src/checkout'
3886
+
3887
+ const migrations = [
3888
+ {
3889
+ id: '0001_orders',
3890
+ statements: [
3891
+ \`CREATE TABLE orders (
3892
+ id TEXT PRIMARY KEY,
3893
+ session_id TEXT NOT NULL UNIQUE,
3894
+ status TEXT NOT NULL,
3895
+ amount INTEGER NOT NULL,
3896
+ currency TEXT NOT NULL,
3897
+ email TEXT,
3898
+ created_at TEXT NOT NULL,
3899
+ paid_at TEXT,
3900
+ payment_intent_id TEXT,
3901
+ refunded_amount INTEGER NOT NULL DEFAULT 0,
3902
+ dispute_status TEXT
3903
+ )\`,
3904
+ // Refund and dispute events name the payment, not the session.
3905
+ 'CREATE INDEX orders_payment_intent ON orders (payment_intent_id)',
3906
+ ],
3907
+ },
3908
+ ]
3909
+
3910
+ /** Where Stripe posts; add it as an endpoint in the Stripe dashboard (README). */
3911
+ export const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'
3912
+
3913
+ /** What sending a receipt needs of the Email Service binding (\`send_email\`). */
3914
+ export interface ReceiptSender {
3619
3915
  send(message: {
3620
3916
  from: string
3621
3917
  to: string
@@ -3625,93 +3921,2234 @@ export interface SignInSender {
3625
3921
  }): Promise<unknown>
3626
3922
  }
3627
3923
 
3628
- const escape = (value: string) =>
3629
- value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
3924
+ export interface CheckoutEnv {
3925
+ DB: Database
3926
+ ROOMS: RoomNamespace<unknown>
3927
+ EMAIL: ReceiptSender
3928
+ RECEIPT_FROM: string
3929
+ STRIPE_SECRET_KEY?: string
3930
+ STRIPE_WEBHOOK_SECRET?: string
3931
+ }
3932
+
3933
+ const COLUMNS =
3934
+ 'id, status, amount, currency, refunded_amount AS refundedAmount, created_at AS createdAt, paid_at AS paidAt'
3935
+
3936
+ export function stripeOf(env: { STRIPE_SECRET_KEY?: string }): Stripe {
3937
+ if (!env.STRIPE_SECRET_KEY) {
3938
+ throw new HttpError(
3939
+ 503,
3940
+ 'Set STRIPE_SECRET_KEY to a test key from the Stripe dashboard (README)',
3941
+ )
3942
+ }
3943
+ return createStripe(env.STRIPE_SECRET_KEY)
3944
+ }
3945
+
3946
+ /** Stripe's refusal, for the page: its message in vite dev, a pointer to the log deployed. */
3947
+ export function refused(error: unknown): never {
3948
+ if (!(error instanceof StripeError)) throw error
3949
+ console.error('[checkout] Stripe refused:', error.status, error.code, error.message)
3950
+ throw new HttpError(
3951
+ 502,
3952
+ import.meta.env.DEV
3953
+ ? \`Stripe: \${error.message}\`
3954
+ : 'The payment provider refused the request (see the Worker log)',
3955
+ )
3956
+ }
3630
3957
 
3631
3958
  /**
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).
3959
+ * Creates a Stripe Checkout Session for PRODUCT and records the order as pending. The order id
3960
+ * is also the idempotency key, so a retried request cannot open a second session.
3634
3961
  */
3635
- export async function sendSignInLink(
3636
- sender: SignInSender,
3637
- from: string,
3638
- email: string,
3639
- url: string,
3962
+ export async function startCheckout(env: CheckoutEnv, origin: string): Promise<{ url: string }> {
3963
+ const stripe = stripeOf(env)
3964
+ const id = crypto.randomUUID()
3965
+ let session: CheckoutSession
3966
+ try {
3967
+ session = await stripe.createCheckoutSession(
3968
+ {
3969
+ mode: 'payment',
3970
+ lineItems: [{ ...PRODUCT, quantity: 1 }],
3971
+ successUrl: \`\${origin}/checkout/\${id}\`,
3972
+ cancelUrl: \`\${origin}/checkout\`,
3973
+ clientReferenceId: id,
3974
+ },
3975
+ { idempotencyKey: id },
3976
+ )
3977
+ } catch (error) {
3978
+ refused(error)
3979
+ }
3980
+ if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')
3981
+ await migrate(env.DB, migrations)
3982
+ await queryRows(
3983
+ env.DB,
3984
+ \`INSERT INTO orders (id, session_id, status, amount, currency, created_at)
3985
+ VALUES (?, ?, 'pending', ?, ?, ?)\`,
3986
+ [id, session.id, PRODUCT.amount, PRODUCT.currency, new Date().toISOString()],
3987
+ (row) => row,
3988
+ )
3989
+ return { url: session.url }
3990
+ }
3991
+
3992
+ /** Where an event moves the order, or \`null\` when it does not move it. */
3993
+ function statusAfter(type: CheckoutEventType, session: CheckoutSession): OrderStatus | null {
3994
+ switch (type) {
3995
+ case 'checkout.session.completed':
3996
+ // \`unpaid\` here is a bank debit that has not settled: the async events decide it.
3997
+ return session.paymentStatus === 'unpaid' ? null : 'paid'
3998
+ case 'checkout.session.async_payment_succeeded':
3999
+ return 'paid'
4000
+ case 'checkout.session.async_payment_failed':
4001
+ return 'failed'
4002
+ case 'checkout.session.expired':
4003
+ return 'expired'
4004
+ }
4005
+ }
4006
+
4007
+ /** The same decision from a session read back from Stripe, with no event to go by. */
4008
+ function statusOf(session: CheckoutSession): OrderStatus | null {
4009
+ if (session.status === 'complete' && session.paymentStatus !== 'unpaid') return 'paid'
4010
+ return session.status === 'expired' ? 'expired' : null
4011
+ }
4012
+
4013
+ /**
4014
+ * Moves a pending order to its final status, once: a retried event, or the order page reading
4015
+ * the session before the webhook arrived, finds it settled and changes nothing. The order is
4016
+ * found by Stripe's session id, never by \`client_reference_id\`, which a buyer can set on a
4017
+ * Payment Link. Then the order's page hears about it, and a paid order gets its receipt.
4018
+ */
4019
+ async function settle(
4020
+ env: CheckoutEnv,
4021
+ session: CheckoutSession,
4022
+ status: OrderStatus,
4023
+ origin: string,
3640
4024
  ): Promise<void> {
4025
+ await migrate(env.DB, migrations)
4026
+ const [order] = await queryRows(
4027
+ env.DB,
4028
+ \`UPDATE orders SET status = ?, paid_at = ?, amount = COALESCE(?, amount),
4029
+ currency = COALESCE(?, currency), email = ?, payment_intent_id = ?
4030
+ WHERE session_id = ? AND status = 'pending' RETURNING \${COLUMNS}\`,
4031
+ [
4032
+ status,
4033
+ status === 'paid' ? new Date().toISOString() : null,
4034
+ session.amountTotal,
4035
+ session.currency,
4036
+ session.customerEmail,
4037
+ session.paymentIntentId,
4038
+ session.id,
4039
+ ],
4040
+ parseOrder,
4041
+ )
4042
+ if (!order) return
4043
+ await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
4044
+ if (order.status === 'paid' && session.customerEmail) {
4045
+ await sendReceipt(env, order, session.customerEmail, origin)
4046
+ }
4047
+ }
4048
+
4049
+ /**
4050
+ * Emails a receipt rendered with @cascivo/email. \`vite dev\` renders it and logs it instead.
4051
+ * A receipt that cannot be sent is logged, not thrown: the payment is recorded either way, and
4052
+ * Stripe retrying the event would not send it again.
4053
+ */
4054
+ async function sendReceipt(env: CheckoutEnv, order: Order, to: string, origin: string) {
4055
+ const total = formatPrice(order.amount, order.currency)
4056
+ const props = {
4057
+ productName: SHOP_NAME,
4058
+ orderId: order.id.slice(0, 8).toUpperCase(),
4059
+ items: [{ description: PRODUCT.name, amount: total }],
4060
+ total,
4061
+ invoiceHref: \`\${origin}/checkout/\${order.id}\`,
4062
+ }
4063
+ const message = renderEmail(createElement(Receipt, props), { subject: receiptSubject(props) })
3641
4064
  if (import.meta.env.DEV) {
3642
- console.log(\`[auth] sign-in link for \${email}: \${url}\`)
4065
+ console.log(\`[checkout] receipt for \${to}: "\${message.subject}" (\${message.html.length} bytes)\`)
3643
4066
  return
3644
4067
  }
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>\`,
4068
+ if (!env.RECEIPT_FROM) {
4069
+ console.warn('[checkout] no receipt sent: set RECEIPT_FROM in wrangler.jsonc')
4070
+ return
4071
+ }
4072
+ try {
4073
+ await env.EMAIL.send({
4074
+ from: env.RECEIPT_FROM,
4075
+ to,
4076
+ subject: message.subject,
4077
+ text: message.text,
4078
+ html: message.html,
4079
+ })
4080
+ } catch (error) {
4081
+ console.error('[checkout] receipt not sent:', error)
4082
+ }
4083
+ }
4084
+
4085
+ /**
4086
+ * A refund, made in the Stripe dashboard or with \`createRefund\`. \`charge.refunded\` carries the
4087
+ * running total, so a retried or late event cannot count a refund twice. All of it refunded
4088
+ * makes the order \`refunded\`; part of it leaves it \`paid\`, with the amount shown.
4089
+ */
4090
+ async function refundOrder(env: CheckoutEnv, charge: Charge): Promise<void> {
4091
+ if (!charge.paymentIntentId) return
4092
+ await migrate(env.DB, migrations)
4093
+ const [order] = await queryRows(
4094
+ env.DB,
4095
+ \`UPDATE orders SET refunded_amount = MAX(refunded_amount, ?),
4096
+ status = CASE WHEN ? = 1 THEN 'refunded' ELSE status END
4097
+ WHERE payment_intent_id = ? AND status IN ('paid', 'refunded') RETURNING \${COLUMNS}\`,
4098
+ [charge.amountRefunded, charge.refunded ? 1 : 0, charge.paymentIntentId],
4099
+ parseOrder,
4100
+ )
4101
+ if (order) await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
4102
+ }
4103
+
4104
+ /**
4105
+ * A chargeback. While it is open the order is \`disputed\`: hold back anything not yet
4106
+ * delivered, and answer it with evidence in the Stripe dashboard before the deadline shown
4107
+ * there. Won, the order is \`paid\` again; lost, it stays \`disputed\`. The dispute's status is
4108
+ * stored, so a \`created\` event arriving after \`closed\` cannot reopen it.
4109
+ */
4110
+ async function disputeOrder(
4111
+ env: CheckoutEnv,
4112
+ type: DisputeEventType,
4113
+ dispute: Dispute,
4114
+ ): Promise<void> {
4115
+ if (!dispute.paymentIntentId) return
4116
+ await migrate(env.DB, migrations)
4117
+ const [order] =
4118
+ type === 'charge.dispute.created'
4119
+ ? await queryRows(
4120
+ env.DB,
4121
+ \`UPDATE orders SET status = 'disputed', dispute_status = ?
4122
+ WHERE payment_intent_id = ? AND status = 'paid' AND dispute_status IS NULL
4123
+ RETURNING \${COLUMNS}\`,
4124
+ [dispute.status, dispute.paymentIntentId],
4125
+ parseOrder,
4126
+ )
4127
+ : await queryRows(
4128
+ env.DB,
4129
+ \`UPDATE orders SET dispute_status = ?,
4130
+ status = CASE WHEN ? = 'won' THEN 'paid' ELSE 'disputed' END
4131
+ WHERE payment_intent_id = ? AND status IN ('paid', 'disputed') RETURNING \${COLUMNS}\`,
4132
+ [dispute.status, dispute.status, dispute.paymentIntentId],
4133
+ parseOrder,
4134
+ )
4135
+ if (!order) return
4136
+ if (type === 'charge.dispute.created') {
4137
+ console.warn(
4138
+ \`[checkout] order \${order.id} is disputed (\${dispute.reason ?? 'no reason given'})\`,
4139
+ )
4140
+ }
4141
+ await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
4142
+ }
4143
+
4144
+ /** What the webhook hands to worker/billing.ts, when the app bills subscriptions. */
4145
+ export interface BillingHooks {
4146
+ /** A subscription changed: store its current state. */
4147
+ subscription(subscriptionId: string): Promise<void>
4148
+ /** A renewal could not be charged: tell the customer. */
4149
+ paymentFailed(invoice: Invoice, origin: string): Promise<void>
4150
+ }
4151
+
4152
+ /**
4153
+ * Stripe's webhook: verified against STRIPE_WEBHOOK_SECRET before anything in it is read,
4154
+ * then each Checkout event settles its order, and refunds and disputes update it. Subscription
4155
+ * and invoice events, and completed subscription checkouts, go to \`billing\` when the app bills
4156
+ * subscriptions (worker/billing.ts). Other events are acknowledged, so Stripe stops sending them.
4157
+ */
4158
+ export async function receiveStripe(
4159
+ request: Request,
4160
+ env: CheckoutEnv,
4161
+ billing?: BillingHooks,
4162
+ ): Promise<Response> {
4163
+ if (!env.STRIPE_WEBHOOK_SECRET) throw new HttpError(503, 'Set STRIPE_WEBHOOK_SECRET (README)')
4164
+ const { body } = await verifyWebhook(request, {
4165
+ scheme: 'stripe',
4166
+ secret: env.STRIPE_WEBHOOK_SECRET,
3652
4167
  })
4168
+ const event = parseStripeEvent(body)
4169
+ const origin = new URL(request.url).origin
4170
+ if (event.kind === 'subscription') await billing?.subscription(event.subscription.id)
4171
+ if (event.kind === 'invoice' && event.type === 'invoice.payment_failed') {
4172
+ await billing?.paymentFailed(event.invoice, origin)
4173
+ }
4174
+ if (event.kind === 'refund') await refundOrder(env, event.charge)
4175
+ if (event.kind === 'dispute') await disputeOrder(env, event.type, event.dispute)
4176
+ if (event.kind === 'checkout' && event.session.mode === 'subscription') {
4177
+ if (event.session.subscriptionId) await billing?.subscription(event.session.subscriptionId)
4178
+ } else if (event.kind === 'checkout') {
4179
+ const status = statusAfter(event.type, event.session)
4180
+ if (status) await settle(env, event.session, status, origin)
4181
+ }
4182
+ return Response.json({ received: true })
4183
+ }
4184
+
4185
+ function parseStored(raw: unknown): { order: Order; sessionId: string } {
4186
+ const sessionId = typeof raw === 'object' && raw !== null ? Reflect.get(raw, 'sessionId') : null
4187
+ if (typeof sessionId !== 'string') throw new Error('Malformed order row')
4188
+ return { order: parseOrder(raw), sessionId }
4189
+ }
4190
+
4191
+ async function readOrder(env: CheckoutEnv, id: string) {
4192
+ await migrate(env.DB, migrations)
4193
+ const [row] = await queryRows(
4194
+ env.DB,
4195
+ \`SELECT \${COLUMNS}, session_id AS sessionId FROM orders WHERE id = ?\`,
4196
+ [id],
4197
+ parseStored,
4198
+ )
4199
+ if (!row) throw new HttpError(404, 'No such order')
4200
+ return row
4201
+ }
4202
+
4203
+ /**
4204
+ * An order, for its page. A pending one is checked with Stripe first, so the page is right
4205
+ * even when the webhook is late or not set up yet (as in \`vite dev\` without \`stripe listen\`).
4206
+ */
4207
+ export async function getOrder(env: CheckoutEnv, id: string, origin: string): Promise<Order> {
4208
+ if (!ORDER_ID.test(id)) throw new HttpError(404, 'No such order')
4209
+ const { order, sessionId } = await readOrder(env, id)
4210
+ if (order.status !== 'pending' || !env.STRIPE_SECRET_KEY) return order
4211
+ try {
4212
+ const session = await stripeOf(env).retrieveCheckoutSession(sessionId)
4213
+ const status = statusOf(session)
4214
+ if (!status) return order
4215
+ await settle(env, session, status, origin)
4216
+ } catch (error) {
4217
+ // Stripe unreachable: show what is known; the webhook settles the order later.
4218
+ console.error('[checkout] could not read the session back:', error)
4219
+ return order
4220
+ }
4221
+ return (await readOrder(env, id)).order
3653
4222
  }
3654
- `}function yt(){return`import {
4223
+ `}function Ct(){return`import { createClient } from '@cascivo/app/api'
4224
+ import {
3655
4225
  Alert,
3656
4226
  Button,
3657
4227
  Card,
3658
4228
  CardContent,
3659
4229
  Flex,
3660
4230
  Heading,
3661
- Input,
3662
- Link,
3663
- Spinner,
3664
4231
  Text,
3665
4232
  signal,
3666
4233
  useSignals,
3667
4234
  } from '@cascivo/react'
3668
- import type { FormEvent } from 'react'
3669
- import { auth } from '../auth'
4235
+ import { api } from '../api'
4236
+ import { PRODUCT, formatPrice } from '../checkout'
3670
4237
 
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)
4238
+ const client = createClient(api)
4239
+ const starting = signal(false)
3674
4240
  const failure = signal<string | null>(null)
3675
- const sending = signal(false)
3676
4241
 
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
4242
+ /** Asks the Worker for a Stripe Checkout page and goes there: Stripe takes the card, not us. */
4243
+ async function buy(): Promise<void> {
4244
+ starting.value = true
3681
4245
  failure.value = null
3682
- sending.value = true
3683
4246
  try {
3684
- const { link } = await auth.start(email)
3685
- sentTo.value = email
3686
- devLink.value = link
4247
+ const { url } = await client.startCheckout()
4248
+ location.assign(url)
3687
4249
  } catch (error) {
3688
- failure.value = error instanceof Error ? error.message : 'Could not send the link'
3689
- } finally {
3690
- sending.value = false
4250
+ failure.value = error instanceof Error ? error.message : 'Could not start the checkout'
4251
+ starting.value = false
3691
4252
  }
3692
4253
  }
3693
4254
 
3694
- export default function Account() {
4255
+ export default function Checkout() {
3695
4256
  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>
4257
+ return (
4258
+ <Flex gap={4}>
4259
+ <Flex gap={1}>
4260
+ <Heading level={1}>Checkout</Heading>
4261
+ <Text muted>
4262
+ Paid on Stripe's hosted page. Stripe tells the Worker when the payment succeeds; the
4263
+ Worker records the order, updates its page and emails a receipt.
4264
+ </Text>
4265
+ </Flex>
4266
+ <Card>
4267
+ <CardContent>
4268
+ <Flex gap={3}>
4269
+ <Flex gap={1}>
4270
+ <Heading level={2}>{PRODUCT.name}</Heading>
4271
+ <Text muted>{PRODUCT.description}</Text>
4272
+ </Flex>
4273
+ <Text size="lg">{formatPrice(PRODUCT.amount, PRODUCT.currency)}</Text>
4274
+ <Flex direction="horizontal">
4275
+ <Button loading={starting.value} onClick={() => void buy()}>
4276
+ Buy now
4277
+ </Button>
4278
+ </Flex>
4279
+ </Flex>
4280
+ </CardContent>
4281
+ </Card>
4282
+ {failure.value ? (
4283
+ <Alert variant="destructive" title="The checkout did not start">
4284
+ {failure.value}
4285
+ </Alert>
4286
+ ) : null}
4287
+ <Text size="sm" muted>
4288
+ In test mode, pay with the card 4242 4242 4242 4242, any future date and any CVC.
4289
+ </Text>
4290
+ </Flex>
4291
+ )
4292
+ }
4293
+ `}function wt(){return`import type { RouteProps } from '@cascivo/app'
4294
+ import { createClient } from '@cascivo/app/api'
4295
+ import { connectRoom } from '@cascivo/app/sync'
4296
+ import {
4297
+ Alert,
4298
+ Badge,
4299
+ EmptyState,
4300
+ Flex,
4301
+ Heading,
4302
+ Link,
4303
+ Spinner,
4304
+ Text,
4305
+ signal,
4306
+ useEffectPropSignal,
4307
+ useSignalEffect,
4308
+ useSignals,
4309
+ } from '@cascivo/react'
4310
+ import { api } from '../../api'
4311
+ import { PRODUCT, formatPrice, parseOrder } from '../../checkout'
4312
+ import type { Order } from '../../checkout'
4313
+
4314
+ const client = createClient(api)
4315
+ /** Orders seen, by id; \`null\` when the id has none. */
4316
+ const orders = signal<Readonly<Record<string, Order | null>>>({})
4317
+
4318
+ /** Keeps the newest word on an order: a final status is never replaced by \`pending\`. */
4319
+ function remember(id: string, order: Order | null): void {
4320
+ const known = orders.peek()[id]
4321
+ if (known && known.status !== 'pending' && order?.status === 'pending') return
4322
+ orders.value = { ...orders.peek(), [id]: order }
4323
+ }
4324
+
4325
+ async function load(id: string): Promise<void> {
4326
+ try {
4327
+ remember(id, await client.getOrder({ params: { id } }))
4328
+ } catch {
4329
+ remember(id, null)
4330
+ }
4331
+ }
4332
+
4333
+ /** Watches the order's room, where the Worker pushes what Stripe reports. Returns the cleanup. */
4334
+ function watch(id: string): () => void {
4335
+ const room = connectRoom(\`/api/orders/\${id}/live\`)
4336
+ const pushed = room.signal<Order | null>('order', null, (raw) =>
4337
+ raw === null ? null : parseOrder(raw),
4338
+ )
4339
+ const stop = pushed.signal.subscribe((order) => {
4340
+ if (order) remember(id, order)
4341
+ })
4342
+ return () => {
4343
+ stop()
4344
+ room.close()
4345
+ }
4346
+ }
4347
+
4348
+ const STATUS = {
4349
+ pending: { variant: 'warning', label: 'Waiting for Stripe' },
4350
+ paid: { variant: 'success', label: 'Paid' },
4351
+ failed: { variant: 'destructive', label: 'Payment failed' },
4352
+ expired: { variant: 'secondary', label: 'Expired' },
4353
+ refunded: { variant: 'secondary', label: 'Refunded' },
4354
+ disputed: { variant: 'destructive', label: 'Disputed' },
4355
+ } as const
4356
+
4357
+ /** \`/checkout/:order\` — where Stripe sends the buyer back. It updates when Stripe confirms. */
4358
+ export default function OrderPage({ params }: RouteProps<'/checkout/:order'>) {
4359
+ useSignals()
4360
+ const id = useEffectPropSignal(params.order)
4361
+ useSignalEffect(() => {
4362
+ void load(id.value)
4363
+ return watch(id.value)
4364
+ })
4365
+ const order = orders.value[params.order]
4366
+
4367
+ if (order === undefined) return <Spinner label="Loading" />
4368
+ if (order === null) {
4369
+ return <EmptyState title="No such order" description="Check the link in your receipt." />
4370
+ }
4371
+ return (
4372
+ <Flex gap={4}>
4373
+ <Flex gap={1}>
4374
+ <Heading level={1}>Your order</Heading>
4375
+ <Text muted>
4376
+ {PRODUCT.name} · {formatPrice(order.amount, order.currency)}
4377
+ </Text>
4378
+ </Flex>
4379
+ <Flex direction="horizontal" align="center" gap={2} wrap>
4380
+ <Badge variant={STATUS[order.status].variant}>{STATUS[order.status].label}</Badge>
4381
+ <Text size="sm" muted>
4382
+ Ordered {new Date(order.createdAt).toLocaleString()}
4383
+ </Text>
4384
+ </Flex>
4385
+ {order.status === 'pending' ? (
4386
+ <Alert variant="info" title="Confirming your payment">
4387
+ This page updates by itself when Stripe confirms. A bank payment can take a few days.
4388
+ </Alert>
4389
+ ) : null}
4390
+ {order.status === 'paid' && order.refundedAmount === 0 ? (
4391
+ <Alert variant="success" title="Thank you">
4392
+ Your payment went through. A receipt is on its way to your inbox.
4393
+ </Alert>
4394
+ ) : null}
4395
+ {order.status === 'paid' && order.refundedAmount > 0 ? (
4396
+ <Alert variant="info" title="Partly refunded">
4397
+ {formatPrice(order.refundedAmount, order.currency)} of this order was refunded to the card
4398
+ that paid it.
4399
+ </Alert>
4400
+ ) : null}
4401
+ {order.status === 'refunded' ? (
4402
+ <Alert variant="info" title="Refunded">
4403
+ The full amount went back to the card that paid it. It can take a few days to show.
4404
+ </Alert>
4405
+ ) : null}
4406
+ {order.status === 'disputed' ? (
4407
+ <Alert variant="warning" title="This payment is disputed">
4408
+ The card's bank is reviewing a chargeback. The order is on hold until it is decided.
4409
+ </Alert>
4410
+ ) : null}
4411
+ {order.status === 'failed' ? (
4412
+ <Alert variant="destructive" title="The payment failed">
4413
+ Nothing was charged. <Link href="/checkout">Try again</Link>
4414
+ </Alert>
4415
+ ) : null}
4416
+ {order.status === 'expired' ? (
4417
+ <Alert variant="warning" title="This checkout expired">
4418
+ It was not paid in time. <Link href="/checkout">Start again</Link>
4419
+ </Alert>
4420
+ ) : null}
4421
+ </Flex>
4422
+ )
4423
+ }
4424
+ `}function Tt(){return`/**
4425
+ * The newsletter's wire types, shared by the Worker (worker/newsletter.ts) and its pages.
4426
+ * Every payload is parsed on arrival: the network is not trusted because the types match.
4427
+ */
4428
+
4429
+ /** Longest subject and body the composer accepts. */
4430
+ export const MAX_SUBJECT = 200
4431
+ export const MAX_BODY = 50_000
4432
+
4433
+ export interface SubscriberCounts {
4434
+ /** Signed up, has not clicked the confirmation link yet. */
4435
+ pending: number
4436
+ subscribed: number
4437
+ unsubscribed: number
4438
+ /** Bounced for good or complained: never mailed again (worker/newsletter.ts). */
4439
+ suppressed: number
4440
+ }
4441
+
4442
+ /** One sent issue and how far its sending has got. */
4443
+ export interface Issue {
4444
+ id: string
4445
+ subject: string
4446
+ createdAt: string
4447
+ /** Subscribers it was queued for. */
4448
+ total: number
4449
+ sent: number
4450
+ failed: number
4451
+ }
4452
+
4453
+ export interface Overview {
4454
+ subscribers: SubscriberCounts
4455
+ issues: Issue[]
4456
+ }
4457
+
4458
+ /** The room the Worker pushes an issue's progress to; the composer watches it. */
4459
+ export const issueRoom = (id: string) => \`issue-\${id}\`
4460
+
4461
+ /** An issue id: a UUID the Worker made. */
4462
+ export const ISSUE_ID = /^[0-9a-f-]{36}$/
4463
+
4464
+ const isRecord = (raw: unknown): raw is Record<string, unknown> =>
4465
+ typeof raw === 'object' && raw !== null
4466
+
4467
+ function text(raw: Record<string, unknown>, key: string, max: number): string {
4468
+ const value = raw[key]
4469
+ if (typeof value !== 'string' || value.trim() === '' || value.length > max) {
4470
+ throw new Error(\`Expected \${key}: some text, at most \${max} characters\`)
4471
+ }
4472
+ return value
4473
+ }
4474
+
4475
+ export function parseEmailInput(raw: unknown): { email: string } {
4476
+ if (!isRecord(raw)) throw new Error('Expected { email }')
4477
+ return { email: text(raw, 'email', 254) }
4478
+ }
4479
+
4480
+ export function parseTokenInput(raw: unknown): { token: string } {
4481
+ if (!isRecord(raw)) throw new Error('Expected { token }')
4482
+ return { token: text(raw, 'token', 100) }
4483
+ }
4484
+
4485
+ /** The composer's key: NEWSLETTER_KEY, which only the sender knows. */
4486
+ export function parseKeyInput(raw: unknown): { key: string } {
4487
+ if (!isRecord(raw)) throw new Error('Expected { key }')
4488
+ return { key: text(raw, 'key', 200) }
4489
+ }
4490
+
4491
+ export interface IssueInput {
4492
+ key: string
4493
+ subject: string
4494
+ /** The body in Markdown, rendered by @cascivo/email's Markdown. */
4495
+ body: string
4496
+ }
4497
+
4498
+ export function parseIssueInput(raw: unknown): IssueInput {
4499
+ if (!isRecord(raw)) throw new Error('Expected { key, subject, body }')
4500
+ return {
4501
+ key: text(raw, 'key', 200),
4502
+ subject: text(raw, 'subject', MAX_SUBJECT),
4503
+ body: text(raw, 'body', MAX_BODY),
4504
+ }
4505
+ }
4506
+
4507
+ export function parseSubscribed(raw: unknown): { devLink: string | null } {
4508
+ if (isRecord(raw) && (raw['devLink'] === null || typeof raw['devLink'] === 'string')) {
4509
+ return { devLink: raw['devLink'] }
4510
+ }
4511
+ throw new Error('Malformed reply')
4512
+ }
4513
+
4514
+ export function parseConfirmed(raw: unknown): { email: string } {
4515
+ if (isRecord(raw) && typeof raw['email'] === 'string') return { email: raw['email'] }
4516
+ throw new Error('Malformed reply')
4517
+ }
4518
+
4519
+ export function parsePreview(raw: unknown): { html: string; bytes: number } {
4520
+ if (isRecord(raw) && typeof raw['html'] === 'string' && typeof raw['bytes'] === 'number') {
4521
+ return { html: raw['html'], bytes: raw['bytes'] }
4522
+ }
4523
+ throw new Error('Malformed preview')
4524
+ }
4525
+
4526
+ const count = (raw: Record<string, unknown>, key: string): number => {
4527
+ const value = raw[key]
4528
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
4529
+ throw new Error(\`Expected a count for \${key}\`)
4530
+ }
4531
+ return value
4532
+ }
4533
+
4534
+ export function parseIssue(raw: unknown): Issue {
4535
+ if (isRecord(raw) && typeof raw['id'] === 'string' && typeof raw['createdAt'] === 'string') {
4536
+ return {
4537
+ id: raw['id'],
4538
+ subject: text(raw, 'subject', MAX_SUBJECT),
4539
+ createdAt: raw['createdAt'],
4540
+ total: count(raw, 'total'),
4541
+ sent: count(raw, 'sent'),
4542
+ failed: count(raw, 'failed'),
4543
+ }
4544
+ }
4545
+ throw new Error('Malformed issue')
4546
+ }
4547
+
4548
+ export function parseOverview(raw: unknown): Overview {
4549
+ if (isRecord(raw) && isRecord(raw['subscribers']) && Array.isArray(raw['issues'])) {
4550
+ const s = raw['subscribers']
4551
+ return {
4552
+ subscribers: {
4553
+ pending: count(s, 'pending'),
4554
+ subscribed: count(s, 'subscribed'),
4555
+ unsubscribed: count(s, 'unsubscribed'),
4556
+ suppressed: count(s, 'suppressed'),
4557
+ },
4558
+ issues: raw['issues'].map(parseIssue),
4559
+ }
4560
+ }
4561
+ throw new Error('Malformed overview')
4562
+ }
4563
+ `}function Et(e){return`import {
4564
+ Body,
4565
+ Button,
4566
+ Container,
4567
+ Footer,
4568
+ Head,
4569
+ Heading,
4570
+ Html,
4571
+ Link,
4572
+ Markdown,
4573
+ Preview,
4574
+ Section,
4575
+ Text,
4576
+ renderEmail,
4577
+ } from '@cascivo/email'
4578
+ import type { RenderResult } from '@cascivo/email'
4579
+ import { createElement as h } from 'react'
4580
+
4581
+ /** Who the newsletter is from, as its emails say. */
4582
+ export const NEWSLETTER_NAME = '${M(e.name).replace(/'/g,`\\'`)}'
4583
+
4584
+ /** Stands in for each reader's unsubscribe token; worker/newsletter.ts swaps it per message. */
4585
+ export const TOKEN_SLOT = '__UNSUBSCRIBE_TOKEN__'
4586
+
4587
+ /**
4588
+ * An issue, rendered once with @cascivo/email: the body is Markdown, drawn through the email
4589
+ * primitives (raw HTML in it stays literal text). The footer carries the unsubscribe link,
4590
+ * with TOKEN_SLOT where each reader's token goes.
4591
+ */
4592
+ export function renderIssue(subject: string, body: string, origin: string): RenderResult {
4593
+ const unsubscribe = \`\${origin}/newsletter/unsubscribe?token=\${TOKEN_SLOT}\`
4594
+ return renderEmail(
4595
+ h(
4596
+ Html,
4597
+ null,
4598
+ h(Head, { title: subject }),
4599
+ h(
4600
+ Body,
4601
+ null,
4602
+ h(Preview, null, subject),
4603
+ h(
4604
+ Container,
4605
+ null,
4606
+ h(Section, { padding: 32 }, h(Heading, { level: 1 }, subject), h(Markdown, null, body)),
4607
+ h(
4608
+ Footer,
4609
+ null,
4610
+ \`You get this because you subscribed to \${NEWSLETTER_NAME}. \`,
4611
+ h(Link, { href: unsubscribe }, 'Unsubscribe'),
4612
+ ),
4613
+ ),
4614
+ ),
4615
+ ),
4616
+ { subject },
4617
+ )
4618
+ }
4619
+
4620
+ /** The double opt-in email: nobody is mailed an issue until they open this link. */
4621
+ export function renderConfirmation(confirmUrl: string): RenderResult {
4622
+ const subject = \`Confirm your subscription to \${NEWSLETTER_NAME}\`
4623
+ return renderEmail(
4624
+ h(
4625
+ Html,
4626
+ null,
4627
+ h(Head, { title: subject }),
4628
+ h(
4629
+ Body,
4630
+ null,
4631
+ h(Preview, null, 'One click and you are on the list.'),
4632
+ h(
4633
+ Container,
4634
+ null,
4635
+ h(
4636
+ Section,
4637
+ { padding: 32 },
4638
+ h(Heading, { level: 1 }, 'Confirm your subscription'),
4639
+ h(Text, null, \`Someone, hopefully you, asked to get \${NEWSLETTER_NAME} by email.\`),
4640
+ h(Button, { href: confirmUrl }, 'Yes, subscribe me'),
4641
+ h(
4642
+ Text,
4643
+ { variant: 'muted', size: '14px' },
4644
+ 'The link works for 24 hours. If you did not ask, ignore this email: you will not hear from us again.',
4645
+ ),
4646
+ ),
4647
+ ),
4648
+ ),
4649
+ ),
4650
+ { subject },
4651
+ )
4652
+ }
4653
+ `}function Dt(){return`import { HttpError } from '@cascivo/app/api'
4654
+ import { normalizeEmail } from '@cascivo/app/auth-server'
4655
+ import { migrate, queryRows } from '@cascivo/app/db'
4656
+ import type { Database } from '@cascivo/app/db'
4657
+ import { SesError, createSes, handleSns, parseSesNotification } from '@cascivo/app/ses'
4658
+ import { writeRoom } from '@cascivo/app/sync-server'
4659
+ import type { RoomNamespace } from '@cascivo/app/sync-server'
4660
+ import { assertSendable, sendEmail } from '@cascivo/email'
4661
+ import type { RenderResult } from '@cascivo/email'
4662
+ import { ISSUE_ID, issueRoom, parseIssue } from '../src/newsletter'
4663
+ import type { Issue, IssueInput, Overview, SubscriberCounts } from '../src/newsletter'
4664
+ import { TOKEN_SLOT, renderConfirmation, renderIssue } from './newsletter-email'
4665
+
4666
+ const migrations = [
4667
+ {
4668
+ id: '0001_newsletter',
4669
+ statements: [
4670
+ \`CREATE TABLE subscribers (
4671
+ email TEXT PRIMARY KEY,
4672
+ status TEXT NOT NULL,
4673
+ confirm_hash TEXT,
4674
+ confirm_expires INTEGER,
4675
+ unsubscribe_token TEXT NOT NULL UNIQUE,
4676
+ reason TEXT,
4677
+ created_at TEXT NOT NULL,
4678
+ confirmed_at TEXT
4679
+ )\`,
4680
+ \`CREATE TABLE issues (
4681
+ id TEXT PRIMARY KEY,
4682
+ subject TEXT NOT NULL,
4683
+ body TEXT NOT NULL,
4684
+ total INTEGER NOT NULL,
4685
+ created_at TEXT NOT NULL
4686
+ )\`,
4687
+ // One row per reader per issue, written as each send finishes: a retried queue message
4688
+ // skips whoever already has one, so nobody gets an issue twice.
4689
+ \`CREATE TABLE deliveries (
4690
+ issue_id TEXT NOT NULL,
4691
+ email TEXT NOT NULL,
4692
+ status TEXT NOT NULL,
4693
+ detail TEXT,
4694
+ at TEXT NOT NULL,
4695
+ PRIMARY KEY (issue_id, email)
4696
+ )\`,
4697
+ ],
4698
+ },
4699
+ ]
4700
+
4701
+ /** Readers per queue message. Each message is sent in one go, one reader after another. */
4702
+ const CHUNK = 25
4703
+ /** A confirmation link works for a day. */
4704
+ const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000
4705
+ /** A pending address gets another link at most this often, so the form cannot flood an inbox. */
4706
+ const RESEND_AFTER_MS = 10 * 60 * 1000
4707
+
4708
+ export interface NewsletterMessage {
4709
+ issueId: string
4710
+ emails: string[]
4711
+ /** The app's origin, for the unsubscribe links: a queue consumer has no request. */
4712
+ origin: string
4713
+ }
4714
+
4715
+ /** What sending needs of the NEWSLETTER queue binding. */
4716
+ export interface NewsletterQueue {
4717
+ sendBatch(messages: Iterable<{ body: NewsletterMessage }>): Promise<void>
4718
+ }
4719
+
4720
+ /** The slice of a Queue consumer's batch \`deliver\` reads. */
4721
+ export interface NewsletterBatch {
4722
+ readonly messages: readonly { readonly body: unknown }[]
4723
+ }
4724
+
4725
+ export interface NewsletterEnv {
4726
+ DB: Database
4727
+ ROOMS: RoomNamespace<unknown>
4728
+ NEWSLETTER: NewsletterQueue
4729
+ /** Set in wrangler.jsonc: the SES region, the From address, the SNS topic for feedback. */
4730
+ AWS_REGION: string
4731
+ NEWSLETTER_FROM: string
4732
+ SNS_TOPIC_ARN: string
4733
+ /** Secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
4734
+ AWS_ACCESS_KEY_ID?: string
4735
+ AWS_SECRET_ACCESS_KEY?: string
4736
+ NEWSLETTER_KEY?: string
4737
+ }
4738
+
4739
+ const encoder = new TextEncoder()
4740
+
4741
+ const cell = (raw: unknown, key: string): unknown =>
4742
+ typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined
4743
+
4744
+ /** A string column of a D1 row. Rows are read like any payload: checked, not cast. */
4745
+ function column(raw: unknown, key: string): string {
4746
+ const value = cell(raw, key)
4747
+ if (typeof value !== 'string') throw new Error(\`Expected a string in column \${key}\`)
4748
+ return value
4749
+ }
4750
+
4751
+ async function sha256(value: string): Promise<string> {
4752
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))
4753
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
4754
+ }
4755
+
4756
+ /** 32 random bytes, URL-safe: a confirmation or unsubscribe token. */
4757
+ function token(): string {
4758
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
4759
+ return btoa(String.fromCharCode(...bytes))
4760
+ .replace(/\\+/g, '-')
4761
+ .replace(/\\//g, '_')
4762
+ .replace(/=+$/, '')
4763
+ }
4764
+
4765
+ /** Refuses a request without NEWSLETTER_KEY. Compares digests, so timing says nothing. */
4766
+ async function requireKey(env: NewsletterEnv, key: string): Promise<void> {
4767
+ if (!env.NEWSLETTER_KEY) throw new HttpError(503, 'Set NEWSLETTER_KEY (README)')
4768
+ if ((await sha256(key)) !== (await sha256(env.NEWSLETTER_KEY))) {
4769
+ throw new HttpError(403, 'Wrong newsletter key')
4770
+ }
4771
+ }
4772
+
4773
+ type Send = (
4774
+ to: string,
4775
+ message: RenderResult,
4776
+ headers?: Record<string, string>,
4777
+ ) => Promise<'sent' | 'logged'>
4778
+
4779
+ /**
4780
+ * Sends one email through SES, or, in \`vite dev\` without AWS credentials, logs it instead.
4781
+ * Deployed without them, it refuses with what to set. The SES client is an \`EmailSender\`, so
4782
+ * \`sendEmail\` checks the message (subject, preheader, text part, size) and every header first.
4783
+ */
4784
+ function mailer(env: NewsletterEnv): Send {
4785
+ const configured =
4786
+ env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_REGION && env.NEWSLETTER_FROM
4787
+ if (!configured) {
4788
+ if (!import.meta.env.DEV) {
4789
+ throw new HttpError(
4790
+ 503,
4791
+ 'Set AWS_REGION and NEWSLETTER_FROM in wrangler.jsonc, and the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets (README)',
4792
+ )
4793
+ }
4794
+ return async (to, message) => {
4795
+ console.log(\`[newsletter] not sent (no SES credentials) to \${to}: "\${message.subject}"\`)
4796
+ return 'logged'
4797
+ }
4798
+ }
4799
+ const ses = createSes({
4800
+ region: env.AWS_REGION,
4801
+ accessKeyId: env.AWS_ACCESS_KEY_ID!,
4802
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,
4803
+ })
4804
+ return async (to, message, headers) => {
4805
+ await sendEmail(ses, message, {
4806
+ from: env.NEWSLETTER_FROM,
4807
+ to,
4808
+ ...(headers ? { headers } : {}),
4809
+ })
4810
+ return 'sent'
4811
+ }
4812
+ }
4813
+
4814
+ /**
4815
+ * Signs someone up: a pending subscriber and a confirmation email. The answer is the same
4816
+ * whoever asks, so the form does not tell strangers who is on the list. \`vite dev\` without
4817
+ * SES also returns the confirmation link, to open instead of an email.
4818
+ */
4819
+ export async function subscribe(
4820
+ env: NewsletterEnv,
4821
+ rawEmail: string,
4822
+ origin: string,
4823
+ ): Promise<{ devLink: string | null }> {
4824
+ const email = normalizeEmail(rawEmail)
4825
+ const send = mailer(env)
4826
+ await migrate(env.DB, migrations)
4827
+ const confirm = token()
4828
+ const now = new Date()
4829
+ const expires = now.getTime() + CONFIRM_TTL_MS
4830
+ // A new or unsubscribed address gets a link; a pending one gets a fresh link at most every
4831
+ // ten minutes. A subscribed or suppressed one is left alone, and nothing is sent.
4832
+ const [row] = await queryRows(
4833
+ env.DB,
4834
+ \`INSERT INTO subscribers
4835
+ (email, status, confirm_hash, confirm_expires, unsubscribe_token, created_at)
4836
+ VALUES (?, 'pending', ?, ?, ?, ?)
4837
+ ON CONFLICT (email) DO UPDATE SET
4838
+ status = 'pending', reason = NULL,
4839
+ confirm_hash = excluded.confirm_hash, confirm_expires = excluded.confirm_expires
4840
+ WHERE subscribers.status = 'unsubscribed'
4841
+ OR (subscribers.status = 'pending' AND subscribers.confirm_expires < ?)
4842
+ RETURNING email\`,
4843
+ [email, await sha256(confirm), expires, token(), now.toISOString(), expires - RESEND_AFTER_MS],
4844
+ (raw) => column(raw, 'email'),
4845
+ )
4846
+ if (!row) return { devLink: null }
4847
+ const link = \`\${origin}/newsletter/confirm?token=\${confirm}\`
4848
+ const message = renderConfirmation(link)
4849
+ let outcome: 'sent' | 'logged'
4850
+ try {
4851
+ outcome = await send(email, message)
4852
+ } catch (error) {
4853
+ if (!(error instanceof SesError)) throw error
4854
+ console.error('[newsletter] confirmation not sent:', error.code, error.message)
4855
+ // Let the reader try again at once rather than wait out the resend interval.
4856
+ await queryRows(
4857
+ env.DB,
4858
+ "UPDATE subscribers SET confirm_expires = 0 WHERE email = ? AND status = 'pending' RETURNING email",
4859
+ [email],
4860
+ (raw) => raw,
4861
+ )
4862
+ throw new HttpError(502, 'Could not send the confirmation email. Try again in a moment.')
4863
+ }
4864
+ return { devLink: outcome === 'logged' ? link : null }
4865
+ }
4866
+
4867
+ /** Opens a confirmation link: the subscriber is on the list from now on. */
4868
+ export async function confirm(env: NewsletterEnv, rawToken: string): Promise<{ email: string }> {
4869
+ await migrate(env.DB, migrations)
4870
+ const [row] = await queryRows(
4871
+ env.DB,
4872
+ \`UPDATE subscribers SET status = 'subscribed', confirm_hash = NULL, confirmed_at = ?
4873
+ WHERE confirm_hash = ? AND confirm_expires > ? AND status = 'pending' RETURNING email\`,
4874
+ [new Date().toISOString(), await sha256(rawToken), Date.now()],
4875
+ (raw) => ({ email: column(raw, 'email') }),
4876
+ )
4877
+ if (!row) throw new HttpError(400, 'This link has expired or was used already. Sign up again.')
4878
+ return row
4879
+ }
4880
+
4881
+ /**
4882
+ * \`POST /api/newsletter/unsubscribe?token=…\`: the page's button, and the one-click
4883
+ * unsubscribe mail clients send (RFC 8058) from the List-Unsubscribe header. Always 200, so a
4884
+ * token says nothing about who it belongs to, and a second click is not an error.
4885
+ */
4886
+ export async function unsubscribe(request: Request, env: NewsletterEnv): Promise<Response> {
4887
+ const unsubscribeToken = new URL(request.url).searchParams.get('token') ?? ''
4888
+ await migrate(env.DB, migrations)
4889
+ await queryRows(
4890
+ env.DB,
4891
+ \`UPDATE subscribers SET status = 'unsubscribed', reason = 'unsubscribed'
4892
+ WHERE unsubscribe_token = ? AND status IN ('pending', 'subscribed') RETURNING email\`,
4893
+ [unsubscribeToken],
4894
+ (raw) => raw,
4895
+ )
4896
+ return Response.json({ unsubscribed: true })
4897
+ }
4898
+
4899
+ const ISSUE_COLUMNS = \`issues.id, issues.subject, issues.created_at AS createdAt, issues.total,
4900
+ (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status != 'failed') AS sent,
4901
+ (SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status = 'failed') AS failed\`
4902
+
4903
+ async function readIssue(db: Database, id: string): Promise<Issue> {
4904
+ const [issue] = await queryRows(
4905
+ db,
4906
+ \`SELECT \${ISSUE_COLUMNS} FROM issues WHERE id = ?\`,
4907
+ [id],
4908
+ parseIssue,
4909
+ )
4910
+ if (!issue) throw new HttpError(404, 'No such issue')
4911
+ return issue
4912
+ }
4913
+
4914
+ /** Subscriber counts and the last 20 issues, for the composer. */
4915
+ export async function overview(env: NewsletterEnv, key: string): Promise<Overview> {
4916
+ await requireKey(env, key)
4917
+ await migrate(env.DB, migrations)
4918
+ const subscribers: SubscriberCounts = {
4919
+ pending: 0,
4920
+ subscribed: 0,
4921
+ unsubscribed: 0,
4922
+ suppressed: 0,
4923
+ }
4924
+ const counts = await queryRows(
4925
+ env.DB,
4926
+ 'SELECT status, COUNT(*) AS n FROM subscribers GROUP BY status',
4927
+ [],
4928
+ (raw) => ({ status: column(raw, 'status'), n: Number(cell(raw, 'n')) }),
4929
+ )
4930
+ for (const { status, n } of counts) {
4931
+ if (status === 'pending' || status === 'subscribed') subscribers[status] = n
4932
+ if (status === 'unsubscribed' || status === 'suppressed') subscribers[status] = n
4933
+ }
4934
+ const issues = await queryRows(
4935
+ env.DB,
4936
+ \`SELECT \${ISSUE_COLUMNS} FROM issues ORDER BY created_at DESC LIMIT 20\`,
4937
+ [],
4938
+ parseIssue,
4939
+ )
4940
+ return { subscribers, issues }
4941
+ }
4942
+
4943
+ /** The issue as readers will get it, for the composer's preview. */
4944
+ export async function preview(
4945
+ env: NewsletterEnv,
4946
+ input: IssueInput,
4947
+ origin: string,
4948
+ ): Promise<{ html: string; bytes: number }> {
4949
+ await requireKey(env, input.key)
4950
+ const { html, stats } = renderIssue(input.subject, input.body, origin)
4951
+ return { html, bytes: stats.bytes }
4952
+ }
4953
+
4954
+ /**
4955
+ * Sends an issue to every confirmed subscriber: the issue is stored, and its readers go onto
4956
+ * the NEWSLETTER queue in chunks, which \`deliver\` sends at the queue's pace.
4957
+ */
4958
+ export async function sendIssue(
4959
+ env: NewsletterEnv,
4960
+ input: IssueInput,
4961
+ origin: string,
4962
+ ): Promise<Issue> {
4963
+ await requireKey(env, input.key)
4964
+ mailer(env) // Deployed without SES, refuse now rather than fail in the queue.
4965
+ try {
4966
+ // An issue too large to send (Gmail clips it) is refused here, not retried in the queue.
4967
+ assertSendable(renderIssue(input.subject, input.body, origin))
4968
+ } catch (error) {
4969
+ throw new HttpError(400, error instanceof Error ? error.message : String(error))
4970
+ }
4971
+ await migrate(env.DB, migrations)
4972
+ const readers = await queryRows(
4973
+ env.DB,
4974
+ "SELECT email FROM subscribers WHERE status = 'subscribed' ORDER BY email",
4975
+ [],
4976
+ (raw) => column(raw, 'email'),
4977
+ )
4978
+ const id = crypto.randomUUID()
4979
+ await queryRows(
4980
+ env.DB,
4981
+ 'INSERT INTO issues (id, subject, body, total, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id',
4982
+ [id, input.subject, input.body, readers.length, new Date().toISOString()],
4983
+ (raw) => raw,
4984
+ )
4985
+ const messages: { body: NewsletterMessage }[] = []
4986
+ for (let i = 0; i < readers.length; i += CHUNK) {
4987
+ messages.push({ body: { issueId: id, emails: readers.slice(i, i + CHUNK), origin } })
4988
+ }
4989
+ // sendBatch takes at most 100 messages a call.
4990
+ for (let i = 0; i < messages.length; i += 100) {
4991
+ await env.NEWSLETTER.sendBatch(messages.slice(i, i + 100))
4992
+ }
4993
+ const issue = await readIssue(env.DB, id)
4994
+ await writeRoom(env.ROOMS, issueRoom(id), 'issue', { ...issue })
4995
+ return issue
4996
+ }
4997
+
4998
+ function parseMessage(raw: unknown): NewsletterMessage {
4999
+ if (typeof raw === 'object' && raw !== null) {
5000
+ const { issueId, emails, origin } = raw as Record<string, unknown>
5001
+ if (
5002
+ typeof issueId === 'string' &&
5003
+ ISSUE_ID.test(issueId) &&
5004
+ Array.isArray(emails) &&
5005
+ emails.every((e) => typeof e === 'string') &&
5006
+ typeof origin === 'string'
5007
+ ) {
5008
+ return { issueId, emails: emails as string[], origin }
5009
+ }
5010
+ }
5011
+ throw new Error('Malformed newsletter message')
5012
+ }
5013
+
5014
+ /**
5015
+ * The NEWSLETTER queue's consumer: sends each reader in the message their copy, with their
5016
+ * own unsubscribe link and the one-click headers bulk senders need. A reader who left or was
5017
+ * suppressed since the issue was queued is skipped. SES throttling or a server error throws,
5018
+ * and the queue retries the message: whoever was sent already has a delivery row and is not
5019
+ * sent again. A refusal for one address (an invalid one, say) is recorded and the rest go on.
5020
+ */
5021
+ export async function deliver(env: NewsletterEnv, batch: NewsletterBatch): Promise<void> {
5022
+ await migrate(env.DB, migrations)
5023
+ const send = mailer(env)
5024
+ for (const { body } of batch.messages) {
5025
+ const { issueId, emails, origin } = parseMessage(body)
5026
+ const [issue] = await queryRows(
5027
+ env.DB,
5028
+ 'SELECT subject, body FROM issues WHERE id = ?',
5029
+ [issueId],
5030
+ (raw) => ({ subject: column(raw, 'subject'), body: column(raw, 'body') }),
5031
+ )
5032
+ if (!issue) continue
5033
+ const rendered = renderIssue(issue.subject, issue.body, origin)
5034
+ const placeholders = emails.map(() => '?').join(', ')
5035
+ const readers = await queryRows(
5036
+ env.DB,
5037
+ \`SELECT s.email, s.unsubscribe_token AS token FROM subscribers s
5038
+ WHERE s.email IN (\${placeholders}) AND s.status = 'subscribed'
5039
+ AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.issue_id = ? AND d.email = s.email)\`,
5040
+ [...emails, issueId],
5041
+ (raw) => ({ email: column(raw, 'email'), token: column(raw, 'token') }),
5042
+ )
5043
+ for (const { email, token: readerToken } of readers) {
5044
+ const unsubscribe = \`\${origin}/api/newsletter/unsubscribe?token=\${readerToken}\`
5045
+ let status: string
5046
+ let detail: string | null = null
5047
+ try {
5048
+ status = await send(
5049
+ email,
5050
+ {
5051
+ ...rendered,
5052
+ html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),
5053
+ text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),
5054
+ },
5055
+ {
5056
+ 'List-Unsubscribe': \`<\${unsubscribe}>\`,
5057
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
5058
+ },
5059
+ )
5060
+ } catch (error) {
5061
+ if (!(error instanceof SesError) || error.retryable) throw error
5062
+ status = 'failed'
5063
+ detail = \`\${error.code ?? error.status}: \${error.message}\`.slice(0, 300)
5064
+ }
5065
+ await queryRows(
5066
+ env.DB,
5067
+ \`INSERT INTO deliveries (issue_id, email, status, detail, at) VALUES (?, ?, ?, ?, ?)
5068
+ ON CONFLICT DO NOTHING RETURNING email\`,
5069
+ [issueId, email, status, detail, new Date().toISOString()],
5070
+ (raw) => raw,
5071
+ )
5072
+ }
5073
+ const progress = await readIssue(env.DB, issueId)
5074
+ await writeRoom(env.ROOMS, issueRoom(issueId), 'issue', { ...progress })
5075
+ }
5076
+ }
5077
+
5078
+ /**
5079
+ * SES's feedback, delivered by SNS: a permanent bounce or a complaint suppresses the address
5080
+ * for good. Sending to them again is what gets an SES account put under review.
5081
+ */
5082
+ export async function receiveFeedback(request: Request, env: NewsletterEnv): Promise<Response> {
5083
+ if (!env.SNS_TOPIC_ARN) throw new HttpError(503, 'Set SNS_TOPIC_ARN in wrangler.jsonc (README)')
5084
+ return handleSns(request, {
5085
+ topicArn: env.SNS_TOPIC_ARN,
5086
+ onNotification: async ({ message }) => {
5087
+ const event = parseSesNotification(message)
5088
+ const suppress =
5089
+ event.kind === 'complaint' || (event.kind === 'bounce' && event.bounceType === 'Permanent')
5090
+ if (!suppress || event.recipients.length === 0) return
5091
+ await migrate(env.DB, migrations)
5092
+ // D1 binds at most 100 parameters; one bounce rarely names more than a few addresses.
5093
+ const recipients = event.recipients
5094
+ .slice(0, 90)
5095
+ .map((address) => address.trim().toLowerCase())
5096
+ await queryRows(
5097
+ env.DB,
5098
+ \`UPDATE subscribers SET status = 'suppressed', reason = ?
5099
+ WHERE email IN (\${recipients.map(() => '?').join(', ')}) RETURNING email\`,
5100
+ [event.kind, ...recipients],
5101
+ (raw) => raw,
5102
+ )
5103
+ },
5104
+ })
5105
+ }
5106
+ `}function Ot(){return`import { createClient } from '@cascivo/app/api'
5107
+ import {
5108
+ Alert,
5109
+ Button,
5110
+ Card,
5111
+ CardContent,
5112
+ Flex,
5113
+ Heading,
5114
+ Input,
5115
+ Link,
5116
+ Text,
5117
+ signal,
5118
+ useSignals,
5119
+ } from '@cascivo/react'
5120
+ import type { FormEvent } from 'react'
5121
+ import { api } from '../api'
5122
+
5123
+ const client = createClient(api)
5124
+ const sentTo = signal<string | null>(null)
5125
+ /** Set only in \`vite dev\` without SES: the confirmation link, to open instead of an email. */
5126
+ const devLink = signal<string | null>(null)
5127
+ const failure = signal<string | null>(null)
5128
+ const sending = signal(false)
5129
+
5130
+ async function signUp(event: FormEvent<HTMLFormElement>): Promise<void> {
5131
+ event.preventDefault()
5132
+ const email = new FormData(event.currentTarget).get('email')
5133
+ if (typeof email !== 'string') return
5134
+ failure.value = null
5135
+ sending.value = true
5136
+ try {
5137
+ const { devLink: link } = await client.subscribe({ body: { email } })
5138
+ sentTo.value = email
5139
+ devLink.value = link
5140
+ } catch (error) {
5141
+ failure.value = error instanceof Error ? error.message : 'Could not sign you up'
5142
+ } finally {
5143
+ sending.value = false
5144
+ }
5145
+ }
5146
+
5147
+ export default function Newsletter() {
5148
+ useSignals()
5149
+ return (
5150
+ <Flex gap={4}>
5151
+ <Flex gap={1}>
5152
+ <Heading level={1}>Newsletter</Heading>
5153
+ <Text muted>
5154
+ An email now and then. We send a link first: you are on the list only once you open it,
5155
+ and every issue has a one-click unsubscribe.
5156
+ </Text>
5157
+ </Flex>
5158
+ <Card>
5159
+ <CardContent>
5160
+ <form onSubmit={(event) => void signUp(event)}>
5161
+ <Flex direction="horizontal" align="end" gap={2} wrap>
5162
+ <Input name="email" type="email" label="Email" autoComplete="email" required />
5163
+ <Button type="submit" loading={sending.value}>
5164
+ Subscribe
5165
+ </Button>
5166
+ </Flex>
5167
+ </form>
5168
+ </CardContent>
5169
+ </Card>
5170
+ {sentTo.value ? (
5171
+ <Alert variant="success" title="Check your inbox">
5172
+ If {sentTo.value} is not on the list yet, a confirmation link is on its way.
5173
+ </Alert>
5174
+ ) : null}
5175
+ {devLink.value ? (
5176
+ <Alert variant="info" title="vite dev sends no email without SES">
5177
+ <Link href={devLink.value}>Open the confirmation link</Link>
5178
+ </Alert>
5179
+ ) : null}
5180
+ {failure.value ? (
5181
+ <Alert variant="destructive" title="Not signed up">
5182
+ {failure.value}
5183
+ </Alert>
5184
+ ) : null}
5185
+ </Flex>
5186
+ )
5187
+ }
5188
+ `}function kt(){return`import { createClient } from '@cascivo/app/api'
5189
+ import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
5190
+ import { api } from '../../api'
5191
+ import { router } from '../../router'
5192
+
5193
+ const client = createClient(api)
5194
+ const confirmed = signal<string | null>(null)
5195
+ const failure = signal<string | null>(null)
5196
+ const busy = signal(false)
5197
+
5198
+ /**
5199
+ * The page a confirmation link opens. It confirms only when you press the button: mail
5200
+ * scanners open every link in a message, and would otherwise subscribe whoever was typed in.
5201
+ */
5202
+ async function confirm(): Promise<void> {
5203
+ const token = new URLSearchParams(router.search.value).get('token')
5204
+ if (!token) {
5205
+ failure.value = 'This link has no token. Sign up again.'
5206
+ return
5207
+ }
5208
+ busy.value = true
5209
+ failure.value = null
5210
+ try {
5211
+ confirmed.value = (await client.confirmSubscription({ body: { token } })).email
5212
+ } catch (error) {
5213
+ failure.value = error instanceof Error ? error.message : 'Could not confirm'
5214
+ } finally {
5215
+ busy.value = false
5216
+ }
5217
+ }
5218
+
5219
+ export default function ConfirmSubscription() {
5220
+ useSignals()
5221
+ return (
5222
+ <Flex gap={4}>
5223
+ <Heading level={1}>Confirm your subscription</Heading>
5224
+ {confirmed.value ? (
5225
+ <Alert variant="success" title="You are on the list">
5226
+ The next issue goes to {confirmed.value}.
5227
+ </Alert>
5228
+ ) : (
5229
+ <Flex gap={2}>
5230
+ <Text muted>One click and you get the newsletter.</Text>
5231
+ <Flex direction="horizontal">
5232
+ <Button loading={busy.value} onClick={() => void confirm()}>
5233
+ Yes, subscribe me
5234
+ </Button>
5235
+ </Flex>
5236
+ </Flex>
5237
+ )}
5238
+ {failure.value ? (
5239
+ <Alert variant="destructive" title="Not confirmed">
5240
+ {failure.value}
5241
+ </Alert>
5242
+ ) : null}
5243
+ </Flex>
5244
+ )
5245
+ }
5246
+ `}function At(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
5247
+ import { router } from '../../router'
5248
+
5249
+ const done = signal(false)
5250
+ const failure = signal<string | null>(null)
5251
+ const busy = signal(false)
5252
+
5253
+ /**
5254
+ * The footer link of every issue. Like the confirmation, it acts on a button press, never on
5255
+ * opening the page. Mail clients that support one-click unsubscribe skip this page and POST
5256
+ * to the same endpoint from the List-Unsubscribe header.
5257
+ */
5258
+ async function unsubscribe(): Promise<void> {
5259
+ const token = new URLSearchParams(router.search.value).get('token') ?? ''
5260
+ busy.value = true
5261
+ failure.value = null
5262
+ try {
5263
+ const response = await fetch(\`/api/newsletter/unsubscribe?token=\${encodeURIComponent(token)}\`, {
5264
+ method: 'POST',
5265
+ })
5266
+ if (!response.ok) throw new Error(\`Request failed with status \${response.status}\`)
5267
+ done.value = true
5268
+ } catch (error) {
5269
+ failure.value = error instanceof Error ? error.message : 'Could not unsubscribe'
5270
+ } finally {
5271
+ busy.value = false
5272
+ }
5273
+ }
5274
+
5275
+ export default function Unsubscribe() {
5276
+ useSignals()
5277
+ return (
5278
+ <Flex gap={4}>
5279
+ <Heading level={1}>Unsubscribe</Heading>
5280
+ {done.value ? (
5281
+ <Alert variant="success" title="You are unsubscribed">
5282
+ You will get no more issues. Sign up again any time.
5283
+ </Alert>
5284
+ ) : (
5285
+ <Flex gap={2}>
5286
+ <Text muted>Stop getting the newsletter at this address.</Text>
5287
+ <Flex direction="horizontal">
5288
+ <Button variant="destructive" loading={busy.value} onClick={() => void unsubscribe()}>
5289
+ Unsubscribe
5290
+ </Button>
5291
+ </Flex>
5292
+ </Flex>
5293
+ )}
5294
+ {failure.value ? (
5295
+ <Alert variant="destructive" title="Not unsubscribed">
5296
+ {failure.value}
5297
+ </Alert>
5298
+ ) : null}
5299
+ </Flex>
5300
+ )
5301
+ }
5302
+ `}function jt(){return`import { createClient } from '@cascivo/app/api'
5303
+ import { connectRoom } from '@cascivo/app/sync'
5304
+ import {
5305
+ Alert,
5306
+ Badge,
5307
+ Button,
5308
+ Card,
5309
+ CardContent,
5310
+ EmptyState,
5311
+ Flex,
5312
+ Heading,
5313
+ Input,
5314
+ Text,
5315
+ Textarea,
5316
+ signal,
5317
+ useSignals,
5318
+ } from '@cascivo/react'
5319
+ import type { MouseEvent } from 'react'
5320
+ import { api } from '../../api'
5321
+ import { MAX_BODY, MAX_SUBJECT, parseIssue } from '../../newsletter'
5322
+ import type { Issue, Overview } from '../../newsletter'
5323
+
5324
+ const client = createClient(api)
5325
+ const overview = signal<Overview | null>(null)
5326
+ const previewHtml = signal<string | null>(null)
5327
+ const busy = signal<'load' | 'preview' | 'send' | null>(null)
5328
+ const failure = signal<string | null>(null)
5329
+ const notice = signal<string | null>(null)
5330
+
5331
+ /** The composer's fields, read from the form the clicked button belongs to. */
5332
+ function fields(event: MouseEvent<HTMLButtonElement>) {
5333
+ const form = event.currentTarget.form
5334
+ const data = form ? new FormData(form) : new FormData()
5335
+ const read = (name: string) => {
5336
+ const value = data.get(name)
5337
+ return typeof value === 'string' ? value : ''
5338
+ }
5339
+ return { key: read('key'), subject: read('subject'), body: read('body') }
5340
+ }
5341
+
5342
+ async function run(kind: 'load' | 'preview' | 'send', task: () => Promise<void>): Promise<void> {
5343
+ busy.value = kind
5344
+ failure.value = null
5345
+ notice.value = null
5346
+ try {
5347
+ await task()
5348
+ } catch (error) {
5349
+ failure.value = error instanceof Error ? error.message : 'Something went wrong'
5350
+ } finally {
5351
+ busy.value = null
5352
+ }
5353
+ }
5354
+
5355
+ /** Shows an issue's progress as the Worker pushes it, until every reader has had their copy. */
5356
+ function watch(issue: Issue): void {
5357
+ const room = connectRoom(\`/api/newsletter/issues/\${issue.id}/live\`)
5358
+ const pushed = room.signal<Issue | null>('issue', null, (raw) =>
5359
+ raw === null ? null : parseIssue(raw),
5360
+ )
5361
+ const stop = pushed.signal.subscribe((latest) => {
5362
+ const current = overview.peek()
5363
+ if (!latest || !current) return
5364
+ overview.value = {
5365
+ ...current,
5366
+ issues: current.issues.map((i) => (i.id === latest.id ? latest : i)),
5367
+ }
5368
+ if (latest.sent + latest.failed >= latest.total) {
5369
+ stop()
5370
+ room.close()
5371
+ }
5372
+ })
5373
+ }
5374
+
5375
+ function load(event: MouseEvent<HTMLButtonElement>): void {
5376
+ const { key } = fields(event)
5377
+ void run('load', async () => {
5378
+ overview.value = await client.newsletterOverview({ body: { key } })
5379
+ })
5380
+ }
5381
+
5382
+ function preview(event: MouseEvent<HTMLButtonElement>): void {
5383
+ const input = fields(event)
5384
+ void run('preview', async () => {
5385
+ previewHtml.value = (await client.previewIssue({ body: input })).html
5386
+ })
5387
+ }
5388
+
5389
+ function send(event: MouseEvent<HTMLButtonElement>): void {
5390
+ const input = fields(event)
5391
+ const readers = overview.value?.subscribers.subscribed ?? 0
5392
+ if (!window.confirm(\`Send "\${input.subject}" to \${readers} subscribers?\`)) return
5393
+ void run('send', async () => {
5394
+ const issue = await client.sendIssue({ body: input })
5395
+ overview.value = await client.newsletterOverview({ body: { key: input.key } })
5396
+ notice.value = \`Queued for \${issue.total} subscribers.\`
5397
+ watch(issue)
5398
+ })
5399
+ }
5400
+
5401
+ export default function SendNewsletter() {
5402
+ useSignals()
5403
+ const counts = overview.value?.subscribers
5404
+ return (
5405
+ <Flex gap={4}>
5406
+ <Flex gap={1}>
5407
+ <Heading level={1}>Send the newsletter</Heading>
5408
+ <Text muted>
5409
+ Written in Markdown, rendered with @cascivo/email, sent through Amazon SES to every
5410
+ confirmed subscriber. Only someone with NEWSLETTER_KEY can send.
5411
+ </Text>
5412
+ </Flex>
5413
+ <form onSubmit={(event) => event.preventDefault()}>
5414
+ <Flex gap={3}>
5415
+ <Flex direction="horizontal" align="end" gap={2} wrap>
5416
+ <Input name="key" type="password" label="Newsletter key" autoComplete="off" required />
5417
+ <Button variant="secondary" loading={busy.value === 'load'} onClick={load}>
5418
+ Show subscribers
5419
+ </Button>
5420
+ </Flex>
5421
+ <Input name="subject" label="Subject" maxLength={MAX_SUBJECT} required />
5422
+ <Textarea
5423
+ name="body"
5424
+ label="Body"
5425
+ hint="Markdown: headings, **bold**, links, lists, quotes and images."
5426
+ rows={12}
5427
+ maxLength={MAX_BODY}
5428
+ required
5429
+ />
5430
+ <Flex direction="horizontal" gap={2} wrap>
5431
+ <Button variant="secondary" loading={busy.value === 'preview'} onClick={preview}>
5432
+ Preview
5433
+ </Button>
5434
+ <Button loading={busy.value === 'send'} disabled={!counts} onClick={send}>
5435
+ Send to {counts ? counts.subscribed : '…'} subscribers
5436
+ </Button>
5437
+ </Flex>
5438
+ </Flex>
5439
+ </form>
5440
+ {failure.value ? (
5441
+ <Alert variant="destructive" title="Not done">
5442
+ {failure.value}
5443
+ </Alert>
5444
+ ) : null}
5445
+ {notice.value ? (
5446
+ <Alert variant="success" title="Sending">
5447
+ {notice.value}
5448
+ </Alert>
5449
+ ) : null}
5450
+ {previewHtml.value ? (
5451
+ <Card>
5452
+ <CardContent>
5453
+ {/* sandbox: the preview runs no script and cannot reach this page. */}
5454
+ <iframe
5455
+ title="Preview"
5456
+ sandbox=""
5457
+ srcDoc={previewHtml.value}
5458
+ width="100%"
5459
+ height="640"
5460
+ />
5461
+ </CardContent>
5462
+ </Card>
5463
+ ) : null}
5464
+ {counts ? (
5465
+ <Flex direction="horizontal" gap={2} wrap>
5466
+ <Badge variant="success">{counts.subscribed} subscribed</Badge>
5467
+ <Badge variant="warning">{counts.pending} not confirmed</Badge>
5468
+ <Badge variant="secondary">{counts.unsubscribed} unsubscribed</Badge>
5469
+ <Badge variant="destructive">{counts.suppressed} suppressed</Badge>
5470
+ </Flex>
5471
+ ) : null}
5472
+ {overview.value && overview.value.issues.length === 0 ? (
5473
+ <EmptyState title="No issues yet" description="Write one above and send it." />
5474
+ ) : null}
5475
+ {overview.value && overview.value.issues.length > 0 ? (
5476
+ <Flex gap={2} role="list" aria-label="Issues">
5477
+ {overview.value.issues.map((issue) => (
5478
+ <Flex key={issue.id} role="listitem" direction="horizontal" align="center" gap={2} wrap>
5479
+ <Badge variant={issue.sent + issue.failed >= issue.total ? 'success' : 'warning'}>
5480
+ {issue.sent}/{issue.total} sent
5481
+ </Badge>
5482
+ {issue.failed > 0 ? <Badge variant="destructive">{issue.failed} failed</Badge> : null}
5483
+ <Text>{issue.subject}</Text>
5484
+ <Text size="sm" muted>
5485
+ {new Date(issue.createdAt).toLocaleString()}
5486
+ </Text>
5487
+ </Flex>
5488
+ ))}
5489
+ </Flex>
5490
+ ) : null}
5491
+ </Flex>
5492
+ )
5493
+ }
5494
+ `}function Mt(){return`/**
5495
+ * The subscription plan /billing sells, shared by the Worker (worker/billing.ts) and the page.
5496
+ * A subscription belongs to a signed-in user, so this exists only with --auth email.
5497
+ */
5498
+
5499
+ /** The plan. The Worker sends this to Stripe; the page only displays it. */
5500
+ export const PLAN = {
5501
+ name: 'Pro',
5502
+ description: 'Everything in the app, billed monthly. Cancel any time from the billing portal.',
5503
+ /** In the currency's smallest unit, per interval: 900 is €9.00. */
5504
+ amount: 900,
5505
+ currency: 'eur',
5506
+ interval: 'month' as const,
5507
+ }
5508
+
5509
+ /** \`none\` before the first subscription; otherwise Stripe's subscription status. */
5510
+ export type BillingStatus =
5511
+ | 'none'
5512
+ | 'incomplete'
5513
+ | 'incomplete_expired'
5514
+ | 'trialing'
5515
+ | 'active'
5516
+ | 'past_due'
5517
+ | 'canceled'
5518
+ | 'unpaid'
5519
+ | 'paused'
5520
+
5521
+ const STATUSES: readonly BillingStatus[] = [
5522
+ 'none',
5523
+ 'incomplete',
5524
+ 'incomplete_expired',
5525
+ 'trialing',
5526
+ 'active',
5527
+ 'past_due',
5528
+ 'canceled',
5529
+ 'unpaid',
5530
+ 'paused',
5531
+ ]
5532
+
5533
+ export interface Billing {
5534
+ status: BillingStatus
5535
+ /**
5536
+ * Whether the plan's features are on: the subscription is active or trialing, or past due
5537
+ * while Stripe retries the renewal (\`isEntitled\` from @cascivo/app/stripe, in the Worker).
5538
+ */
5539
+ active: boolean
5540
+ /** When the current period ends: the next charge, or the end of a cancelled plan. */
5541
+ currentPeriodEnd: string | null
5542
+ /** Cancelled, but running until currentPeriodEnd. */
5543
+ cancelAtPeriodEnd: boolean
5544
+ /** Has a Stripe customer, so the billing portal can open. */
5545
+ canManage: boolean
5546
+ }
5547
+
5548
+ export function parseBilling(raw: unknown): Billing {
5549
+ if (typeof raw === 'object' && raw !== null) {
5550
+ const { status, active, currentPeriodEnd, cancelAtPeriodEnd, canManage } = raw as Record<
5551
+ string,
5552
+ unknown
5553
+ >
5554
+ const known = STATUSES.find((s) => s === status)
5555
+ if (
5556
+ known &&
5557
+ typeof active === 'boolean' &&
5558
+ (currentPeriodEnd === null || typeof currentPeriodEnd === 'string') &&
5559
+ typeof cancelAtPeriodEnd === 'boolean' &&
5560
+ typeof canManage === 'boolean'
5561
+ ) {
5562
+ return { status: known, active, currentPeriodEnd, cancelAtPeriodEnd, canManage }
5563
+ }
5564
+ }
5565
+ throw new Error('Malformed billing')
5566
+ }
5567
+
5568
+ /** Where to send the browser next: Stripe's checkout or its billing portal. */
5569
+ export function parseRedirect(raw: unknown): { url: string } {
5570
+ if (typeof raw === 'object' && raw !== null) {
5571
+ const { url } = raw as Record<string, unknown>
5572
+ if (typeof url === 'string' && url.startsWith('https://')) return { url }
5573
+ }
5574
+ throw new Error('Malformed redirect')
5575
+ }
5576
+
5577
+ export function parseSyncInput(raw: unknown): { sessionId: string } {
5578
+ if (typeof raw === 'object' && raw !== null) {
5579
+ const { sessionId } = raw as Record<string, unknown>
5580
+ if (typeof sessionId === 'string' && /^cs_[\\w]{1,250}$/.test(sessionId)) return { sessionId }
5581
+ }
5582
+ throw new Error('Expected { sessionId }: a Checkout Session id')
5583
+ }
5584
+ `}function Nt(){return`import { HttpError } from '@cascivo/app/api'
5585
+ import { requireUser } from '@cascivo/app/auth-server'
5586
+ import { migrate, queryRows } from '@cascivo/app/db'
5587
+ import type { Database } from '@cascivo/app/db'
5588
+ import { isEntitled, requireEntitlement } from '@cascivo/app/stripe'
5589
+ import type { Invoice, Subscription } from '@cascivo/app/stripe'
5590
+ import {
5591
+ Body,
5592
+ Button,
5593
+ Container,
5594
+ Head,
5595
+ Heading,
5596
+ Html,
5597
+ Preview,
5598
+ Section,
5599
+ Text,
5600
+ renderEmail,
5601
+ } from '@cascivo/email'
5602
+ import { createElement as h } from 'react'
5603
+ import { PLAN, parseBilling } from '../src/billing'
5604
+ import type { Billing } from '../src/billing'
5605
+ import { SHOP_NAME, formatPrice } from '../src/checkout'
5606
+ import { refused, stripeOf } from './checkout'
5607
+ import type { BillingHooks, ReceiptSender } from './checkout'
5608
+
5609
+ const migrations = [
5610
+ {
5611
+ id: '0001_billing',
5612
+ statements: [
5613
+ \`CREATE TABLE billing (
5614
+ user_id TEXT PRIMARY KEY,
5615
+ customer_id TEXT,
5616
+ subscription_id TEXT,
5617
+ status TEXT NOT NULL,
5618
+ current_period_end INTEGER,
5619
+ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
5620
+ updated_at TEXT NOT NULL,
5621
+ reminded TEXT
5622
+ )\`,
5623
+ ],
5624
+ },
5625
+ ]
5626
+
5627
+ export interface BillingEnv {
5628
+ DB: Database
5629
+ EMAIL: ReceiptSender
5630
+ RECEIPT_FROM: string
5631
+ STRIPE_SECRET_KEY?: string
5632
+ }
5633
+
5634
+ interface Row {
5635
+ status: string
5636
+ customerId: string | null
5637
+ currentPeriodEnd: number | null
5638
+ cancelAtPeriodEnd: number
5639
+ }
5640
+
5641
+ const cell = (raw: unknown, key: string): unknown =>
5642
+ typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined
5643
+
5644
+ function parseRow(raw: unknown): Row {
5645
+ const status = cell(raw, 'status')
5646
+ const customerId = cell(raw, 'customerId')
5647
+ const end = cell(raw, 'currentPeriodEnd')
5648
+ if (typeof status !== 'string') throw new Error('Malformed billing row')
5649
+ return {
5650
+ status,
5651
+ customerId: typeof customerId === 'string' ? customerId : null,
5652
+ currentPeriodEnd: typeof end === 'number' ? end : null,
5653
+ cancelAtPeriodEnd: Number(cell(raw, 'cancelAtPeriodEnd')),
5654
+ }
5655
+ }
5656
+
5657
+ async function readRow(db: Database, userId: string): Promise<Row | null> {
5658
+ await migrate(db, migrations)
5659
+ const [row] = await queryRows(
5660
+ db,
5661
+ \`SELECT status, customer_id AS customerId, current_period_end AS currentPeriodEnd,
5662
+ cancel_at_period_end AS cancelAtPeriodEnd FROM billing WHERE user_id = ?\`,
5663
+ [userId],
5664
+ parseRow,
5665
+ )
5666
+ return row ?? null
5667
+ }
5668
+
5669
+ /** A stored row as the page sees it; the status is checked against the known ones. */
5670
+ function toBilling(row: Row | null): Billing {
5671
+ const billing = parseBilling({
5672
+ status: row?.status ?? 'none',
5673
+ active: false,
5674
+ currentPeriodEnd:
5675
+ row?.currentPeriodEnd != null ? new Date(row.currentPeriodEnd * 1000).toISOString() : null,
5676
+ cancelAtPeriodEnd: row?.cancelAtPeriodEnd === 1,
5677
+ canManage: row?.customerId != null,
5678
+ })
5679
+ return { ...billing, active: isEntitled(billing.status) }
5680
+ }
5681
+
5682
+ /**
5683
+ * Stores a subscription's current state for the user its metadata names. The metadata is set
5684
+ * by startSubscription, server side: a subscription made any other way (a Payment Link, the
5685
+ * dashboard) names no user here and is ignored. A different subscription replaces the stored
5686
+ * one only when that one is over, so a late event about an old plan cannot end a new one.
5687
+ */
5688
+ async function store(db: Database, subscription: Subscription): Promise<void> {
5689
+ const userId = subscription.metadata['user']
5690
+ if (!userId) return
5691
+ await migrate(db, migrations)
5692
+ await queryRows(
5693
+ db,
5694
+ \`INSERT INTO billing
5695
+ (user_id, customer_id, subscription_id, status, current_period_end, cancel_at_period_end, updated_at)
5696
+ VALUES (?, ?, ?, ?, ?, ?, ?)
5697
+ ON CONFLICT (user_id) DO UPDATE SET
5698
+ customer_id = excluded.customer_id, subscription_id = excluded.subscription_id,
5699
+ status = excluded.status, current_period_end = excluded.current_period_end,
5700
+ cancel_at_period_end = excluded.cancel_at_period_end, updated_at = excluded.updated_at
5701
+ WHERE billing.subscription_id IS excluded.subscription_id
5702
+ OR billing.status NOT IN ('active', 'trialing', 'past_due')
5703
+ RETURNING user_id\`,
5704
+ [
5705
+ userId,
5706
+ subscription.customerId,
5707
+ subscription.id,
5708
+ subscription.status,
5709
+ subscription.currentPeriodEnd,
5710
+ subscription.cancelAtPeriodEnd ? 1 : 0,
5711
+ new Date().toISOString(),
5712
+ ],
5713
+ (raw) => raw,
5714
+ )
5715
+ }
5716
+
5717
+ /**
5718
+ * The webhook's part (worker/checkout.ts passes subscription events here). The event's copy
5719
+ * may be stale, since events arrive out of order: the subscription is read back from Stripe.
5720
+ */
5721
+ export async function syncSubscription(env: BillingEnv, subscriptionId: string): Promise<void> {
5722
+ await store(env.DB, await stripeOf(env).retrieveSubscription(subscriptionId))
5723
+ }
5724
+
5725
+ /** The signed-in user's plan. */
5726
+ export async function getBilling(env: BillingEnv, request: Request): Promise<Billing> {
5727
+ const user = await requireUser(env.DB, request)
5728
+ return toBilling(await readRow(env.DB, user.id))
5729
+ }
5730
+
5731
+ /**
5732
+ * Refuses a user whose plan is not on: 401 signed out, 402 without the plan. Call it in the
5733
+ * Worker before serving a paid feature, never trusting what the page shows.
5734
+ */
5735
+ export async function requirePlan(env: BillingEnv, request: Request): Promise<void> {
5736
+ const user = await requireUser(env.DB, request)
5737
+ requireEntitlement((await readRow(env.DB, user.id))?.status)
5738
+ }
5739
+
5740
+ /** The email for a renewal that could not be charged, with where to pay it. */
5741
+ function renderPaymentFailed(invoice: Invoice, payHref: string) {
5742
+ const subject = \`Your \${PLAN.name} payment did not go through\`
5743
+ const amount = formatPrice(invoice.amountDue, invoice.currency)
5744
+ return renderEmail(
5745
+ h(
5746
+ Html,
5747
+ null,
5748
+ h(Head, { title: subject }),
5749
+ h(
5750
+ Body,
5751
+ null,
5752
+ h(Preview, null, \`We could not charge \${amount}. Update your card to keep \${PLAN.name}.\`),
5753
+ h(
5754
+ Container,
5755
+ null,
5756
+ h(
5757
+ Section,
5758
+ { padding: 32 },
5759
+ h(Heading, { level: 1 }, 'Your payment did not go through'),
5760
+ h(
5761
+ Text,
5762
+ null,
5763
+ \`We could not charge \${amount} for \${SHOP_NAME} \${PLAN.name}. Your plan stays on while we try again; pay with another card to keep it.\`,
5764
+ ),
5765
+ h(Button, { href: payHref }, 'Update payment'),
5766
+ ),
5767
+ ),
5768
+ ),
5769
+ ),
5770
+ { subject },
5771
+ )
5772
+ }
5773
+
5774
+ /**
5775
+ * A renewal Stripe could not charge (\`invoice.payment_failed\`). The customer hears about it
5776
+ * once per attempt, with Stripe's page for paying the invoice with another card. The plan stays
5777
+ * on while Stripe retries (\`past_due\`); the Stripe dashboard's failed-payment settings decide
5778
+ * when it ends. Only customers this app bills are written to: a retried event, or an invoice
5779
+ * for something else on the same Stripe account, sends nothing.
5780
+ */
5781
+ export async function remindPayment(
5782
+ env: BillingEnv,
5783
+ invoice: Invoice,
5784
+ origin: string,
5785
+ ): Promise<void> {
5786
+ if (!invoice.customerId || !invoice.customerEmail) return
5787
+ await migrate(env.DB, migrations)
5788
+ const attempt = \`\${invoice.id}:\${invoice.attemptCount}\`
5789
+ const [row] = await queryRows(
5790
+ env.DB,
5791
+ 'UPDATE billing SET reminded = ? WHERE customer_id = ? AND reminded IS NOT ? RETURNING user_id',
5792
+ [attempt, invoice.customerId, attempt],
5793
+ (raw) => raw,
5794
+ )
5795
+ if (!row) return
5796
+ const message = renderPaymentFailed(invoice, invoice.hostedInvoiceUrl ?? \`\${origin}/billing\`)
5797
+ if (import.meta.env.DEV) {
5798
+ console.log(\`[billing] payment reminder for \${invoice.customerEmail}: "\${message.subject}"\`)
5799
+ return
5800
+ }
5801
+ if (!env.RECEIPT_FROM) {
5802
+ console.warn('[billing] no payment reminder sent: set RECEIPT_FROM in wrangler.jsonc')
5803
+ return
5804
+ }
5805
+ try {
5806
+ await env.EMAIL.send({
5807
+ from: env.RECEIPT_FROM,
5808
+ to: invoice.customerEmail,
5809
+ subject: message.subject,
5810
+ text: message.text,
5811
+ html: message.html,
5812
+ })
5813
+ } catch (error) {
5814
+ // Logged, not thrown: Stripe retrying the event would not send it again.
5815
+ console.error('[billing] payment reminder not sent:', error)
5816
+ }
5817
+ }
5818
+
5819
+ /** What the Stripe webhook (worker/checkout.ts) hands over to billing. */
5820
+ export function billingHooks(env: BillingEnv): BillingHooks {
5821
+ return {
5822
+ subscription: (id) => syncSubscription(env, id),
5823
+ paymentFailed: (invoice, origin) => remindPayment(env, invoice, origin),
5824
+ }
5825
+ }
5826
+
5827
+ /** Opens a subscription checkout for PLAN, naming the user in the subscription's metadata. */
5828
+ export async function startSubscription(
5829
+ env: BillingEnv,
5830
+ request: Request,
5831
+ origin: string,
5832
+ ): Promise<{ url: string }> {
5833
+ const user = await requireUser(env.DB, request)
5834
+ const row = await readRow(env.DB, user.id)
5835
+ if (row && toBilling(row).active) {
5836
+ throw new HttpError(409, 'You already have the plan. Manage it in the billing portal.')
5837
+ }
5838
+ const stripe = stripeOf(env)
5839
+ try {
5840
+ const session = await stripe.createCheckoutSession({
5841
+ mode: 'subscription',
5842
+ lineItems: [{ ...PLAN, quantity: 1 }],
5843
+ // Stripe fills in {CHECKOUT_SESSION_ID}, so the page can sync before any webhook.
5844
+ successUrl: \`\${origin}/billing?session={CHECKOUT_SESSION_ID}\`,
5845
+ cancelUrl: \`\${origin}/billing\`,
5846
+ ...(row?.customerId ? { customer: row.customerId } : { customerEmail: user.email }),
5847
+ clientReferenceId: user.id,
5848
+ subscriptionMetadata: { user: user.id },
5849
+ })
5850
+ if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')
5851
+ return { url: session.url }
5852
+ } catch (error) {
5853
+ if (error instanceof HttpError) throw error
5854
+ refused(error)
5855
+ }
5856
+ }
5857
+
5858
+ /**
5859
+ * Back from Stripe's checkout: reads the session and its subscription, so the page is right
5860
+ * before the webhook arrives (or in \`vite dev\` without \`stripe listen\`). Only the user the
5861
+ * subscription names can sync it.
5862
+ */
5863
+ export async function syncBilling(
5864
+ env: BillingEnv,
5865
+ request: Request,
5866
+ sessionId: string,
5867
+ ): Promise<Billing> {
5868
+ const user = await requireUser(env.DB, request)
5869
+ const stripe = stripeOf(env)
5870
+ try {
5871
+ const session = await stripe.retrieveCheckoutSession(sessionId)
5872
+ if (session.mode === 'subscription' && session.subscriptionId) {
5873
+ const subscription = await stripe.retrieveSubscription(session.subscriptionId)
5874
+ if (subscription.metadata['user'] !== user.id) {
5875
+ throw new HttpError(403, 'This checkout belongs to another account')
5876
+ }
5877
+ await store(env.DB, subscription)
5878
+ }
5879
+ } catch (error) {
5880
+ if (error instanceof HttpError) throw error
5881
+ refused(error)
5882
+ }
5883
+ return toBilling(await readRow(env.DB, user.id))
5884
+ }
5885
+
5886
+ /** Opens Stripe's Customer Portal: plan, card, invoices and cancellation, all hosted. */
5887
+ export async function openPortal(
5888
+ env: BillingEnv,
5889
+ request: Request,
5890
+ origin: string,
5891
+ ): Promise<{ url: string }> {
5892
+ const user = await requireUser(env.DB, request)
5893
+ const row = await readRow(env.DB, user.id)
5894
+ if (!row?.customerId) throw new HttpError(409, 'Subscribe first: there is nothing to manage yet')
5895
+ try {
5896
+ return await stripeOf(env).createPortalSession({
5897
+ customer: row.customerId,
5898
+ returnUrl: \`\${origin}/billing\`,
5899
+ })
5900
+ } catch (error) {
5901
+ refused(error)
5902
+ }
5903
+ }
5904
+ `}function Pt(){return`import { createClient } from '@cascivo/app/api'
5905
+ import {
5906
+ Alert,
5907
+ Badge,
5908
+ Button,
5909
+ Card,
5910
+ CardContent,
5911
+ Flex,
5912
+ Heading,
5913
+ Link,
5914
+ Spinner,
5915
+ Text,
5916
+ signal,
5917
+ useSignalEffect,
5918
+ useSignals,
5919
+ } from '@cascivo/react'
5920
+ import { api } from '../api'
5921
+ import { auth } from '../auth'
5922
+ import { PLAN } from '../billing'
5923
+ import type { Billing, BillingStatus } from '../billing'
5924
+ import { formatPrice } from '../checkout'
5925
+ import { router } from '../router'
5926
+
5927
+ const client = createClient(api)
5928
+ const billing = signal<Billing | null>(null)
5929
+ const busy = signal<'subscribe' | 'portal' | null>(null)
5930
+ const failure = signal<string | null>(null)
5931
+
5932
+ /**
5933
+ * Loads the plan. Back from Stripe's checkout, the URL carries the session id: syncing it
5934
+ * shows the new subscription at once, before the webhook. Then the id leaves the URL.
5935
+ */
5936
+ async function load(): Promise<void> {
5937
+ failure.value = null
5938
+ try {
5939
+ const sessionId = new URLSearchParams(router.search.peek()).get('session')
5940
+ if (sessionId) {
5941
+ billing.value = await client.syncBilling({ body: { sessionId } })
5942
+ router.navigate('/billing', { replace: true })
5943
+ } else {
5944
+ billing.value = await client.getBilling()
5945
+ }
5946
+ } catch (error) {
5947
+ failure.value = error instanceof Error ? error.message : 'Could not load billing'
5948
+ }
5949
+ }
5950
+
5951
+ /** Both buttons leave for a Stripe page: checkout, or the billing portal. */
5952
+ async function leaveFor(kind: 'subscribe' | 'portal'): Promise<void> {
5953
+ busy.value = kind
5954
+ failure.value = null
5955
+ try {
5956
+ const { url } =
5957
+ kind === 'subscribe' ? await client.startSubscription() : await client.openBillingPortal()
5958
+ location.assign(url)
5959
+ } catch (error) {
5960
+ failure.value = error instanceof Error ? error.message : 'Stripe did not open'
5961
+ busy.value = null
5962
+ }
5963
+ }
5964
+
5965
+ const LABEL: Record<BillingStatus, string> = {
5966
+ none: 'No plan',
5967
+ incomplete: 'Payment pending',
5968
+ incomplete_expired: 'Payment expired',
5969
+ trialing: 'Trial',
5970
+ active: 'Active',
5971
+ past_due: 'Payment failed: retrying',
5972
+ canceled: 'Cancelled',
5973
+ unpaid: 'Unpaid',
5974
+ paused: 'Paused',
5975
+ }
5976
+
5977
+ export default function BillingPage() {
5978
+ useSignals()
5979
+ useSignalEffect(() => {
5980
+ if (auth.user.value) void load()
5981
+ })
5982
+ const user = auth.user.value
5983
+ const plan = billing.value
5984
+
5985
+ if (user === undefined) return <Spinner label="Loading" />
5986
+ if (user === null) {
5987
+ return (
5988
+ <Flex gap={4}>
5989
+ <Heading level={1}>Billing</Heading>
5990
+ <Text>
5991
+ <Link href="/account">Sign in</Link> to subscribe: a plan belongs to your account.
5992
+ </Text>
5993
+ </Flex>
5994
+ )
5995
+ }
5996
+ return (
5997
+ <Flex gap={4}>
5998
+ <Flex gap={1}>
5999
+ <Heading level={1}>Billing</Heading>
6000
+ <Text muted>
6001
+ Paid on Stripe's hosted checkout; changed, paused or cancelled in Stripe's billing portal.
6002
+ The Worker keeps your plan in step through Stripe's webhook.
6003
+ </Text>
6004
+ </Flex>
6005
+ <Card>
6006
+ <CardContent>
6007
+ <Flex gap={3}>
6008
+ <Flex direction="horizontal" align="center" gap={2} wrap>
6009
+ <Heading level={2}>{PLAN.name}</Heading>
6010
+ {plan ? (
6011
+ <Badge variant={plan.active ? 'success' : 'secondary'}>{LABEL[plan.status]}</Badge>
6012
+ ) : null}
6013
+ </Flex>
6014
+ <Text muted>{PLAN.description}</Text>
6015
+ <Text size="lg">
6016
+ {formatPrice(PLAN.amount, PLAN.currency)} a {PLAN.interval}
6017
+ </Text>
6018
+ {plan?.currentPeriodEnd ? (
6019
+ <Text size="sm" muted>
6020
+ {plan.cancelAtPeriodEnd ? 'Ends' : 'Renews'} on{' '}
6021
+ {new Date(plan.currentPeriodEnd).toLocaleDateString()}
6022
+ </Text>
6023
+ ) : null}
6024
+ <Flex direction="horizontal" gap={2} wrap>
6025
+ {plan && !plan.active ? (
6026
+ <Button
6027
+ loading={busy.value === 'subscribe'}
6028
+ onClick={() => void leaveFor('subscribe')}
6029
+ >
6030
+ Subscribe
6031
+ </Button>
6032
+ ) : null}
6033
+ {plan?.canManage ? (
6034
+ <Button
6035
+ variant="secondary"
6036
+ loading={busy.value === 'portal'}
6037
+ onClick={() => void leaveFor('portal')}
6038
+ >
6039
+ Manage billing
6040
+ </Button>
6041
+ ) : null}
6042
+ </Flex>
6043
+ </Flex>
6044
+ </CardContent>
6045
+ </Card>
6046
+ {failure.value ? (
6047
+ <Alert variant="destructive" title="Not done">
6048
+ {failure.value}
6049
+ </Alert>
6050
+ ) : null}
6051
+ </Flex>
6052
+ )
6053
+ }
6054
+ `}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\`). */
6055
+ export interface SignInSender {
6056
+ send(message: {
6057
+ from: string
6058
+ to: string
6059
+ subject: string
6060
+ text: string
6061
+ html: string
6062
+ }): Promise<unknown>
6063
+ }
6064
+
6065
+ const escape = (value: string) =>
6066
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
6067
+
6068
+ /**
6069
+ * Emails a sign-in link. \`vite dev\` sends nothing: the link is logged, and the Account page
6070
+ * shows it (\`exposeLink\` in worker/index.ts).
6071
+ */
6072
+ export async function sendSignInLink(
6073
+ sender: SignInSender,
6074
+ from: string,
6075
+ email: string,
6076
+ url: string,
6077
+ ): Promise<void> {
6078
+ if (import.meta.env.DEV) {
6079
+ console.log(\`[auth] sign-in link for \${email}: \${url}\`)
6080
+ return
6081
+ }
6082
+ if (!from) throw new Error('Set AUTH_FROM in wrangler.jsonc to an address on your domain')
6083
+ await sender.send({
6084
+ from,
6085
+ to: email,
6086
+ subject: 'Your sign-in link',
6087
+ text: \`Sign in: \${url}\\n\\nThe link works once, for 15 minutes. If you did not ask for it, ignore this email.\`,
6088
+ 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>\`,
6089
+ })
6090
+ }
6091
+ `}function Lt(){return`import {
6092
+ Alert,
6093
+ Button,
6094
+ Card,
6095
+ CardContent,
6096
+ Flex,
6097
+ Heading,
6098
+ Input,
6099
+ Link,
6100
+ Spinner,
6101
+ Text,
6102
+ signal,
6103
+ useSignals,
6104
+ } from '@cascivo/react'
6105
+ import type { FormEvent } from 'react'
6106
+ import { auth } from '../auth'
6107
+
6108
+ const sentTo = signal<string | null>(null)
6109
+ /** Set only in \`vite dev\`, where no email is sent: the link to open instead. */
6110
+ const devLink = signal<string | null>(null)
6111
+ const failure = signal<string | null>(null)
6112
+ const sending = signal(false)
6113
+
6114
+ async function start(event: FormEvent<HTMLFormElement>): Promise<void> {
6115
+ event.preventDefault()
6116
+ const email = new FormData(event.currentTarget).get('email')
6117
+ if (typeof email !== 'string') return
6118
+ failure.value = null
6119
+ sending.value = true
6120
+ try {
6121
+ const { link } = await auth.start(email)
6122
+ sentTo.value = email
6123
+ devLink.value = link
6124
+ } catch (error) {
6125
+ failure.value = error instanceof Error ? error.message : 'Could not send the link'
6126
+ } finally {
6127
+ sending.value = false
6128
+ }
6129
+ }
6130
+
6131
+ export default function Account() {
6132
+ useSignals()
6133
+ const user = auth.user.value
6134
+
6135
+ if (user === undefined) return <Spinner label="Loading" />
6136
+ if (user) {
6137
+ return (
6138
+ <Flex gap={4}>
6139
+ <Heading level={1}>Account</Heading>
6140
+ <Card>
6141
+ <CardContent>
6142
+ <Flex gap={3}>
6143
+ <Text>Signed in as {user.email}</Text>
6144
+ <Flex direction="horizontal">
6145
+ <Button variant="secondary" onClick={() => void auth.signOut()}>
6146
+ Sign out
6147
+ </Button>
6148
+ </Flex>
6149
+ </Flex>
6150
+ </CardContent>
6151
+ </Card>
3715
6152
  </Flex>
3716
6153
  )
3717
6154
  }
@@ -3747,7 +6184,7 @@ export default function Account() {
3747
6184
  </Flex>
3748
6185
  )
3749
6186
  }
3750
- `}function bt(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
6187
+ `}function Rt(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
3751
6188
  import { auth } from '../../auth'
3752
6189
  import { router } from '../../router'
3753
6190
 
@@ -3797,7 +6234,7 @@ export default function VerifySignIn() {
3797
6234
  </Flex>
3798
6235
  )
3799
6236
  }
3800
- `}function xt(){return`import type { ViewConfig } from '@cascivo/render'
6237
+ `}function zt(){return`import type { ViewConfig } from '@cascivo/render'
3801
6238
  import { validateView } from '@cascivo/render/validate'
3802
6239
 
3803
6240
  /**
@@ -3869,7 +6306,7 @@ export function parsePage(raw: unknown): Page {
3869
6306
  const { view } = raw as Record<string, unknown>
3870
6307
  return { ...summary, ...parsePageInput({ title: summary.title, view }) }
3871
6308
  }
3872
- `}function St(){return`import { HttpError } from '@cascivo/app/api'
6309
+ `}function Bt(){return`import { HttpError } from '@cascivo/app/api'
3873
6310
  import { migrate, queryRows } from '@cascivo/app/db'
3874
6311
  import type { Database } from '@cascivo/app/db'
3875
6312
  import { isSlug, parsePage } from '../src/pages'
@@ -3934,7 +6371,7 @@ export async function getPage(db: Database, slug: string): Promise<Page> {
3934
6371
  if (!page) throw new HttpError(404, 'No such page')
3935
6372
  return page
3936
6373
  }
3937
- `}function Ct(){return`import { CascivoView } from '@cascivo/render'
6374
+ `}function Vt(){return`import { CascivoView } from '@cascivo/render'
3938
6375
  import { Flex, Heading } from '@cascivo/react'
3939
6376
  import { createElement } from 'react'
3940
6377
  import { renderToStaticMarkup } from 'react-dom/server'
@@ -4060,7 +6497,7 @@ export async function renderPageHtml(
4060
6497
  headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' },
4061
6498
  })
4062
6499
  }
4063
- `}function wt(){return`import { migrate, queryRows } from '@cascivo/app/db'
6500
+ `}function Ht(){return`import { migrate, queryRows } from '@cascivo/app/db'
4064
6501
  import type { Database } from '@cascivo/app/db'
4065
6502
  import { exportPage } from '@cascivo/app/export'
4066
6503
  import type { ExportBrowser } from '@cascivo/app/export'
@@ -4117,7 +6554,7 @@ export async function pagePreview(
4117
6554
  headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' },
4118
6555
  })
4119
6556
  }
4120
- `}function Tt(){return`import { createClient } from '@cascivo/app/api'
6557
+ `}function Ut(){return`import { createClient } from '@cascivo/app/api'
4121
6558
  import { CascivoView } from '@cascivo/render'
4122
6559
  import type { ViewConfig } from '@cascivo/render'
4123
6560
  import { validateView } from '@cascivo/render/validate'
@@ -4277,7 +6714,7 @@ export default function Publish() {
4277
6714
  </Flex>
4278
6715
  )
4279
6716
  }
4280
- `}function Et(){return`import type { RouteProps } from '@cascivo/app'
6717
+ `}function Wt(){return`import type { RouteProps } from '@cascivo/app'
4281
6718
  import { createClient } from '@cascivo/app/api'
4282
6719
  import { CascivoView } from '@cascivo/render'
4283
6720
  import {
@@ -4327,7 +6764,7 @@ export default function PublishedPage({ params }: RouteProps<'/p/:slug'>) {
4327
6764
  </Flex>
4328
6765
  )
4329
6766
  }
4330
- `}function Dt(){return`import { signal } from '@cascivo/react'
6767
+ `}function Gt(){return`import { signal } from '@cascivo/react'
4331
6768
  import { VoiceClient } from 'agents/voice/client'
4332
6769
  import type { TranscriptMessage, VoiceStatus } from 'agents/voice/client'
4333
6770
 
@@ -4418,7 +6855,7 @@ export function voice(): VoiceClient {
4418
6855
  client = c
4419
6856
  return c
4420
6857
  }
4421
- `}function Ot(){return`import { Agent } from 'agents'
6858
+ `}function Kt(){return`import { Agent } from 'agents'
4422
6859
  import type { Connection } from 'agents'
4423
6860
  import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from 'agents/voice'
4424
6861
  import type { TextSource, VoiceTurnContext } from 'agents/voice'
@@ -4482,7 +6919,7 @@ export class Voice extends VoiceAgent {
4482
6919
  }
4483
6920
  }
4484
6921
  }
4485
- `}function kt(){return`import type {
6922
+ `}function qt(){return`import type {
4486
6923
  TTSProvider,
4487
6924
  Transcriber,
4488
6925
  TranscriberSession,
@@ -4528,7 +6965,7 @@ const REPLIES: Record<string, string> = {
4528
6965
  export function scriptedReply(transcript: string): string {
4529
6966
  return REPLIES[transcript] ?? \`You said: \${transcript}\`
4530
6967
  }
4531
- `}function At(){return`import {
6968
+ `}function Jt(){return`import {
4532
6969
  Badge,
4533
6970
  Button,
4534
6971
  Card,
@@ -4645,7 +7082,7 @@ export default function VoicePage() {
4645
7082
  </Flex>
4646
7083
  )
4647
7084
  }
4648
- `}function jt(){return`import { defineLive } from '@cascivo/app/live'
7085
+ `}function Yt(){return`import { defineLive } from '@cascivo/app/live'
4649
7086
 
4650
7087
  /**
4651
7088
  * The live dashboard's metrics, shared by the Worker (which records them) and the page (which
@@ -4667,7 +7104,7 @@ export function parseAccepted(raw: unknown): Accepted {
4667
7104
  }
4668
7105
  throw new Error('Malformed reply')
4669
7106
  }
4670
- `}function Mt(){return`import { Kpi, LineChart } from '@cascivo/charts'
7107
+ `}function Xt(){return`import { Kpi, LineChart } from '@cascivo/charts'
4671
7108
  import { createClient } from '@cascivo/app/api'
4672
7109
  import { watchLive } from '@cascivo/app/live'
4673
7110
  import type { LiveEvent, LivePoint } from '@cascivo/app/live'
@@ -4790,7 +7227,7 @@ export default function Ops() {
4790
7227
  </Flex>
4791
7228
  )
4792
7229
  }
4793
- `}function Nt(){return`import { defineTable } from '@cascivo/app/db'
7230
+ `}function Zt(){return`import { defineTable } from '@cascivo/app/db'
4794
7231
 
4795
7232
  /**
4796
7233
  * The customers table, shared by the Worker (which queries it) and the page (which shows
@@ -4866,7 +7303,7 @@ export function parseDeleted(raw: unknown): { id: string } {
4866
7303
  }
4867
7304
  throw new Error('Malformed delete result')
4868
7305
  }
4869
- `}function Pt(){return`import { createClient } from '@cascivo/app/api'
7306
+ `}function Qt(){return`import { createClient } from '@cascivo/app/api'
4870
7307
  import type { TableQuery } from '@cascivo/app/db'
4871
7308
  import { signal } from '@cascivo/react'
4872
7309
  import { api } from './api'
@@ -4921,7 +7358,7 @@ export async function remove(id: string): Promise<void> {
4921
7358
  await client.deleteCustomer({ params: { id } })
4922
7359
  await load()
4923
7360
  }
4924
- `}function Ft(){return`import { HttpError } from '@cascivo/app/api'
7361
+ `}function $t(){return`import { HttpError } from '@cascivo/app/api'
4925
7362
  import { migrate, queryTable } from '@cascivo/app/db'
4926
7363
  import type { Database, TablePage, TableQuery } from '@cascivo/app/db'
4927
7364
  import { customersTable, parseCustomer } from '../src/customers'
@@ -4990,7 +7427,7 @@ export async function deleteCustomer(db: Database, id: string): Promise<{ id: st
4990
7427
  if (!row) throw new HttpError(404, 'No such customer')
4991
7428
  return { id }
4992
7429
  }
4993
- `}function It(){return`import type { Migration } from '@cascivo/app/db'
7430
+ `}function en(){return`import type { Migration } from '@cascivo/app/db'
4994
7431
 
4995
7432
  const FIRST = [
4996
7433
  'Ada',
@@ -5040,7 +7477,7 @@ export const migrations: Migration[] = [
5040
7477
  ],
5041
7478
  },
5042
7479
  ]
5043
- `}function Lt(){return`import type { Column } from '@cascivo/react'
7480
+ `}function tn(){return`import type { Column } from '@cascivo/react'
5044
7481
  import {
5045
7482
  Badge,
5046
7483
  Button,
@@ -5220,15 +7657,16 @@ export default function Customers() {
5220
7657
  </Flex>
5221
7658
  )
5222
7659
  }
5223
- `}function Rt(){return`${B()}# Rewritten by @cascivo/app/vite whenever a route file changes.
7660
+ `}function nn(){return`${V()}# Rewritten by @cascivo/app/vite whenever a route file changes.
5224
7661
  src/routes.gen.ts
5225
- `}function zt(){return`node_modules
7662
+ `}function rn(){return`node_modules
5226
7663
  dist
5227
7664
  .wrangler
5228
7665
  .dev.vars*
7666
+ !.dev.vars.example
5229
7667
  *.local
5230
7668
  .DS_Store
5231
- `}function Bt(e){let t=e.pm??`npm`,n=$(e);return`# ${e.name}
7669
+ `}function an(e){let t=e.pm??`npm`,n=$(e);return`# ${e.name}
5232
7670
 
5233
7671
  A [cascivo](https://cascivo.com) app on Cloudflare: a client-rendered
5234
7672
  ${n===`preact`?`Preact`:`React`} app and its API, deployed as one Worker.
@@ -5265,7 +7703,7 @@ This builds the app, then deploys it to a temporary Cloudflare account with
5265
7703
 
5266
7704
  It works only while wrangler is logged out. If you are logged in, use \`deploy\` instead.
5267
7705
  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.`:``}
7706
+ 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
7707
 
5270
7708
  ## Structure
5271
7709
 
@@ -5282,7 +7720,7 @@ support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves th
5282
7720
  - \`src/Shell.tsx\` — the app shell (header + side nav + content slot).
5283
7721
 
5284
7722
  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`)?`
7723
+ 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
7724
 
5287
7725
  ## Files (uploads)
5288
7726
 
@@ -5298,7 +7736,7 @@ IndexedDB).${K(e,`board`)?"\n\n## Board (multiplayer)\n\n`/board` is a shared bo
5298
7736
 
5299
7737
  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
7738
  \`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`)?`
7739
+ 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
7740
 
5303
7741
  ## Ops (live dashboard)
5304
7742
 
@@ -5318,7 +7756,7 @@ A new viewer, or one back from a dropped connection, starts with the whole windo
5318
7756
  at least once: a batch retried after a lost reply counts twice. \`/api/events\` has no auth, so
5319
7757
  anyone can move the numbers: check who is sending before real use (\`--auth access\` puts
5320
7758
  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`)?`
7759
+ \`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
7760
 
5323
7761
  ## Search (by meaning)
5324
7762
 
@@ -5336,7 +7774,119 @@ Create the index once before deploying, with the embedding model's dimensions:
5336
7774
  Neither Vectorize nor Workers AI runs locally, so \`vite dev\` searches by keyword with SQLite's
5337
7775
  full-text search instead, and the page says so. \`VITE_REAL_AI=1 ${C(t,`dev`)}\` uses the real ones
5338
7776
  (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`)?`
7777
+ 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`)?`
7778
+
7779
+ ## Checkout (Stripe)
7780
+
7781
+ \`/checkout\` sells one product on Stripe's hosted Checkout page: the card never touches this
7782
+ app. Stripe tells the Worker when the payment succeeds; the Worker marks the order paid, pushes
7783
+ it to the order page, and emails a receipt rendered with \`@cascivo/email\`.
7784
+
7785
+ 1. In the Stripe dashboard, in test mode, copy the secret key (\`sk_test_…\`) into \`.dev.vars\`
7786
+ as \`STRIPE_SECRET_KEY\`, then run the app and buy with the card \`4242 4242 4242 4242\`.
7787
+ The order page confirms the payment by reading the session back from Stripe, so this works
7788
+ before any webhook is set up.
7789
+ 2. To receive the webhook locally, run
7790
+ \`stripe listen --forward-to localhost:5173/api/stripe/webhook\` (the Stripe CLI) and put the
7791
+ \`whsec_…\` secret it prints in \`.dev.vars\` as \`STRIPE_WEBHOOK_SECRET\`.
7792
+ 3. Deployed: \`npx wrangler secret put STRIPE_SECRET_KEY\` and
7793
+ \`npx wrangler secret put STRIPE_WEBHOOK_SECRET\`. In the dashboard, add a webhook endpoint
7794
+ at \`https://<your app>/api/stripe/webhook\` for \`checkout.session.completed\`,
7795
+ \`checkout.session.async_payment_succeeded\`, \`checkout.session.async_payment_failed\`,
7796
+ \`checkout.session.expired\`, \`charge.refunded\`, \`charge.dispute.created\` and
7797
+ \`charge.dispute.closed\`; its signing secret is \`STRIPE_WEBHOOK_SECRET\`.
7798
+ 4. Receipts: set \`RECEIPT_FROM\` in \`wrangler.jsonc\` to an address on a domain you have
7799
+ onboarded to Email Service. \`vite dev\` renders each receipt and logs it instead.
7800
+
7801
+ What you sell is \`PRODUCT\` in \`src/checkout.ts\`. The Worker sends that price to Stripe, so a
7802
+ browser cannot change it.
7803
+
7804
+ - \`worker/checkout.ts\` — \`createStripe\` (\`@cascivo/app/stripe\`) creates the session, with
7805
+ the order id as its idempotency key. The webhook is checked by \`verifyWebhook\` (scheme
7806
+ \`stripe\`: signature and a five-minute window) before \`parseStripeEvent\` reads it.
7807
+ - An order moves from \`pending\` to \`paid\`, \`failed\` or \`expired\` once. A retried event, or
7808
+ the page getting there before the webhook, changes nothing, so the receipt goes out once.
7809
+ Orders are found by Stripe's session id, never by \`client_reference_id\`, which a buyer can
7810
+ set on a Payment Link.
7811
+ - A bank debit completes the session as \`unpaid\`: the order stays pending until
7812
+ \`async_payment_succeeded\` or \`async_payment_failed\` arrives, possibly days later.
7813
+ - Refunds are made in the Stripe dashboard (or with \`createRefund\`); \`charge.refunded\` records
7814
+ the amount, and an order refunded in full becomes \`refunded\`. A chargeback makes it
7815
+ \`disputed\` until it is decided: answer it with evidence in the dashboard. Both find the
7816
+ order by the payment it stored when it was paid.
7817
+ - \`src/routes/checkout/[order].tsx\` — where Stripe sends the buyer back. It watches the
7818
+ order's read-only room, so it updates when the webhook arrives.
7819
+
7820
+ Each caller (by IP) may start 20 checkouts a minute.${J(e)?`
7821
+
7822
+ ### Subscriptions (\`/billing\`)
7823
+
7824
+ Signed-in users subscribe to \`PLAN\` (\`src/billing.ts\`) on Stripe's checkout and change,
7825
+ pause or cancel it in Stripe's hosted billing portal, so the app has no billing screens to build.
7826
+
7827
+ 1. Add \`customer.subscription.created\`, \`customer.subscription.updated\`,
7828
+ \`customer.subscription.deleted\` and \`invoice.payment_failed\` to the webhook endpoint's
7829
+ events.
7830
+ 2. In the Stripe dashboard, save the Customer Portal's settings once (test mode too): until
7831
+ then, Stripe refuses to open it.
7832
+ 3. Gate a paid feature in the Worker with \`await billingStore.requirePlan(env, request)\` (402
7833
+ without the plan), never on what the page shows.
7834
+
7835
+ - \`worker/billing.ts\` — the subscription names its user in its metadata, which only the
7836
+ Worker sets (\`client_reference_id\` can be set by a buyer on a Payment Link). Every
7837
+ subscription event is read back from Stripe before it is stored, because events arrive out of
7838
+ order, and a late event about an older subscription cannot end a live one.
7839
+ - Back from checkout, \`/billing\` reads the session and its subscription at once, so it is right
7840
+ before the webhook arrives, and only for the user the subscription names.
7841
+ - A renewal that cannot be charged leaves the plan on (\`past_due\`) while Stripe retries, and
7842
+ emails the customer once per attempt with Stripe's page to pay with another card. How long
7843
+ Stripe retries, and whether it then cancels, is set in the dashboard (Billing → Subscriptions
7844
+ and emails); turn off Stripe's own failed-payment emails there, or customers get two.`:`
7845
+
7846
+ Subscriptions need an account to belong to: \`cascivo create --framework cloudflare
7847
+ --example checkout --auth email\` adds a \`/billing\` page with a monthly plan and Stripe's
7848
+ billing portal.`}`:``}${X(e,`newsletter`)?`
7849
+
7850
+ ## Newsletter (Amazon SES)
7851
+
7852
+ \`/newsletter\` signs readers up; \`/newsletter/send\` writes an issue in Markdown, previews it
7853
+ as the email it will be (rendered with \`@cascivo/email\`), and sends it to every confirmed
7854
+ reader through Amazon SES. It runs in \`vite dev\` with nothing set up: emails are logged instead
7855
+ of sent, and the sign-up page shows the confirmation link. The composer's key is in
7856
+ \`.dev.vars\` (\`NEWSLETTER_KEY\`).
7857
+
7858
+ To send for real:
7859
+
7860
+ 1. In SES, verify the domain you send from (SES gives DNS records for DKIM; add them in
7861
+ Cloudflare DNS, with an SPF and a DMARC record), and ask AWS to move the account out of the
7862
+ sandbox, where it can mail only verified addresses.
7863
+ 2. Create an IAM user allowed \`ses:SendEmail\` and nothing else, and give its keys to the
7864
+ Worker: \`npx wrangler secret put AWS_ACCESS_KEY_ID\` and \`AWS_SECRET_ACCESS_KEY\` (in
7865
+ \`.dev.vars\` locally). Set \`AWS_REGION\` and \`NEWSLETTER_FROM\` in \`wrangler.jsonc\`, and
7866
+ \`npx wrangler secret put NEWSLETTER_KEY\` to a long random value.
7867
+ 3. Bounces and complaints: create an SNS topic, set it as the SES identity's bounce and
7868
+ complaint notification topic, and subscribe \`https://<your app>/api/sns/ses\` to it (HTTPS).
7869
+ Put the topic's ARN in \`SNS_TOPIC_ARN\`. The Worker confirms the subscription itself.
7870
+ 4. Create the queue once: \`npx wrangler queues create ${Y(e)}\`.
7871
+
7872
+ - \`worker/newsletter.ts\` — double opt-in: a sign-up gets a confirmation link (valid a day,
7873
+ resent at most every ten minutes) and gets no issue until it is opened. Sending stores the
7874
+ issue and puts its readers on the \`NEWSLETTER\` queue, 25 per message; the consumer sends
7875
+ them one by one through \`createSes\` (\`@cascivo/app/ses\`), handed to \`sendEmail\` from
7876
+ \`@cascivo/email\`, which checks each message before it leaves, one message at a time. Each
7877
+ send is recorded, so a message retried after SES throttling skips whoever already has it.
7878
+ - Every issue carries \`List-Unsubscribe\` and \`List-Unsubscribe-Post\` (one-click
7879
+ unsubscribe, RFC 8058, which Gmail and Yahoo require of bulk senders) and a footer link to
7880
+ \`/newsletter/unsubscribe\`.
7881
+ - \`handleSns\` checks each SNS message's signature against SNS's certificate before
7882
+ \`parseSesNotification\` reads it. A permanent bounce or a complaint suppresses the address
7883
+ for good: mailing it again is what gets an SES account reviewed.
7884
+ - \`worker/newsletter-email.ts\` — the issue and confirmation emails. The body is Markdown drawn
7885
+ through the email primitives; raw HTML in it stays literal text.
7886
+
7887
+ Sign-ups and every use of the newsletter key count against the rate limit (20 a minute per
7888
+ IP). Sending reads every confirmed address into memory; past a few hundred thousand readers,
7889
+ 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
7890
 
5341
7891
  ## Assistant (generative UI)
5342
7892
 
@@ -5355,7 +7905,7 @@ again. The page renders the result with \`<CascivoView>\`: real components, no g
5355
7905
  offline and without an account. \`VITE_REAL_AI=1 ${C(t,`dev`)}\` uses Workers AI instead
5356
7906
  (after \`npx wrangler login\`); a deployed Worker always does.
5357
7907
 
5358
- Workers AI bills per use beyond its free daily allocation.`:``}${K(e,`publish`)?`
7908
+ Workers AI bills per use beyond its free daily allocation.`:``}${X(e,`publish`)?`
5359
7909
 
5360
7910
  ## Publish (views as pages)
5361
7911
 
@@ -5373,10 +7923,10 @@ code of its author's and needs no sandbox.
5373
7923
  - \`src/routes/p/[slug].tsx\` — a published page.
5374
7924
  - \`worker/page-html.ts\` — the Worker answers \`/p/<slug>\` with index.html carrying the
5375
7925
  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.":``}
7926
+ 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
7927
 
5378
7928
  ${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`)?`
7929
+ \`wrangler.jsonc\`). D1 works on a temporary account.`:``}${X(e,`voice`)?`
5380
7930
 
5381
7931
  ## Voice
5382
7932
 
@@ -5401,10 +7951,10 @@ Add more components with \`npx cascivo add <component>\`.
5401
7951
 
5402
7952
  ## ${n===`preact`?`Preact or React`:`React or Preact`}
5403
7953
 
5404
- ${K(e,`agent`)?`This app runs on React. The Agents SDK's hooks call React 19's \`use()\`, which
7954
+ ${X(e,`agent`)?`This app runs on React. The Agents SDK's hooks call React 19's \`use()\`, which
5405
7955
  Preact's compat layer does not implement, so the assistant page needs React. (The other pages
5406
7956
  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)}
7957
+ `}function on(e){return`${U(e)}
5408
7958
  ## Server
5409
7959
 
5410
7960
  The API contract is \`src/api.ts\` (\`defineApi\` from \`@cascivo/app/api\`); its handlers are
@@ -5426,14 +7976,15 @@ To show the user a running version, run \`${w(e.pm??`npm`,`deploy:preview`)}\`.
5426
7976
  It needs no Cloudflare account. Give the user both URLs it prints: the live one, and the
5427
7977
  claim URL. The deployment is public, and it is deleted after 60 minutes unless the user
5428
7978
  opens the claim URL and signs in.
5429
- `}function Ht(e){let t=I(e),n=t.indexOf(` * Adding a router?`),r=t.indexOf(` */
7979
+ `}function sn(e){let t=oe(e),n=t.indexOf(` * Adding a router?`),r=t.indexOf(` */
5430
7980
  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
7981
  * 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(`
7982
+ `+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(`
7983
+ `).map(e=>e.startsWith(`#`)?e:e.replace(/=.*$/,`=`)).join(`
7984
+ `)}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
7985
  Next steps:`),console.log(` cd ${e}`),console.log(` ${S(M)}`),console.log(` ${C(M,`dev`)}`),console.log(`
5435
7986
  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
7987
  No account yet? Share a 60-minute preview, claimable into a free account:`),console.log(` ${w(M,`deploy:preview`)}`)):m===`astro`?(console.log(`
5437
7988
  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
7989
+ 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};
7990
+ //# sourceMappingURL=create-DmBKREqv.mjs.map