@ambasada/cli 0.8.18 → 0.8.20

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.
Files changed (2) hide show
  1. package/dist/ambasada.mjs +176 -276
  2. package/package.json +4 -4
package/dist/ambasada.mjs CHANGED
@@ -1,19 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // <define:__AMBASADA_TEMPLATES__>
4
- var define_AMBASADA_TEMPLATES_default = { next: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers, and a notes example.\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look good and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)`; don't add\n Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the starter UI. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea: the main action\n > visible on a phone without scrolling. Write complete files, one code block per file, with the file path\n > above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design for people who decide in 10 seconds\n\nPeople open your app on a phone, often inside the Ambasada app, between two other things. They decide in\nseconds whether it is for them. Build for that.\n\n## One page, one action\n\n- **One page first.** What it is, and the one thing to do, fit on the first screen of a phone (375 \xD7 667).\n Extra pages only when the action needs them.\n- **One action.** One button that does the main thing. Add a second only after people use the first.\n- **Few words.** A headline of up to 8 words, one sentence under it, the button. Cut every adjective you can.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no cookie banner, no tour. Ask for sign-in when the app\n needs to remember someone (Sign in with Ambasada is one tap inside the Ambasada app).\n\n## The button\n\n- Label it with the action: **Make my poem**, not **Submit**.\n- Big (at least 48 px tall), full width on phones, one strong colour, nothing else in that colour.\n- If people must type something first, put that one field right above the button.\n- After the action, show the result, or "Working on it\u2026" and the result appearing when ready (a live room\n or polling). Never a blank page.\n\n## Look\n\n- The templates use `@ambasada/ui` (the Ambasada app\'s look: tokens, `ui-card`, `ui-button`); start there\n ([mini-app.md](mini-app.md)). Otherwise plain CSS: one font, two colours plus grey, generous spacing.\n- Large text: 18 px body on phones, a headline twice that. Light theme.\n- Works with one thumb: tap targets 48 px, no hover-only menus.\n- Loads fast: no video backgrounds, no 3 MB hero images, no animation libraries.\n\n## Words\n\n- Say what they get, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- Write in the language of your users; one is fine.\n\n## Before and after\n\n```\nBad: Welcome to PoemForge\u2122, the next-generation AI-powered creative writing platform\n [Features] [Pricing] [About] [Sign up] \u2026scroll\u2026 [Get started]\n\nGood: Your name as a folk poem\n Type a name. Get a 4-line Romanian folk poem in 10 seconds.\n [ Name: _________ ]\n [ Make my poem ]\n "Ana, floare de la munte, / \u2026" \u2190 one real example\n```\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID, check the code, pick the app, Allow\n```\n\nThe browser lists every app you are on, the folder's own pre-selected. A HackATon company **is** an app: pick it,\nnever make a second app for it at developer.ambasada.dev. The key the CLI gets is named \"CLI \xB7 <laptop>\", listed and\nrevocable in the app's settings. In a folder whose `id` is another app that was never deployed (a scaffold called\n`my-shop`, the company `theoffice`), `login` offers to make the folder that app; `npx ambasada bind theoffice` does it\nlater. No browser on this machine (SSH, CI): it prints the link and code (`--no-browser`), open it anywhere. If it\nasks for a key instead (an older API): HackATon \u2192 Team \u2192 Settings \u2192 New key, or developer.ambasada.dev \u2192 your app \u2192\nSettings \u2192 New key.\nEach app's key is kept under its id: with two apps on one laptop, log in once in each folder, and each folder uses\nits own. `deploy`, `db`/`kv`/`files`, `env`, `logs`, `tunnel` and the village's commands that change something refuse\na key of another app than the folder's `id` (\"this folder is X, you are logged in as Y\"); `--company Y` goes ahead.\n\nThen, every time you want the new version online:\n\n```sh\nnpx ambasada deploy\n```\n\nIt does, in order:\n\n1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);\n2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/.well-known/ambasada.json`\n (written from `ambasada.config.ts` first, when the mini app is on: [mini-app.md](mini-app.md));\n a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);\n3. **upload**: the URL is `https://<your-slug>.ambasada.dev`, with `AMBASADA_API_KEY`, `AMBASADA_API_URL`,\n Sign in with Ambasada and the app secret set for you, and the app's own database, KV namespace and bucket\n bound as `DB`, `KV`, `FILES` (created on the first deploy) plus the Durable Objects from `wrangler.jsonc`.\n The live URL is kept in `.ambasada/project.json` (`live_url`): `ambasada check` and the village's offerings use it.\n\nThe database's tables are not created by the deploy: run `npx ambasada db migrate` (it applies the new files\nin `db/migrations/`). No Cloudflare account needed. `--dry-run` builds and prints what it would upload (before the\nfirst deploy too). A deploy keeps the app's `AMBASADA_API_KEY`; `--rotate` makes a new one and says so (the old one\nworks 5 more minutes, so running jobs finish). With the village add-on, `--offerings` re-opens offerings paused live.\n\nYour own secrets (an API key for another service): `npx ambasada env set NAME value` (live within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names. A setting that isn't secret: `env set NAME value --plain`,\nand `env list` shows its value. `.env` (and `.env.local`, `.env.production`, `.dev.vars`) never\ngoes online: the deploy builds without them, so a value that is only there is missing on ambasada.dev; `env set` it.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nRecords the live URL in `.ambasada/project.json` (`live_url`; `product.url` stays your local dev server) and checks\nthat `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check # in the folder: the live app (https, no redirects, speed), or the mini-app rules\n```\n\nA request that answers 5xx shows with what your code logged for it: its `console.error` / `console.warn` lines and\nan error's message with the line it was thrown from (`lib/errors.ts` wraps the Worker's `fetch`; a route that throws\nanswers 500 through `serverError`). A request that succeeded shows only its lines that start with `[village]`\n(`village.warn(\"\u2026\")` writes one: say when an answer was degraded, e.g. a tool was rate-limited), as `warning 200\n/api/question \u2026`, at most 50 an hour, never counted as errors. Keep that wrapper if you rewrite the Worker's entry. Run `logs` in the folder you\ndeploy from: `deploy` keeps each version's source maps in `.ambasada/maps/`, and `logs` shows where in your source an\nerror was thrown (`\u2192 at boom (src/api/checks.ts:12:9)`). In a next route that line is the route's file\n(`\u2192 in app/api/boom/route.ts`): OpenNext's build of Next.js's server has no source map.\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\nnpx ambasada miniapp enable --entry /app --force # move it: --force removes what the old entry left (it lists them otherwise)\nnpx ambasada miniapp disable # off again: the config key, the manifest and the glue (pages at /app stay until --force)\n```\n\nWith the mini app off, `listing: { age_rating: "12+", kind: "product", tags: ["quiz"] }` in `ambasada.config.ts` keeps\nhow the app is listed; a mini app\'s own `age_rating`, `kind` and `tags` win, `enable` writes only the defaults it does\nnot set, and `disable` moves the mini app\'s into `listing`.\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app", age_rating: "4+", kind: "tool" }` | `ambasada.config.ts`; optional `support` (email or URL, else the app\'s `support`), `tags` (\u2264 5), `subtitle`, `description`, `icon`, `category`, `accent`, `scopes`, `visibility`. The hub refuses an app without `age_rating` (`4+`, `9+`, `12+`, `17+`) |\n| the in-app layout and page, no site header | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp` | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`. On a first deploy the webhook\'s secret takes a few\nminutes to arrive: the deploy says `checks: running (up to 5 min)`, and `npx ambasada check` shows the result.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe templates import `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`, `ui-row`,\n`ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`). Use those classes before writing CSS.\nReact: `import { Card, Button, Sheet } from "@ambasada/ui/react"`. Plain HTML: one\n`<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nLeave space for the app\'s bar: the page uses `--ambasada-safe-area-*` and `--ambasada-host-bar`.\nLight theme only; one red (primary) button per screen.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/(site)/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in on open\n\n**Installed apps with `identity` are signed in on open; never show a sign-in button inside the shell.**\nInstall is the consent (it grants what `miniapp.scopes` asks for; `identity` is the default when the app has the\n`/api/auth/*` routes). The in-app glue the CLI wrote (`in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) does it\nbefore the page shows: `auth.signInSilently()` from `@ambasada/miniapp` gets a short token from the app and POSTs it\nto `/api/auth/shell`, which sets the same session as "Sign in with Ambasada"; the page stays hidden meanwhile (at\nmost 5 s, under the app\'s splash). Keep that glue, and don\'t add your own sign-in step or screen in front of it.\nServer code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\n\n"Sign in with Ambasada" is for the web, and for the app opened without installing it: `signInSilently()` resolves\n`null` then (also outside the app, or when the hub isn\'t set up yet: `/api/auth/shell` answers 503) and the page\nshows its button. Render the button only when nobody is signed in once the glue is done, as the templates do.\n`ambasada check` warns when `identity` is declared but `/api/auth/shell` is missing.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor".\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html>, the Ambasada look (@ambasada/ui) and globals.css\n app/(site)/layout.tsx the site\'s header (app/account.tsx) around its pages; (site) is not in the URL\n app/(site)/page.tsx the landing page\n app/notes.tsx the notes example (a client component calling app/api/notes)\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/account.tsx the header: "Sign in with Ambasada", or the person\'s name and avatar\n app/(site)/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/(site)/room/ the live room demo\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe template\'s header already has the button. In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Your app\'s own URL (links in emails, notifications, answers): `appOrigin()` in `lib/ambasada.ts`, from\n `AMBASADA_APP_ORIGIN` (the deploy and `ambasada dev` set it), not `req.url`: a call the platform forwards to your\n app may come without its Host.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only. `ambasada deploy` builds without `.env*`: nothing in them reaches the Worker.\n- Anything named `NEXT_PUBLIC_*` (`VITE_*` in the spa) is copied into the browser. Never put a key there. From `.env`\n they work under `ambasada dev` only; a public value the live page needs goes in the code.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| work that takes longer than a request: a slow model call, a report, a delivery after a payment | a job (`env.JOBS`, `startJob`) | `waitUntil`, or the work inside a webhook |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Jobs: work that outlives a request\n\nA model call that thinks for a minute, a report, a batch of pushes: not in the request (30 s, and a webhook gets 10 s;\n`waitUntil` is cut at ~30 s). Every template has the `Jobs` Durable Object (`env.JOBS`, src/jobs/jobs.ts): one object\nper job, run from an alarm with its own limits, retried after 10 s, 30 s, 2 min, 10 min and 30 min when it throws.\n\n```ts\n// src/jobs/handlers.ts: what a kind of job does (safe to run twice)\nexport const HANDLERS: Record<string, JobHandler> = {\n "send-report": { async run(input, env) { const { id } = input as { id: number }; /* \u2026 */ return { sent: true }; } },\n};\n\n// anywhere on the server (lib/jobs.ts): answers at once; the same name again is a no-op\nawait startJob(env, `report:${id}`, "send-report", { id });\nawait jobState(env, `report:${id}`); // { status: "queued" | "working" | "retrying" | "done" | "failed", result, error, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "AGENTS.md": "# Agents\n\nThis is an Ambasada app: Next.js that runs as one Cloudflare Worker (OpenNext), with `@ambasada/sdk` for\nsign-in, notifications and the store. Read `README.md` first, and `.claude/skills/ambasada-product/`.\nRun it with `npm run dev` (`ambasada dev`: the mock API + `next dev`). Data and realtime (`env.DB`, `env.KV`,\n`env.FILES`, live rooms): `.claude/skills/ambasada-product/reference/storage.md`.\n\n<!-- BEGIN:nextjs-agent-rules -->\n\n## This is NOT the Next.js you know\n\nThis version has breaking changes \u2014 APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.\n\nThis block is written and re-added by `next dev` \u2014 verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.\n\n<!-- END:nextjs-agent-rules -->\n", "README.md": "# My App\n\nAn app on Ambasada, Next.js on one Cloudflare Worker (template `next`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\napp/(site)/, app/notes.tsx the site: header, landing page, room, privacy; the notes example\napp/api/auth/* Sign in with Ambasada (login, callback, logout, me, shell); app/account.tsx is the header\napp/api/ambasada/webhook the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napp/api/notes, app/api/files env.DB + env.KV, uploads to env.FILES; app/files/[...key] serves them\nlib/ambasada.ts the SDK client from the Worker's env (getCloudflareContext), process.env under `next dev`\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables; lib/bindings.ts reaches them\nsrc/realtime/ the live room (a Durable Object) and its React hook; worker.ts routes its WebSockets\nnext.config.ts, open-next.config.ts, wrangler.jsonc, worker.ts Next.js \u2192 one Worker (OpenNext, nodejs_compat)\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `next dev` on http://localhost:3000\n```\n\n`npm install` warns that `node-domexception` and `glob@10.3.3` are deprecated: dependencies of OpenNext's, harmless,\nand gone when it updates them.\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `app/api/<name>/route.ts`. Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nStay inside what runs on a Worker: pages, server components, route handlers, server actions and `fetch`.\nNot: the `next/image` default loader, ISR / `revalidate`, `fs`, `child_process` or native packages, a custom\nserver. `npm run preview` builds the Worker (`opennextjs-cloudflare build`) and runs it in workerd, as in production.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID and pick the app\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx opennextjs-cloudflare build && npx opennextjs-cloudflare deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the header's button signs people in with their Ambasada ID; on the server,\n`ambasada(\u2026).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `app/api/notes/route.ts` |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `app/api/files/route.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts` (wired in `worker.ts`), hook `src/realtime/use-room.ts`, demo at `/room` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nUnder `next dev`, `env.DB`, `env.KV` and `env.FILES` work (`lib/bindings.ts`); live rooms need workerd: `npm run preview`.\n\nOptional: an agent with the Agents SDK in `examples/agent/` (its README: `npm i agents` and four lines to wire it).\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, app/app/ (layout, page) and app/app/in-app.tsx, a placeholder\n`public/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. The ambasada-product skill:\nreference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "Notes, a live room and sign-in",\n // Where `ambasada dev` serves the app on this laptop. Hosted, it is https://my-app.ambasada.dev: `ambasada deploy`\n // (or `ambasada link https://\u2026` when you host elsewhere) keeps that in .ambasada/project.json, never here.\n product: { url: "http://localhost:3000" },\n});\n', "app/(site)/layout.tsx": `import type { ReactNode } from "react";
5
- import { Account } from "../account.tsx";
6
-
7
- /** The site's pages (the route group adds nothing to the URL): the header with sign-in, then the page. */
8
- export default function SiteLayout({ children }: { children: ReactNode }) {
9
- return (
10
- <>
11
- <Account />
12
- <main className="page">{children}</main>
13
- </>
14
- );
15
- }
16
- `, "app/(site)/page.tsx": 'import { currentUser } from "../../lib/ambasada.ts";\nimport { Notes } from "../notes.tsx";\n\n// Who is signed in comes from the request: render per request, not at build time.\nexport const dynamic = "force-dynamic";\n\nexport default async function Home() {\n const user = await currentUser();\n return (\n <>\n <p className="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p className="lead">\n {user ? `Hello, ${user.name ?? "you"}. ` : ""}Notes in a database, a live room, and Sign in with Ambasada. Change anything.\n </p>\n {/* app/api/notes: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. */}\n <Notes />\n <p>\n <a href="/room">Open the live room</a> in two tabs and type.\n </p>\n </>\n );\n}\n', "app/(site)/privacy/page.tsx": `// /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
4
+ var define_AMBASADA_TEMPLATES_default = { next: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers.\n\nThe page is a **Hello, world**: no header, no styles, no layout (`app/page.tsx`, `src/App.tsx`, `web/index.html`).\nBuild the user's app there, in the user's design ([reference/design.md](reference/design.md)). The demos are in\n`examples/`, outside the app until copied in (each README says how):\n\n| Example | What |\n|---|---|\n| `examples/sign-in/` | the \"Sign in with Ambasada\" button, or the person's name, avatar and \"Sign out\" |\n| `examples/notes/` | a form and a list on `/api/notes` (rows in `env.DB`, cached in `env.KV`) |\n| `examples/room/` | a live room page (`env.ROOMS`, `/api/rooms/<id>/ws`) |\n| `examples/agent/` | an agent with the Agents SDK |\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look the way they want, on phones and laptops, and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)` (the button:\n `examples/sign-in/`); don't add Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key\n per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the Hello, world in `src/App.tsx`. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea, in this design: \u2026\n > The main action must be easy to find on a phone and on a laptop. Write complete files, one code block per\n > file, with the file path above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design: the user\'s, not ours\n\nThe scaffold has no look and no layout: a Hello, world and nothing else. There is no house style to follow. The app\nlooks the way the user wants it to look.\n\n## Whose design\n\n- **The user\'s, first.** A mockup, a screenshot, a site they like, a brand, a few words ("dark and calm", "like a\n newspaper"): follow it. Ask once if they gave nothing and care; otherwise pick a direction that fits the idea and\n keep it consistent.\n- **Any stack.** Plain CSS, CSS modules, Tailwind, a component kit: whatever the user or the code they brought uses.\n- **`@ambasada/ui` is optional.** It is the Ambasada app\'s look (tokens, `ui-card`, `ui-button`;\n [mini-app.md](mini-app.md#look-like-the-app-ambasadaui)). The mini app\'s in-app page uses it; use it on the site\n only if the user wants their app to look like the Ambasada app.\n\n## Every screen size\n\nPages are ordinary responsive web pages: they work on a phone and on a laptop alike. No fixed phone-width column,\nno desktop-only layout. Check both before saying "done" (a narrow window and a wide one). Tap targets big enough for\na thumb, no hover-only menus, a `<meta name="viewport" content="width=device-width, initial-scale=1">` (the templates\nhave it).\n\n## Clear beats clever\n\nTips, not rules; the user\'s design wins:\n\n- **Say what people get**, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- **One main action per page**, easy to find. Label its button with the action: **Make my poem**, not **Submit**.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no tour. Ask for sign-in when the app needs to remember someone\n (`examples/sign-in/` in the project).\n- **After an action**, show the result, or "Working on it\u2026" and the result appearing when ready (a live room or\n polling). Never a blank page.\n- **Loads fast**: no video backgrounds, no 3 MB hero images, no animation library for one fade.\n- Write in the language of the users; one is fine.\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID, check the code, pick the app, Allow\n```\n\nThe browser lists every app you are on, the folder's own pre-selected. A HackATon company **is** an app: pick it,\nnever make a second app for it at developer.ambasada.dev. The key the CLI gets is named \"CLI \xB7 <laptop>\", listed and\nrevocable in the app's settings. In a folder whose `id` is another app that was never deployed (a scaffold called\n`my-shop`, the company `theoffice`), `login` offers to make the folder that app; `npx ambasada bind theoffice` does it\nlater. No browser on this machine (SSH, CI): it prints the link and code (`--no-browser`), open it anywhere. If it\nasks for a key instead (an older API): HackATon \u2192 Team \u2192 Settings \u2192 New key, or developer.ambasada.dev \u2192 your app \u2192\nSettings \u2192 New key.\nEach app's key is kept under its id: with two apps on one laptop, log in once in each folder, and each folder uses\nits own. `deploy`, `db`/`kv`/`files`, `env`, `logs`, `tunnel` and the village's commands that change something refuse\na key of another app than the folder's `id` (\"this folder is X, you are logged in as Y\"); `--company Y` goes ahead.\n\nThen, every time you want the new version online:\n\n```sh\nnpx ambasada deploy\n```\n\nIt does, in order:\n\n1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);\n2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/.well-known/ambasada.json`\n (written from `ambasada.config.ts` first, when the mini app is on: [mini-app.md](mini-app.md));\n a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);\n3. **upload**: the URL is `https://<your-slug>.ambasada.dev`, with `AMBASADA_API_KEY`, `AMBASADA_API_URL`,\n Sign in with Ambasada and the app secret set for you, and the app's own database, KV namespace and bucket\n bound as `DB`, `KV`, `FILES` (created on the first deploy) plus the Durable Objects from `wrangler.jsonc`.\n The live URL is kept in `.ambasada/project.json` (`live_url`): `ambasada check` and the village's offerings use it.\n\nThe database's tables are not created by the deploy: run `npx ambasada db migrate` (it applies the new files\nin `db/migrations/`). No Cloudflare account needed. `--dry-run` builds and prints what it would upload (before the\nfirst deploy too). A deploy keeps the app's `AMBASADA_API_KEY`; `--rotate` makes a new one and says so (the old one\nworks 5 more minutes, so running jobs finish). With the village add-on, `--offerings` re-opens offerings paused live.\n\nYour own secrets (an API key for another service): `npx ambasada env set NAME value` (live within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names. A setting that isn't secret: `env set NAME value --plain`,\nand `env list` shows its value. `.env` (and `.env.local`, `.env.production`, `.dev.vars`) never\ngoes online: the deploy builds without them, so a value that is only there is missing on ambasada.dev; `env set` it.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nRecords the live URL in `.ambasada/project.json` (`live_url`; `product.url` stays your local dev server) and checks\nthat `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check # in the folder: the live app (https, no redirects, speed), or the mini-app rules\n```\n\nA request that answers 5xx shows with what your code logged for it: its `console.error` / `console.warn` lines and\nan error's message with the line it was thrown from (`lib/errors.ts` wraps the Worker's `fetch`; a route that throws\nanswers 500 through `serverError`). A request that succeeded shows only its lines that start with `[village]`\n(`village.warn(\"\u2026\")` writes one: say when an answer was degraded, e.g. a tool was rate-limited), as `warning 200\n/api/question \u2026`, at most 50 an hour, never counted as errors. Keep that wrapper if you rewrite the Worker's entry. Run `logs` in the folder you\ndeploy from: `deploy` keeps each version's source maps in `.ambasada/maps/`, and `logs` shows where in your source an\nerror was thrown (`\u2192 at boom (src/api/checks.ts:12:9)`). In a next route that line is the route's file\n(`\u2192 in app/api/boom/route.ts`): OpenNext's build of Next.js's server has no source map.\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\nnpx ambasada miniapp enable --entry /app --force # move it: --force removes what the old entry left (it lists them otherwise)\nnpx ambasada miniapp disable # off again: the config key, the manifest and the glue (pages at /app stay until --force)\n```\n\nWith the mini app off, `listing: { age_rating: "12+", kind: "product", tags: ["quiz"] }` in `ambasada.config.ts` keeps\nhow the app is listed; a mini app\'s own `age_rating`, `kind` and `tags` win, `enable` writes only the defaults it does\nnot set, and `disable` moves the mini app\'s into `listing`.\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app", age_rating: "4+", kind: "tool" }` | `ambasada.config.ts`; optional `support` (email or URL, else the app\'s `support`), `tags` (\u2264 5), `subtitle`, `description`, `icon`, `category`, `accent`, `scopes`, `visibility`. The hub refuses an app without `age_rating` (`4+`, `9+`, `12+`, `17+`) |\n| the in-app layout and page, in the Ambasada app\'s look (`@ambasada/ui`) | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp`, and `@ambasada/ui` for the in-app layout | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`. On a first deploy the webhook\'s secret takes a few\nminutes to arrive: the deploy says `checks: running (up to 5 min)`, and `npx ambasada check` shows the result.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe in-app layout `enable` adds imports `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`,\n`ui-row`, `ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`), for the mini app\'s pages only; the site\nkeeps its own look. On those pages, use the classes before writing CSS. React: `import { Card, Button, Sheet } from\n"@ambasada/ui/react"`. Plain HTML: one `<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nOptional everywhere: a mini app in the user\'s own design is fine (light theme; the checks refuse dark-only).\nThe app\'s bar and the safe areas are handled: `Page` (`ui-page`) keeps clear of them, and with `--entry /` the in-app\ncode (`app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) pads `<body>` by them inside the Ambasada app only\n(nothing on the web, nothing on a page that has `ui-page`). Don\'t add your own padding for the bar on top of it.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in on open\n\n**Installed apps with `identity` are signed in on open; never show a sign-in button inside the shell.**\nInstall is the consent (it grants what `miniapp.scopes` asks for; `identity` is the default when the app has the\n`/api/auth/*` routes). The in-app glue the CLI wrote (`in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) does it\nbefore the page shows: `auth.signInSilently()` from `@ambasada/miniapp` gets a short token from the app and POSTs it\nto `/api/auth/shell`, which sets the same session as "Sign in with Ambasada"; the page stays hidden meanwhile (at\nmost 5 s, under the app\'s splash). Keep that glue, and don\'t add your own sign-in step or screen in front of it.\nServer code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\n\n"Sign in with Ambasada" is for the web, and for the app opened without installing it: `signInSilently()` resolves\n`null` then (also outside the app, or when the hub isn\'t set up yet: `/api/auth/shell` answers 503) and the page\nshows its button. Render the button only when nobody is signed in once the glue is done, as the templates do.\n`ambasada check` warns when `identity` is declared but `/api/auth/shell` is missing.\n\n## The app\'s account is the one signed in\n\n**Inside the Ambasada app, the app\'s account is the one signed in.** The app\'s web view keeps cookies between opens,\nso it can still hold a session from an earlier "Sign in with Ambasada" as someone else. On every load the glue\ncompares the session\'s user with the app\'s (`auth.sessionMatches(user)`: the session\'s `id` against\n`context().user.id`, both the Ambasada ID user id, `user_\u2026`) and, when they differ, signs the app\'s account in over\nit: a shell token POSTed to `/api/auth/shell` replaces the session cookie, then one reload. It tries once per account\nin a web view session, so a session that did not stick can\'t loop. A session that matches is left alone, and on the\nweb nothing changes: whoever signed in stays.\n\n- **Don\'t remember who the person is across opens** (localStorage, a module variable, a cached `/api/auth/me`, a\n service worker). Read the session user on every request (`currentUser()` / `ambasada().auth.user(req)`) and key\n per-person data by its `id`.\n- **Your own sign-in code** instead of the CLI\'s glue: call `auth.signInSilently()` with its default `me`; it\n replaces another account\'s session by itself. Reload when the user it resolves is not the one the page rendered.\n Or do as the glue does: `upgrading.md` has the lines.\n- **"Sign out" inside the app** lasts until the next page: the glue signs the app\'s account back in (one reload,\n nothing to see). Hide the link there (`isInAmbasada()`, or the `in-ambasada` class `ready()` puts on `<html>`);\n to be someone else, the person changes account in the Ambasada app.\n- When the app gives no token (not installed, `identity` not granted), the session stays as it was.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor". To see the app\'s account take over, sign\nin on the web first with "Continue as Second Visitor" on the mock\'s consent page, then open the `?ambasada_shell=mock`\nURL: one reload and "Fake Visitor" is signed in.\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html> and <body>; no styles (add yours here)\n app/page.tsx the page: Hello, world\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV (a page for it: examples/notes/)\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n examples/ sign-in/, notes/, room/, agent/: copy in what you want (not built until then)\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe routes are in `app/api/auth/`; the button is `examples/sign-in/` (copy `account.tsx` to `app/`). In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind (the template has no\n styles: add the ones the user\'s design needs)\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Your app\'s own URL (links in emails, notifications, answers): `appOrigin()` in `lib/ambasada.ts`, from\n `AMBASADA_APP_ORIGIN` (the deploy and `ambasada dev` set it), not `req.url`: a call the platform forwards to your\n app may come without its Host.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only. `ambasada deploy` builds without `.env*`: nothing in them reaches the Worker.\n- Anything named `NEXT_PUBLIC_*` (`VITE_*` in the spa) is copied into the browser. Never put a key there. From `.env`\n they work under `ambasada dev` only; a public value the live page needs goes in the code.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| work that takes longer than a request: a slow model call, a report, a delivery after a payment | a job (`env.JOBS`, `startJob`) | `waitUntil`, or the work inside a webhook |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Jobs: work that outlives a request\n\nA model call that thinks for a minute, a report, a batch of pushes: not in the request (30 s, and a webhook gets 10 s;\n`waitUntil` is cut at ~30 s). Every template has the `Jobs` Durable Object (`env.JOBS`, src/jobs/jobs.ts): one object\nper job, run from an alarm with its own limits, retried after 10 s, 30 s, 2 min, 10 min and 30 min when it throws.\n\n```ts\n// src/jobs/handlers.ts: what a kind of job does (safe to run twice)\nexport const HANDLERS: Record<string, JobHandler> = {\n "send-report": { async run(input, env) { const { id } = input as { id: number }; /* \u2026 */ return { sent: true }; } },\n};\n\n// anywhere on the server (lib/jobs.ts): answers at once; the same name again is a no-op\nawait startJob(env, `report:${id}`, "send-report", { id });\nawait jobState(env, `report:${id}`); // { status: "queued" | "working" | "retrying" | "done" | "failed", result, error, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.19 \u2192 the next release: inside the Ambasada app, the app\'s account is the one signed in\n\nUp to 0.8.19 the mini app\'s glue signed the person in only when nobody was signed in. The Ambasada app\'s web view\nkeeps cookies, so a session from an earlier "Sign in with Ambasada" as someone else stayed, and the mini app opened as\nthat other person. Now a session whose user id is not `context().user.id` is replaced by the app\'s account\n(`mini-app.md`, "The app\'s account is the one signed in").\n\nDone by `upgrade`, when the glue is still the file a 0.8.12 to 0.8.19 CLI wrote (`app/in-app.tsx` or\n`app/<entry>/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`): the lines below, nothing else in the file. It needs\nthis release\'s `@ambasada/miniapp` (`auth.sessionMatches`), which `upgrade` sets in `package.json`.\n\nBy hand, for a coding agent, when `upgrade` says the glue "is left alone" (you changed it, or it is from 0.8.11 or\nolder and still calls `auth.signIn()`):\n\n- **next** (`in-app.tsx`) and **spa** (`src/in-app/shell.ts`): import `context` from `@ambasada/miniapp` too, then\n where the file decides whether to sign in (`if (me?.user) \u2026 else if (!sessionStorage.getItem(RELOADED) \u2026`):\n ```ts\n // Inside the app, the app\'s account is the one signed in: a session for someone else is replaced, once per account.\n const inApp = context().user?.id ?? "1";\n if (auth.sessionMatches(me?.user)) sessionStorage.removeItem(RELOADED);\n else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n ```\n In the spa\'s `shell.ts` the same test and guard go around its own body (`auth.sessionMatches(user)`,\n `sessionStorage.setItem(RELOADED, inApp)` before `onSignedIn()`), and a failed sign-in keeps the user `/api/auth/me`\n gave: `const signedIn = await auth.signInSilently<InAppUser>({ me: false }); user = signedIn ?? user;`.\n- **worker** (`web/in-app.js`, the hub\'s `miniapp.js` may be an older build, so it compares by hand):\n ```js\n const inApp = a?.context().user?.id ?? "1";\n if (me.user && (!a?.context().user || me.user.id === inApp)) sessionStorage.removeItem(RELOADED);\n else if (a && sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity(a)) && (await signInSilently(a))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n ```\n- **Glue from 0.8.11 or older** (it calls `auth.signIn()` after `ready()`): copy the glue file from a fresh\n `npm create ambasada@latest -- --miniapp` of the same template; spa: `src/in-app/InApp.tsx` with it, they changed\n together.\n- **Your own sign-in code**: the rule is the same. A session is the app\'s only when its user id equals\n `context().user.id`; otherwise get a shell token and POST it (`auth.signInSilently()` with its default `me` does\n both), and try at most once per account in a `sessionStorage` session.\n\nCheck it under `npx ambasada dev`: sign in on the web with "Continue as Second Visitor", open\n`/app?ambasada_shell=mock` (your entry): "Fake Visitor" is signed in after one reload.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\nNew apps start as a Hello, world (the header, notes and room pages, the Ambasada look and the village shop moved to\n`examples/` and `/example`). Nothing to do in an existing app: `upgrade` never touches your pages or styles. Keep\n`@ambasada/ui` in `package.json` while your pages import it.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "AGENTS.md": "# Agents\n\nThis is an Ambasada app: Next.js that runs as one Cloudflare Worker (OpenNext), with `@ambasada/sdk` for\nsign-in, notifications and the store. Read `README.md` first, and `.claude/skills/ambasada-product/`.\nRun it with `npm run dev` (`ambasada dev`: the mock API + `next dev`). Data and realtime (`env.DB`, `env.KV`,\n`env.FILES`, live rooms): `.claude/skills/ambasada-product/reference/storage.md`.\n\n<!-- BEGIN:nextjs-agent-rules -->\n\n## This is NOT the Next.js you know\n\nThis version has breaking changes \u2014 APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.\n\nThis block is written and re-added by `next dev` \u2014 verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.\n\n<!-- END:nextjs-agent-rules -->\n", "README.md": "# My App\n\nAn app on Ambasada, Next.js on one Cloudflare Worker (template `next`). The page is a Hello, world, with no look and\nno layout: build yours. Behind it: sign-in with Ambasada ID, a database, key-value, files and live rooms, ready to\nuse; a mini app inside the Ambasada app if you want one.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\napp/page.tsx the page: Hello, world (app/layout.tsx around it; app/privacy/ for the mini app)\napp/api/auth/* Sign in with Ambasada (login, callback, logout, me, shell)\napp/api/ambasada/webhook the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napp/api/notes, app/api/files env.DB + env.KV, uploads to env.FILES; app/files/[...key] serves them\nlib/ambasada.ts the SDK client from the Worker's env (getCloudflareContext), process.env under `next dev`\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables; lib/bindings.ts reaches them\nsrc/realtime/ the live room (a Durable Object) and its React hook; worker.ts routes its WebSockets\nnext.config.ts, open-next.config.ts, wrangler.jsonc, worker.ts Next.js \u2192 one Worker (OpenNext, nodejs_compat)\nexamples/ copy in what you want: sign-in/ (the button), notes/, room/ (a live room page), agent/\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `next dev` on http://localhost:3000\n```\n\n`npm install` warns that `node-domexception` and `glob@10.3.3` are deprecated: dependencies of OpenNext's, harmless,\nand gone when it updates them.\n\nOpen http://localhost:3000: Hello, world. Edit `app/page.tsx` and it changes.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- Your pages in `app/`, with your own look: plain CSS, CSS modules or Tailwind. Want the Ambasada app's look?\n `npm i @ambasada/ui`, then `import \"@ambasada/ui\"` in `app/layout.tsx` (optional).\n- A new endpoint: `app/api/<name>/route.ts`. Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nStay inside what runs on a Worker: pages, server components, route handlers, server actions and `fetch`.\nNot: the `next/image` default loader, ISR / `revalidate`, `fs`, `child_process` or native packages, a custom\nserver. `npm run preview` builds the Worker (`opennextjs-cloudflare build`) and runs it in workerd, as in production.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID and pick the app\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx opennextjs-cloudflare build && npx opennextjs-cloudflare deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the routes are in `app/api/auth/`; `examples/sign-in/` is the button. On the server,\n`currentUser()` (`lib/ambasada.ts`) gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `app/api/notes/route.ts` (a page for it: `examples/notes/`) |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `app/api/files/route.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts` (wired in `worker.ts`), hook `src/realtime/use-room.ts`, a page in `examples/room/` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nUnder `next dev`, `env.DB`, `env.KV` and `env.FILES` work (`lib/bindings.ts`); live rooms need workerd: `npm run preview`.\n\n## Examples\n\nNot part of the app until you copy them in; each README says where.\n\n| | |\n|---|---|\n| `examples/sign-in/` | \"Sign in with Ambasada\", or the person's name, avatar and \"Sign out\" |\n| `examples/notes/` | a form and a list on `/api/notes` (`env.DB` + `env.KV`) |\n| `examples/room/` | `/room`, a live room between tabs (`env.ROOMS`) |\n| `examples/agent/` | an agent with the Agents SDK (`npm i agents` and four lines to wire it) |\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, app/app/ (layout, page) and app/app/in-app.tsx, in the\nAmbasada app's look (`@ambasada/ui`, added to `package.json`; the rest of the site keeps its own), a placeholder\n`public/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. With `--entry /` your own\npages are the mini app; inside the Ambasada app the in-app code pads the page clear of the app's bar (on the web,\nnothing changes). The ambasada-product skill: reference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "A new Ambasada app",\n // Where `ambasada dev` serves the app on this laptop. Hosted, it is https://my-app.ambasada.dev: `ambasada deploy`\n // (or `ambasada link https://\u2026` when you host elsewhere) keeps that in .ambasada/project.json, never here.\n product: { url: "http://localhost:3000" },\n});\n', "app/api/ambasada/webhook/route.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../../../../lib/notifications.ts";\n\nexport async function POST(req: Request): Promise<Response> {\n const app = ambasada();\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/auth/callback/route.ts": '// GET /api/auth/callback: back from Ambasada ID. Sets an HttpOnly session cookie and redirects to returnTo.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport async function GET(req: Request): Promise<Response> {\n try {\n return await ambasada().auth.callback(req);\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/auth/login/route.ts": '// GET /api/auth/login?returnTo=/: Sign in with Ambasada (Ambasada ID, OAuth + PKCE). Zero setup: on\n// https://<slug>.ambasada.dev the deploy injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport async function GET(req: Request): Promise<Response> {\n try {\n return await ambasada().auth.login(req);\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/auth/logout/route.ts": '// GET /api/auth/logout: clears the session cookie (signs out of this product only).\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport function GET(req: Request): Response {\n return ambasada().auth.logout(req);\n}\n', "app/api/auth/me/route.ts": '// GET /api/auth/me \u2192 { user } (null when signed out), for client components. On the server, use currentUser() (lib/ambasada.ts).\nimport { ambasada, env } from "../../../../lib/ambasada.ts";\n\nexport async function GET(req: Request): Promise<Response> {\n const { auth } = ambasada();\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env("AMBASADA_AUTH_ISSUER")?.startsWith("http://") ? env("AMBASADA_AUTH_ISSUER") : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n}\n', "app/api/auth/shell/route.ts": '// POST /api/auth/shell { token }: silent sign-in inside the Ambasada app (docs/MINI-APPS.md \xA75). The page gets the\n// token from the shell (`auth.signInSilently()` in @ambasada/miniapp does both steps); this verifies it and sets the same\n// session cookie as /api/auth/callback, so currentUser() works the same either way.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport async function POST(req: Request): Promise<Response> {\n try {\n return await ambasada().auth.shell(req);\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/files/route.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by app/files/[...key]/route.ts.\nimport { currentUser } from "../../../lib/ambasada.ts";\nimport { bindings } from "../../../lib/bindings.ts";\nimport { upload } from "../../../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport async function POST(req: Request): Promise<Response> {\n const user = await currentUser();\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(bindings().FILES, await req.arrayBuffer(), { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "app/api/notes/route.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport { bindings } from "../../../lib/bindings.ts";\nimport { all, run } from "../../../lib/db.ts";\nimport { cached, remove } from "../../../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport async function GET(): Promise<Response> {\n const { DB, KV } = bindings();\n const latest = await cached(KV, "notes:latest", 60, () => all<Note>(DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n\nexport async function POST(req: Request): Promise<Response> {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { DB, KV } = bindings();\n const { lastRowId } = await run(DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n}\n', "app/files/[...key]/route.ts": '// GET /files/<key>: a file from env.FILES (lib/files.ts), public, with ETag, ranges and a cache header.\nimport { bindings } from "../../../lib/bindings.ts";\nimport { serveFile } from "../../../lib/files.ts";\n\nexport const GET = (req: Request) => serveFile(bindings().FILES, req);\nexport const HEAD = GET;\n', "app/health/route.ts": "// GET /health: `ambasada link` and the deploy smoke test check that the product answers.\nexport function GET(): Response {\n return Response.json({ ok: true });\n}\n", "app/layout.tsx": 'import type { Metadata } from "next";\nimport type { ReactNode } from "react";\n\nexport const metadata: Metadata = {\n title: "My App",\n description: "A new Ambasada app",\n};\n\nexport default function RootLayout({ children }: { children: ReactNode }) {\n return (\n <html lang="en">\n <body>{children}</body>\n </html>\n );\n}\n', "app/page.tsx": "export default function Home() {\n return (\n <main>\n <h1>Hello, world</h1>\n <p>\n Edit <code>app/page.tsx</code> to start.\n </p>\n </main>\n );\n}\n", "app/privacy/page.tsx": `// /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
17
5
  // (docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes.
18
6
  import Link from "next/link";
19
7
 
@@ -21,101 +9,36 @@ export const metadata = { title: "Privacy \xB7 My App" };
21
9
 
22
10
  export default function Privacy() {
23
11
  return (
24
- <>
12
+ <main>
25
13
  <h1>Privacy</h1>
26
- <p className="lead">What My App keeps about you, and why.</p>
14
+ <p>What My App keeps about you, and why.</p>
27
15
  <ul>
28
16
  <li>When you sign in with Ambasada: your Ambasada ID, name and avatar, in a cookie on this site only. Signing out deletes it.</li>
29
- <li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when your order is ready. Turn them off in the app and we forget it.</li>
30
- <li>Notes you post are kept in the database of My App and shown to everyone who opens it; a file you upload is public at its link.</li>
17
+ <li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when something you wait for is ready. Turn them off in the app and we forget it.</li>
31
18
  </ul>
32
19
  <p>
33
20
  <Link href="/">\u2190 back</Link>
34
21
  </p>
35
- </>
22
+ </main>
36
23
  );
37
24
  }
38
- `, "app/(site)/room/live-room.tsx": '"use client";\nimport { useState } from "react";\nimport { useRoom } from "../../../src/realtime/use-room.ts";\n\nexport function LiveRoom({ id }: { id: string }) {\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <>\n <h1>Live room</h1>\n <p className="ui-label">{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n {status === "closed" && <p className="hint">Under `next dev` rooms don&apos;t connect: try `npm run preview`.</p>}\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button className="ui-button">Send</button>\n </form>\n </>\n );\n}\n', "app/(site)/room/page.tsx": '// /room: a live room (src/realtime/room.ts). Open it in two tabs and type; ?room=<id> picks the room.\n// The WebSocket is answered by worker.ts: deployed and under `npm run preview`, not under `next dev`.\nimport { LiveRoom } from "./live-room.tsx";\n\nexport default async function RoomPage({ searchParams }: { searchParams: Promise<{ room?: string }> }) {\n return <LiveRoom id={(await searchParams).room ?? "lobby"} />;\n}\n', "app/account.tsx": `import { ambasada, currentUser } from "../lib/ambasada.ts";
25
+ `, "async-hooks.d.ts": '// node:async_hooks as workerd has it (nodejs_compat): the part lib/errors.ts uses. For tsconfig.worker.json only\n// (Next.js has @types/node).\ndeclare module "node:async_hooks" {\n export class AsyncLocalStorage<T> {\n run<R>(store: T, callback: () => R): R;\n getStore(): T | undefined;\n }\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', "examples/notes/README.md": '# Notes: a database and a cache\n\nThe API is already in the app: `GET /api/notes` reads the latest rows from `env.DB` (the `notes` table,\n`db/migrations/0001_init.sql`), cached a minute in `env.KV`; `POST /api/notes { text }` adds one\n(`app/api/notes/route.ts`). `notes.tsx` is a form and a list that use it.\n\n1. Copy `notes.tsx` to `app/notes.tsx`.\n2. In `app/page.tsx`: `import { Notes } from "./notes.tsx";` and `<Notes />` in the page.\n\nNotes are public: anyone who opens the app sees them and can add one. Key per-person data by the signed-in user\'s id\ninstead (`examples/sign-in/`). Not using notes? Delete `app/api/notes/` and add a migration that drops the table.\n', "examples/notes/notes.tsx": '// Copy to app/notes.tsx (README.md). Unstyled: give it your own look.\n"use client";\n\nimport { useEffect, useState } from "react";\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** The notes example: GET and POST /api/notes (app/api/notes/route.ts). */\nexport function Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" /> <button type="submit">Add</button>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "examples/room/README.md": '# A live room\n\nThe room is already in the app: a Durable Object (`src/realtime/room.ts`, `env.ROOMS`) that keeps the last messages\nand who is online, at `/api/rooms/<id>/ws` (routed in `worker.ts`), and a React hook for it\n(`src/realtime/use-room.ts`). This folder is a page that uses them.\n\n1. Copy this folder to `app/room/` (`page.tsx`, `live-room.tsx`).\n2. `npm run preview` (rooms need workerd; `next dev` can\'t answer the WebSocket), open `/room` in two tabs and type.\n\nIn your own component: `const { messages, online, send, status } = useRoom("lobby", { name: user.name })`.\n', "examples/room/live-room.tsx": '"use client";\nimport { useState } from "react";\nimport { useRoom } from "../../src/realtime/use-room.ts";\n\nexport function LiveRoom({ id }: { id: string }) {\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <main>\n <h1>Live room</h1>\n <p>{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n {status === "closed" && <p>Under `next dev` rooms don&apos;t connect: try `npm run preview`.</p>}\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button>Send</button>\n </form>\n </main>\n );\n}\n', "examples/room/page.tsx": '// Copy this folder to app/room/ (README.md). /room: a live room (src/realtime/room.ts); ?room=<id> picks the room.\n// The WebSocket is answered by worker.ts: deployed and under `npm run preview`, not under `next dev`.\nimport { LiveRoom } from "./live-room.tsx";\n\nexport default async function RoomPage({ searchParams }: { searchParams: Promise<{ room?: string }> }) {\n return <LiveRoom id={(await searchParams).room ?? "lobby"} />;\n}\n', "examples/sign-in/README.md": '# Sign in with Ambasada\n\nThe routes are already in the app: `/api/auth/login`, `callback`, `logout`, `me` and `shell` (`app/api/auth/`). On\nthe server, `currentUser()` from `lib/ambasada.ts` gives `{ id, name, avatar_url, builder, pass }` or null. What is\nmissing is a button; `account.tsx` is one: "Sign in with Ambasada", or the person\'s name, avatar and "Sign out".\n\n1. Copy `account.tsx` to `app/account.tsx`.\n2. Render it where you want it, `app/page.tsx` for example (it reads the session cookie, so the page renders per request):\n ```tsx\n import { Account } from "./account.tsx";\n\n export const dynamic = "force-dynamic";\n // in the page: <Account />\n ```\n\nBack to a page after signing in: `/api/auth/login?returnTo=/orders`. In a client component, `fetch("/api/auth/me")`\ngives `{ user }`. Locally the mock signs you in as "Fake Visitor".\n\nInside the Ambasada app the app\'s account is the one signed in, so "Sign out" there lasts until the next page: hide\nit in the app (`isInAmbasada()` from `@ambasada/miniapp`, or the `in-ambasada` class on `<html>`).\n', "examples/sign-in/account.tsx": `// Copy to app/account.tsx (README.md). Unstyled: give it your own look.
26
+ import { ambasada, currentUser } from "../lib/ambasada.ts";
39
27
 
40
- /** The header: "Sign in with Ambasada", or the person's name and avatar once signed in (app/api/auth/*). */
28
+ /** "Sign in with Ambasada", or the person's name and avatar once signed in (app/api/auth/*). */
41
29
  export async function Account() {
42
30
  const user = await currentUser();
43
- return (
44
- <header className="topbar">
45
- <a className="brand" href="/">
46
- My App
47
- </a>
48
- {user ? (
49
- <span className="account">
50
- {/* eslint-disable-next-line @next/next/no-img-element */}
51
- {user.avatar_url && <img src={user.avatar_url} alt="" />}
52
- <span>{user.name ?? "Signed in"}</span>
53
- <a href="/api/auth/logout">Sign out</a>
54
- </span>
55
- ) : ambasada().auth.configured ? (
56
- <a className="ui-button secondary signin" href="/api/auth/login">
57
- Sign in with Ambasada
58
- </a>
59
- ) : null}
60
- </header>
61
- );
62
- }
63
- `, "app/api/ambasada/webhook/route.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../../../../lib/notifications.ts";\n\nexport async function POST(req: Request): Promise<Response> {\n const app = ambasada();\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/auth/callback/route.ts": '// GET /api/auth/callback: back from Ambasada ID. Sets an HttpOnly session cookie and redirects to returnTo.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport async function GET(req: Request): Promise<Response> {\n try {\n return await ambasada().auth.callback(req);\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/auth/login/route.ts": '// GET /api/auth/login?returnTo=/: Sign in with Ambasada (Ambasada ID, OAuth + PKCE). Zero setup: on\n// https://<slug>.ambasada.dev the deploy injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport async function GET(req: Request): Promise<Response> {\n try {\n return await ambasada().auth.login(req);\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/auth/logout/route.ts": '// GET /api/auth/logout: clears the session cookie (signs out of this product only).\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport function GET(req: Request): Response {\n return ambasada().auth.logout(req);\n}\n', "app/api/auth/me/route.ts": '// GET /api/auth/me \u2192 { user } (null when signed out), for client components. On the server, use currentUser() (lib/ambasada.ts).\nimport { ambasada, env } from "../../../../lib/ambasada.ts";\n\nexport async function GET(req: Request): Promise<Response> {\n const { auth } = ambasada();\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env("AMBASADA_AUTH_ISSUER")?.startsWith("http://") ? env("AMBASADA_AUTH_ISSUER") : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n}\n', "app/api/auth/shell/route.ts": '// POST /api/auth/shell { token }: silent sign-in inside the Ambasada app (docs/MINI-APPS.md \xA75). The page gets the\n// token from the shell (`auth.signInSilently()` in @ambasada/miniapp does both steps); this verifies it and sets the same\n// session cookie as /api/auth/callback, so currentUser() works the same either way.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada } from "../../../../lib/ambasada.ts";\n\nexport async function POST(req: Request): Promise<Response> {\n try {\n return await ambasada().auth.shell(req);\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "app/api/files/route.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by app/files/[...key]/route.ts.\nimport { currentUser } from "../../../lib/ambasada.ts";\nimport { bindings } from "../../../lib/bindings.ts";\nimport { upload } from "../../../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport async function POST(req: Request): Promise<Response> {\n const user = await currentUser();\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(bindings().FILES, await req.arrayBuffer(), { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "app/api/notes/route.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport { bindings } from "../../../lib/bindings.ts";\nimport { all, run } from "../../../lib/db.ts";\nimport { cached, remove } from "../../../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport async function GET(): Promise<Response> {\n const { DB, KV } = bindings();\n const latest = await cached(KV, "notes:latest", 60, () => all<Note>(DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n\nexport async function POST(req: Request): Promise<Response> {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { DB, KV } = bindings();\n const { lastRowId } = await run(DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n}\n', "app/files/[...key]/route.ts": '// GET /files/<key>: a file from env.FILES (lib/files.ts), public, with ETag, ranges and a cache header.\nimport { bindings } from "../../../lib/bindings.ts";\nimport { serveFile } from "../../../lib/files.ts";\n\nexport const GET = (req: Request) => serveFile(bindings().FILES, req);\nexport const HEAD = GET;\n', "app/globals.css": `/*
64
- * This product's own styles, on top of @ambasada/ui: the Ambasada tokens (var(--color-\u2026), var(--space-\u2026))
65
- * and components (ui-card, ui-button, ui-label, \u2026), loaded first by app/layout.tsx (next), src/main.tsx (spa)
66
- * or a <link> in web/index.html (worker). Use the tokens rather than raw values. The same file in all three.
67
- */
68
- /* The fallbacks are for a page that loads only this file. */
69
- :root {
70
- font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
71
- color: var(--color-ink, #0c0a09);
72
- background: var(--color-bg, #fafaf9);
73
- line-height: 1.5;
74
- }
75
- * { box-sizing: border-box; }
76
- body { margin: 0; }
77
- /* Inside the Ambasada app, @ambasada/miniapp sets the safe-area and host-bar variables; on the web they are 0. */
78
- .page { max-width: var(--page-max, 30rem); margin: 0 auto; padding: calc(var(--space-10) + var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)) var(--space-5) calc(var(--space-10) + var(--space-6) + var(--ambasada-safe-area-bottom, 0px)); }
79
- .topbar + .page { padding-top: calc(var(--space-10) + var(--space-4)); }
80
- .eyebrow { margin: 0; }
81
- .eyebrow::before { content: "// "; }
82
- h1 { margin: var(--space-2) 0 var(--space-3); font-size: clamp(2.2rem, 7vw, 3.4rem); font-weight: var(--weight-semibold); line-height: 1.05; letter-spacing: -0.03em; }
83
- .lead { margin: 0 0 var(--space-8); font-size: 1.125rem; color: var(--color-ink-2); }
84
- textarea, select { font: inherit; font-size: var(--text-base); color: inherit; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface); }
85
- textarea:focus, select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-soft); }
86
- textarea { resize: vertical; min-height: 7rem; }
87
- .row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; }
88
- label { display: flex; gap: var(--space-2); align-items: center; color: var(--color-ink-2); }
89
- .small, footer { color: var(--color-ink-3); font-size: var(--text-sm); }
90
- footer { margin-top: calc(var(--space-10) + var(--space-2)); }
91
- code { font-family: var(--font-mono); font-size: 0.9em; background: var(--color-surface-2); padding: 0.1em 0.35em; border-radius: var(--radius-xs); }
92
- blockquote { margin: var(--space-6) 0; padding: var(--space-4) var(--space-5); background: var(--color-surface); border-left: 4px solid var(--color-accent); border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: 1.125rem; }
93
- a { color: var(--color-accent-text); }
94
- .topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); max-width: var(--page-max, 30rem); margin: 0 auto; padding: calc(var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)) var(--space-5) 0; }
95
- .brand { font-weight: var(--weight-bold); color: var(--color-ink); text-decoration: none; }
96
- .account { display: flex; align-items: center; gap: var(--space-2); font-weight: var(--weight-semibold); }
97
- .account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
98
- .account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
99
- [hidden] { display: none !important; }
100
- `, "app/health/route.ts": "// GET /health: `ambasada link` and the deploy smoke test check that the product answers.\nexport function GET(): Response {\n return Response.json({ ok: true });\n}\n", "app/layout.tsx": `import type { Metadata } from "next";
101
- import type { ReactNode } from "react";
102
- // The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this app's own styles.
103
- import "@ambasada/ui";
104
- import "./globals.css";
105
-
106
- export const metadata: Metadata = {
107
- title: "My App",
108
- description: "Notes, a live room and sign-in",
109
- };
110
-
111
- export default function RootLayout({ children }: { children: ReactNode }) {
112
- return (
113
- <html lang="en">
114
- <body>{children}</body>
115
- </html>
116
- );
31
+ if (user) {
32
+ return (
33
+ <p>
34
+ {/* eslint-disable-next-line @next/next/no-img-element */}
35
+ {user.avatar_url && <img src={user.avatar_url} alt="" width={28} height={28} />} {user.name ?? "Signed in"} \xB7 <a href="/api/auth/logout">Sign out</a>
36
+ </p>
37
+ );
38
+ }
39
+ return ambasada().auth.configured ? <a href="/api/auth/login">Sign in with Ambasada</a> : null;
117
40
  }
118
- `, "app/notes.tsx": '"use client";\n\nimport { useEffect, useState } from "react";\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** The notes example: GET and POST /api/notes (app/api/notes/route.ts). */\nexport function Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "async-hooks.d.ts": '// node:async_hooks as workerd has it (nodejs_compat): the part lib/errors.ts uses. For tsconfig.worker.json only\n// (Next.js has @types/node).\ndeclare module "node:async_hooks" {\n export class AsyncLocalStorage<T> {\n run<R>(store: T, callback: () => R): R;\n getStore(): T | undefined;\n }\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\n.next/\n.open-next/\n.wrangler/\nnext-env.d.ts\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": 'import { createAmbasada, type Ambasada, type AmbasadaUser } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { headers } from "next/headers";\n\n/**\n * The Worker\'s env (secrets injected at deploy, `.env` locally). Under `next dev` there is no\n * Cloudflare context: the SDK then reads process.env, which `ambasada dev` points at the mock.\n */\nfunction cloudflareEnv(): Record<string, unknown> | undefined {\n try {\n return getCloudflareContext().env as unknown as Record<string, unknown>;\n } catch {\n return undefined;\n }\n}\n\n/** A variable from the Worker\'s env, else process.env. */\nexport function env(name: string): string | undefined {\n const value = cloudflareEnv()?.[name] ?? process.env[name];\n return typeof value === "string" && value ? value : undefined;\n}\n\n/**\n * This app\'s own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request\'s. Not `req.url` alone: a call the platform forwards (an\n * agent\'s purchase, a webhook) reaches Next.js without the Host it builds `req.url` from (`https://undefined`).\n */\nexport function appOrigin(req?: Request): string {\n return env("AMBASADA_APP_ORIGIN") ?? (req ? new URL(req.url).origin : "http://localhost:3000");\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API\'s signing keys. */\nexport function ambasada(): Ambasada {\n return (client ??= createAmbasada({ env: cloudflareEnv() }));\n}\n\n/** Who is signed in with Ambasada (app/api/auth/*), in server components and route handlers; null when signed out. */\nexport async function currentUser(): Promise<AmbasadaUser | null> {\n return ambasada().auth.user({ headers: await headers() });\n}\n', "lib/bindings.ts": `import { getCloudflareContext } from "@opennextjs/cloudflare";
41
+ `, gitignore: "node_modules/\n.next/\n.open-next/\n.wrangler/\nnext-env.d.ts\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": 'import { createAmbasada, type Ambasada, type AmbasadaUser } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { headers } from "next/headers";\n\n/**\n * The Worker\'s env (secrets injected at deploy, `.env` locally). Under `next dev` there is no\n * Cloudflare context: the SDK then reads process.env, which `ambasada dev` points at the mock.\n */\nfunction cloudflareEnv(): Record<string, unknown> | undefined {\n try {\n return getCloudflareContext().env as unknown as Record<string, unknown>;\n } catch {\n return undefined;\n }\n}\n\n/** A variable from the Worker\'s env, else process.env. */\nexport function env(name: string): string | undefined {\n const value = cloudflareEnv()?.[name] ?? process.env[name];\n return typeof value === "string" && value ? value : undefined;\n}\n\n/**\n * This app\'s own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request\'s. Not `req.url` alone: a call the platform forwards (an\n * agent\'s purchase, a webhook) reaches Next.js without the Host it builds `req.url` from (`https://undefined`).\n */\nexport function appOrigin(req?: Request): string {\n return env("AMBASADA_APP_ORIGIN") ?? (req ? new URL(req.url).origin : "http://localhost:3000");\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API\'s signing keys. */\nexport function ambasada(): Ambasada {\n return (client ??= createAmbasada({ env: cloudflareEnv() }));\n}\n\n/** Who is signed in with Ambasada (app/api/auth/*), in server components and route handlers; null when signed out. */\nexport async function currentUser(): Promise<AmbasadaUser | null> {\n return ambasada().auth.user({ headers: await headers() });\n}\n', "lib/bindings.ts": `import { getCloudflareContext } from "@opennextjs/cloudflare";
119
42
  import type { Database } from "./db.ts";
120
43
  import type { Bucket } from "./files.ts";
121
44
  import type { KeyValue } from "./kv.ts";
@@ -132,7 +55,7 @@ export interface Bindings {
132
55
  }
133
56
 
134
57
  export const bindings = (): Bindings => getCloudflareContext().env as unknown as Bindings;
135
- `, "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/errors.ts": '/**\n * What `ambasada logs` shows for a request that failed: the request\'s console.error / console.warn lines, an error\n * with its message and where it was thrown. On a 5xx answer they ride in the `x-ambasada-error` header, which\n * ambasada.dev records for `ambasada logs` and the Developer app and takes off the response (visitors never see it).\n * A request that succeeded with a degraded answer says so with `village.warn(\u2026)` (a `[village]` line): it rides in\n * `x-ambasada-warn` and shows in `ambasada logs` as a warning. Locally (`ambasada dev`) the headers are harmless; the\n * lines are in your terminal anyway.\n */\nimport { AsyncLocalStorage } from "node:async_hooks";\n\nexport const ERROR_HEADER = "x-ambasada-error";\n/** What a 5xx carries at most: the last lines of the request\'s log. */\nconst MAX_CHARS = 1500;\n\nconst requestLog = new AsyncLocalStorage<string[]>();\n\n/**\n * An error as one line plus where it was thrown: its stack down to the first frame in a file (`ambasada logs` maps\n * that one to your source), past built-ins like `at JSON.parse (<anonymous>)`; anything else as text.\n */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const frames = (value.stack ?? "").split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n return [`${value.name}: ${value.message}`, ...frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3)).map((line) => ` ${line}`)].join("\\n");\n}\n\nfor (const level of ["error", "warn"] as const) {\n const print = console[level].bind(console);\n console[level] = (...args: unknown[]) => {\n requestLog.getStore()?.push(args.map(describeError).join(" "));\n print(...args);\n };\n}\n\n/** A request that did not fail but logged `[village]` lines (`village.warn`: a degraded answer) carries them here. */\nexport const WARN_HEADER = "x-ambasada-warn";\nconst WARN_PREFIX = "[village]";\n\n/**\n * Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`, and\n * any other answer its `[village]` lines (ambasada.dev keeps at most 50 an hour).\n */\nexport async function withRequestLog(handle: () => Promise<Response>): Promise<Response> {\n const lines: string[] = [];\n const res = await requestLog.run(lines, handle);\n if (res.status < 500) {\n const warnings = lines.filter((line) => line.startsWith(WARN_PREFIX));\n if (!warnings.length || res.status === 101) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(WARN_HEADER, encodeURIComponent(warnings.join("\\n").slice(-MAX_CHARS)));\n return answer;\n }\n if (!lines.length) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(ERROR_HEADER, encodeURIComponent(lines.join("\\n").slice(-MAX_CHARS)));\n return answer;\n}\n\n/** The catch-all\'s answer: the error logged (message and stack, so `ambasada logs` has it), a 500 without details. */\nexport function serverError(error: unknown): Response {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/jobs.ts": '/**\n * Start work that outlives the request (src/jobs/jobs.ts, a Durable Object per job) and ask how it is going.\n *\n * await startJob("report:42", "send-report", { id: 42 }) // answers at once; the same name again is a no-op\n * await jobState("report:42") // { status: "working" | "done" | \u2026, result, error, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n *\n * Durable Objects don\'t run under `next dev`: there a job runs in the dev server\'s process, with the same retries\n * and an env like the object\'s: wrangler\'s local DB, KV and FILES, and the vars (`npm run preview` and the deployed app\n * use the Durable Object).\n */\nimport { createAmbasada } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\ninterface JobStub {\n start(kind: string, input: unknown, options?: JobOptions): Promise<JobState>;\n state(): Promise<JobState | null>;\n}\n\nconst stub = (name: string): JobStub => (getCloudflareContext().env as unknown as { JOBS: { getByName(name: string): JobStub } }).JOBS.getByName(name);\nconst local = process.env.NODE_ENV === "development";\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return local ? startLocal(name, kind, input, options) : stub(name).start(kind, input, { ...options, name });\n}\n\nexport async function jobState(name: string): Promise<JobState | null> {\n return local ? (devJobs.get(name) ?? null) : stub(name).state();\n}\n\n// \u2500\u2500 under `next dev` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * One registry for the dev server\'s process: Next.js loads this file once for route handlers and once more for server\n * components, so a module-level map gave /thanks its own copy, and it ran the webhook\'s job a second time (village dry\n * run 5 #5). On globalThis, both see the same job.\n */\nconst devJobs: Map<string, JobState> = ((globalThis as { __ambasadaDevJobs?: Map<string, JobState> }).__ambasadaDevJobs ??= new Map());\n\nasync function startLocal(name: string, kind: string, input: unknown, options: JobOptions): Promise<JobState> {\n // Loaded here, not at the top: the handlers import what imports this file. From here on nothing awaits until the\n // job is in devJobs, so the same name twice starts it once.\n const { HANDLERS } = await import("../src/jobs/handlers.ts");\n const current = devJobs.get(name);\n if (current) return current;\n const handler = HANDLERS[kind];\n if (!handler) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const job: JobState = { kind, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: Date.now(), deadline: deadlineMs(options.deadline), failures: [], updated_at: Date.now() };\n const retry = { lag_retries: 0, unauthorized_since: null as number | null };\n devJobs.set(name, job);\n let env: JobEnv = {};\n const attempt = async (): Promise<void> => {\n Object.assign(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null, updated_at: Date.now() });\n try {\n env = await devEnv();\n Object.assign(job, { status: "done", result: (await handler.run(input, env, { attempt: job.attempts })) ?? null, error: null, updated_at: Date.now() });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n // What the Durable Object reports to `ambasada logs` (the mock\'s, under `ambasada dev`).\n await createAmbasada({ env: env as Record<string, unknown> })\n .logs.report({ level: "error", message: `${kind} attempt ${job.attempts}: ${error}`, stack, job: name })\n .catch(() => {});\n const plan = nextRetry({ ...job, ...retry }, e, now);\n if (isUnauthorized(e)) retry.unauthorized_since ??= now;\n if (plan.lag) retry.lag_retries++;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n Object.assign(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error, updated_at: Date.now() });\n await handler.failed?.(input, env, error).catch(() => {});\n return;\n }\n Object.assign(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs, updated_at: Date.now() });\n await handler.retrying?.(input, env, { attempt: job.attempts, error, retryInMs }).catch(() => {});\n setTimeout(() => void attempt(), retryInMs);\n }\n };\n // What the Durable Object answers: the job as queued, its first attempt right after.\n const queued = { ...job };\n void attempt();\n return queued;\n}\n\n/**\n * What the Durable Object\'s env holds, under `next dev`: the bindings from wrangler\'s local copies (next.config.ts),\n * over the vars `ambasada dev` sets. Async mode: a retry runs from a timer, outside any request.\n */\nasync function devEnv(): Promise<JobEnv> {\n const { env } = await getCloudflareContext({ async: true });\n return { ...process.env, ...(env as unknown as JobEnv) };\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "next.config.ts": 'import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";\nimport type { NextConfig } from "next";\nimport { PHASE_DEVELOPMENT_SERVER } from "next/constants.js";\n\n// The product runs as one Cloudflare Worker (OpenNext). Stick to pages, server components, route\n// handlers, server actions and fetch: no next/image default loader, ISR, fs or native dependencies.\nconst nextConfig: NextConfig = {\n // `ambasada dev --tunnel` serves `next dev` at https://<slug>-dev.ambasada.dev: let it load dev assets and HMR from there.\n allowedDevOrigins: ["*.ambasada.dev"],\n};\n\nexport default function config(phase: string): NextConfig {\n // `next dev` reaches env.DB, env.KV and env.FILES (wrangler\'s local copies) through getCloudflareContext()\n // (lib/bindings.ts). Not in `next build`: the deployed Worker has its own, and wrangler\'s stand-in Worker for the\n // local copies doesn\'t export Room and Jobs (worker.ts does), so it warns they "will fail at runtime".\n if (phase === PHASE_DEVELOPMENT_SERVER) initOpenNextCloudflareForDev();\n return nextConfig;\n}\n', "open-next.config.ts": "import { defineCloudflareConfig } from \"@opennextjs/cloudflare\";\n\n// `opennextjs-cloudflare build` turns the Next.js app into one Worker (.open-next/worker.js + assets).\n// No source maps for Next's routes: OpenNext (1.20) bundles the server with esbuild and no `sourcemap` option, and\n// this config has none to set, so `ambasada logs` names a route's file (`\u2192 in app/api/boom/route.ts`), not its line.\n// lib/, src/jobs and worker.ts map to their lines.\nexport default defineCloudflareConfig();\n", "package.json": '{\n "name": "@ambasada/template-next",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Next.js as one Cloudflare Worker (OpenNext), with sign-in, notifications and storage.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "next build",\n "build:worker": "opennextjs-cloudflare build",\n "preview": "opennextjs-cloudflare build && wrangler dev --port 3000",\n "typecheck": "next typegen && tsc --noEmit && wrangler types node_modules/.worker-env.d.ts && tsc -p tsconfig.worker.json",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "@ambasada/ui": "workspace:*",\n "@opennextjs/cloudflare": "~1.20.15",\n "next": "~16.4.3",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/realtime/use-room.ts": '/**\n * `useRoom(id)`: a live room in a React component (src/realtime/room.ts on the server). Reconnects on its own.\n *\n * const { messages, online, send, status } = useRoom("lobby", { name: "Ana" });\n */\n"use client";\nimport { useCallback, useEffect, useRef, useState } from "react";\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\ntype RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport function useRoom(id: string, options: { name?: string } = {}) {\n const [messages, setMessages] = useState<RoomMessage[]>([]);\n const [online, setOnline] = useState(0);\n const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");\n const socket = useRef<WebSocket | null>(null);\n\n useEffect(() => {\n let stopped = false;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let tries = 0;\n const connect = () => {\n const url = new URL(`/api/rooms/${encodeURIComponent(id)}/ws`, location.href);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n if (options.name) url.searchParams.set("name", options.name);\n const ws = new WebSocket(url);\n socket.current = ws;\n setStatus("connecting");\n const timeout = setTimeout(() => ws.close(), 10_000); // no answer (e.g. `next dev`, which can\'t serve rooms): retry later\n ws.onopen = () => {\n clearTimeout(timeout);\n tries = 0;\n setStatus("open");\n };\n ws.onmessage = (e) => {\n const event = JSON.parse(String(e.data)) as RoomEvent;\n if (event.type === "history") setMessages(event.messages);\n else if (event.type === "message") setMessages((m) => [...m.slice(-49), event.message]);\n else setOnline(event.online);\n };\n ws.onclose = () => {\n clearTimeout(timeout);\n setStatus("closed");\n if (!stopped) retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); // back off; give up never\n };\n };\n connect();\n return () => {\n stopped = true;\n clearTimeout(retry);\n socket.current?.close();\n };\n }, [id, options.name]);\n\n const send = useCallback((text: string) => {\n if (socket.current?.readyState !== WebSocket.OPEN) return false;\n socket.current.send(JSON.stringify({ text }));\n return true;\n }, []);\n\n return { messages, online, status, send };\n}\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "lib": [\n "DOM",\n "DOM.Iterable",\n "ES2022"\n ],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "jsx": "react-jsx",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "esModuleInterop": true,\n "incremental": true,\n "plugins": [\n {\n "name": "next"\n }\n ],\n "allowJs": true\n },\n "include": [\n "next-env.d.ts",\n "**/*.ts",\n "**/*.tsx",\n ".next/types/**/*.ts",\n ".next/dev/types/**/*.ts"\n ],\n "exclude": [\n "node_modules",\n ".open-next",\n ".wrangler",\n "worker.ts",\n "async-hooks.d.ts",\n "src/realtime/room.ts",\n "src/jobs/jobs.ts",\n "examples"\n ]\n}\n', "tsconfig.worker.json": '{\n // worker.ts and the Durable Objects run in workerd, not in Next.js: their types come from\n // `wrangler types` (npm run typecheck writes them to node_modules/.worker-env.d.ts).\n "compilerOptions": {\n "target": "ES2022",\n "lib": ["ES2022"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "allowImportingTsExtensions": true,\n "types": []\n },\n "files": ["node_modules/.worker-env.d.ts", "async-hooks.d.ts", "worker.ts", "src/realtime/room.ts", "src/jobs/jobs.ts"]\n}\n', "worker.ts": '/**\n * The Worker\'s entry (wrangler.jsonc `main`): the live rooms\' WebSockets, which Next.js can\'t answer\n * (/api/rooms/<id>/ws \u2192 src/realtime/room.ts), then everything else to the Next.js app OpenNext built.\n * Durable Object classes are exported from here. A request that fails keeps what Next.js logged about it (the error\n * and its stack, your console.error lines) for `ambasada logs` (lib/errors.ts).\n */\n// @ts-ignore: .open-next/worker.js is written by `opennextjs-cloudflare build`\nimport { default as nextApp } from "./.open-next/worker.js";\nimport { withRequestLog } from "./lib/errors.ts";\n\nexport { Room } from "./src/realtime/room.ts";\nexport { Jobs } from "./src/jobs/jobs.ts";\n\nexport default {\n fetch(req, env, ctx) {\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(new URL(req.url).pathname);\n if (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n return withRequestLog(() => nextApp.fetch(req, env, ctx));\n },\n} satisfies ExportedHandler<Env>;\n', "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: worker.ts (live rooms, then the Next.js app built by OpenNext into .open-next/) plus static assets.\n "name": "my-app",\n "main": "worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": ".open-next/assets",\n "binding": "ASSETS"\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, spa: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers, and a notes example.\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look good and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)`; don't add\n Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the starter UI. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea: the main action\n > visible on a phone without scrolling. Write complete files, one code block per file, with the file path\n > above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design for people who decide in 10 seconds\n\nPeople open your app on a phone, often inside the Ambasada app, between two other things. They decide in\nseconds whether it is for them. Build for that.\n\n## One page, one action\n\n- **One page first.** What it is, and the one thing to do, fit on the first screen of a phone (375 \xD7 667).\n Extra pages only when the action needs them.\n- **One action.** One button that does the main thing. Add a second only after people use the first.\n- **Few words.** A headline of up to 8 words, one sentence under it, the button. Cut every adjective you can.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no cookie banner, no tour. Ask for sign-in when the app\n needs to remember someone (Sign in with Ambasada is one tap inside the Ambasada app).\n\n## The button\n\n- Label it with the action: **Make my poem**, not **Submit**.\n- Big (at least 48 px tall), full width on phones, one strong colour, nothing else in that colour.\n- If people must type something first, put that one field right above the button.\n- After the action, show the result, or "Working on it\u2026" and the result appearing when ready (a live room\n or polling). Never a blank page.\n\n## Look\n\n- The templates use `@ambasada/ui` (the Ambasada app\'s look: tokens, `ui-card`, `ui-button`); start there\n ([mini-app.md](mini-app.md)). Otherwise plain CSS: one font, two colours plus grey, generous spacing.\n- Large text: 18 px body on phones, a headline twice that. Light theme.\n- Works with one thumb: tap targets 48 px, no hover-only menus.\n- Loads fast: no video backgrounds, no 3 MB hero images, no animation libraries.\n\n## Words\n\n- Say what they get, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- Write in the language of your users; one is fine.\n\n## Before and after\n\n```\nBad: Welcome to PoemForge\u2122, the next-generation AI-powered creative writing platform\n [Features] [Pricing] [About] [Sign up] \u2026scroll\u2026 [Get started]\n\nGood: Your name as a folk poem\n Type a name. Get a 4-line Romanian folk poem in 10 seconds.\n [ Name: _________ ]\n [ Make my poem ]\n "Ana, floare de la munte, / \u2026" \u2190 one real example\n```\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID, check the code, pick the app, Allow\n```\n\nThe browser lists every app you are on, the folder's own pre-selected. A HackATon company **is** an app: pick it,\nnever make a second app for it at developer.ambasada.dev. The key the CLI gets is named \"CLI \xB7 <laptop>\", listed and\nrevocable in the app's settings. In a folder whose `id` is another app that was never deployed (a scaffold called\n`my-shop`, the company `theoffice`), `login` offers to make the folder that app; `npx ambasada bind theoffice` does it\nlater. No browser on this machine (SSH, CI): it prints the link and code (`--no-browser`), open it anywhere. If it\nasks for a key instead (an older API): HackATon \u2192 Team \u2192 Settings \u2192 New key, or developer.ambasada.dev \u2192 your app \u2192\nSettings \u2192 New key.\nEach app's key is kept under its id: with two apps on one laptop, log in once in each folder, and each folder uses\nits own. `deploy`, `db`/`kv`/`files`, `env`, `logs`, `tunnel` and the village's commands that change something refuse\na key of another app than the folder's `id` (\"this folder is X, you are logged in as Y\"); `--company Y` goes ahead.\n\nThen, every time you want the new version online:\n\n```sh\nnpx ambasada deploy\n```\n\nIt does, in order:\n\n1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);\n2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/.well-known/ambasada.json`\n (written from `ambasada.config.ts` first, when the mini app is on: [mini-app.md](mini-app.md));\n a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);\n3. **upload**: the URL is `https://<your-slug>.ambasada.dev`, with `AMBASADA_API_KEY`, `AMBASADA_API_URL`,\n Sign in with Ambasada and the app secret set for you, and the app's own database, KV namespace and bucket\n bound as `DB`, `KV`, `FILES` (created on the first deploy) plus the Durable Objects from `wrangler.jsonc`.\n The live URL is kept in `.ambasada/project.json` (`live_url`): `ambasada check` and the village's offerings use it.\n\nThe database's tables are not created by the deploy: run `npx ambasada db migrate` (it applies the new files\nin `db/migrations/`). No Cloudflare account needed. `--dry-run` builds and prints what it would upload (before the\nfirst deploy too). A deploy keeps the app's `AMBASADA_API_KEY`; `--rotate` makes a new one and says so (the old one\nworks 5 more minutes, so running jobs finish). With the village add-on, `--offerings` re-opens offerings paused live.\n\nYour own secrets (an API key for another service): `npx ambasada env set NAME value` (live within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names. A setting that isn't secret: `env set NAME value --plain`,\nand `env list` shows its value. `.env` (and `.env.local`, `.env.production`, `.dev.vars`) never\ngoes online: the deploy builds without them, so a value that is only there is missing on ambasada.dev; `env set` it.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nRecords the live URL in `.ambasada/project.json` (`live_url`; `product.url` stays your local dev server) and checks\nthat `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check # in the folder: the live app (https, no redirects, speed), or the mini-app rules\n```\n\nA request that answers 5xx shows with what your code logged for it: its `console.error` / `console.warn` lines and\nan error's message with the line it was thrown from (`lib/errors.ts` wraps the Worker's `fetch`; a route that throws\nanswers 500 through `serverError`). A request that succeeded shows only its lines that start with `[village]`\n(`village.warn(\"\u2026\")` writes one: say when an answer was degraded, e.g. a tool was rate-limited), as `warning 200\n/api/question \u2026`, at most 50 an hour, never counted as errors. Keep that wrapper if you rewrite the Worker's entry. Run `logs` in the folder you\ndeploy from: `deploy` keeps each version's source maps in `.ambasada/maps/`, and `logs` shows where in your source an\nerror was thrown (`\u2192 at boom (src/api/checks.ts:12:9)`). In a next route that line is the route's file\n(`\u2192 in app/api/boom/route.ts`): OpenNext's build of Next.js's server has no source map.\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\nnpx ambasada miniapp enable --entry /app --force # move it: --force removes what the old entry left (it lists them otherwise)\nnpx ambasada miniapp disable # off again: the config key, the manifest and the glue (pages at /app stay until --force)\n```\n\nWith the mini app off, `listing: { age_rating: "12+", kind: "product", tags: ["quiz"] }` in `ambasada.config.ts` keeps\nhow the app is listed; a mini app\'s own `age_rating`, `kind` and `tags` win, `enable` writes only the defaults it does\nnot set, and `disable` moves the mini app\'s into `listing`.\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app", age_rating: "4+", kind: "tool" }` | `ambasada.config.ts`; optional `support` (email or URL, else the app\'s `support`), `tags` (\u2264 5), `subtitle`, `description`, `icon`, `category`, `accent`, `scopes`, `visibility`. The hub refuses an app without `age_rating` (`4+`, `9+`, `12+`, `17+`) |\n| the in-app layout and page, no site header | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp` | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`. On a first deploy the webhook\'s secret takes a few\nminutes to arrive: the deploy says `checks: running (up to 5 min)`, and `npx ambasada check` shows the result.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe templates import `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`, `ui-row`,\n`ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`). Use those classes before writing CSS.\nReact: `import { Card, Button, Sheet } from "@ambasada/ui/react"`. Plain HTML: one\n`<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nLeave space for the app\'s bar: the page uses `--ambasada-safe-area-*` and `--ambasada-host-bar`.\nLight theme only; one red (primary) button per screen.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/(site)/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in on open\n\n**Installed apps with `identity` are signed in on open; never show a sign-in button inside the shell.**\nInstall is the consent (it grants what `miniapp.scopes` asks for; `identity` is the default when the app has the\n`/api/auth/*` routes). The in-app glue the CLI wrote (`in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) does it\nbefore the page shows: `auth.signInSilently()` from `@ambasada/miniapp` gets a short token from the app and POSTs it\nto `/api/auth/shell`, which sets the same session as "Sign in with Ambasada"; the page stays hidden meanwhile (at\nmost 5 s, under the app\'s splash). Keep that glue, and don\'t add your own sign-in step or screen in front of it.\nServer code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\n\n"Sign in with Ambasada" is for the web, and for the app opened without installing it: `signInSilently()` resolves\n`null` then (also outside the app, or when the hub isn\'t set up yet: `/api/auth/shell` answers 503) and the page\nshows its button. Render the button only when nobody is signed in once the glue is done, as the templates do.\n`ambasada check` warns when `identity` is declared but `/api/auth/shell` is missing.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor".\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html>, the Ambasada look (@ambasada/ui) and globals.css\n app/(site)/layout.tsx the site\'s header (app/account.tsx) around its pages; (site) is not in the URL\n app/(site)/page.tsx the landing page\n app/notes.tsx the notes example (a client component calling app/api/notes)\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/account.tsx the header: "Sign in with Ambasada", or the person\'s name and avatar\n app/(site)/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/(site)/room/ the live room demo\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe template\'s header already has the button. In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Your app\'s own URL (links in emails, notifications, answers): `appOrigin()` in `lib/ambasada.ts`, from\n `AMBASADA_APP_ORIGIN` (the deploy and `ambasada dev` set it), not `req.url`: a call the platform forwards to your\n app may come without its Host.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only. `ambasada deploy` builds without `.env*`: nothing in them reaches the Worker.\n- Anything named `NEXT_PUBLIC_*` (`VITE_*` in the spa) is copied into the browser. Never put a key there. From `.env`\n they work under `ambasada dev` only; a public value the live page needs goes in the code.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| work that takes longer than a request: a slow model call, a report, a delivery after a payment | a job (`env.JOBS`, `startJob`) | `waitUntil`, or the work inside a webhook |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Jobs: work that outlives a request\n\nA model call that thinks for a minute, a report, a batch of pushes: not in the request (30 s, and a webhook gets 10 s;\n`waitUntil` is cut at ~30 s). Every template has the `Jobs` Durable Object (`env.JOBS`, src/jobs/jobs.ts): one object\nper job, run from an alarm with its own limits, retried after 10 s, 30 s, 2 min, 10 min and 30 min when it throws.\n\n```ts\n// src/jobs/handlers.ts: what a kind of job does (safe to run twice)\nexport const HANDLERS: Record<string, JobHandler> = {\n "send-report": { async run(input, env) { const { id } = input as { id: number }; /* \u2026 */ return { sent: true }; } },\n};\n\n// anywhere on the server (lib/jobs.ts): answers at once; the same name again is a no-op\nawait startJob(env, `report:${id}`, "send-report", { id });\nawait jobState(env, `report:${id}`); // { status: "queued" | "working" | "retrying" | "done" | "failed", result, error, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "README.md": "# My App\n\nAn app on Ambasada, Vite + React on one Cloudflare Worker (template `spa`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/ the React app (drop a Lovable/Bolt export here); src/App.tsx has the header and the notes example\nworker/index.ts the Worker: /api/<name> \u2192 api/<name>.ts, everything else is the app\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker's env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/ the live room (a Durable Object) and its React hook\nvite.config.ts, wrangler.jsonc @cloudflare/vite-plugin: `vite` runs the app and the Worker together\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `vite` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `api/<name>.ts` (and a line in `worker/index.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID and pick the app\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx vite build && npx wrangler deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the header's button signs people in with their Ambasada ID; on the server,\n`ambasada(\u2026).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `api/notes.ts` |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `api/files.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts`, hook `src/realtime/use-room.ts`, demo at `/room` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nOptional: an agent with the Agents SDK in `examples/agent/` (its README: `npm i agents` and four lines to wire it).\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, src/in-app/ (the in-app page, rendered by src/main.tsx under the entry), a placeholder\n`public/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. The ambasada-product skill:\nreference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "Notes, a live room and sign-in",\n // Where `ambasada dev` serves the app on this laptop. Hosted, it is https://my-app.ambasada.dev: `ambasada deploy`\n // (or `ambasada link https://\u2026` when you host elsewhere) keeps that in .ambasada/project.json, never here.\n product: { url: "http://localhost:3000" },\n});\n', "api/ambasada-webhook.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../lib/notifications.ts";\n\nexport default async function appWebhook(req: Request, env: Env): Promise<Response> {\n const app = ambasada(env);\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/auth.ts": '// Sign in with Ambasada (Ambasada ID, OAuth + PKCE): zero setup. On https://<slug>.ambasada.dev the deploy\n// injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\n// GET /api/auth/login?returnTo=/ \u2192 the Ambasada ID consent screen\n// GET /api/auth/callback \u2192 back from it: sets an HttpOnly session cookie, redirects to returnTo\n// GET /api/auth/logout \u2192 clears it\n// GET /api/auth/me \u2192 { user } (null when signed out), for the browser\n// POST /api/auth/shell { token } \u2192 silent sign-in inside the Ambasada app: the same session, no consent screen\n// (the page calls `auth.signInSilently()` from @ambasada/miniapp)\n// On the server: `await ambasada(env).auth.user(req)` \u2192 { id, name, avatar_url, builder, pass, \u2026 } or null.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\n\nexport default async function auth(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n try {\n switch (new URL(req.url).pathname.replace(/\\/+$/, "")) {\n case "/api/auth/login":\n return await auth.login(req);\n case "/api/auth/callback":\n return await auth.callback(req);\n case "/api/auth/logout":\n return auth.logout(req);\n case "/api/auth/shell":\n return await auth.shell(req);\n case "/api/auth/me": {\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env.AMBASADA_AUTH_ISSUER?.startsWith("http://") ? env.AMBASADA_AUTH_ISSUER : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n }\n }\n return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/files.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by the /files/* route.\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { upload } from "../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport default async function files(req: Request, env: Env): Promise<Response> {\n if (req.method !== "POST") return Response.json({ error: { code: "INVALID_REQUEST", message: "POST the file" } }, { status: 405 });\n const user = await ambasada(env).auth.user(req);\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "api/notes.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport type { Env } from "../lib/ambasada.ts";\nimport { all, run } from "../lib/db.ts";\nimport { cached, remove } from "../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport default async function notes(req: Request, env: Env): Promise<Response> {\n if (req.method === "POST") {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { lastRowId } = await run(env.DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(env.KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n }\n const latest = await cached(env.KV, "notes:latest", 60, () => all<Note>(env.DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\ndist/\n.wrangler/\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "index.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="/src/main.tsx"></script>\n </body>\n</html>\n', "lib/ambasada.ts": "import { createAmbasada, type Ambasada } from \"@ambasada/sdk\";\nimport type { Jobs } from \"../src/jobs/jobs.ts\";\nimport type { Room } from \"../src/realtime/room.ts\";\n\n/** The Worker's env: `.env` locally (`ambasada dev` points it at the mock), secrets injected at deploy. */\nexport interface Env {\n ASSETS: Fetcher;\n /** The app's SQLite database (lib/db.ts), key-value (lib/kv.ts), files (lib/files.ts) and live rooms (src/realtime/room.ts). */\n DB: D1Database;\n KV: KVNamespace;\n FILES: R2Bucket;\n ROOMS: DurableObjectNamespace<Room>;\n /** Work that outlives a request (src/jobs/jobs.ts, lib/jobs.ts). */\n JOBS: DurableObjectNamespace<Jobs>;\n /** On ambasada.dev: a service binding to the API, which the SDK uses for its calls (set by `ambasada deploy`). */\n AMBASADA_API?: Fetcher;\n AMBASADA_API_KEY?: string;\n /** Set by `ambasada deploy` and `ambasada dev`; the SDK defaults to https://api.ambasada.dev. */\n AMBASADA_API_URL?: string;\n AMBASADA_APP_ID?: string;\n /** Sign in with Ambasada (api/auth.ts): set by `ambasada deploy` on ambasada.dev, and by `ambasada dev`. */\n AMBASADA_AUTH_CLIENT_ID?: string;\n AMBASADA_AUTH_CLIENT_SECRET?: string;\n AMBASADA_AUTH_ISSUER?: string;\n /** Mini-app notifications (api/ambasada-webhook.ts, lib/notifications.ts): set on ambasada.dev and by `ambasada dev`. */\n AMBASADA_APP_SECRET?: string;\n AMBASADA_HUB_URL?: string;\n /** This app's origin, set by `ambasada deploy` and `ambasada dev`: use `appOrigin(env, req)`. */\n AMBASADA_APP_ORIGIN?: string;\n}\n\n/**\n * This app's own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request's (a call the platform forwards may carry another host).\n */\nexport function appOrigin(env: Env, req: Request): string {\n return env.AMBASADA_APP_ORIGIN ?? new URL(req.url).origin;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API's signing keys. */\nexport function ambasada(env: Env): Ambasada {\n return (client ??= createAmbasada({ env: env as unknown as Record<string, unknown> }));\n}\n", "lib/async-hooks.d.ts": '// node:async_hooks as workerd has it (nodejs_compat): the part lib/errors.ts uses.\ndeclare module "node:async_hooks" {\n export class AsyncLocalStorage<T> {\n run<R>(store: T, callback: () => R): R;\n getStore(): T | undefined;\n }\n}\n', "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/errors.ts": '/**\n * What `ambasada logs` shows for a request that failed: the request\'s console.error / console.warn lines, an error\n * with its message and where it was thrown. On a 5xx answer they ride in the `x-ambasada-error` header, which\n * ambasada.dev records for `ambasada logs` and the Developer app and takes off the response (visitors never see it).\n * A request that succeeded with a degraded answer says so with `village.warn(\u2026)` (a `[village]` line): it rides in\n * `x-ambasada-warn` and shows in `ambasada logs` as a warning. Locally (`ambasada dev`) the headers are harmless; the\n * lines are in your terminal anyway.\n */\nimport { AsyncLocalStorage } from "node:async_hooks";\n\nexport const ERROR_HEADER = "x-ambasada-error";\n/** What a 5xx carries at most: the last lines of the request\'s log. */\nconst MAX_CHARS = 1500;\n\nconst requestLog = new AsyncLocalStorage<string[]>();\n\n/**\n * An error as one line plus where it was thrown: its stack down to the first frame in a file (`ambasada logs` maps\n * that one to your source), past built-ins like `at JSON.parse (<anonymous>)`; anything else as text.\n */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const frames = (value.stack ?? "").split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n return [`${value.name}: ${value.message}`, ...frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3)).map((line) => ` ${line}`)].join("\\n");\n}\n\nfor (const level of ["error", "warn"] as const) {\n const print = console[level].bind(console);\n console[level] = (...args: unknown[]) => {\n requestLog.getStore()?.push(args.map(describeError).join(" "));\n print(...args);\n };\n}\n\n/** A request that did not fail but logged `[village]` lines (`village.warn`: a degraded answer) carries them here. */\nexport const WARN_HEADER = "x-ambasada-warn";\nconst WARN_PREFIX = "[village]";\n\n/**\n * Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`, and\n * any other answer its `[village]` lines (ambasada.dev keeps at most 50 an hour).\n */\nexport async function withRequestLog(handle: () => Promise<Response>): Promise<Response> {\n const lines: string[] = [];\n const res = await requestLog.run(lines, handle);\n if (res.status < 500) {\n const warnings = lines.filter((line) => line.startsWith(WARN_PREFIX));\n if (!warnings.length || res.status === 101) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(WARN_HEADER, encodeURIComponent(warnings.join("\\n").slice(-MAX_CHARS)));\n return answer;\n }\n if (!lines.length) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(ERROR_HEADER, encodeURIComponent(lines.join("\\n").slice(-MAX_CHARS)));\n return answer;\n}\n\n/** The catch-all\'s answer: the error logged (message and stack, so `ambasada logs` has it), a 500 without details. */\nexport function serverError(error: unknown): Response {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/jobs.ts": '/**\n * Start work that outlives the request (src/jobs/jobs.ts, a Durable Object per job) and ask how it is going.\n *\n * await startJob(env, "report:42", "send-report", { id: 42 }) // answers at once; the same name again is a no-op\n * await jobState(env, "report:42") // { status: "working" | "done" | \u2026, result, error, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobOptions, JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(env: Env, name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input, { ...options, name })) as JobState;\n}\n\nexport async function jobState(env: Env, name: string): Promise<JobState | null> {\n return (await env.JOBS.getByName(name).state()) as JobState | null;\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-spa",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Vite + React (Lovable/Bolt exports) and api/*.ts handlers as one Cloudflare Worker.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "vite build",\n "preview": "vite build && vite preview --port 3000",\n "typecheck": "tsc -b",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "@ambasada/ui": "workspace:*",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/vite-plugin": "^1.67.0",\n "@cloudflare/workers-types": "^5.20261102.1",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "@vitejs/plugin-react": "^6.1.2",\n "typescript": "~5.9.3",\n "vite": "^8.3.5",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "public/privacy.html": `<!doctype html>
58
+ `, "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/errors.ts": '/**\n * What `ambasada logs` shows for a request that failed: the request\'s console.error / console.warn lines, an error\n * with its message and where it was thrown. On a 5xx answer they ride in the `x-ambasada-error` header, which\n * ambasada.dev records for `ambasada logs` and the Developer app and takes off the response (visitors never see it).\n * A request that succeeded with a degraded answer says so with `village.warn(\u2026)` (a `[village]` line): it rides in\n * `x-ambasada-warn` and shows in `ambasada logs` as a warning. Locally (`ambasada dev`) the headers are harmless; the\n * lines are in your terminal anyway.\n */\nimport { AsyncLocalStorage } from "node:async_hooks";\n\nexport const ERROR_HEADER = "x-ambasada-error";\n/** What a 5xx carries at most: the last lines of the request\'s log. */\nconst MAX_CHARS = 1500;\n\nconst requestLog = new AsyncLocalStorage<string[]>();\n\n/**\n * An error as one line plus where it was thrown: its stack down to the first frame in a file (`ambasada logs` maps\n * that one to your source), past built-ins like `at JSON.parse (<anonymous>)`; anything else as text.\n */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const frames = (value.stack ?? "").split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n return [`${value.name}: ${value.message}`, ...frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3)).map((line) => ` ${line}`)].join("\\n");\n}\n\nfor (const level of ["error", "warn"] as const) {\n const print = console[level].bind(console);\n console[level] = (...args: unknown[]) => {\n requestLog.getStore()?.push(args.map(describeError).join(" "));\n print(...args);\n };\n}\n\n/** A request that did not fail but logged `[village]` lines (`village.warn`: a degraded answer) carries them here. */\nexport const WARN_HEADER = "x-ambasada-warn";\nconst WARN_PREFIX = "[village]";\n\n/**\n * Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`, and\n * any other answer its `[village]` lines (ambasada.dev keeps at most 50 an hour).\n */\nexport async function withRequestLog(handle: () => Promise<Response>): Promise<Response> {\n const lines: string[] = [];\n const res = await requestLog.run(lines, handle);\n if (res.status < 500) {\n const warnings = lines.filter((line) => line.startsWith(WARN_PREFIX));\n if (!warnings.length || res.status === 101) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(WARN_HEADER, encodeURIComponent(warnings.join("\\n").slice(-MAX_CHARS)));\n return answer;\n }\n if (!lines.length) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(ERROR_HEADER, encodeURIComponent(lines.join("\\n").slice(-MAX_CHARS)));\n return answer;\n}\n\n/** The catch-all\'s answer: the error logged (message and stack, so `ambasada logs` has it), a 500 without details. */\nexport function serverError(error: unknown): Response {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/jobs.ts": '/**\n * Start work that outlives the request (src/jobs/jobs.ts, a Durable Object per job) and ask how it is going.\n *\n * await startJob("report:42", "send-report", { id: 42 }) // answers at once; the same name again is a no-op\n * await jobState("report:42") // { status: "working" | "done" | \u2026, result, error, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n *\n * Durable Objects don\'t run under `next dev`: there a job runs in the dev server\'s process, with the same retries\n * and an env like the object\'s: wrangler\'s local DB, KV and FILES, and the vars (`npm run preview` and the deployed app\n * use the Durable Object).\n */\nimport { createAmbasada } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\ninterface JobStub {\n start(kind: string, input: unknown, options?: JobOptions): Promise<JobState>;\n state(): Promise<JobState | null>;\n}\n\nconst stub = (name: string): JobStub => (getCloudflareContext().env as unknown as { JOBS: { getByName(name: string): JobStub } }).JOBS.getByName(name);\nconst local = process.env.NODE_ENV === "development";\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return local ? startLocal(name, kind, input, options) : stub(name).start(kind, input, { ...options, name });\n}\n\nexport async function jobState(name: string): Promise<JobState | null> {\n return local ? (devJobs.get(name) ?? null) : stub(name).state();\n}\n\n// \u2500\u2500 under `next dev` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * One registry for the dev server\'s process: Next.js loads this file once for route handlers and once more for server\n * components, so a module-level map gave /thanks its own copy, and it ran the webhook\'s job a second time (village dry\n * run 5 #5). On globalThis, both see the same job.\n */\nconst devJobs: Map<string, JobState> = ((globalThis as { __ambasadaDevJobs?: Map<string, JobState> }).__ambasadaDevJobs ??= new Map());\n\nasync function startLocal(name: string, kind: string, input: unknown, options: JobOptions): Promise<JobState> {\n // Loaded here, not at the top: the handlers import what imports this file. From here on nothing awaits until the\n // job is in devJobs, so the same name twice starts it once.\n const { HANDLERS } = await import("../src/jobs/handlers.ts");\n const current = devJobs.get(name);\n if (current) return current;\n const handler = HANDLERS[kind];\n if (!handler) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const job: JobState = { kind, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: Date.now(), deadline: deadlineMs(options.deadline), failures: [], updated_at: Date.now() };\n const retry = { lag_retries: 0, unauthorized_since: null as number | null };\n devJobs.set(name, job);\n let env: JobEnv = {};\n const attempt = async (): Promise<void> => {\n Object.assign(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null, updated_at: Date.now() });\n try {\n env = await devEnv();\n Object.assign(job, { status: "done", result: (await handler.run(input, env, { attempt: job.attempts })) ?? null, error: null, updated_at: Date.now() });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n // What the Durable Object reports to `ambasada logs` (the mock\'s, under `ambasada dev`).\n await createAmbasada({ env: env as Record<string, unknown> })\n .logs.report({ level: "error", message: `${kind} attempt ${job.attempts}: ${error}`, stack, job: name })\n .catch(() => {});\n const plan = nextRetry({ ...job, ...retry }, e, now);\n if (isUnauthorized(e)) retry.unauthorized_since ??= now;\n if (plan.lag) retry.lag_retries++;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n Object.assign(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error, updated_at: Date.now() });\n await handler.failed?.(input, env, error).catch(() => {});\n return;\n }\n Object.assign(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs, updated_at: Date.now() });\n await handler.retrying?.(input, env, { attempt: job.attempts, error, retryInMs }).catch(() => {});\n setTimeout(() => void attempt(), retryInMs);\n }\n };\n // What the Durable Object answers: the job as queued, its first attempt right after.\n const queued = { ...job };\n void attempt();\n return queued;\n}\n\n/**\n * What the Durable Object\'s env holds, under `next dev`: the bindings from wrangler\'s local copies (next.config.ts),\n * over the vars `ambasada dev` sets. Async mode: a retry runs from a timer, outside any request.\n */\nasync function devEnv(): Promise<JobEnv> {\n const { env } = await getCloudflareContext({ async: true });\n return { ...process.env, ...(env as unknown as JobEnv) };\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "next.config.ts": 'import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";\nimport type { NextConfig } from "next";\nimport { PHASE_DEVELOPMENT_SERVER } from "next/constants.js";\n\n// The product runs as one Cloudflare Worker (OpenNext). Stick to pages, server components, route\n// handlers, server actions and fetch: no next/image default loader, ISR, fs or native dependencies.\nconst nextConfig: NextConfig = {\n // `ambasada dev --tunnel` serves `next dev` at https://<slug>-dev.ambasada.dev: let it load dev assets and HMR from there.\n allowedDevOrigins: ["*.ambasada.dev"],\n};\n\nexport default function config(phase: string): NextConfig {\n // `next dev` reaches env.DB, env.KV and env.FILES (wrangler\'s local copies) through getCloudflareContext()\n // (lib/bindings.ts). Not in `next build`: the deployed Worker has its own, and wrangler\'s stand-in Worker for the\n // local copies doesn\'t export Room and Jobs (worker.ts does), so it warns they "will fail at runtime".\n if (phase === PHASE_DEVELOPMENT_SERVER) initOpenNextCloudflareForDev();\n return nextConfig;\n}\n', "open-next.config.ts": "import { defineCloudflareConfig } from \"@opennextjs/cloudflare\";\n\n// `opennextjs-cloudflare build` turns the Next.js app into one Worker (.open-next/worker.js + assets).\n// No source maps for Next's routes: OpenNext (1.20) bundles the server with esbuild and no `sourcemap` option, and\n// this config has none to set, so `ambasada logs` names a route's file (`\u2192 in app/api/boom/route.ts`), not its line.\n// lib/, src/jobs and worker.ts map to their lines.\nexport default defineCloudflareConfig();\n", "package.json": '{\n "name": "@ambasada/template-next",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Next.js as one Cloudflare Worker (OpenNext), with sign-in, notifications and storage.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "next build",\n "build:worker": "opennextjs-cloudflare build",\n "preview": "opennextjs-cloudflare build && wrangler dev --port 3000",\n "typecheck": "next typegen && tsc --noEmit && wrangler types node_modules/.worker-env.d.ts && tsc -p tsconfig.worker.json",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "@opennextjs/cloudflare": "~1.20.15",\n "next": "~16.4.3",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/realtime/use-room.ts": '/**\n * `useRoom(id)`: a live room in a React component (src/realtime/room.ts on the server). Reconnects on its own.\n *\n * const { messages, online, send, status } = useRoom("lobby", { name: "Ana" });\n */\n"use client";\nimport { useCallback, useEffect, useRef, useState } from "react";\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\ntype RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport function useRoom(id: string, options: { name?: string } = {}) {\n const [messages, setMessages] = useState<RoomMessage[]>([]);\n const [online, setOnline] = useState(0);\n const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");\n const socket = useRef<WebSocket | null>(null);\n\n useEffect(() => {\n let stopped = false;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let tries = 0;\n const connect = () => {\n const url = new URL(`/api/rooms/${encodeURIComponent(id)}/ws`, location.href);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n if (options.name) url.searchParams.set("name", options.name);\n const ws = new WebSocket(url);\n socket.current = ws;\n setStatus("connecting");\n const timeout = setTimeout(() => ws.close(), 10_000); // no answer (e.g. `next dev`, which can\'t serve rooms): retry later\n ws.onopen = () => {\n clearTimeout(timeout);\n tries = 0;\n setStatus("open");\n };\n ws.onmessage = (e) => {\n const event = JSON.parse(String(e.data)) as RoomEvent;\n if (event.type === "history") setMessages(event.messages);\n else if (event.type === "message") setMessages((m) => [...m.slice(-49), event.message]);\n else setOnline(event.online);\n };\n ws.onclose = () => {\n clearTimeout(timeout);\n setStatus("closed");\n if (!stopped) retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); // back off; give up never\n };\n };\n connect();\n return () => {\n stopped = true;\n clearTimeout(retry);\n socket.current?.close();\n };\n }, [id, options.name]);\n\n const send = useCallback((text: string) => {\n if (socket.current?.readyState !== WebSocket.OPEN) return false;\n socket.current.send(JSON.stringify({ text }));\n return true;\n }, []);\n\n return { messages, online, status, send };\n}\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "lib": [\n "DOM",\n "DOM.Iterable",\n "ES2022"\n ],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "jsx": "react-jsx",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "esModuleInterop": true,\n "incremental": true,\n "plugins": [\n {\n "name": "next"\n }\n ],\n "allowJs": true\n },\n "include": [\n "next-env.d.ts",\n "**/*.ts",\n "**/*.tsx",\n ".next/types/**/*.ts",\n ".next/dev/types/**/*.ts"\n ],\n "exclude": [\n "node_modules",\n ".open-next",\n ".wrangler",\n "worker.ts",\n "async-hooks.d.ts",\n "src/realtime/room.ts",\n "src/jobs/jobs.ts",\n "examples"\n ]\n}\n', "tsconfig.worker.json": '{\n // worker.ts and the Durable Objects run in workerd, not in Next.js: their types come from\n // `wrangler types` (npm run typecheck writes them to node_modules/.worker-env.d.ts).\n "compilerOptions": {\n "target": "ES2022",\n "lib": ["ES2022"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "allowImportingTsExtensions": true,\n "types": []\n },\n "files": ["node_modules/.worker-env.d.ts", "async-hooks.d.ts", "worker.ts", "src/realtime/room.ts", "src/jobs/jobs.ts"]\n}\n', "worker.ts": '/**\n * The Worker\'s entry (wrangler.jsonc `main`): the live rooms\' WebSockets, which Next.js can\'t answer\n * (/api/rooms/<id>/ws \u2192 src/realtime/room.ts), then everything else to the Next.js app OpenNext built.\n * Durable Object classes are exported from here. A request that fails keeps what Next.js logged about it (the error\n * and its stack, your console.error lines) for `ambasada logs` (lib/errors.ts).\n */\n// @ts-ignore: .open-next/worker.js is written by `opennextjs-cloudflare build`\nimport { default as nextApp } from "./.open-next/worker.js";\nimport { withRequestLog } from "./lib/errors.ts";\n\nexport { Room } from "./src/realtime/room.ts";\nexport { Jobs } from "./src/jobs/jobs.ts";\n\nexport default {\n fetch(req, env, ctx) {\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(new URL(req.url).pathname);\n if (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n return withRequestLog(() => nextApp.fetch(req, env, ctx));\n },\n} satisfies ExportedHandler<Env>;\n', "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: worker.ts (live rooms, then the Next.js app built by OpenNext into .open-next/) plus static assets.\n "name": "my-app",\n "main": "worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": ".open-next/assets",\n "binding": "ASSETS"\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, spa: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers.\n\nThe page is a **Hello, world**: no header, no styles, no layout (`app/page.tsx`, `src/App.tsx`, `web/index.html`).\nBuild the user's app there, in the user's design ([reference/design.md](reference/design.md)). The demos are in\n`examples/`, outside the app until copied in (each README says how):\n\n| Example | What |\n|---|---|\n| `examples/sign-in/` | the \"Sign in with Ambasada\" button, or the person's name, avatar and \"Sign out\" |\n| `examples/notes/` | a form and a list on `/api/notes` (rows in `env.DB`, cached in `env.KV`) |\n| `examples/room/` | a live room page (`env.ROOMS`, `/api/rooms/<id>/ws`) |\n| `examples/agent/` | an agent with the Agents SDK |\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look the way they want, on phones and laptops, and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)` (the button:\n `examples/sign-in/`); don't add Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key\n per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the Hello, world in `src/App.tsx`. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea, in this design: \u2026\n > The main action must be easy to find on a phone and on a laptop. Write complete files, one code block per\n > file, with the file path above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design: the user\'s, not ours\n\nThe scaffold has no look and no layout: a Hello, world and nothing else. There is no house style to follow. The app\nlooks the way the user wants it to look.\n\n## Whose design\n\n- **The user\'s, first.** A mockup, a screenshot, a site they like, a brand, a few words ("dark and calm", "like a\n newspaper"): follow it. Ask once if they gave nothing and care; otherwise pick a direction that fits the idea and\n keep it consistent.\n- **Any stack.** Plain CSS, CSS modules, Tailwind, a component kit: whatever the user or the code they brought uses.\n- **`@ambasada/ui` is optional.** It is the Ambasada app\'s look (tokens, `ui-card`, `ui-button`;\n [mini-app.md](mini-app.md#look-like-the-app-ambasadaui)). The mini app\'s in-app page uses it; use it on the site\n only if the user wants their app to look like the Ambasada app.\n\n## Every screen size\n\nPages are ordinary responsive web pages: they work on a phone and on a laptop alike. No fixed phone-width column,\nno desktop-only layout. Check both before saying "done" (a narrow window and a wide one). Tap targets big enough for\na thumb, no hover-only menus, a `<meta name="viewport" content="width=device-width, initial-scale=1">` (the templates\nhave it).\n\n## Clear beats clever\n\nTips, not rules; the user\'s design wins:\n\n- **Say what people get**, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- **One main action per page**, easy to find. Label its button with the action: **Make my poem**, not **Submit**.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no tour. Ask for sign-in when the app needs to remember someone\n (`examples/sign-in/` in the project).\n- **After an action**, show the result, or "Working on it\u2026" and the result appearing when ready (a live room or\n polling). Never a blank page.\n- **Loads fast**: no video backgrounds, no 3 MB hero images, no animation library for one fade.\n- Write in the language of the users; one is fine.\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID, check the code, pick the app, Allow\n```\n\nThe browser lists every app you are on, the folder's own pre-selected. A HackATon company **is** an app: pick it,\nnever make a second app for it at developer.ambasada.dev. The key the CLI gets is named \"CLI \xB7 <laptop>\", listed and\nrevocable in the app's settings. In a folder whose `id` is another app that was never deployed (a scaffold called\n`my-shop`, the company `theoffice`), `login` offers to make the folder that app; `npx ambasada bind theoffice` does it\nlater. No browser on this machine (SSH, CI): it prints the link and code (`--no-browser`), open it anywhere. If it\nasks for a key instead (an older API): HackATon \u2192 Team \u2192 Settings \u2192 New key, or developer.ambasada.dev \u2192 your app \u2192\nSettings \u2192 New key.\nEach app's key is kept under its id: with two apps on one laptop, log in once in each folder, and each folder uses\nits own. `deploy`, `db`/`kv`/`files`, `env`, `logs`, `tunnel` and the village's commands that change something refuse\na key of another app than the folder's `id` (\"this folder is X, you are logged in as Y\"); `--company Y` goes ahead.\n\nThen, every time you want the new version online:\n\n```sh\nnpx ambasada deploy\n```\n\nIt does, in order:\n\n1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);\n2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/.well-known/ambasada.json`\n (written from `ambasada.config.ts` first, when the mini app is on: [mini-app.md](mini-app.md));\n a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);\n3. **upload**: the URL is `https://<your-slug>.ambasada.dev`, with `AMBASADA_API_KEY`, `AMBASADA_API_URL`,\n Sign in with Ambasada and the app secret set for you, and the app's own database, KV namespace and bucket\n bound as `DB`, `KV`, `FILES` (created on the first deploy) plus the Durable Objects from `wrangler.jsonc`.\n The live URL is kept in `.ambasada/project.json` (`live_url`): `ambasada check` and the village's offerings use it.\n\nThe database's tables are not created by the deploy: run `npx ambasada db migrate` (it applies the new files\nin `db/migrations/`). No Cloudflare account needed. `--dry-run` builds and prints what it would upload (before the\nfirst deploy too). A deploy keeps the app's `AMBASADA_API_KEY`; `--rotate` makes a new one and says so (the old one\nworks 5 more minutes, so running jobs finish). With the village add-on, `--offerings` re-opens offerings paused live.\n\nYour own secrets (an API key for another service): `npx ambasada env set NAME value` (live within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names. A setting that isn't secret: `env set NAME value --plain`,\nand `env list` shows its value. `.env` (and `.env.local`, `.env.production`, `.dev.vars`) never\ngoes online: the deploy builds without them, so a value that is only there is missing on ambasada.dev; `env set` it.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nRecords the live URL in `.ambasada/project.json` (`live_url`; `product.url` stays your local dev server) and checks\nthat `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check # in the folder: the live app (https, no redirects, speed), or the mini-app rules\n```\n\nA request that answers 5xx shows with what your code logged for it: its `console.error` / `console.warn` lines and\nan error's message with the line it was thrown from (`lib/errors.ts` wraps the Worker's `fetch`; a route that throws\nanswers 500 through `serverError`). A request that succeeded shows only its lines that start with `[village]`\n(`village.warn(\"\u2026\")` writes one: say when an answer was degraded, e.g. a tool was rate-limited), as `warning 200\n/api/question \u2026`, at most 50 an hour, never counted as errors. Keep that wrapper if you rewrite the Worker's entry. Run `logs` in the folder you\ndeploy from: `deploy` keeps each version's source maps in `.ambasada/maps/`, and `logs` shows where in your source an\nerror was thrown (`\u2192 at boom (src/api/checks.ts:12:9)`). In a next route that line is the route's file\n(`\u2192 in app/api/boom/route.ts`): OpenNext's build of Next.js's server has no source map.\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\nnpx ambasada miniapp enable --entry /app --force # move it: --force removes what the old entry left (it lists them otherwise)\nnpx ambasada miniapp disable # off again: the config key, the manifest and the glue (pages at /app stay until --force)\n```\n\nWith the mini app off, `listing: { age_rating: "12+", kind: "product", tags: ["quiz"] }` in `ambasada.config.ts` keeps\nhow the app is listed; a mini app\'s own `age_rating`, `kind` and `tags` win, `enable` writes only the defaults it does\nnot set, and `disable` moves the mini app\'s into `listing`.\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app", age_rating: "4+", kind: "tool" }` | `ambasada.config.ts`; optional `support` (email or URL, else the app\'s `support`), `tags` (\u2264 5), `subtitle`, `description`, `icon`, `category`, `accent`, `scopes`, `visibility`. The hub refuses an app without `age_rating` (`4+`, `9+`, `12+`, `17+`) |\n| the in-app layout and page, in the Ambasada app\'s look (`@ambasada/ui`) | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp`, and `@ambasada/ui` for the in-app layout | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`. On a first deploy the webhook\'s secret takes a few\nminutes to arrive: the deploy says `checks: running (up to 5 min)`, and `npx ambasada check` shows the result.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe in-app layout `enable` adds imports `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`,\n`ui-row`, `ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`), for the mini app\'s pages only; the site\nkeeps its own look. On those pages, use the classes before writing CSS. React: `import { Card, Button, Sheet } from\n"@ambasada/ui/react"`. Plain HTML: one `<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nOptional everywhere: a mini app in the user\'s own design is fine (light theme; the checks refuse dark-only).\nThe app\'s bar and the safe areas are handled: `Page` (`ui-page`) keeps clear of them, and with `--entry /` the in-app\ncode (`app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) pads `<body>` by them inside the Ambasada app only\n(nothing on the web, nothing on a page that has `ui-page`). Don\'t add your own padding for the bar on top of it.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in on open\n\n**Installed apps with `identity` are signed in on open; never show a sign-in button inside the shell.**\nInstall is the consent (it grants what `miniapp.scopes` asks for; `identity` is the default when the app has the\n`/api/auth/*` routes). The in-app glue the CLI wrote (`in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) does it\nbefore the page shows: `auth.signInSilently()` from `@ambasada/miniapp` gets a short token from the app and POSTs it\nto `/api/auth/shell`, which sets the same session as "Sign in with Ambasada"; the page stays hidden meanwhile (at\nmost 5 s, under the app\'s splash). Keep that glue, and don\'t add your own sign-in step or screen in front of it.\nServer code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\n\n"Sign in with Ambasada" is for the web, and for the app opened without installing it: `signInSilently()` resolves\n`null` then (also outside the app, or when the hub isn\'t set up yet: `/api/auth/shell` answers 503) and the page\nshows its button. Render the button only when nobody is signed in once the glue is done, as the templates do.\n`ambasada check` warns when `identity` is declared but `/api/auth/shell` is missing.\n\n## The app\'s account is the one signed in\n\n**Inside the Ambasada app, the app\'s account is the one signed in.** The app\'s web view keeps cookies between opens,\nso it can still hold a session from an earlier "Sign in with Ambasada" as someone else. On every load the glue\ncompares the session\'s user with the app\'s (`auth.sessionMatches(user)`: the session\'s `id` against\n`context().user.id`, both the Ambasada ID user id, `user_\u2026`) and, when they differ, signs the app\'s account in over\nit: a shell token POSTed to `/api/auth/shell` replaces the session cookie, then one reload. It tries once per account\nin a web view session, so a session that did not stick can\'t loop. A session that matches is left alone, and on the\nweb nothing changes: whoever signed in stays.\n\n- **Don\'t remember who the person is across opens** (localStorage, a module variable, a cached `/api/auth/me`, a\n service worker). Read the session user on every request (`currentUser()` / `ambasada().auth.user(req)`) and key\n per-person data by its `id`.\n- **Your own sign-in code** instead of the CLI\'s glue: call `auth.signInSilently()` with its default `me`; it\n replaces another account\'s session by itself. Reload when the user it resolves is not the one the page rendered.\n Or do as the glue does: `upgrading.md` has the lines.\n- **"Sign out" inside the app** lasts until the next page: the glue signs the app\'s account back in (one reload,\n nothing to see). Hide the link there (`isInAmbasada()`, or the `in-ambasada` class `ready()` puts on `<html>`);\n to be someone else, the person changes account in the Ambasada app.\n- When the app gives no token (not installed, `identity` not granted), the session stays as it was.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor". To see the app\'s account take over, sign\nin on the web first with "Continue as Second Visitor" on the mock\'s consent page, then open the `?ambasada_shell=mock`\nURL: one reload and "Fake Visitor" is signed in.\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html> and <body>; no styles (add yours here)\n app/page.tsx the page: Hello, world\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV (a page for it: examples/notes/)\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n examples/ sign-in/, notes/, room/, agent/: copy in what you want (not built until then)\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe routes are in `app/api/auth/`; the button is `examples/sign-in/` (copy `account.tsx` to `app/`). In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind (the template has no\n styles: add the ones the user\'s design needs)\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Your app\'s own URL (links in emails, notifications, answers): `appOrigin()` in `lib/ambasada.ts`, from\n `AMBASADA_APP_ORIGIN` (the deploy and `ambasada dev` set it), not `req.url`: a call the platform forwards to your\n app may come without its Host.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only. `ambasada deploy` builds without `.env*`: nothing in them reaches the Worker.\n- Anything named `NEXT_PUBLIC_*` (`VITE_*` in the spa) is copied into the browser. Never put a key there. From `.env`\n they work under `ambasada dev` only; a public value the live page needs goes in the code.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| work that takes longer than a request: a slow model call, a report, a delivery after a payment | a job (`env.JOBS`, `startJob`) | `waitUntil`, or the work inside a webhook |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Jobs: work that outlives a request\n\nA model call that thinks for a minute, a report, a batch of pushes: not in the request (30 s, and a webhook gets 10 s;\n`waitUntil` is cut at ~30 s). Every template has the `Jobs` Durable Object (`env.JOBS`, src/jobs/jobs.ts): one object\nper job, run from an alarm with its own limits, retried after 10 s, 30 s, 2 min, 10 min and 30 min when it throws.\n\n```ts\n// src/jobs/handlers.ts: what a kind of job does (safe to run twice)\nexport const HANDLERS: Record<string, JobHandler> = {\n "send-report": { async run(input, env) { const { id } = input as { id: number }; /* \u2026 */ return { sent: true }; } },\n};\n\n// anywhere on the server (lib/jobs.ts): answers at once; the same name again is a no-op\nawait startJob(env, `report:${id}`, "send-report", { id });\nawait jobState(env, `report:${id}`); // { status: "queued" | "working" | "retrying" | "done" | "failed", result, error, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.19 \u2192 the next release: inside the Ambasada app, the app\'s account is the one signed in\n\nUp to 0.8.19 the mini app\'s glue signed the person in only when nobody was signed in. The Ambasada app\'s web view\nkeeps cookies, so a session from an earlier "Sign in with Ambasada" as someone else stayed, and the mini app opened as\nthat other person. Now a session whose user id is not `context().user.id` is replaced by the app\'s account\n(`mini-app.md`, "The app\'s account is the one signed in").\n\nDone by `upgrade`, when the glue is still the file a 0.8.12 to 0.8.19 CLI wrote (`app/in-app.tsx` or\n`app/<entry>/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`): the lines below, nothing else in the file. It needs\nthis release\'s `@ambasada/miniapp` (`auth.sessionMatches`), which `upgrade` sets in `package.json`.\n\nBy hand, for a coding agent, when `upgrade` says the glue "is left alone" (you changed it, or it is from 0.8.11 or\nolder and still calls `auth.signIn()`):\n\n- **next** (`in-app.tsx`) and **spa** (`src/in-app/shell.ts`): import `context` from `@ambasada/miniapp` too, then\n where the file decides whether to sign in (`if (me?.user) \u2026 else if (!sessionStorage.getItem(RELOADED) \u2026`):\n ```ts\n // Inside the app, the app\'s account is the one signed in: a session for someone else is replaced, once per account.\n const inApp = context().user?.id ?? "1";\n if (auth.sessionMatches(me?.user)) sessionStorage.removeItem(RELOADED);\n else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n ```\n In the spa\'s `shell.ts` the same test and guard go around its own body (`auth.sessionMatches(user)`,\n `sessionStorage.setItem(RELOADED, inApp)` before `onSignedIn()`), and a failed sign-in keeps the user `/api/auth/me`\n gave: `const signedIn = await auth.signInSilently<InAppUser>({ me: false }); user = signedIn ?? user;`.\n- **worker** (`web/in-app.js`, the hub\'s `miniapp.js` may be an older build, so it compares by hand):\n ```js\n const inApp = a?.context().user?.id ?? "1";\n if (me.user && (!a?.context().user || me.user.id === inApp)) sessionStorage.removeItem(RELOADED);\n else if (a && sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity(a)) && (await signInSilently(a))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n ```\n- **Glue from 0.8.11 or older** (it calls `auth.signIn()` after `ready()`): copy the glue file from a fresh\n `npm create ambasada@latest -- --miniapp` of the same template; spa: `src/in-app/InApp.tsx` with it, they changed\n together.\n- **Your own sign-in code**: the rule is the same. A session is the app\'s only when its user id equals\n `context().user.id`; otherwise get a shell token and POST it (`auth.signInSilently()` with its default `me` does\n both), and try at most once per account in a `sessionStorage` session.\n\nCheck it under `npx ambasada dev`: sign in on the web with "Continue as Second Visitor", open\n`/app?ambasada_shell=mock` (your entry): "Fake Visitor" is signed in after one reload.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\nNew apps start as a Hello, world (the header, notes and room pages, the Ambasada look and the village shop moved to\n`examples/` and `/example`). Nothing to do in an existing app: `upgrade` never touches your pages or styles. Keep\n`@ambasada/ui` in `package.json` while your pages import it.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "README.md": '# My App\n\nAn app on Ambasada, Vite + React on one Cloudflare Worker (template `spa`). The page is a Hello, world, with no look\nand no layout: build yours. Behind it: sign-in with Ambasada ID, a database, key-value, files and live rooms, ready\nto use; a mini app inside the Ambasada app if you want one.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/ the React app (drop a Lovable/Bolt export here); src/App.tsx is the page: Hello, world\nworker/index.ts the Worker: /api/<name> \u2192 api/<name>.ts, everything else is the app\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app\'s events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker\'s env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/ the live room (a Durable Object) and its React hook\nvite.config.ts, wrangler.jsonc @cloudflare/vite-plugin: `vite` runs the app and the Worker together\nexamples/ copy in what you want: sign-in/ (the button), notes/, room/ (a live room page), agent/\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `vite` on http://localhost:3000\n```\n\nOpen http://localhost:3000: Hello, world. Edit `src/App.tsx` and it changes.\nhttp://127.0.0.1:8788 is the mock\'s page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- Your pages in `src/`, with your own look (any CSS, Tailwind, a component kit). Want the Ambasada app\'s look?\n `npm i @ambasada/ui`, then `import "@ambasada/ui"` in `src/main.tsx` (optional).\n- A new endpoint: `api/<name>.ts` (and a line in `worker/index.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID and pick the app\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app\'s database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx vite build && npx wrangler deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the routes are in `api/auth.ts`; `examples/sign-in/` is the button. In the Worker,\n`ambasada(env).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null; the browser asks `/api/auth/me`.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `api/notes.ts` (a page for it: `examples/notes/`) |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `api/files.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts`, hook `src/realtime/use-room.ts`, a page in `examples/room/` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app\'s database online\nnpx ambasada db shell "SELECT * FROM notes" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\n## Examples\n\nNot part of the app until you copy them in; each README says where.\n\n| | |\n|---|---|\n| `examples/sign-in/` | "Sign in with Ambasada", or the person\'s name, avatar and "Sign out" |\n| `examples/notes/` | a form and a list on `/api/notes` (`env.DB` + `env.KV`) |\n| `examples/room/` | `/room`, a live room between tabs (`env.ROOMS`) |\n| `examples/agent/` | an agent with the Agents SDK (`npm i agents` and four lines to wire it) |\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: "/app" }` in `ambasada.config.ts`, src/in-app/ (the in-app page, rendered by src/main.tsx under the entry,\nin the Ambasada app\'s look: `@ambasada/ui`, added to `package.json`, loaded for that page only), a placeholder\n`public/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. With `--entry /` your own\npages are the mini app; inside the Ambasada app the in-app code pads the page clear of the app\'s bar (on the web,\nnothing changes). The ambasada-product skill: reference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n', "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "A new Ambasada app",\n // Where `ambasada dev` serves the app on this laptop. Hosted, it is https://my-app.ambasada.dev: `ambasada deploy`\n // (or `ambasada link https://\u2026` when you host elsewhere) keeps that in .ambasada/project.json, never here.\n product: { url: "http://localhost:3000" },\n});\n', "api/ambasada-webhook.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../lib/notifications.ts";\n\nexport default async function appWebhook(req: Request, env: Env): Promise<Response> {\n const app = ambasada(env);\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/auth.ts": '// Sign in with Ambasada (Ambasada ID, OAuth + PKCE): zero setup. On https://<slug>.ambasada.dev the deploy\n// injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\n// GET /api/auth/login?returnTo=/ \u2192 the Ambasada ID consent screen\n// GET /api/auth/callback \u2192 back from it: sets an HttpOnly session cookie, redirects to returnTo\n// GET /api/auth/logout \u2192 clears it\n// GET /api/auth/me \u2192 { user } (null when signed out), for the browser\n// POST /api/auth/shell { token } \u2192 silent sign-in inside the Ambasada app: the same session, no consent screen\n// (the page calls `auth.signInSilently()` from @ambasada/miniapp)\n// On the server: `await ambasada(env).auth.user(req)` \u2192 { id, name, avatar_url, builder, pass, \u2026 } or null.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\n\nexport default async function auth(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n try {\n switch (new URL(req.url).pathname.replace(/\\/+$/, "")) {\n case "/api/auth/login":\n return await auth.login(req);\n case "/api/auth/callback":\n return await auth.callback(req);\n case "/api/auth/logout":\n return auth.logout(req);\n case "/api/auth/shell":\n return await auth.shell(req);\n case "/api/auth/me": {\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env.AMBASADA_AUTH_ISSUER?.startsWith("http://") ? env.AMBASADA_AUTH_ISSUER : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n }\n }\n return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/files.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by the /files/* route.\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { upload } from "../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport default async function files(req: Request, env: Env): Promise<Response> {\n if (req.method !== "POST") return Response.json({ error: { code: "INVALID_REQUEST", message: "POST the file" } }, { status: 405 });\n const user = await ambasada(env).auth.user(req);\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "api/notes.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport type { Env } from "../lib/ambasada.ts";\nimport { all, run } from "../lib/db.ts";\nimport { cached, remove } from "../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport default async function notes(req: Request, env: Env): Promise<Response> {\n if (req.method === "POST") {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { lastRowId } = await run(env.DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(env.KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n }\n const latest = await cached(env.KV, "notes:latest", 60, () => all<Note>(env.DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', "examples/notes/Notes.tsx": '// Copy to src/Notes.tsx (README.md). Unstyled: give it your own look.\nimport { useEffect, useState } from "react";\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** The notes example: GET and POST /api/notes (api/notes.ts). */\nexport function Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" /> <button type="submit">Add</button>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "examples/notes/README.md": '# Notes: a database and a cache\n\nThe API is already in the app: `GET /api/notes` reads the latest rows from `env.DB` (the `notes` table,\n`db/migrations/0001_init.sql`), cached a minute in `env.KV`; `POST /api/notes { text }` adds one (`api/notes.ts`).\n`Notes.tsx` is a form and a list that use it.\n\n1. Copy `Notes.tsx` to `src/Notes.tsx`.\n2. In `src/App.tsx`: `import { Notes } from "./Notes.tsx";` and `<Notes />` in the page.\n\nNotes are public: anyone who opens the app sees them and can add one. Key per-person data by the signed-in user\'s id\ninstead (`examples/sign-in/`). Not using notes? Take `notes` out of `API` in `worker/index.ts`, delete `api/notes.ts`\nand add a migration that drops the table.\n', "examples/room/README.md": '# A live room\n\nThe room is already in the app: a Durable Object (`src/realtime/room.ts`, `env.ROOMS`) that keeps the last messages\nand who is online, at `/api/rooms/<id>/ws` (routed in `worker/index.ts`), and a React hook for it\n(`src/realtime/use-room.ts`). `RoomDemo.tsx` is a page that uses them.\n\n1. Copy `RoomDemo.tsx` to `src/RoomDemo.tsx`.\n2. In `src/App.tsx`: `import { RoomDemo } from "./RoomDemo.tsx";` and, first thing in `App()`,\n `if (location.pathname === "/room") return <RoomDemo />;`\n3. `npm run dev`, open `/room` in two tabs and type.\n\nIn your own component: `const { messages, online, send, status } = useRoom("lobby", { name: user.name })`.\n', "examples/room/RoomDemo.tsx": '// Copy to src/RoomDemo.tsx (README.md). Unstyled: give it your own look.\nimport { useState } from "react";\nimport { useRoom } from "./realtime/use-room.ts";\n\n/** /room: a live room (src/realtime/room.ts). Open it in two tabs and type; ?room=<id> picks the room. */\nexport function RoomDemo() {\n const id = new URLSearchParams(location.search).get("room") ?? "lobby";\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <main>\n <h1>Live room</h1>\n <p>{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button>Send</button>\n </form>\n </main>\n );\n}\n', "examples/sign-in/Account.tsx": '// Copy to src/Account.tsx (README.md). Unstyled: give it your own look.\nimport { useEffect, useState } from "react";\n\n/** What GET /api/auth/me returns (api/auth.ts): the person signed in with Ambasada, or null. */\ntype User = { id: string; name: string | null; avatar_url: string | null; builder: boolean; pass: string | null };\n\ntype Me = { user: User | null; configured: boolean };\n\n/** "Sign in with Ambasada", or the person\'s name and avatar once signed in. */\nexport function Account() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n }, []);\n if (me?.user) {\n return (\n <p>\n {me.user.avatar_url && <img src={me.user.avatar_url} alt="" width={28} height={28} />} {me.user.name ?? "Signed in"} \xB7 <a href="/api/auth/logout">Sign out</a>\n </p>\n );\n }\n return me?.configured ? <a href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>Sign in with Ambasada</a> : null;\n}\n', "examples/sign-in/README.md": '# Sign in with Ambasada\n\nThe routes are already in the app: `/api/auth/login`, `callback`, `logout`, `me` and `shell` (`api/auth.ts`). In the\nWorker, `await ambasada(env).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null; in the browser,\n`fetch("/api/auth/me")` gives `{ user }`. What is missing is a button; `Account.tsx` is one: "Sign in with Ambasada",\nor the person\'s name, avatar and "Sign out".\n\n1. Copy `Account.tsx` to `src/Account.tsx`.\n2. In `src/App.tsx`: `import { Account } from "./Account.tsx";` and `<Account />` where you want it.\n\nBack to a page after signing in: `/api/auth/login?returnTo=/orders`. Locally the mock signs you in as "Fake Visitor".\n\nInside the Ambasada app the app\'s account is the one signed in, so "Sign out" there lasts until the next page: hide\nit in the app (`isInAmbasada()` from `@ambasada/miniapp`, or the `in-ambasada` class on `<html>`).\n', gitignore: "node_modules/\ndist/\n.wrangler/\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "index.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="/src/main.tsx"></script>\n </body>\n</html>\n', "lib/ambasada.ts": "import { createAmbasada, type Ambasada } from \"@ambasada/sdk\";\nimport type { Jobs } from \"../src/jobs/jobs.ts\";\nimport type { Room } from \"../src/realtime/room.ts\";\n\n/** The Worker's env: `.env` locally (`ambasada dev` points it at the mock), secrets injected at deploy. */\nexport interface Env {\n ASSETS: Fetcher;\n /** The app's SQLite database (lib/db.ts), key-value (lib/kv.ts), files (lib/files.ts) and live rooms (src/realtime/room.ts). */\n DB: D1Database;\n KV: KVNamespace;\n FILES: R2Bucket;\n ROOMS: DurableObjectNamespace<Room>;\n /** Work that outlives a request (src/jobs/jobs.ts, lib/jobs.ts). */\n JOBS: DurableObjectNamespace<Jobs>;\n /** On ambasada.dev: a service binding to the API, which the SDK uses for its calls (set by `ambasada deploy`). */\n AMBASADA_API?: Fetcher;\n AMBASADA_API_KEY?: string;\n /** Set by `ambasada deploy` and `ambasada dev`; the SDK defaults to https://api.ambasada.dev. */\n AMBASADA_API_URL?: string;\n AMBASADA_APP_ID?: string;\n /** Sign in with Ambasada (api/auth.ts): set by `ambasada deploy` on ambasada.dev, and by `ambasada dev`. */\n AMBASADA_AUTH_CLIENT_ID?: string;\n AMBASADA_AUTH_CLIENT_SECRET?: string;\n AMBASADA_AUTH_ISSUER?: string;\n /** Mini-app notifications (api/ambasada-webhook.ts, lib/notifications.ts): set on ambasada.dev and by `ambasada dev`. */\n AMBASADA_APP_SECRET?: string;\n AMBASADA_HUB_URL?: string;\n /** This app's origin, set by `ambasada deploy` and `ambasada dev`: use `appOrigin(env, req)`. */\n AMBASADA_APP_ORIGIN?: string;\n}\n\n/**\n * This app's own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request's (a call the platform forwards may carry another host).\n */\nexport function appOrigin(env: Env, req: Request): string {\n return env.AMBASADA_APP_ORIGIN ?? new URL(req.url).origin;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API's signing keys. */\nexport function ambasada(env: Env): Ambasada {\n return (client ??= createAmbasada({ env: env as unknown as Record<string, unknown> }));\n}\n", "lib/async-hooks.d.ts": '// node:async_hooks as workerd has it (nodejs_compat): the part lib/errors.ts uses.\ndeclare module "node:async_hooks" {\n export class AsyncLocalStorage<T> {\n run<R>(store: T, callback: () => R): R;\n getStore(): T | undefined;\n }\n}\n', "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/errors.ts": '/**\n * What `ambasada logs` shows for a request that failed: the request\'s console.error / console.warn lines, an error\n * with its message and where it was thrown. On a 5xx answer they ride in the `x-ambasada-error` header, which\n * ambasada.dev records for `ambasada logs` and the Developer app and takes off the response (visitors never see it).\n * A request that succeeded with a degraded answer says so with `village.warn(\u2026)` (a `[village]` line): it rides in\n * `x-ambasada-warn` and shows in `ambasada logs` as a warning. Locally (`ambasada dev`) the headers are harmless; the\n * lines are in your terminal anyway.\n */\nimport { AsyncLocalStorage } from "node:async_hooks";\n\nexport const ERROR_HEADER = "x-ambasada-error";\n/** What a 5xx carries at most: the last lines of the request\'s log. */\nconst MAX_CHARS = 1500;\n\nconst requestLog = new AsyncLocalStorage<string[]>();\n\n/**\n * An error as one line plus where it was thrown: its stack down to the first frame in a file (`ambasada logs` maps\n * that one to your source), past built-ins like `at JSON.parse (<anonymous>)`; anything else as text.\n */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const frames = (value.stack ?? "").split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n return [`${value.name}: ${value.message}`, ...frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3)).map((line) => ` ${line}`)].join("\\n");\n}\n\nfor (const level of ["error", "warn"] as const) {\n const print = console[level].bind(console);\n console[level] = (...args: unknown[]) => {\n requestLog.getStore()?.push(args.map(describeError).join(" "));\n print(...args);\n };\n}\n\n/** A request that did not fail but logged `[village]` lines (`village.warn`: a degraded answer) carries them here. */\nexport const WARN_HEADER = "x-ambasada-warn";\nconst WARN_PREFIX = "[village]";\n\n/**\n * Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`, and\n * any other answer its `[village]` lines (ambasada.dev keeps at most 50 an hour).\n */\nexport async function withRequestLog(handle: () => Promise<Response>): Promise<Response> {\n const lines: string[] = [];\n const res = await requestLog.run(lines, handle);\n if (res.status < 500) {\n const warnings = lines.filter((line) => line.startsWith(WARN_PREFIX));\n if (!warnings.length || res.status === 101) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(WARN_HEADER, encodeURIComponent(warnings.join("\\n").slice(-MAX_CHARS)));\n return answer;\n }\n if (!lines.length) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(ERROR_HEADER, encodeURIComponent(lines.join("\\n").slice(-MAX_CHARS)));\n return answer;\n}\n\n/** The catch-all\'s answer: the error logged (message and stack, so `ambasada logs` has it), a 500 without details. */\nexport function serverError(error: unknown): Response {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/jobs.ts": '/**\n * Start work that outlives the request (src/jobs/jobs.ts, a Durable Object per job) and ask how it is going.\n *\n * await startJob(env, "report:42", "send-report", { id: 42 }) // answers at once; the same name again is a no-op\n * await jobState(env, "report:42") // { status: "working" | "done" | \u2026, result, error, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobOptions, JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(env: Env, name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input, { ...options, name })) as JobState;\n}\n\nexport async function jobState(env: Env, name: string): Promise<JobState | null> {\n return (await env.JOBS.getByName(name).state()) as JobState | null;\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-spa",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Vite + React (Lovable/Bolt exports) and api/*.ts handlers as one Cloudflare Worker.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "vite build",\n "preview": "vite build && vite preview --port 3000",\n "typecheck": "tsc -b",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/vite-plugin": "^1.67.0",\n "@cloudflare/workers-types": "^5.20261102.1",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "@vitejs/plugin-react": "^6.1.2",\n "typescript": "~5.9.3",\n "vite": "^8.3.5",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "public/privacy.html": `<!doctype html>
136
59
  <!-- /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
137
60
  (docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes. -->
138
61
  <html lang="en">
@@ -140,120 +63,20 @@ export const bindings = (): Bindings => getCloudflareContext().env as unknown as
140
63
  <meta charset="utf-8" />
141
64
  <meta name="viewport" content="width=device-width, initial-scale=1" />
142
65
  <title>Privacy \xB7 My App</title>
143
- <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />
144
66
  </head>
145
67
  <body>
146
- <main class="ui-page">
68
+ <main>
147
69
  <h1>Privacy</h1>
148
70
  <p>What My App keeps about you, and why.</p>
149
71
  <ul>
150
72
  <li>When you sign in with Ambasada: your Ambasada ID, name and avatar, in a cookie on this site only. Signing out deletes it.</li>
151
- <li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when your order is ready. Turn them off in the app and we forget it.</li>
152
- <li>Notes you post are kept in the database of My App and shown to everyone who opens it; a file you upload is public at its link.</li>
73
+ <li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when something you wait for is ready. Turn them off in the app and we forget it.</li>
153
74
  </ul>
154
75
  <p><a href="/">\u2190 back</a></p>
155
76
  </main>
156
77
  </body>
157
78
  </html>
158
- `, "src/App.tsx": 'import { useEffect, useState } from "react";\nimport { RoomDemo } from "./realtime/room-demo.tsx";\n\n/** Two pages: the landing page and /room (a live room). */\nexport function App() {\n return (\n <>\n <Header />\n <main className="page">{location.pathname === "/room" ? <RoomDemo /> : <Home />}</main>\n </>\n );\n}\n\n/** What GET /api/auth/me returns (api/auth.ts): the person signed in with Ambasada, or null. */\ntype User = { id: string; name: string | null; avatar_url: string | null; builder: boolean; pass: string | null };\n\ntype Me = { user: User | null; configured: boolean; dev_issuer?: string };\n\n/** "Sign in with Ambasada", or the person\'s name and avatar once signed in. */\nfunction Header() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n }, []);\n return (\n <header className="topbar">\n <a className="brand" href="/">\n My App\n </a>\n {me?.user ? (\n <span className="account">\n {me.user.avatar_url && <img src={me.user.avatar_url} alt="" />}\n <span>{me.user.name ?? "Signed in"}</span>\n <a href="/api/auth/logout">Sign out</a>\n </span>\n ) : me?.configured ? (\n <a className="ui-button secondary signin" href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>\n Sign in with Ambasada\n </a>\n ) : null}\n </header>\n );\n}\n\nfunction Home() {\n return (\n <>\n <p className="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p className="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>\n <Notes />\n <p>\n <a href="/room">Open the live room</a> in two tabs and type.\n </p>\n </>\n );\n}\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. */\nfunction Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "src/env.d.ts": '/// <reference types="vite/client" />\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/main.tsx": `import { StrictMode } from "react";
159
- import { createRoot } from "react-dom/client";
160
- import { App } from "./App.tsx";
161
- // The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this product's own styles.
162
- import "@ambasada/ui";
163
- import "./style.css";
164
-
165
- createRoot(document.getElementById("root")!).render(
166
- <StrictMode>
167
- <App />
168
- </StrictMode>,
169
- );
170
- `, "src/realtime/room-demo.tsx": 'import { useState } from "react";\nimport { useRoom } from "./use-room.ts";\n\n/** /room: a live room (src/realtime/room.ts). Open it in two tabs and type; ?room=<id> picks the room. */\nexport function RoomDemo() {\n const id = new URLSearchParams(location.search).get("room") ?? "lobby";\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <>\n <h1>Live room</h1>\n <p className="ui-label">{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button className="ui-button">Send</button>\n </form>\n </>\n );\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/realtime/use-room.ts": '/**\n * `useRoom(id)`: a live room in a React component (src/realtime/room.ts on the server). Reconnects on its own.\n *\n * const { messages, online, send, status } = useRoom("lobby", { name: "Ana" });\n */\n"use client";\nimport { useCallback, useEffect, useRef, useState } from "react";\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\ntype RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport function useRoom(id: string, options: { name?: string } = {}) {\n const [messages, setMessages] = useState<RoomMessage[]>([]);\n const [online, setOnline] = useState(0);\n const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");\n const socket = useRef<WebSocket | null>(null);\n\n useEffect(() => {\n let stopped = false;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let tries = 0;\n const connect = () => {\n const url = new URL(`/api/rooms/${encodeURIComponent(id)}/ws`, location.href);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n if (options.name) url.searchParams.set("name", options.name);\n const ws = new WebSocket(url);\n socket.current = ws;\n setStatus("connecting");\n const timeout = setTimeout(() => ws.close(), 10_000); // no answer (e.g. `next dev`, which can\'t serve rooms): retry later\n ws.onopen = () => {\n clearTimeout(timeout);\n tries = 0;\n setStatus("open");\n };\n ws.onmessage = (e) => {\n const event = JSON.parse(String(e.data)) as RoomEvent;\n if (event.type === "history") setMessages(event.messages);\n else if (event.type === "message") setMessages((m) => [...m.slice(-49), event.message]);\n else setOnline(event.online);\n };\n ws.onclose = () => {\n clearTimeout(timeout);\n setStatus("closed");\n if (!stopped) retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); // back off; give up never\n };\n };\n connect();\n return () => {\n stopped = true;\n clearTimeout(retry);\n socket.current?.close();\n };\n }, [id, options.name]);\n\n const send = useCallback((text: string) => {\n if (socket.current?.readyState !== WebSocket.OPEN) return false;\n socket.current.send(JSON.stringify({ text }));\n return true;\n }, []);\n\n return { messages, online, status, send };\n}\n', "src/style.css": `/*
171
- * This product's own styles, on top of @ambasada/ui: the Ambasada tokens (var(--color-\u2026), var(--space-\u2026))
172
- * and components (ui-card, ui-button, ui-label, \u2026), loaded first by app/layout.tsx (next), src/main.tsx (spa)
173
- * or a <link> in web/index.html (worker). Use the tokens rather than raw values. The same file in all three.
174
- */
175
- /* The fallbacks are for a page that loads only this file. */
176
- :root {
177
- font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
178
- color: var(--color-ink, #0c0a09);
179
- background: var(--color-bg, #fafaf9);
180
- line-height: 1.5;
181
- }
182
- * { box-sizing: border-box; }
183
- body { margin: 0; }
184
- /* Inside the Ambasada app, @ambasada/miniapp sets the safe-area and host-bar variables; on the web they are 0. */
185
- .page { max-width: var(--page-max, 30rem); margin: 0 auto; padding: calc(var(--space-10) + var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)) var(--space-5) calc(var(--space-10) + var(--space-6) + var(--ambasada-safe-area-bottom, 0px)); }
186
- .topbar + .page { padding-top: calc(var(--space-10) + var(--space-4)); }
187
- .eyebrow { margin: 0; }
188
- .eyebrow::before { content: "// "; }
189
- h1 { margin: var(--space-2) 0 var(--space-3); font-size: clamp(2.2rem, 7vw, 3.4rem); font-weight: var(--weight-semibold); line-height: 1.05; letter-spacing: -0.03em; }
190
- .lead { margin: 0 0 var(--space-8); font-size: 1.125rem; color: var(--color-ink-2); }
191
- textarea, select { font: inherit; font-size: var(--text-base); color: inherit; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface); }
192
- textarea:focus, select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-soft); }
193
- textarea { resize: vertical; min-height: 7rem; }
194
- .row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; }
195
- label { display: flex; gap: var(--space-2); align-items: center; color: var(--color-ink-2); }
196
- .small, footer { color: var(--color-ink-3); font-size: var(--text-sm); }
197
- footer { margin-top: calc(var(--space-10) + var(--space-2)); }
198
- code { font-family: var(--font-mono); font-size: 0.9em; background: var(--color-surface-2); padding: 0.1em 0.35em; border-radius: var(--radius-xs); }
199
- blockquote { margin: var(--space-6) 0; padding: var(--space-4) var(--space-5); background: var(--color-surface); border-left: 4px solid var(--color-accent); border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: 1.125rem; }
200
- a { color: var(--color-accent-text); }
201
- .topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); max-width: var(--page-max, 30rem); margin: 0 auto; padding: calc(var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)) var(--space-5) 0; }
202
- .brand { font-weight: var(--weight-bold); color: var(--color-ink); text-decoration: none; }
203
- .account { display: flex; align-items: center; gap: var(--space-2); font-weight: var(--weight-semibold); }
204
- .account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
205
- .account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
206
- [hidden] { display: none !important; }
207
- `, "tsconfig.app.json": '{\n "compilerOptions": {\n "composite": true,\n "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",\n "target": "ES2022",\n "lib": ["ES2022", "DOM", "DOM.Iterable"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "jsx": "react-jsx",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["src"],\n "exclude": ["src/realtime/room.ts", "src/jobs"]\n}\n', "tsconfig.json": '{\n "files": [],\n "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.worker.json" }, { "path": "./tsconfig.node.json" }]\n}\n', "tsconfig.node.json": '{\n "compilerOptions": {\n "composite": true,\n "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",\n "target": "ES2022",\n "lib": ["ES2023"],\n "types": ["node"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true\n },\n "include": ["vite.config.ts"]\n}\n', "tsconfig.worker.json": '{\n "compilerOptions": {\n "composite": true,\n "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",\n "target": "ES2022",\n "lib": ["ES2022"],\n "types": ["@cloudflare/workers-types"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["worker", "api", "lib", "src/realtime/room.ts", "src/jobs", "ambasada.config.ts"]\n}\n', "vite.config.ts": 'import { cloudflare } from "@cloudflare/vite-plugin";\nimport react from "@vitejs/plugin-react";\nimport { defineConfig } from "vite";\n\nexport default defineConfig({\n plugins: [react(), cloudflare()],\n // allowedHosts: `ambasada dev --tunnel` serves this at https://<slug>-dev.ambasada.dev.\n server: { port: 3000, allowedHosts: [".ambasada.dev"] },\n});\n', "worker/index.ts": '/**\n * The app\'s Worker (`vite` runs it locally through @cloudflare/vite-plugin):\n *\n * /api/<name> \u2192 api/<name>.ts (fetch handlers; add yours to API below)\n * /api/auth/* \u2192 api/auth.ts: Sign in with Ambasada (login, callback, logout, me, shell)\n * /api/ambasada/webhook \u2192 api/ambasada-webhook.ts: the Ambasada app\'s events (notifications on/off, ping)\n * /api/notes, /api/files \u2192 api/notes.ts (env.DB + env.KV), api/files.ts (uploads to env.FILES)\n * /api/rooms/<id>/ws \u2192 the live room <id> (src/realtime/room.ts, a Durable Object); demo at /room\n * /files/* \u2192 files from env.FILES (lib/files.ts)\n * /health \u2192 { ok: true }\n * the rest \u2192 the React app in src/ (static assets; unknown paths get index.html, see wrangler.jsonc)\n */\nimport appWebhook from "../api/ambasada-webhook.ts";\nimport auth from "../api/auth.ts";\nimport files from "../api/files.ts";\nimport notes from "../api/notes.ts";\nimport type { Env } from "../lib/ambasada.ts";\nimport { FILES_PATH, serveFile } from "../lib/files.ts";\nimport { serverError, withRequestLog } from "../lib/errors.ts";\n\nexport { Room } from "../src/realtime/room.ts";\nexport { Jobs } from "../src/jobs/jobs.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\nexport default {\n fetch(req, env) {\n // A failing request\'s console.error lines and error go to `ambasada logs` (lib/errors.ts).\n return withRequestLog(() => handle(req, env));\n },\n} satisfies ExportedHandler<Env>;\n\n/** Every route; a throw anywhere answers 500 and is logged with its stack. */\nasync function handle(req: Request, env: Env): Promise<Response> {\n const { pathname } = new URL(req.url);\n try {\n if (pathname === "/health") return Response.json({ ok: true });\n if (pathname.startsWith("/api/auth/")) return await auth(req, env);\n if (pathname === "/api/ambasada/webhook") return await appWebhook(req, env);\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\n if (room) return await env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n if (pathname.startsWith(FILES_PATH)) return await serveFile(env.FILES, req);\n const api = /^\\/api\\/([^/]+)\\/?$/.exec(pathname);\n const handler = api ? API[api[1]!] : undefined;\n if (handler) return await handler(req, env);\n if (api) return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n return env.ASSETS.fetch(req);\n } catch (error) {\n return serverError(error);\n }\n}\n', "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: worker/index.ts answers /api/*, /files/* and /health; the React app (vite build) is served as assets.\n "name": "my-app",\n "main": "worker/index.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "binding": "ASSETS",\n "not_found_handling": "single-page-application",\n "run_worker_first": ["/api/*", "/files/*", "/health", "/mcp"]\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, worker: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers, and a notes example.\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look good and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)`; don't add\n Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the starter UI. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea: the main action\n > visible on a phone without scrolling. Write complete files, one code block per file, with the file path\n > above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design for people who decide in 10 seconds\n\nPeople open your app on a phone, often inside the Ambasada app, between two other things. They decide in\nseconds whether it is for them. Build for that.\n\n## One page, one action\n\n- **One page first.** What it is, and the one thing to do, fit on the first screen of a phone (375 \xD7 667).\n Extra pages only when the action needs them.\n- **One action.** One button that does the main thing. Add a second only after people use the first.\n- **Few words.** A headline of up to 8 words, one sentence under it, the button. Cut every adjective you can.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no cookie banner, no tour. Ask for sign-in when the app\n needs to remember someone (Sign in with Ambasada is one tap inside the Ambasada app).\n\n## The button\n\n- Label it with the action: **Make my poem**, not **Submit**.\n- Big (at least 48 px tall), full width on phones, one strong colour, nothing else in that colour.\n- If people must type something first, put that one field right above the button.\n- After the action, show the result, or "Working on it\u2026" and the result appearing when ready (a live room\n or polling). Never a blank page.\n\n## Look\n\n- The templates use `@ambasada/ui` (the Ambasada app\'s look: tokens, `ui-card`, `ui-button`); start there\n ([mini-app.md](mini-app.md)). Otherwise plain CSS: one font, two colours plus grey, generous spacing.\n- Large text: 18 px body on phones, a headline twice that. Light theme.\n- Works with one thumb: tap targets 48 px, no hover-only menus.\n- Loads fast: no video backgrounds, no 3 MB hero images, no animation libraries.\n\n## Words\n\n- Say what they get, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- Write in the language of your users; one is fine.\n\n## Before and after\n\n```\nBad: Welcome to PoemForge\u2122, the next-generation AI-powered creative writing platform\n [Features] [Pricing] [About] [Sign up] \u2026scroll\u2026 [Get started]\n\nGood: Your name as a folk poem\n Type a name. Get a 4-line Romanian folk poem in 10 seconds.\n [ Name: _________ ]\n [ Make my poem ]\n "Ana, floare de la munte, / \u2026" \u2190 one real example\n```\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID, check the code, pick the app, Allow\n```\n\nThe browser lists every app you are on, the folder's own pre-selected. A HackATon company **is** an app: pick it,\nnever make a second app for it at developer.ambasada.dev. The key the CLI gets is named \"CLI \xB7 <laptop>\", listed and\nrevocable in the app's settings. In a folder whose `id` is another app that was never deployed (a scaffold called\n`my-shop`, the company `theoffice`), `login` offers to make the folder that app; `npx ambasada bind theoffice` does it\nlater. No browser on this machine (SSH, CI): it prints the link and code (`--no-browser`), open it anywhere. If it\nasks for a key instead (an older API): HackATon \u2192 Team \u2192 Settings \u2192 New key, or developer.ambasada.dev \u2192 your app \u2192\nSettings \u2192 New key.\nEach app's key is kept under its id: with two apps on one laptop, log in once in each folder, and each folder uses\nits own. `deploy`, `db`/`kv`/`files`, `env`, `logs`, `tunnel` and the village's commands that change something refuse\na key of another app than the folder's `id` (\"this folder is X, you are logged in as Y\"); `--company Y` goes ahead.\n\nThen, every time you want the new version online:\n\n```sh\nnpx ambasada deploy\n```\n\nIt does, in order:\n\n1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);\n2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/.well-known/ambasada.json`\n (written from `ambasada.config.ts` first, when the mini app is on: [mini-app.md](mini-app.md));\n a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);\n3. **upload**: the URL is `https://<your-slug>.ambasada.dev`, with `AMBASADA_API_KEY`, `AMBASADA_API_URL`,\n Sign in with Ambasada and the app secret set for you, and the app's own database, KV namespace and bucket\n bound as `DB`, `KV`, `FILES` (created on the first deploy) plus the Durable Objects from `wrangler.jsonc`.\n The live URL is kept in `.ambasada/project.json` (`live_url`): `ambasada check` and the village's offerings use it.\n\nThe database's tables are not created by the deploy: run `npx ambasada db migrate` (it applies the new files\nin `db/migrations/`). No Cloudflare account needed. `--dry-run` builds and prints what it would upload (before the\nfirst deploy too). A deploy keeps the app's `AMBASADA_API_KEY`; `--rotate` makes a new one and says so (the old one\nworks 5 more minutes, so running jobs finish). With the village add-on, `--offerings` re-opens offerings paused live.\n\nYour own secrets (an API key for another service): `npx ambasada env set NAME value` (live within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names. A setting that isn't secret: `env set NAME value --plain`,\nand `env list` shows its value. `.env` (and `.env.local`, `.env.production`, `.dev.vars`) never\ngoes online: the deploy builds without them, so a value that is only there is missing on ambasada.dev; `env set` it.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nRecords the live URL in `.ambasada/project.json` (`live_url`; `product.url` stays your local dev server) and checks\nthat `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check # in the folder: the live app (https, no redirects, speed), or the mini-app rules\n```\n\nA request that answers 5xx shows with what your code logged for it: its `console.error` / `console.warn` lines and\nan error's message with the line it was thrown from (`lib/errors.ts` wraps the Worker's `fetch`; a route that throws\nanswers 500 through `serverError`). A request that succeeded shows only its lines that start with `[village]`\n(`village.warn(\"\u2026\")` writes one: say when an answer was degraded, e.g. a tool was rate-limited), as `warning 200\n/api/question \u2026`, at most 50 an hour, never counted as errors. Keep that wrapper if you rewrite the Worker's entry. Run `logs` in the folder you\ndeploy from: `deploy` keeps each version's source maps in `.ambasada/maps/`, and `logs` shows where in your source an\nerror was thrown (`\u2192 at boom (src/api/checks.ts:12:9)`). In a next route that line is the route's file\n(`\u2192 in app/api/boom/route.ts`): OpenNext's build of Next.js's server has no source map.\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\nnpx ambasada miniapp enable --entry /app --force # move it: --force removes what the old entry left (it lists them otherwise)\nnpx ambasada miniapp disable # off again: the config key, the manifest and the glue (pages at /app stay until --force)\n```\n\nWith the mini app off, `listing: { age_rating: "12+", kind: "product", tags: ["quiz"] }` in `ambasada.config.ts` keeps\nhow the app is listed; a mini app\'s own `age_rating`, `kind` and `tags` win, `enable` writes only the defaults it does\nnot set, and `disable` moves the mini app\'s into `listing`.\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app", age_rating: "4+", kind: "tool" }` | `ambasada.config.ts`; optional `support` (email or URL, else the app\'s `support`), `tags` (\u2264 5), `subtitle`, `description`, `icon`, `category`, `accent`, `scopes`, `visibility`. The hub refuses an app without `age_rating` (`4+`, `9+`, `12+`, `17+`) |\n| the in-app layout and page, no site header | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp` | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`. On a first deploy the webhook\'s secret takes a few\nminutes to arrive: the deploy says `checks: running (up to 5 min)`, and `npx ambasada check` shows the result.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe templates import `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`, `ui-row`,\n`ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`). Use those classes before writing CSS.\nReact: `import { Card, Button, Sheet } from "@ambasada/ui/react"`. Plain HTML: one\n`<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nLeave space for the app\'s bar: the page uses `--ambasada-safe-area-*` and `--ambasada-host-bar`.\nLight theme only; one red (primary) button per screen.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/(site)/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in on open\n\n**Installed apps with `identity` are signed in on open; never show a sign-in button inside the shell.**\nInstall is the consent (it grants what `miniapp.scopes` asks for; `identity` is the default when the app has the\n`/api/auth/*` routes). The in-app glue the CLI wrote (`in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) does it\nbefore the page shows: `auth.signInSilently()` from `@ambasada/miniapp` gets a short token from the app and POSTs it\nto `/api/auth/shell`, which sets the same session as "Sign in with Ambasada"; the page stays hidden meanwhile (at\nmost 5 s, under the app\'s splash). Keep that glue, and don\'t add your own sign-in step or screen in front of it.\nServer code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\n\n"Sign in with Ambasada" is for the web, and for the app opened without installing it: `signInSilently()` resolves\n`null` then (also outside the app, or when the hub isn\'t set up yet: `/api/auth/shell` answers 503) and the page\nshows its button. Render the button only when nobody is signed in once the glue is done, as the templates do.\n`ambasada check` warns when `identity` is declared but `/api/auth/shell` is missing.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor".\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html>, the Ambasada look (@ambasada/ui) and globals.css\n app/(site)/layout.tsx the site\'s header (app/account.tsx) around its pages; (site) is not in the URL\n app/(site)/page.tsx the landing page\n app/notes.tsx the notes example (a client component calling app/api/notes)\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/account.tsx the header: "Sign in with Ambasada", or the person\'s name and avatar\n app/(site)/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/(site)/room/ the live room demo\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe template\'s header already has the button. In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Your app\'s own URL (links in emails, notifications, answers): `appOrigin()` in `lib/ambasada.ts`, from\n `AMBASADA_APP_ORIGIN` (the deploy and `ambasada dev` set it), not `req.url`: a call the platform forwards to your\n app may come without its Host.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only. `ambasada deploy` builds without `.env*`: nothing in them reaches the Worker.\n- Anything named `NEXT_PUBLIC_*` (`VITE_*` in the spa) is copied into the browser. Never put a key there. From `.env`\n they work under `ambasada dev` only; a public value the live page needs goes in the code.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| work that takes longer than a request: a slow model call, a report, a delivery after a payment | a job (`env.JOBS`, `startJob`) | `waitUntil`, or the work inside a webhook |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Jobs: work that outlives a request\n\nA model call that thinks for a minute, a report, a batch of pushes: not in the request (30 s, and a webhook gets 10 s;\n`waitUntil` is cut at ~30 s). Every template has the `Jobs` Durable Object (`env.JOBS`, src/jobs/jobs.ts): one object\nper job, run from an alarm with its own limits, retried after 10 s, 30 s, 2 min, 10 min and 30 min when it throws.\n\n```ts\n// src/jobs/handlers.ts: what a kind of job does (safe to run twice)\nexport const HANDLERS: Record<string, JobHandler> = {\n "send-report": { async run(input, env) { const { id } = input as { id: number }; /* \u2026 */ return { sent: true }; } },\n};\n\n// anywhere on the server (lib/jobs.ts): answers at once; the same name again is a no-op\nawait startJob(env, `report:${id}`, "send-report", { id });\nawait jobState(env, `report:${id}`); // { status: "queued" | "working" | "retrying" | "done" | "failed", result, error, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "README.md": "# My App\n\nAn app on Ambasada, one bare Cloudflare Worker (template `worker`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/worker.ts the Worker: /api/<name> \u2192 api/<name>.ts, / (landing page), the rest from web/\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker's env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/room.ts the live room (a Durable Object); web/room.html is the demo\nweb/ the landing page, static assets\nwrangler.jsonc one Worker + assets, nodejs_compat, the storage bindings\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `wrangler dev` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `api/<name>.ts` (and a line in `src/worker.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nNo framework, no build step: `wrangler` bundles `src/worker.ts`, and `web/` is served as is.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID and pick the app\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx wrangler deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the header's button signs people in with their Ambasada ID; on the server,\n`ambasada(\u2026).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `api/notes.ts` |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `api/files.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts`, demo at `/room` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nOptional: an agent with the Agents SDK in `examples/agent/` (its README: `npm i agents` and four lines to wire it).\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, web/app.html and web/in-app.js, a placeholder\n`web/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. The ambasada-product skill:\nreference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "Notes, a live room and sign-in",\n // Where `ambasada dev` serves the app on this laptop. Hosted, it is https://my-app.ambasada.dev: `ambasada deploy`\n // (or `ambasada link https://\u2026` when you host elsewhere) keeps that in .ambasada/project.json, never here.\n product: { url: "http://localhost:3000" },\n});\n', "api/ambasada-webhook.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../lib/notifications.ts";\n\nexport default async function appWebhook(req: Request, env: Env): Promise<Response> {\n const app = ambasada(env);\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/auth.ts": '// Sign in with Ambasada (Ambasada ID, OAuth + PKCE): zero setup. On https://<slug>.ambasada.dev the deploy\n// injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\n// GET /api/auth/login?returnTo=/ \u2192 the Ambasada ID consent screen\n// GET /api/auth/callback \u2192 back from it: sets an HttpOnly session cookie, redirects to returnTo\n// GET /api/auth/logout \u2192 clears it\n// GET /api/auth/me \u2192 { user } (null when signed out), for the browser\n// POST /api/auth/shell { token } \u2192 silent sign-in inside the Ambasada app: the same session, no consent screen\n// (the page calls `auth.signInSilently()` from @ambasada/miniapp)\n// On the server: `await ambasada(env).auth.user(req)` \u2192 { id, name, avatar_url, builder, pass, \u2026 } or null.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\n\nexport default async function auth(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n try {\n switch (new URL(req.url).pathname.replace(/\\/+$/, "")) {\n case "/api/auth/login":\n return await auth.login(req);\n case "/api/auth/callback":\n return await auth.callback(req);\n case "/api/auth/logout":\n return auth.logout(req);\n case "/api/auth/shell":\n return await auth.shell(req);\n case "/api/auth/me": {\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env.AMBASADA_AUTH_ISSUER?.startsWith("http://") ? env.AMBASADA_AUTH_ISSUER : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n }\n }\n return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/files.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by the /files/* route.\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { upload } from "../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport default async function files(req: Request, env: Env): Promise<Response> {\n if (req.method !== "POST") return Response.json({ error: { code: "INVALID_REQUEST", message: "POST the file" } }, { status: 405 });\n const user = await ambasada(env).auth.user(req);\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "api/notes.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport type { Env } from "../lib/ambasada.ts";\nimport { all, run } from "../lib/db.ts";\nimport { cached, remove } from "../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport default async function notes(req: Request, env: Env): Promise<Response> {\n if (req.method === "POST") {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { lastRowId } = await run(env.DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(env.KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n }\n const latest = await cached(env.KV, "notes:latest", 60, () => all<Note>(env.DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\n.env*\n.dev.vars*\n.wrangler/\ndist/\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": "import { createAmbasada, type Ambasada } from \"@ambasada/sdk\";\nimport type { Jobs } from \"../src/jobs/jobs.ts\";\nimport type { Room } from \"../src/realtime/room.ts\";\n\n/** The Worker's env: `.env` locally (`ambasada dev` points it at the mock), secrets injected at deploy. */\nexport interface Env {\n ASSETS: Fetcher;\n /** The app's SQLite database (lib/db.ts), key-value (lib/kv.ts), files (lib/files.ts) and live rooms (src/realtime/room.ts). */\n DB: D1Database;\n KV: KVNamespace;\n FILES: R2Bucket;\n ROOMS: DurableObjectNamespace<Room>;\n /** Work that outlives a request (src/jobs/jobs.ts, lib/jobs.ts). */\n JOBS: DurableObjectNamespace<Jobs>;\n /** On ambasada.dev: a service binding to the API, which the SDK uses for its calls (set by `ambasada deploy`). */\n AMBASADA_API?: Fetcher;\n AMBASADA_API_KEY?: string;\n /** Set by `ambasada deploy` and `ambasada dev`; the SDK defaults to https://api.ambasada.dev. */\n AMBASADA_API_URL?: string;\n AMBASADA_APP_ID?: string;\n /** Sign in with Ambasada (api/auth.ts): set by `ambasada deploy` on ambasada.dev, and by `ambasada dev`. */\n AMBASADA_AUTH_CLIENT_ID?: string;\n AMBASADA_AUTH_CLIENT_SECRET?: string;\n AMBASADA_AUTH_ISSUER?: string;\n /** Mini-app notifications (api/ambasada-webhook.ts, lib/notifications.ts): set on ambasada.dev and by `ambasada dev`. */\n AMBASADA_APP_SECRET?: string;\n AMBASADA_HUB_URL?: string;\n /** This app's origin, set by `ambasada deploy` and `ambasada dev`: use `appOrigin(env, req)`. */\n AMBASADA_APP_ORIGIN?: string;\n}\n\n/**\n * This app's own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request's (a call the platform forwards may carry another host).\n */\nexport function appOrigin(env: Env, req: Request): string {\n return env.AMBASADA_APP_ORIGIN ?? new URL(req.url).origin;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API's signing keys. */\nexport function ambasada(env: Env): Ambasada {\n return (client ??= createAmbasada({ env: env as unknown as Record<string, unknown> }));\n}\n", "lib/async-hooks.d.ts": '// node:async_hooks as workerd has it (nodejs_compat): the part lib/errors.ts uses.\ndeclare module "node:async_hooks" {\n export class AsyncLocalStorage<T> {\n run<R>(store: T, callback: () => R): R;\n getStore(): T | undefined;\n }\n}\n', "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/errors.ts": '/**\n * What `ambasada logs` shows for a request that failed: the request\'s console.error / console.warn lines, an error\n * with its message and where it was thrown. On a 5xx answer they ride in the `x-ambasada-error` header, which\n * ambasada.dev records for `ambasada logs` and the Developer app and takes off the response (visitors never see it).\n * A request that succeeded with a degraded answer says so with `village.warn(\u2026)` (a `[village]` line): it rides in\n * `x-ambasada-warn` and shows in `ambasada logs` as a warning. Locally (`ambasada dev`) the headers are harmless; the\n * lines are in your terminal anyway.\n */\nimport { AsyncLocalStorage } from "node:async_hooks";\n\nexport const ERROR_HEADER = "x-ambasada-error";\n/** What a 5xx carries at most: the last lines of the request\'s log. */\nconst MAX_CHARS = 1500;\n\nconst requestLog = new AsyncLocalStorage<string[]>();\n\n/**\n * An error as one line plus where it was thrown: its stack down to the first frame in a file (`ambasada logs` maps\n * that one to your source), past built-ins like `at JSON.parse (<anonymous>)`; anything else as text.\n */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const frames = (value.stack ?? "").split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n return [`${value.name}: ${value.message}`, ...frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3)).map((line) => ` ${line}`)].join("\\n");\n}\n\nfor (const level of ["error", "warn"] as const) {\n const print = console[level].bind(console);\n console[level] = (...args: unknown[]) => {\n requestLog.getStore()?.push(args.map(describeError).join(" "));\n print(...args);\n };\n}\n\n/** A request that did not fail but logged `[village]` lines (`village.warn`: a degraded answer) carries them here. */\nexport const WARN_HEADER = "x-ambasada-warn";\nconst WARN_PREFIX = "[village]";\n\n/**\n * Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`, and\n * any other answer its `[village]` lines (ambasada.dev keeps at most 50 an hour).\n */\nexport async function withRequestLog(handle: () => Promise<Response>): Promise<Response> {\n const lines: string[] = [];\n const res = await requestLog.run(lines, handle);\n if (res.status < 500) {\n const warnings = lines.filter((line) => line.startsWith(WARN_PREFIX));\n if (!warnings.length || res.status === 101) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(WARN_HEADER, encodeURIComponent(warnings.join("\\n").slice(-MAX_CHARS)));\n return answer;\n }\n if (!lines.length) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(ERROR_HEADER, encodeURIComponent(lines.join("\\n").slice(-MAX_CHARS)));\n return answer;\n}\n\n/** The catch-all\'s answer: the error logged (message and stack, so `ambasada logs` has it), a 500 without details. */\nexport function serverError(error: unknown): Response {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/jobs.ts": '/**\n * Start work that outlives the request (src/jobs/jobs.ts, a Durable Object per job) and ask how it is going.\n *\n * await startJob(env, "report:42", "send-report", { id: 42 }) // answers at once; the same name again is a no-op\n * await jobState(env, "report:42") // { status: "working" | "done" | \u2026, result, error, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobOptions, JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(env: Env, name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input, { ...options, name })) as JobState;\n}\n\nexport async function jobState(env: Env, name: string): Promise<JobState | null> {\n return (await env.JOBS.getByName(name).state()) as JobState | null;\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-worker",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: one bare Cloudflare Worker, web/ + api/*.ts fetch handlers.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "preview": "wrangler dev --port 3000",\n "typecheck": "tsc -p .",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/workers-types": "^5.20261102.1",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/worker.ts": '/**\n * The app: one Cloudflare Worker (`wrangler dev`, or `ambasada dev` with the mock API).\n *\n * /api/<name> \u2192 api/<name>.ts (fetch handlers; add yours to API below)\n * /api/auth/* \u2192 api/auth.ts: Sign in with Ambasada (login, callback, logout, me, shell)\n * /api/ambasada/webhook \u2192 api/ambasada-webhook.ts: the Ambasada app\'s events (notifications on/off, ping)\n * /api/notes, /api/files \u2192 api/notes.ts (env.DB + env.KV), api/files.ts (uploads to env.FILES)\n * /api/rooms/<id>/ws \u2192 the live room <id> (src/realtime/room.ts, a Durable Object); demo at /room\n * /files/* \u2192 files from env.FILES (lib/files.ts)\n * / \u2192 web/index.html with who is signed in filled in\n * the rest \u2192 static files from web/ (wrangler.jsonc \u2192 assets)\n */\nimport appWebhook from "../api/ambasada-webhook.ts";\nimport auth from "../api/auth.ts";\nimport files from "../api/files.ts";\nimport notes from "../api/notes.ts";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { FILES_PATH, serveFile } from "../lib/files.ts";\nimport { serverError, withRequestLog } from "../lib/errors.ts";\n\nexport { Room } from "./realtime/room.ts";\nexport { Jobs } from "./jobs/jobs.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\n/** The landing page, with the header\'s "Sign in with Ambasada" button or, once signed in, the person\'s name and avatar. */\nasync function landing(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n const user = await auth.user(req);\n return new HTMLRewriter()\n .on("[data-auth-signin]", { element: (el) => void ((user || !auth.configured) && el.remove()) })\n .on("[data-auth-user]", { element: (el) => void (user ? el.removeAttribute("hidden") : el.remove()) })\n .on("[data-auth-avatar]", { element: (el) => void (user?.avatar_url ? el.setAttribute("src", user.avatar_url) : el.remove()) })\n .on("[data-auth-name]", { element: (el) => void el.setInnerContent(user?.name ?? "Signed in") })\n .transform(await env.ASSETS.fetch(req));\n}\n\nexport default {\n fetch(req, env) {\n // A failing request\'s console.error lines and error go to `ambasada logs` (lib/errors.ts).\n return withRequestLog(() => handle(req, env));\n },\n} satisfies ExportedHandler<Env>;\n\n/** Every route; a throw anywhere answers 500 and is logged with its stack. */\nasync function handle(req: Request, env: Env): Promise<Response> {\n const { pathname } = new URL(req.url);\n try {\n if (pathname === "/health") return Response.json({ ok: true });\n if (pathname.startsWith("/api/auth/")) return await auth(req, env);\n if (pathname === "/api/ambasada/webhook") return await appWebhook(req, env);\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\n if (room) return await env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n if (pathname.startsWith(FILES_PATH)) return await serveFile(env.FILES, req);\n const api = /^\\/api\\/([^/]+)\\/?$/.exec(pathname);\n if (api) {\n const handler = API[api[1]!];\n return handler ? await handler(req, env) : Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n }\n if (pathname === "/") return await landing(req, env);\n return env.ASSETS.fetch(req);\n } catch (error) {\n return serverError(error);\n }\n}\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "lib": ["ES2022"],\n "types": ["@cloudflare/workers-types"],\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["ambasada.config.ts", "src", "api", "lib"]\n}\n', "web/index.html": `<!doctype html>
208
- <html lang="en">
209
- <head>
210
- <meta charset="utf-8" />
211
- <meta name="viewport" content="width=device-width, initial-scale=1" />
212
- <title>My App</title>
213
- <!-- The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this product's own styles. -->
214
- <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />
215
- <link rel="stylesheet" href="/style.css" />
216
- </head>
217
- <body>
218
- <!-- Sign in with Ambasada: src/worker.ts keeps the button, or shows who is signed in (api/auth.ts). -->
219
- <header class="topbar">
220
- <a class="brand" href="/">My App</a>
221
- <a class="ui-button secondary signin" href="/api/auth/login" data-auth-signin>Sign in with Ambasada</a>
222
- <span class="account" data-auth-user hidden><img data-auth-avatar alt="" /><span data-auth-name></span><a href="/api/auth/logout">Sign out</a></span>
223
- </header>
224
- <main class="page">
225
- <p class="ui-label eyebrow">Ambasada app</p>
226
- <h1>My App</h1>
227
- <p class="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>
228
-
229
- <!-- api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. -->
230
- <form class="ui-card" id="notes">
231
- <div class="row">
232
- <input name="text" maxlength="500" required placeholder="A note" aria-label="A note" />
233
- <button type="submit" class="ui-button">Add</button>
234
- </div>
235
- <ul id="list"></ul>
236
- </form>
237
- <p><a href="/room">Open the live room</a> in two tabs and type.</p>
238
- </main>
239
- <script>
240
- const form = document.getElementById("notes");
241
- const list = document.getElementById("list");
242
- const load = async () => {
243
- const { notes } = await fetch("/api/notes").then((r) => r.json());
244
- list.replaceChildren(...notes.map((n) => Object.assign(document.createElement("li"), { textContent: n.text })));
245
- };
246
- form.addEventListener("submit", async (e) => {
247
- e.preventDefault();
248
- await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: form.text.value }) });
249
- form.reset();
250
- await load();
251
- });
252
- load();
253
- </script>
254
- </body>
255
- </html>
256
- `, "web/privacy.html": `<!doctype html>
79
+ `, "src/App.tsx": "export function App() {\n return (\n <main>\n <h1>Hello, world</h1>\n <p>\n Edit <code>src/App.tsx</code> to start.\n </p>\n </main>\n );\n}\n", "src/env.d.ts": '/// <reference types="vite/client" />\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/main.tsx": 'import { StrictMode } from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App.tsx";\n\ncreateRoot(document.getElementById("root")!).render(\n <StrictMode>\n <App />\n </StrictMode>,\n);\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/realtime/use-room.ts": '/**\n * `useRoom(id)`: a live room in a React component (src/realtime/room.ts on the server). Reconnects on its own.\n *\n * const { messages, online, send, status } = useRoom("lobby", { name: "Ana" });\n */\n"use client";\nimport { useCallback, useEffect, useRef, useState } from "react";\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\ntype RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport function useRoom(id: string, options: { name?: string } = {}) {\n const [messages, setMessages] = useState<RoomMessage[]>([]);\n const [online, setOnline] = useState(0);\n const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");\n const socket = useRef<WebSocket | null>(null);\n\n useEffect(() => {\n let stopped = false;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let tries = 0;\n const connect = () => {\n const url = new URL(`/api/rooms/${encodeURIComponent(id)}/ws`, location.href);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n if (options.name) url.searchParams.set("name", options.name);\n const ws = new WebSocket(url);\n socket.current = ws;\n setStatus("connecting");\n const timeout = setTimeout(() => ws.close(), 10_000); // no answer (e.g. `next dev`, which can\'t serve rooms): retry later\n ws.onopen = () => {\n clearTimeout(timeout);\n tries = 0;\n setStatus("open");\n };\n ws.onmessage = (e) => {\n const event = JSON.parse(String(e.data)) as RoomEvent;\n if (event.type === "history") setMessages(event.messages);\n else if (event.type === "message") setMessages((m) => [...m.slice(-49), event.message]);\n else setOnline(event.online);\n };\n ws.onclose = () => {\n clearTimeout(timeout);\n setStatus("closed");\n if (!stopped) retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); // back off; give up never\n };\n };\n connect();\n return () => {\n stopped = true;\n clearTimeout(retry);\n socket.current?.close();\n };\n }, [id, options.name]);\n\n const send = useCallback((text: string) => {\n if (socket.current?.readyState !== WebSocket.OPEN) return false;\n socket.current.send(JSON.stringify({ text }));\n return true;\n }, []);\n\n return { messages, online, status, send };\n}\n', "tsconfig.app.json": '{\n "compilerOptions": {\n "composite": true,\n "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",\n "target": "ES2022",\n "lib": ["ES2022", "DOM", "DOM.Iterable"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "jsx": "react-jsx",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["src"],\n "exclude": ["src/realtime/room.ts", "src/jobs"]\n}\n', "tsconfig.json": '{\n "files": [],\n "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.worker.json" }, { "path": "./tsconfig.node.json" }]\n}\n', "tsconfig.node.json": '{\n "compilerOptions": {\n "composite": true,\n "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",\n "target": "ES2022",\n "lib": ["ES2023"],\n "types": ["node"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true\n },\n "include": ["vite.config.ts"]\n}\n', "tsconfig.worker.json": '{\n "compilerOptions": {\n "composite": true,\n "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",\n "target": "ES2022",\n "lib": ["ES2022"],\n "types": ["@cloudflare/workers-types"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["worker", "api", "lib", "src/realtime/room.ts", "src/jobs", "ambasada.config.ts"]\n}\n', "vite.config.ts": 'import { cloudflare } from "@cloudflare/vite-plugin";\nimport react from "@vitejs/plugin-react";\nimport { defineConfig } from "vite";\n\nexport default defineConfig({\n plugins: [react(), cloudflare()],\n // allowedHosts: `ambasada dev --tunnel` serves this at https://<slug>-dev.ambasada.dev.\n server: { port: 3000, allowedHosts: [".ambasada.dev"] },\n});\n', "worker/index.ts": '/**\n * The app\'s Worker (`vite` runs it locally through @cloudflare/vite-plugin):\n *\n * /api/<name> \u2192 api/<name>.ts (fetch handlers; add yours to API below)\n * /api/auth/* \u2192 api/auth.ts: Sign in with Ambasada (login, callback, logout, me, shell)\n * /api/ambasada/webhook \u2192 api/ambasada-webhook.ts: the Ambasada app\'s events (notifications on/off, ping)\n * /api/notes, /api/files \u2192 api/notes.ts (env.DB + env.KV), api/files.ts (uploads to env.FILES)\n * /api/rooms/<id>/ws \u2192 the live room <id> (src/realtime/room.ts, a Durable Object); a page for it in examples/room/\n * /files/* \u2192 files from env.FILES (lib/files.ts)\n * /health \u2192 { ok: true }\n * the rest \u2192 the React app in src/ (static assets; unknown paths get index.html, see wrangler.jsonc)\n */\nimport appWebhook from "../api/ambasada-webhook.ts";\nimport auth from "../api/auth.ts";\nimport files from "../api/files.ts";\nimport notes from "../api/notes.ts";\nimport type { Env } from "../lib/ambasada.ts";\nimport { FILES_PATH, serveFile } from "../lib/files.ts";\nimport { serverError, withRequestLog } from "../lib/errors.ts";\n\nexport { Room } from "../src/realtime/room.ts";\nexport { Jobs } from "../src/jobs/jobs.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\nexport default {\n fetch(req, env) {\n // A failing request\'s console.error lines and error go to `ambasada logs` (lib/errors.ts).\n return withRequestLog(() => handle(req, env));\n },\n} satisfies ExportedHandler<Env>;\n\n/** Every route; a throw anywhere answers 500 and is logged with its stack. */\nasync function handle(req: Request, env: Env): Promise<Response> {\n const { pathname } = new URL(req.url);\n try {\n if (pathname === "/health") return Response.json({ ok: true });\n if (pathname.startsWith("/api/auth/")) return await auth(req, env);\n if (pathname === "/api/ambasada/webhook") return await appWebhook(req, env);\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\n if (room) return await env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n if (pathname.startsWith(FILES_PATH)) return await serveFile(env.FILES, req);\n const api = /^\\/api\\/([^/]+)\\/?$/.exec(pathname);\n const handler = api ? API[api[1]!] : undefined;\n if (handler) return await handler(req, env);\n if (api) return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n return env.ASSETS.fetch(req);\n } catch (error) {\n return serverError(error);\n }\n}\n', "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: worker/index.ts answers /api/*, /files/* and /health; the React app (vite build) is served as assets.\n "name": "my-app",\n "main": "worker/index.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "binding": "ASSETS",\n "not_found_handling": "single-page-application",\n "run_worker_first": ["/api/*", "/files/*", "/health", "/mcp"]\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, worker: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers.\n\nThe page is a **Hello, world**: no header, no styles, no layout (`app/page.tsx`, `src/App.tsx`, `web/index.html`).\nBuild the user's app there, in the user's design ([reference/design.md](reference/design.md)). The demos are in\n`examples/`, outside the app until copied in (each README says how):\n\n| Example | What |\n|---|---|\n| `examples/sign-in/` | the \"Sign in with Ambasada\" button, or the person's name, avatar and \"Sign out\" |\n| `examples/notes/` | a form and a list on `/api/notes` (rows in `env.DB`, cached in `env.KV`) |\n| `examples/room/` | a live room page (`env.ROOMS`, `/api/rooms/<id>/ws`) |\n| `examples/agent/` | an agent with the Agents SDK |\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look the way they want, on phones and laptops, and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)` (the button:\n `examples/sign-in/`); don't add Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key\n per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the Hello, world in `src/App.tsx`. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea, in this design: \u2026\n > The main action must be easy to find on a phone and on a laptop. Write complete files, one code block per\n > file, with the file path above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design: the user\'s, not ours\n\nThe scaffold has no look and no layout: a Hello, world and nothing else. There is no house style to follow. The app\nlooks the way the user wants it to look.\n\n## Whose design\n\n- **The user\'s, first.** A mockup, a screenshot, a site they like, a brand, a few words ("dark and calm", "like a\n newspaper"): follow it. Ask once if they gave nothing and care; otherwise pick a direction that fits the idea and\n keep it consistent.\n- **Any stack.** Plain CSS, CSS modules, Tailwind, a component kit: whatever the user or the code they brought uses.\n- **`@ambasada/ui` is optional.** It is the Ambasada app\'s look (tokens, `ui-card`, `ui-button`;\n [mini-app.md](mini-app.md#look-like-the-app-ambasadaui)). The mini app\'s in-app page uses it; use it on the site\n only if the user wants their app to look like the Ambasada app.\n\n## Every screen size\n\nPages are ordinary responsive web pages: they work on a phone and on a laptop alike. No fixed phone-width column,\nno desktop-only layout. Check both before saying "done" (a narrow window and a wide one). Tap targets big enough for\na thumb, no hover-only menus, a `<meta name="viewport" content="width=device-width, initial-scale=1">` (the templates\nhave it).\n\n## Clear beats clever\n\nTips, not rules; the user\'s design wins:\n\n- **Say what people get**, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- **One main action per page**, easy to find. Label its button with the action: **Make my poem**, not **Submit**.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no tour. Ask for sign-in when the app needs to remember someone\n (`examples/sign-in/` in the project).\n- **After an action**, show the result, or "Working on it\u2026" and the result appearing when ready (a live room or\n polling). Never a blank page.\n- **Loads fast**: no video backgrounds, no 3 MB hero images, no animation library for one fade.\n- Write in the language of the users; one is fine.\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID, check the code, pick the app, Allow\n```\n\nThe browser lists every app you are on, the folder's own pre-selected. A HackATon company **is** an app: pick it,\nnever make a second app for it at developer.ambasada.dev. The key the CLI gets is named \"CLI \xB7 <laptop>\", listed and\nrevocable in the app's settings. In a folder whose `id` is another app that was never deployed (a scaffold called\n`my-shop`, the company `theoffice`), `login` offers to make the folder that app; `npx ambasada bind theoffice` does it\nlater. No browser on this machine (SSH, CI): it prints the link and code (`--no-browser`), open it anywhere. If it\nasks for a key instead (an older API): HackATon \u2192 Team \u2192 Settings \u2192 New key, or developer.ambasada.dev \u2192 your app \u2192\nSettings \u2192 New key.\nEach app's key is kept under its id: with two apps on one laptop, log in once in each folder, and each folder uses\nits own. `deploy`, `db`/`kv`/`files`, `env`, `logs`, `tunnel` and the village's commands that change something refuse\na key of another app than the folder's `id` (\"this folder is X, you are logged in as Y\"); `--company Y` goes ahead.\n\nThen, every time you want the new version online:\n\n```sh\nnpx ambasada deploy\n```\n\nIt does, in order:\n\n1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);\n2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/.well-known/ambasada.json`\n (written from `ambasada.config.ts` first, when the mini app is on: [mini-app.md](mini-app.md));\n a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);\n3. **upload**: the URL is `https://<your-slug>.ambasada.dev`, with `AMBASADA_API_KEY`, `AMBASADA_API_URL`,\n Sign in with Ambasada and the app secret set for you, and the app's own database, KV namespace and bucket\n bound as `DB`, `KV`, `FILES` (created on the first deploy) plus the Durable Objects from `wrangler.jsonc`.\n The live URL is kept in `.ambasada/project.json` (`live_url`): `ambasada check` and the village's offerings use it.\n\nThe database's tables are not created by the deploy: run `npx ambasada db migrate` (it applies the new files\nin `db/migrations/`). No Cloudflare account needed. `--dry-run` builds and prints what it would upload (before the\nfirst deploy too). A deploy keeps the app's `AMBASADA_API_KEY`; `--rotate` makes a new one and says so (the old one\nworks 5 more minutes, so running jobs finish). With the village add-on, `--offerings` re-opens offerings paused live.\n\nYour own secrets (an API key for another service): `npx ambasada env set NAME value` (live within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names. A setting that isn't secret: `env set NAME value --plain`,\nand `env list` shows its value. `.env` (and `.env.local`, `.env.production`, `.dev.vars`) never\ngoes online: the deploy builds without them, so a value that is only there is missing on ambasada.dev; `env set` it.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nRecords the live URL in `.ambasada/project.json` (`live_url`; `product.url` stays your local dev server) and checks\nthat `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check # in the folder: the live app (https, no redirects, speed), or the mini-app rules\n```\n\nA request that answers 5xx shows with what your code logged for it: its `console.error` / `console.warn` lines and\nan error's message with the line it was thrown from (`lib/errors.ts` wraps the Worker's `fetch`; a route that throws\nanswers 500 through `serverError`). A request that succeeded shows only its lines that start with `[village]`\n(`village.warn(\"\u2026\")` writes one: say when an answer was degraded, e.g. a tool was rate-limited), as `warning 200\n/api/question \u2026`, at most 50 an hour, never counted as errors. Keep that wrapper if you rewrite the Worker's entry. Run `logs` in the folder you\ndeploy from: `deploy` keeps each version's source maps in `.ambasada/maps/`, and `logs` shows where in your source an\nerror was thrown (`\u2192 at boom (src/api/checks.ts:12:9)`). In a next route that line is the route's file\n(`\u2192 in app/api/boom/route.ts`): OpenNext's build of Next.js's server has no source map.\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\nnpx ambasada miniapp enable --entry /app --force # move it: --force removes what the old entry left (it lists them otherwise)\nnpx ambasada miniapp disable # off again: the config key, the manifest and the glue (pages at /app stay until --force)\n```\n\nWith the mini app off, `listing: { age_rating: "12+", kind: "product", tags: ["quiz"] }` in `ambasada.config.ts` keeps\nhow the app is listed; a mini app\'s own `age_rating`, `kind` and `tags` win, `enable` writes only the defaults it does\nnot set, and `disable` moves the mini app\'s into `listing`.\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app", age_rating: "4+", kind: "tool" }` | `ambasada.config.ts`; optional `support` (email or URL, else the app\'s `support`), `tags` (\u2264 5), `subtitle`, `description`, `icon`, `category`, `accent`, `scopes`, `visibility`. The hub refuses an app without `age_rating` (`4+`, `9+`, `12+`, `17+`) |\n| the in-app layout and page, in the Ambasada app\'s look (`@ambasada/ui`) | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp`, and `@ambasada/ui` for the in-app layout | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`. On a first deploy the webhook\'s secret takes a few\nminutes to arrive: the deploy says `checks: running (up to 5 min)`, and `npx ambasada check` shows the result.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe in-app layout `enable` adds imports `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`,\n`ui-row`, `ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`), for the mini app\'s pages only; the site\nkeeps its own look. On those pages, use the classes before writing CSS. React: `import { Card, Button, Sheet } from\n"@ambasada/ui/react"`. Plain HTML: one `<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nOptional everywhere: a mini app in the user\'s own design is fine (light theme; the checks refuse dark-only).\nThe app\'s bar and the safe areas are handled: `Page` (`ui-page`) keeps clear of them, and with `--entry /` the in-app\ncode (`app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) pads `<body>` by them inside the Ambasada app only\n(nothing on the web, nothing on a page that has `ui-page`). Don\'t add your own padding for the bar on top of it.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in on open\n\n**Installed apps with `identity` are signed in on open; never show a sign-in button inside the shell.**\nInstall is the consent (it grants what `miniapp.scopes` asks for; `identity` is the default when the app has the\n`/api/auth/*` routes). The in-app glue the CLI wrote (`in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`) does it\nbefore the page shows: `auth.signInSilently()` from `@ambasada/miniapp` gets a short token from the app and POSTs it\nto `/api/auth/shell`, which sets the same session as "Sign in with Ambasada"; the page stays hidden meanwhile (at\nmost 5 s, under the app\'s splash). Keep that glue, and don\'t add your own sign-in step or screen in front of it.\nServer code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\n\n"Sign in with Ambasada" is for the web, and for the app opened without installing it: `signInSilently()` resolves\n`null` then (also outside the app, or when the hub isn\'t set up yet: `/api/auth/shell` answers 503) and the page\nshows its button. Render the button only when nobody is signed in once the glue is done, as the templates do.\n`ambasada check` warns when `identity` is declared but `/api/auth/shell` is missing.\n\n## The app\'s account is the one signed in\n\n**Inside the Ambasada app, the app\'s account is the one signed in.** The app\'s web view keeps cookies between opens,\nso it can still hold a session from an earlier "Sign in with Ambasada" as someone else. On every load the glue\ncompares the session\'s user with the app\'s (`auth.sessionMatches(user)`: the session\'s `id` against\n`context().user.id`, both the Ambasada ID user id, `user_\u2026`) and, when they differ, signs the app\'s account in over\nit: a shell token POSTed to `/api/auth/shell` replaces the session cookie, then one reload. It tries once per account\nin a web view session, so a session that did not stick can\'t loop. A session that matches is left alone, and on the\nweb nothing changes: whoever signed in stays.\n\n- **Don\'t remember who the person is across opens** (localStorage, a module variable, a cached `/api/auth/me`, a\n service worker). Read the session user on every request (`currentUser()` / `ambasada().auth.user(req)`) and key\n per-person data by its `id`.\n- **Your own sign-in code** instead of the CLI\'s glue: call `auth.signInSilently()` with its default `me`; it\n replaces another account\'s session by itself. Reload when the user it resolves is not the one the page rendered.\n Or do as the glue does: `upgrading.md` has the lines.\n- **"Sign out" inside the app** lasts until the next page: the glue signs the app\'s account back in (one reload,\n nothing to see). Hide the link there (`isInAmbasada()`, or the `in-ambasada` class `ready()` puts on `<html>`);\n to be someone else, the person changes account in the Ambasada app.\n- When the app gives no token (not installed, `identity` not granted), the session stays as it was.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor". To see the app\'s account take over, sign\nin on the web first with "Continue as Second Visitor" on the mock\'s consent page, then open the `?ambasada_shell=mock`\nURL: one reload and "Fake Visitor" is signed in.\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html> and <body>; no styles (add yours here)\n app/page.tsx the page: Hello, world\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV (a page for it: examples/notes/)\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n examples/ sign-in/, notes/, room/, agent/: copy in what you want (not built until then)\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe routes are in `app/api/auth/`; the button is `examples/sign-in/` (copy `account.tsx` to `app/`). In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind (the template has no\n styles: add the ones the user\'s design needs)\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Your app\'s own URL (links in emails, notifications, answers): `appOrigin()` in `lib/ambasada.ts`, from\n `AMBASADA_APP_ORIGIN` (the deploy and `ambasada dev` set it), not `req.url`: a call the platform forwards to your\n app may come without its Host.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only. `ambasada deploy` builds without `.env*`: nothing in them reaches the Worker.\n- Anything named `NEXT_PUBLIC_*` (`VITE_*` in the spa) is copied into the browser. Never put a key there. From `.env`\n they work under `ambasada dev` only; a public value the live page needs goes in the code.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| work that takes longer than a request: a slow model call, a report, a delivery after a payment | a job (`env.JOBS`, `startJob`) | `waitUntil`, or the work inside a webhook |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Jobs: work that outlives a request\n\nA model call that thinks for a minute, a report, a batch of pushes: not in the request (30 s, and a webhook gets 10 s;\n`waitUntil` is cut at ~30 s). Every template has the `Jobs` Durable Object (`env.JOBS`, src/jobs/jobs.ts): one object\nper job, run from an alarm with its own limits, retried after 10 s, 30 s, 2 min, 10 min and 30 min when it throws.\n\n```ts\n// src/jobs/handlers.ts: what a kind of job does (safe to run twice)\nexport const HANDLERS: Record<string, JobHandler> = {\n "send-report": { async run(input, env) { const { id } = input as { id: number }; /* \u2026 */ return { sent: true }; } },\n};\n\n// anywhere on the server (lib/jobs.ts): answers at once; the same name again is a no-op\nawait startJob(env, `report:${id}`, "send-report", { id });\nawait jobState(env, `report:${id}`); // { status: "queued" | "working" | "retrying" | "done" | "failed", result, error, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.19 \u2192 the next release: inside the Ambasada app, the app\'s account is the one signed in\n\nUp to 0.8.19 the mini app\'s glue signed the person in only when nobody was signed in. The Ambasada app\'s web view\nkeeps cookies, so a session from an earlier "Sign in with Ambasada" as someone else stayed, and the mini app opened as\nthat other person. Now a session whose user id is not `context().user.id` is replaced by the app\'s account\n(`mini-app.md`, "The app\'s account is the one signed in").\n\nDone by `upgrade`, when the glue is still the file a 0.8.12 to 0.8.19 CLI wrote (`app/in-app.tsx` or\n`app/<entry>/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`): the lines below, nothing else in the file. It needs\nthis release\'s `@ambasada/miniapp` (`auth.sessionMatches`), which `upgrade` sets in `package.json`.\n\nBy hand, for a coding agent, when `upgrade` says the glue "is left alone" (you changed it, or it is from 0.8.11 or\nolder and still calls `auth.signIn()`):\n\n- **next** (`in-app.tsx`) and **spa** (`src/in-app/shell.ts`): import `context` from `@ambasada/miniapp` too, then\n where the file decides whether to sign in (`if (me?.user) \u2026 else if (!sessionStorage.getItem(RELOADED) \u2026`):\n ```ts\n // Inside the app, the app\'s account is the one signed in: a session for someone else is replaced, once per account.\n const inApp = context().user?.id ?? "1";\n if (auth.sessionMatches(me?.user)) sessionStorage.removeItem(RELOADED);\n else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n ```\n In the spa\'s `shell.ts` the same test and guard go around its own body (`auth.sessionMatches(user)`,\n `sessionStorage.setItem(RELOADED, inApp)` before `onSignedIn()`), and a failed sign-in keeps the user `/api/auth/me`\n gave: `const signedIn = await auth.signInSilently<InAppUser>({ me: false }); user = signedIn ?? user;`.\n- **worker** (`web/in-app.js`, the hub\'s `miniapp.js` may be an older build, so it compares by hand):\n ```js\n const inApp = a?.context().user?.id ?? "1";\n if (me.user && (!a?.context().user || me.user.id === inApp)) sessionStorage.removeItem(RELOADED);\n else if (a && sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity(a)) && (await signInSilently(a))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n ```\n- **Glue from 0.8.11 or older** (it calls `auth.signIn()` after `ready()`): copy the glue file from a fresh\n `npm create ambasada@latest -- --miniapp` of the same template; spa: `src/in-app/InApp.tsx` with it, they changed\n together.\n- **Your own sign-in code**: the rule is the same. A session is the app\'s only when its user id equals\n `context().user.id`; otherwise get a shell token and POST it (`auth.signInSilently()` with its default `me` does\n both), and try at most once per account in a `sessionStorage` session.\n\nCheck it under `npx ambasada dev`: sign in on the web with "Continue as Second Visitor", open\n`/app?ambasada_shell=mock` (your entry): "Fake Visitor" is signed in after one reload.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\nNew apps start as a Hello, world (the header, notes and room pages, the Ambasada look and the village shop moved to\n`examples/` and `/example`). Nothing to do in an existing app: `upgrade` never touches your pages or styles. Keep\n`@ambasada/ui` in `package.json` while your pages import it.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "README.md": '# My App\n\nAn app on Ambasada, one bare Cloudflare Worker (template `worker`). The page is a Hello, world, with no look and no\nlayout: build yours. Behind it: sign-in with Ambasada ID, a database, key-value, files and live rooms, ready to use;\na mini app inside the Ambasada app if you want one.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/worker.ts the Worker: /api/<name> \u2192 api/<name>.ts, the rest from web/\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app\'s events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker\'s env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/room.ts the live room (a Durable Object)\nweb/ the page (web/index.html: Hello, world), privacy, static assets\nwrangler.jsonc one Worker + assets, nodejs_compat, the storage bindings\nexamples/ copy in what you want: sign-in/ (the button), notes/, room/ (a live room page), agent/\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `wrangler dev` on http://localhost:3000\n```\n\nOpen http://localhost:3000: Hello, world. Edit `web/index.html` and reload.\nhttp://127.0.0.1:8788 is the mock\'s page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- Your pages in `web/`, with your own look. Want the Ambasada app\'s look? One `<link rel="stylesheet"\n href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />` (optional).\n- A new endpoint: `api/<name>.ts` (and a line in `src/worker.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nNo framework, no build step: `wrangler` bundles `src/worker.ts`, and `web/` is served as is.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # opens your browser: sign in with Ambasada ID and pick the app\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app\'s database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx wrangler deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the routes are in `api/auth.ts`; `examples/sign-in/` is the button. In the Worker,\n`ambasada(env).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null; the browser asks `/api/auth/me`.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `api/notes.ts` (a page for it: `examples/notes/`) |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `api/files.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts`, a page in `examples/room/` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app\'s database online\nnpx ambasada db shell "SELECT * FROM notes" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\n## Examples\n\nNot part of the app until you copy them in; each README says where.\n\n| | |\n|---|---|\n| `examples/sign-in/` | "Sign in with Ambasada", or the person\'s name, avatar and "Sign out" |\n| `examples/notes/` | `/notes`, a form and a list on `/api/notes` (`env.DB` + `env.KV`) |\n| `examples/room/` | `/room`, a live room between tabs (`env.ROOMS`) |\n| `examples/agent/` | an agent with the Agents SDK (`npm i agents` and four lines to wire it) |\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: "/app" }` in `ambasada.config.ts`, web/app.html (in the Ambasada app\'s look, `@ambasada/ui`)\nand web/in-app.js, a placeholder\n`web/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. With `--entry /` your own\npages are the mini app; inside the Ambasada app the in-app code pads the page clear of the app\'s bar (on the web,\nnothing changes). The ambasada-product skill: reference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n', "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "A new Ambasada app",\n // Where `ambasada dev` serves the app on this laptop. Hosted, it is https://my-app.ambasada.dev: `ambasada deploy`\n // (or `ambasada link https://\u2026` when you host elsewhere) keeps that in .ambasada/project.json, never here.\n product: { url: "http://localhost:3000" },\n});\n', "api/ambasada-webhook.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../lib/notifications.ts";\n\nexport default async function appWebhook(req: Request, env: Env): Promise<Response> {\n const app = ambasada(env);\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/auth.ts": '// Sign in with Ambasada (Ambasada ID, OAuth + PKCE): zero setup. On https://<slug>.ambasada.dev the deploy\n// injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\n// GET /api/auth/login?returnTo=/ \u2192 the Ambasada ID consent screen\n// GET /api/auth/callback \u2192 back from it: sets an HttpOnly session cookie, redirects to returnTo\n// GET /api/auth/logout \u2192 clears it\n// GET /api/auth/me \u2192 { user } (null when signed out), for the browser\n// POST /api/auth/shell { token } \u2192 silent sign-in inside the Ambasada app: the same session, no consent screen\n// (the page calls `auth.signInSilently()` from @ambasada/miniapp)\n// On the server: `await ambasada(env).auth.user(req)` \u2192 { id, name, avatar_url, builder, pass, \u2026 } or null.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\n\nexport default async function auth(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n try {\n switch (new URL(req.url).pathname.replace(/\\/+$/, "")) {\n case "/api/auth/login":\n return await auth.login(req);\n case "/api/auth/callback":\n return await auth.callback(req);\n case "/api/auth/logout":\n return auth.logout(req);\n case "/api/auth/shell":\n return await auth.shell(req);\n case "/api/auth/me": {\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env.AMBASADA_AUTH_ISSUER?.startsWith("http://") ? env.AMBASADA_AUTH_ISSUER : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n }\n }\n return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/files.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by the /files/* route.\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { upload } from "../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport default async function files(req: Request, env: Env): Promise<Response> {\n if (req.method !== "POST") return Response.json({ error: { code: "INVALID_REQUEST", message: "POST the file" } }, { status: 405 });\n const user = await ambasada(env).auth.user(req);\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "api/notes.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport type { Env } from "../lib/ambasada.ts";\nimport { all, run } from "../lib/db.ts";\nimport { cached, remove } from "../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport default async function notes(req: Request, env: Env): Promise<Response> {\n if (req.method === "POST") {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { lastRowId } = await run(env.DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(env.KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n }\n const latest = await cached(env.KV, "notes:latest", 60, () => all<Note>(env.DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', "examples/notes/README.md": "# Notes: a database and a cache\n\nThe API is already in the app: `GET /api/notes` reads the latest rows from `env.DB` (the `notes` table,\n`db/migrations/0001_init.sql`), cached a minute in `env.KV`; `POST /api/notes { text }` adds one (`api/notes.ts`).\n`notes.html` is a form and a list that use it.\n\n1. Copy `notes.html` to `web/notes.html`.\n2. `npm run dev` and open `/notes`.\n\nNotes are public: anyone who opens the app sees them and can add one. Key per-person data by the signed-in user's id\ninstead (`examples/sign-in/`). Not using notes? Take `notes` out of `API` in `src/worker.ts`, delete `api/notes.ts`\nand add a migration that drops the table.\n", "examples/notes/notes.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>Notes</title>\n </head>\n <body>\n <!-- Copy to web/notes.html (README.md): /notes. api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the\n list cached in env.KV. Unstyled: give it your own look. -->\n <main>\n <h1>Notes</h1>\n <form id="notes">\n <input name="text" maxlength="500" required placeholder="A note" aria-label="A note" />\n <button type="submit">Add</button>\n </form>\n <ul id="list"></ul>\n </main>\n <script>\n const form = document.getElementById("notes");\n const list = document.getElementById("list");\n const load = async () => {\n const { notes } = await fetch("/api/notes").then((r) => r.json());\n list.replaceChildren(...notes.map((n) => Object.assign(document.createElement("li"), { textContent: n.text })));\n };\n form.addEventListener("submit", async (e) => {\n e.preventDefault();\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: form.text.value }) });\n form.reset();\n await load();\n });\n load();\n </script>\n </body>\n</html>\n', "examples/room/README.md": '# A live room\n\nThe room is already in the app: a Durable Object (`src/realtime/room.ts`, `env.ROOMS`) that keeps the last messages\nand who is online, at `/api/rooms/<id>/ws` (routed in `src/worker.ts`). `room.html` is a page that uses it.\n\n1. Copy `room.html` to `web/room.html`.\n2. `npm run dev`, open `/room` in two tabs and type; `?room=<id>` picks the room.\n\nThe protocol, for your own page: connect to `/api/rooms/<id>/ws?name=<name>`, send `{ "text": "\u2026" }`; you receive\n`{ type: "history", messages }`, then `{ type: "message", message }` and `{ type: "presence", online }`.\n', "examples/room/room.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>Live room</title>\n </head>\n <body>\n <!-- Copy to web/room.html (README.md). A live room (src/realtime/room.ts): open /room in two tabs and type.\n ?room=<id> picks the room. Unstyled: give it your own look. -->\n <main>\n <h1>Live room</h1>\n <p id="status">connecting\u2026</p>\n <ul id="messages"></ul>\n <form id="send"><input name="text" autocomplete="off" placeholder="Say something" /> <button>Send</button></form>\n <p><a href="/">\u2190 back</a></p>\n </main>\n <script>\n const room = new URLSearchParams(location.search).get("room") || "lobby";\n const list = document.getElementById("messages");\n const status = document.getElementById("status");\n const show = (m) => list.insertAdjacentHTML("beforeend", "<li></li>") || (list.lastElementChild.textContent = `${m.name}: ${m.text}`);\n let ws;\n function connect() {\n ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/rooms/${encodeURIComponent(room)}/ws?name=guest`);\n ws.onmessage = (e) => {\n const event = JSON.parse(e.data);\n if (event.type === "history") (list.textContent = "", event.messages.forEach(show));\n else if (event.type === "message") show(event.message);\n else status.textContent = `${event.online} online`;\n };\n ws.onclose = () => ((status.textContent = "reconnecting\u2026"), setTimeout(connect, 2000));\n }\n connect();\n document.getElementById("send").onsubmit = (e) => {\n e.preventDefault();\n if (ws.readyState === WebSocket.OPEN && e.target.text.value.trim()) ws.send(JSON.stringify({ text: e.target.text.value }));\n e.target.text.value = "";\n };\n </script>\n </body>\n</html>\n', "examples/sign-in/README.md": '# Sign in with Ambasada\n\nThe routes are already in the app: `/api/auth/login`, `callback`, `logout`, `me` and `shell` (`api/auth.ts`). In the\nWorker, `await ambasada(env).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null; in the browser,\n`fetch("/api/auth/me")` gives `{ user }`. What is missing is a button; `sign-in.html` is one: "Sign in with\nAmbasada", or the person\'s name, avatar and "Sign out".\n\n1. Paste `sign-in.html` into a page in `web/` (`web/index.html`, inside `<body>`).\n2. `npm run dev`, open the page and sign in.\n\nBack to a page after signing in: `/api/auth/login?returnTo=/orders`. Locally the mock signs you in as "Fake Visitor".\n\nInside the Ambasada app the app\'s account is the one signed in, so "Sign out" there lasts until the next page: hide\nit in the app (`isInAmbasada()` from `@ambasada/miniapp`, or the `in-ambasada` class on `<html>`).\n', "examples/sign-in/sign-in.html": '<!-- Paste into any page in web/ (README.md): "Sign in with Ambasada", or the person\'s name and avatar once signed in.\n Unstyled: give it your own look. -->\n<p data-account hidden>\n <a href="/api/auth/login" data-signin>Sign in with Ambasada</a>\n <span data-user hidden><img data-avatar alt="" width="28" height="28" hidden /> <span data-name></span> \xB7 <a href="/api/auth/logout">Sign out</a></span>\n</p>\n<script>\n // GET /api/auth/me (api/auth.ts): { user } (null when signed out), and whether sign-in is configured.\n fetch("/api/auth/me")\n .then((r) => r.json())\n .then(({ user, configured }) => {\n const box = document.querySelector("[data-account]");\n if (!user && !configured) return;\n box.querySelector("[data-signin]").href = `/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`;\n if (user) {\n box.querySelector("[data-signin]").hidden = true;\n box.querySelector("[data-user]").hidden = false;\n box.querySelector("[data-name]").textContent = user.name ?? "Signed in";\n if (user.avatar_url) Object.assign(box.querySelector("[data-avatar]"), { src: user.avatar_url, hidden: false });\n }\n box.hidden = false;\n });\n</script>\n', gitignore: "node_modules/\n.env*\n.dev.vars*\n.wrangler/\ndist/\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": "import { createAmbasada, type Ambasada } from \"@ambasada/sdk\";\nimport type { Jobs } from \"../src/jobs/jobs.ts\";\nimport type { Room } from \"../src/realtime/room.ts\";\n\n/** The Worker's env: `.env` locally (`ambasada dev` points it at the mock), secrets injected at deploy. */\nexport interface Env {\n ASSETS: Fetcher;\n /** The app's SQLite database (lib/db.ts), key-value (lib/kv.ts), files (lib/files.ts) and live rooms (src/realtime/room.ts). */\n DB: D1Database;\n KV: KVNamespace;\n FILES: R2Bucket;\n ROOMS: DurableObjectNamespace<Room>;\n /** Work that outlives a request (src/jobs/jobs.ts, lib/jobs.ts). */\n JOBS: DurableObjectNamespace<Jobs>;\n /** On ambasada.dev: a service binding to the API, which the SDK uses for its calls (set by `ambasada deploy`). */\n AMBASADA_API?: Fetcher;\n AMBASADA_API_KEY?: string;\n /** Set by `ambasada deploy` and `ambasada dev`; the SDK defaults to https://api.ambasada.dev. */\n AMBASADA_API_URL?: string;\n AMBASADA_APP_ID?: string;\n /** Sign in with Ambasada (api/auth.ts): set by `ambasada deploy` on ambasada.dev, and by `ambasada dev`. */\n AMBASADA_AUTH_CLIENT_ID?: string;\n AMBASADA_AUTH_CLIENT_SECRET?: string;\n AMBASADA_AUTH_ISSUER?: string;\n /** Mini-app notifications (api/ambasada-webhook.ts, lib/notifications.ts): set on ambasada.dev and by `ambasada dev`. */\n AMBASADA_APP_SECRET?: string;\n AMBASADA_HUB_URL?: string;\n /** This app's origin, set by `ambasada deploy` and `ambasada dev`: use `appOrigin(env, req)`. */\n AMBASADA_APP_ORIGIN?: string;\n}\n\n/**\n * This app's own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request's (a call the platform forwards may carry another host).\n */\nexport function appOrigin(env: Env, req: Request): string {\n return env.AMBASADA_APP_ORIGIN ?? new URL(req.url).origin;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API's signing keys. */\nexport function ambasada(env: Env): Ambasada {\n return (client ??= createAmbasada({ env: env as unknown as Record<string, unknown> }));\n}\n", "lib/async-hooks.d.ts": '// node:async_hooks as workerd has it (nodejs_compat): the part lib/errors.ts uses.\ndeclare module "node:async_hooks" {\n export class AsyncLocalStorage<T> {\n run<R>(store: T, callback: () => R): R;\n getStore(): T | undefined;\n }\n}\n', "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/errors.ts": '/**\n * What `ambasada logs` shows for a request that failed: the request\'s console.error / console.warn lines, an error\n * with its message and where it was thrown. On a 5xx answer they ride in the `x-ambasada-error` header, which\n * ambasada.dev records for `ambasada logs` and the Developer app and takes off the response (visitors never see it).\n * A request that succeeded with a degraded answer says so with `village.warn(\u2026)` (a `[village]` line): it rides in\n * `x-ambasada-warn` and shows in `ambasada logs` as a warning. Locally (`ambasada dev`) the headers are harmless; the\n * lines are in your terminal anyway.\n */\nimport { AsyncLocalStorage } from "node:async_hooks";\n\nexport const ERROR_HEADER = "x-ambasada-error";\n/** What a 5xx carries at most: the last lines of the request\'s log. */\nconst MAX_CHARS = 1500;\n\nconst requestLog = new AsyncLocalStorage<string[]>();\n\n/**\n * An error as one line plus where it was thrown: its stack down to the first frame in a file (`ambasada logs` maps\n * that one to your source), past built-ins like `at JSON.parse (<anonymous>)`; anything else as text.\n */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const frames = (value.stack ?? "").split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n return [`${value.name}: ${value.message}`, ...frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3)).map((line) => ` ${line}`)].join("\\n");\n}\n\nfor (const level of ["error", "warn"] as const) {\n const print = console[level].bind(console);\n console[level] = (...args: unknown[]) => {\n requestLog.getStore()?.push(args.map(describeError).join(" "));\n print(...args);\n };\n}\n\n/** A request that did not fail but logged `[village]` lines (`village.warn`: a degraded answer) carries them here. */\nexport const WARN_HEADER = "x-ambasada-warn";\nconst WARN_PREFIX = "[village]";\n\n/**\n * Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`, and\n * any other answer its `[village]` lines (ambasada.dev keeps at most 50 an hour).\n */\nexport async function withRequestLog(handle: () => Promise<Response>): Promise<Response> {\n const lines: string[] = [];\n const res = await requestLog.run(lines, handle);\n if (res.status < 500) {\n const warnings = lines.filter((line) => line.startsWith(WARN_PREFIX));\n if (!warnings.length || res.status === 101) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(WARN_HEADER, encodeURIComponent(warnings.join("\\n").slice(-MAX_CHARS)));\n return answer;\n }\n if (!lines.length) return res;\n const answer = new Response(res.body, res);\n answer.headers.set(ERROR_HEADER, encodeURIComponent(lines.join("\\n").slice(-MAX_CHARS)));\n return answer;\n}\n\n/** The catch-all\'s answer: the error logged (message and stack, so `ambasada logs` has it), a 500 without details. */\nexport function serverError(error: unknown): Response {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/jobs.ts": '/**\n * Start work that outlives the request (src/jobs/jobs.ts, a Durable Object per job) and ask how it is going.\n *\n * await startJob(env, "report:42", "send-report", { id: 42 }) // answers at once; the same name again is a no-op\n * await jobState(env, "report:42") // { status: "working" | "done" | \u2026, result, error, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobOptions, JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(env: Env, name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input, { ...options, name })) as JobState;\n}\n\nexport async function jobState(env: Env, name: string): Promise<JobState | null> {\n return (await env.JOBS.getByName(name).state()) as JobState | null;\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-worker",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: one bare Cloudflare Worker, web/ + api/*.ts fetch handlers.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "preview": "wrangler dev --port 3000",\n "typecheck": "tsc -p .",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/workers-types": "^5.20261102.1",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/worker.ts": '/**\n * The app: one Cloudflare Worker (`wrangler dev`, or `ambasada dev` with the mock API).\n *\n * /api/<name> \u2192 api/<name>.ts (fetch handlers; add yours to API below)\n * /api/auth/* \u2192 api/auth.ts: Sign in with Ambasada (login, callback, logout, me, shell)\n * /api/ambasada/webhook \u2192 api/ambasada-webhook.ts: the Ambasada app\'s events (notifications on/off, ping)\n * /api/notes, /api/files \u2192 api/notes.ts (env.DB + env.KV), api/files.ts (uploads to env.FILES)\n * /api/rooms/<id>/ws \u2192 the live room <id> (src/realtime/room.ts, a Durable Object); a page for it in examples/room/\n * /files/* \u2192 files from env.FILES (lib/files.ts)\n * the rest \u2192 static files from web/ (wrangler.jsonc \u2192 assets): / is web/index.html\n */\nimport appWebhook from "../api/ambasada-webhook.ts";\nimport auth from "../api/auth.ts";\nimport files from "../api/files.ts";\nimport notes from "../api/notes.ts";\nimport type { Env } from "../lib/ambasada.ts";\nimport { FILES_PATH, serveFile } from "../lib/files.ts";\nimport { serverError, withRequestLog } from "../lib/errors.ts";\n\nexport { Room } from "./realtime/room.ts";\nexport { Jobs } from "./jobs/jobs.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\nexport default {\n fetch(req, env) {\n // A failing request\'s console.error lines and error go to `ambasada logs` (lib/errors.ts).\n return withRequestLog(() => handle(req, env));\n },\n} satisfies ExportedHandler<Env>;\n\n/** Every route; a throw anywhere answers 500 and is logged with its stack. */\nasync function handle(req: Request, env: Env): Promise<Response> {\n const { pathname } = new URL(req.url);\n try {\n if (pathname === "/health") return Response.json({ ok: true });\n if (pathname.startsWith("/api/auth/")) return await auth(req, env);\n if (pathname === "/api/ambasada/webhook") return await appWebhook(req, env);\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\n if (room) return await env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n if (pathname.startsWith(FILES_PATH)) return await serveFile(env.FILES, req);\n const api = /^\\/api\\/([^/]+)\\/?$/.exec(pathname);\n if (api) {\n const handler = API[api[1]!];\n return handler ? await handler(req, env) : Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n }\n return env.ASSETS.fetch(req);\n } catch (error) {\n return serverError(error);\n }\n}\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "lib": ["ES2022"],\n "types": ["@cloudflare/workers-types"],\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["ambasada.config.ts", "src", "api", "lib"]\n}\n', "web/index.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n </head>\n <body>\n <main>\n <h1>Hello, world</h1>\n <p>Edit <code>web/index.html</code> to start.</p>\n </main>\n </body>\n</html>\n', "web/privacy.html": `<!doctype html>
257
80
  <!-- /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
258
81
  (docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes. -->
259
82
  <html lang="en">
@@ -261,59 +84,20 @@ a { color: var(--color-accent-text); }
261
84
  <meta charset="utf-8" />
262
85
  <meta name="viewport" content="width=device-width, initial-scale=1" />
263
86
  <title>Privacy \xB7 My App</title>
264
- <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />
265
87
  </head>
266
88
  <body>
267
- <main class="ui-page">
89
+ <main>
268
90
  <h1>Privacy</h1>
269
91
  <p>What My App keeps about you, and why.</p>
270
92
  <ul>
271
93
  <li>When you sign in with Ambasada: your Ambasada ID, name and avatar, in a cookie on this site only. Signing out deletes it.</li>
272
- <li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when your order is ready. Turn them off in the app and we forget it.</li>
273
- <li>Notes you post are kept in the database of My App and shown to everyone who opens it; a file you upload is public at its link.</li>
94
+ <li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when something you wait for is ready. Turn them off in the app and we forget it.</li>
274
95
  </ul>
275
96
  <p><a href="/">\u2190 back</a></p>
276
97
  </main>
277
98
  </body>
278
99
  </html>
279
- `, "web/room.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>Live room</title>\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />\n <link rel="stylesheet" href="/style.css" />\n </head>\n <body>\n <!-- A live room (src/realtime/room.ts): open this page in two tabs and type. ?room=<id> picks the room. -->\n <main class="page">\n <h1>Live room</h1>\n <p class="ui-label" id="status">connecting\u2026</p>\n <ul id="messages"></ul>\n <form id="send"><input name="text" autocomplete="off" placeholder="Say something" /> <button class="ui-button">Send</button></form>\n <p><a href="/">\u2190 back</a></p>\n </main>\n <script>\n const room = new URLSearchParams(location.search).get("room") || "lobby";\n const list = document.getElementById("messages");\n const status = document.getElementById("status");\n const show = (m) => list.insertAdjacentHTML("beforeend", "<li></li>") || (list.lastElementChild.textContent = `${m.name}: ${m.text}`);\n let ws;\n function connect() {\n ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/rooms/${encodeURIComponent(room)}/ws?name=guest`);\n ws.onmessage = (e) => {\n const event = JSON.parse(e.data);\n if (event.type === "history") (list.textContent = "", event.messages.forEach(show));\n else if (event.type === "message") show(event.message);\n else status.textContent = `${event.online} online`;\n };\n ws.onclose = () => ((status.textContent = "reconnecting\u2026"), setTimeout(connect, 2000));\n }\n connect();\n document.getElementById("send").onsubmit = (e) => {\n e.preventDefault();\n if (ws.readyState === WebSocket.OPEN && e.target.text.value.trim()) ws.send(JSON.stringify({ text: e.target.text.value }));\n e.target.text.value = "";\n };\n </script>\n </body>\n</html>\n', "web/style.css": `/*
280
- * This product's own styles, on top of @ambasada/ui: the Ambasada tokens (var(--color-\u2026), var(--space-\u2026))
281
- * and components (ui-card, ui-button, ui-label, \u2026), loaded first by app/layout.tsx (next), src/main.tsx (spa)
282
- * or a <link> in web/index.html (worker). Use the tokens rather than raw values. The same file in all three.
283
- */
284
- /* The fallbacks are for a page that loads only this file. */
285
- :root {
286
- font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
287
- color: var(--color-ink, #0c0a09);
288
- background: var(--color-bg, #fafaf9);
289
- line-height: 1.5;
290
- }
291
- * { box-sizing: border-box; }
292
- body { margin: 0; }
293
- /* Inside the Ambasada app, @ambasada/miniapp sets the safe-area and host-bar variables; on the web they are 0. */
294
- .page { max-width: var(--page-max, 30rem); margin: 0 auto; padding: calc(var(--space-10) + var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)) var(--space-5) calc(var(--space-10) + var(--space-6) + var(--ambasada-safe-area-bottom, 0px)); }
295
- .topbar + .page { padding-top: calc(var(--space-10) + var(--space-4)); }
296
- .eyebrow { margin: 0; }
297
- .eyebrow::before { content: "// "; }
298
- h1 { margin: var(--space-2) 0 var(--space-3); font-size: clamp(2.2rem, 7vw, 3.4rem); font-weight: var(--weight-semibold); line-height: 1.05; letter-spacing: -0.03em; }
299
- .lead { margin: 0 0 var(--space-8); font-size: 1.125rem; color: var(--color-ink-2); }
300
- textarea, select { font: inherit; font-size: var(--text-base); color: inherit; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface); }
301
- textarea:focus, select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-soft); }
302
- textarea { resize: vertical; min-height: 7rem; }
303
- .row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; }
304
- label { display: flex; gap: var(--space-2); align-items: center; color: var(--color-ink-2); }
305
- .small, footer { color: var(--color-ink-3); font-size: var(--text-sm); }
306
- footer { margin-top: calc(var(--space-10) + var(--space-2)); }
307
- code { font-family: var(--font-mono); font-size: 0.9em; background: var(--color-surface-2); padding: 0.1em 0.35em; border-radius: var(--radius-xs); }
308
- blockquote { margin: var(--space-6) 0; padding: var(--space-4) var(--space-5); background: var(--color-surface); border-left: 4px solid var(--color-accent); border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: 1.125rem; }
309
- a { color: var(--color-accent-text); }
310
- .topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); max-width: var(--page-max, 30rem); margin: 0 auto; padding: calc(var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)) var(--space-5) 0; }
311
- .brand { font-weight: var(--weight-bold); color: var(--color-ink); text-decoration: none; }
312
- .account { display: flex; align-items: center; gap: var(--space-2); font-weight: var(--weight-semibold); }
313
- .account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
314
- .account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
315
- [hidden] { display: none !important; }
316
- `, "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: src/worker.ts answers /, /api/*, /files/*, /thanks and /health; web/ is served as static assets.\n "name": "my-app",\n "main": "src/worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": "./web",\n "binding": "ASSETS",\n "run_worker_first": ["/", "/api/*", "/files/*", "/thanks", "/health", "/mcp"]\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, miniapp: { "README.md": '# The mini-app pieces\n\nWhat `ambasada miniapp enable` and `ambasada init --miniapp` add to an app made from templates/{next,spa,worker}\n(docs/MINI-APPS.md \xA715, decision 15.6). Not a template on its own: packages/cli/src/miniapp.ts writes these files.\n\n- `__ENTRY__` is `miniapp.entry` ("/app"), `__ENTRY_DIR__` the same without the leading slash ("app"), `__ROOT__`\n the way back from the file to the app\'s root ("../..").\n- Entry `/`: only the glue (`in-app.tsx`, `shell.ts`, `in-app.js`), hooked into the root (app/layout.tsx,\n src/main.tsx, web/index.html). Any other entry: the in-app layout and page under that path too.\n- `icon.png`: the 1024\xD71024 placeholder the manifest points at.\n- On open the glue signs the person in silently when the manifest\'s `scopes` include `identity` (docs/MINI-APPS.md\n \xA715, decision 16): the page stays hidden (\u2264 5 s, under the app\'s splash) while `auth.signInSilently()` runs, then\n reloads signed in, or shows with its "Sign in with Ambasada" button when the shell says no.\n', "icon.png": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAAACXBIWXMAAAsTAAALEwEAmpwYAAAalUlEQVR42u3Xuw3CQBRFwa2IZnACZeAAauejtSwCp8ScuZoKVsi8M+Y4AQAAEcMTAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAAAsArAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAAAsArAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAOAHy7rzDgACAID/d77N52u+P/P68BoAAgCAwPX/nQYAEAAAVK5/DQAgAABoXf8aAEAAANC6/jUAgAAAoHX9awAAAQBA6/rXAAACAIDW9a8BAAQAAK3rXwMACAAAWte/BgAQAAC0rn8NACAAAGhd/xoAQAAA0Lr+NQCAAACgdf1rAAABAEDr+tcAAAIAgNb1rwEABAAAretfAwAIAABa178GABAAALSufw0AIAAAaF3/GgBAAADQuv41AIAAAKB1/WsAAAEAQOv61wAAAgCA1vWvAQAEAACt618DAAgAAFrXvwYAEAAAtK5/DQAgAABoXf8aAEAAANC6/jUAgAAAoHX9awAAAQBA6/rXAAACAIDW9a8BAAQAAK3rXwMACAAAWte/BgAQAAC0rn8NACAAAGhd/xoAQAAA0Lr+NQCAAACgdf1rAAABAEDr+tcAAAIAgNb1rwEABACA6791/WsAAAEA4PovTgMACAAA178GAEAAALj+NQAAAgDA9a8BABAAAK5/DQCAAABw/WsAAAQAgOtfAwAIAABc/xoAQAAA4PrXAAACAADXvwYAEAAAuP41AIAAAHD9mwYAEAAArn/TAAACAMD1rwE0AIAAAHD9awAABACA618DACAAAFz/GgAAAQDg+tcAAAIAANe/BgAQAAC4/jUAgAAAwPWvAQAEAIDr3zQAgAAAcP2bBgAQAACuf9MAAAIAwPVvGgBAAAC4/k0DAAgAANe/BtAAAAIAwPWvAQAQAACufw0AIAAAcP1rAAABAIDrXwMACAAA179pAAABAOD6Nw0AIAAAXP+mAQAEAIDr3zQAgAAAcP2bBgAQAACuf9MAAAIAwPVvGgBAAAC4/k0DAAgAANe/BvBrBwSAJwBc/65/DQAgAABc/6YBAAQAgOvfNACAAABw/ZsGABAAAK5/0wAAAgDA9W8aAEAAALj+TQMACAAA179pAAABAOD6Nw0AIAAAXP+mAQAEAOD6d/2bBgAQAIDr30wDAAIAwPVvGgBAAAC4/k0DAAgAANe/aQAAAQDg+jcNACAAAFz/pgEABACA6980AIAAAHD9mwYAEAAArn/TAAACAHD9m2kAAAEAuP7NNACAAABc/2YaABAAAK5/Mw0ACAAA17+ZBgAEAIDr3zQAgAAAcP2bBgAQAACuf9MAAAIAwPVvGgBAAAC4/k0DAAgAwPVvpgEABADg+jfTAAACAHD9m2kAAAEAuP7NNAAgAABc/2YaABAAAK5/Mw0ACAAA17+ZBgAEAIDr30wDAAIAwPVvGgBAAAC4/k0DAAgAwPVvpgEABADg+jfTAAACAHD9m2kAAAEAuP7NNACAAABc/2YaABAAAK5/Mw0ACAAA17+ZBgAEAIDr30wDAAIAwPVvpgEAAQDg+jfTAIAAAFz/ZqYBAAEAuP7NNACAAABc/2YaAEAAAK5/Mw0AIAAA17+ZBgAQAIDr30wDAAgAwPVvpgEAAQDg+jfTAIAAAHD9m2kAQAAAuP7NNAAgAADXv5lpAEAAAK5/M9MAgAAAXP9mpgEAAQC4/s1MAwACAHD9m2kAAAEAuP7NNACAAABc/2YaAEAAAK5/Mw0ACAAA17+ZBgAEAIDr30wDAAIAcP2bmQYABADg+jczDQAIAMD1b2YaABAAgOvfzDQAIAAA17+ZaQBAAACufzPTAIAAAFz/ZqYBAAEAuP7NNACAAABc/2YaABAAAK5/Mw0ACADA9W9mGgAQAIDr38w0ACAAANe/mWkAQAAArn8z0wCAAABc/2amAQABALj+zUwDAAIAcP2bmQYABADg+jczDQAIAMD1b2YaABAAgOvfzDQACADA9W9mGgAQAIDr38w0ACAAANe/mWkAQAAArn8z0wCAAABc/2amAQABALj+zUwDAAIAcP2bmQYABADg+jczDQAIAMD1b2YaABAAgOvfzDQAIADA9W9mpgFAAHgFcP2bmWkAEACA69/MNIAGAAEAuP7NTAMAAgBw/ZuZBgAEAOD6NzMNAAgAwPVvZhoAEACA69/MNAAgAADXv5lpAEAAAK5/M9MAgAAA17+ZmQYABAC4/s3MNAAgAMD1b2amAUAAAK5/MzMNAAIAcP2bmWkAEACA69/MNIAGAAEAuP7NTAMAAgBw/ZuZBgAEAOD6NzMNAAgAwPVvZhoAEADg+jcz0wCAAADXv5mZBgAEALj+zcw0ACAAwPVvZqYBQAAArn8zMw0AAgBw/ZuZaQAQAIDr38xMA4AAAFz/ZmYaAAQA4Po3Mw3gmw8CAFz/rn8z0wCAAADXv5mZBgAEALj+zcw0ACAAwPVvZqYBAAEArn8zMw0ACABw/ZuZaQAQAIDr38xMA4AAAFz/ZmYaAAQA4Po3M9MAIAAA17+ZmQYAAQCuf9e/mZkGAAEArn8zM9MAIADA9W9mpgEAAQCufzMzDQAIAHD9m5lpAEAAgOvfzEwDAAIAXP9mZhoABADg+jcz0wAgAADXv5mZBgABALj+zcw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzPTAIAAANe/mZkGAAQAuP7NzDQAIADA9W9mpgFAAACufzMzDQACAFz/ZmamAUAAgOvfzMw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACAFz/ZmYaABAA4Po3M9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACAFz/ZmamAUAAgOvfzMw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAMD1b2ZmpgEQAOD6NzMzDQACAFz/ZmamAUAAgOvfzMw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACANe/mZmZBgABgOvfzMxMAyAAwPVvZmamARAA4Po3MzPTAAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACANe/mZmZBgABgOvfzMxMA4AAwPVvZmamARAAXgHXv5mZmQZAAIDr38zMTAMgAMD1b2ZmpgEQAOD6NzMz0wAIAHD9m5mZBgABAK5/MzPTACAAwPVvZmYaAAQArn8zMzMNAAIA17+ZmZkGAAGA69/MzEwDgADA9W9mZqYBQADg+jczM9MACABw/ZuZmWkABAC4/s3MzDQAAgBc/2ZmZhoAAQCufzMzMw2AAAABYGZmAuBydwkgANAAZmZmrn8QAGgAMzMz1z8IADSAmZmZ6x8EABrAzMzM9Q8CAA1gZmbm+gcBgAYwMzNz/SMAQAOYmZm5/hEAoAHMzMxc/wgA0ABmZmaufwQAaAAzM3P9+0NHAIAGMDMz1z8IANAAZmbm+gcBABrAzMxc/yAA0ABmZmaufxAAaAAzMzPXPwgANICZmZnrHwQAGsDMzMz1jwAADWBmZub6RwCABjAzM3P9IwBAA5iZmesfBABoADMzc/2DAAANYGZmrn8QAKABzMzM9Q8CADSAmZm5/kEAgAYwMzPXPwgA0ABmZub6BwGABjAzM3P9gwBAA5iZmbn+EQCgAczMzFz/CADQAGZm5voHAQAawMzMXP8gAEADmJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzMz1DwIANICZmbn+QQCABjAzM9c/CADQAGZm5voHAQAawMzMXP8IAK+ABjAzM9c/CADQAGZm5voHAQAawMzMXP8gAEADmJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzMz1DwIANICZmbn+QQCABjAzM9c/CADQAGZm5voHAQAawMzM9Q8IANAAZmaufxAAoAE0gJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzMz1DwIANICZmbn+QQCABjAzM9c/CADQAGZm5voHAQAawMzMXP8gAEADmJm5/gEBABrAzMz1DwgA0ABmZq5/EACABjAzc/2DAAA0gJmZ6x8EAGgADWBm5voHAQAawMzMXP8gAEADmJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzFz/gAAADWBm5voHBABoADMz1z8gAEADmJm5/kEAABrAzMz1DwIA0ABmZq5/EACABjAzc/2DAAA0gJmZ6x8EAGgAf+tmZq5/EACgAczMzPUPAgA0gJmZ6x8QAKABzMxc/4AAAA1gZub6BwQAaAAzM9c/IABAA5iZuf5BAAAawMzM9Q8CANAAZmaufxAAgAYwM3P9gwAANICZmesfBACgAczMXP8gAAANYGauf0AAgAYwM3P9AwIANICZmesfEACgAczMXP+AAAANYGbm+gcEAGgAMzPXPwgATwAawMzM9Q8CANAAZmaufxAAgAYwM3P9gwAANICZmesfBACgAczM9Q8IAEADmJnrHxAAgAYwM9c/IAAADWBmrn9AAIAGMDNz/QMCADSAmZnrHxAAoAHMzFz/gAAADWBm5voHAQBoADMz1z8IAEADmJnr3/UPAgDQAGbm+gcEAKABzMz1DwgAQAOYmesfEACABjAz1z8gAAANYGauf0AAABrAzFz/gAAANICZuf4BAQAawMzM9Q8IANAAZmaufxAAgAYwM9e/6x8EAKABzMz1DwgAQAOYmesfEACABjAz1z8gAAANYGauf0AAABrAzFz/gAAANICZuf4BAQBoADNz/QMCANAAZub6BwQAoAHMzPUPCABAA5i5/n0DQQAAGsDMXP+AAAA0gJm5/gEBAGgAM3P9AwIA0ABm5voHBACgAczM9Q8IAEADmJnrHxAAgAYwM9c/IAAADWBmrn9AAAAawMxc/4AAADSAmevfpwwQAIAGMHP9AwIAQAOYuf4BAQCgAcxc/4AAADSAmbn+AQEAaAAzc/0DAgDQAGbm+gcEAKABzMz1DwgAQAOYmesfEACABjAz1z8gAAANYOb6BxAAgAYwc/0DCABAA5i5/gEEAKABzFz/gAAA0ABmrn9AAABoADPXPyAAADSAmesfEACABjAz1z8gAAANYGauf0AAABrAzFz/gAAANICZ6x9AAAAawMz1DyAAAA1g5voHEACABjBz/QMIAEADmLn+AQEAoAHMXP+AAADQAGauf0AAAGgAM9c/IAAANICZ6x8QAAAawMz1DwgAQAOYuf4BBACgAcxc/wACANAAZq5/AAEAaAAz1z+AAAA0gJnrH0AAABrAzPUPCAAADWDm+gcEAIAGMHP9AwIAQAOYuf4BAQCgAcxc/4AAANAA5voHEAAAGsBc/wACANAAGsBc/wACANAAZq5/AAEAaAAz1z+AAAA0gJnrH0AAABrAzPUPCAAADWDm+gcEAIAGMHP9AwIAQAOYuf4BAQCgAcz1DyAAADSAuf4BBACABjDXP4AAANAA5voHEAAAGsBc/wACANAAGsBc/wACANAAZq5/AAEAaAAz1z8gALwCoAHMXP+AAADQAGauf0AAAGgAc/0DCAAADWCufwABAKABzPUPIAAANIC5/gEEAIAGMNc/gAAA0ADm+gcQAAAawFz/AAIAQAOY6x9AAAAawHFrrn8AAQBoADPXPyAAADSAuf4BBACABjDXP4AAANAA5voHEAAAGsBc/wACAEADmOsfQAAAaABz/QMIAAANYK5/AAEAoAHM9Q8gAAA0gLn+AQQAgAYw1z+AAADQAK5/AAEAoAHM9Q8gAAA0gLn+AQQAgAYw1z+AAADQAOb6BxAAABrAXP8AAgBAA5jrH0AAAGgAc/0DCAAADWCufwABAKABzPUPIAAANIDrHwABAKABXP8AAgAADeD6BxAAAGgA1z+AAADQAOb6BxAAABrAXP8AAgBAA5jrH0AAAGgAc/0DCAAADWCufwABAKABXP+ufwABAKABXP8ACAAADeD6B0AAAGgA1z+AAABAA7j+AQQAABrA9Q8gAADQAK5/AAEAgAZw/QMIAAANYK5/AAEAoAHM9Q8gAAA0gOvf9Q8gAAA0gOsfAAEAoAFc/wAIAAAN4PoHQAAAaADXPwACAEADuP4BBAAAGsD1DyAAANAArn8AAQCABnD9AwgAADSA6x9AAACQbgDXP4AAANAArn8ABACABnD9AyAAADSA6x8AAQCgAVz/AAgAAA3g+gdAAABoANc/gAAAQAO4/gEEAAAawPUPIAAA0ACufwABAEC9AVz/AAIAgEoDuP4BBAAAlQZw/QMIAAAqDeD6BxAAAFQawPUPIAAAqDSA6x9AAABQaQDXP4AAAKDSAK5/AAEAQKUBXP8AAgCASgO4/gEEAACVBnD9AwgAACoN4PoHEAAAVBrA9Q8gAACoNIDrH0AAAFBpANc/gAAAoNIArn8AAQBApQFc/wACAIBKA7j+AQQAAJUGcP0DCAAAKg3g+gcQAABUGsD1DyAAAKg0gOsfQAAAUGkA1z+AAACg0gCufwABAEClAVz/AAIAgEoDuP4BBAAAlQZw/QMIAAAqDeD6BxAAAFQawPUPIAAAqDSA6x9AAABQaQDXP4AAAKBiWXfeAUAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgATwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgArwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAOGzzA06D16OwLgAAAABJRU5ErkJggg==", "next/in-app.tsx": '"use client";\n\nimport { auth, mockEnv, ready } from "@ambasada/miniapp";\nimport { useEffect } from "react";\n\n/** Set before the reload that follows a silent sign-in, so a session that did not stick can\'t loop. */\nconst RELOADED = "ambasada_signed_in";\n\n/**\n * On open inside the Ambasada app (docs/MINI-APPS.md \xA75, decision 16): when the manifest asks for `identity` and\n * nobody is signed in, signs the person in before the page shows. `auth.signInSilently()` gets a shell token and\n * POSTs it to /api/auth/shell, which sets the same session as "Sign in with Ambasada"; the page then reloads, rendered\n * signed in. Nothing shows meanwhile (at most 5 s; the app\'s splash stays until `ready()`). When the shell says no,\n * the page shows with its "Sign in with Ambasada" button. On the web: only `ready()`, a no-op. Under `ambasada dev`,\n * `?ambasada_shell=mock` pretends to be inside the app (the mock signs the token).\n */\nexport function InApp() {\n useEffect(() => {\n started ??= startInApp(); // once per page load (React runs effects twice in development)\n }, []);\n return null;\n}\n\nlet started: Promise<void> | undefined;\n\nasync function startInApp(): Promise<void> {\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock";\n const page = document.documentElement.style;\n if (mock || auth.canSignInSilently()) page.visibility = "hidden";\n const me = (await fetch("/api/auth/me")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { user?: unknown; dev_issuer?: string } | null;\n if (mock && me?.dev_issuer) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n if (me?.user) sessionStorage.removeItem(RELOADED);\n else if (!sessionStorage.getItem(RELOADED) && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {\n sessionStorage.setItem(RELOADED, "1");\n return location.reload();\n }\n page.visibility = "";\n ready();\n}\n\n/** Inside the app, and the manifest\'s `scopes` (`miniapp.scopes` in ambasada.config.ts) include identity. */\nasync function wantsIdentity(): Promise<boolean> {\n if (!auth.canSignInSilently()) return false;\n const manifest = (await fetch("/.well-known/ambasada.json")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { scopes?: unknown } | null;\n // Unreadable: try anyway, the shell knows what Install granted.\n return !Array.isArray(manifest?.scopes) || manifest.scopes.includes("identity");\n}\n', "next/layout.tsx": 'import { Page } from "@ambasada/ui/react";\nimport type { ReactNode } from "react";\nimport { InApp } from "./in-app.tsx";\n\n/**\n * The mini app: what the Ambasada app opens (`miniapp.entry` in ambasada.config.ts). Outside the (site) group, so\n * no site header: the Ambasada app draws its own bar, and `Page` (ui-page) keeps clear of it and the safe areas.\n */\nexport default function InAppLayout({ children }: { children: ReactNode }) {\n return (\n <Page>\n {children}\n <InApp />\n </Page>\n );\n}\n', "next/page.tsx": 'import { Card } from "@ambasada/ui/react";\nimport { ambasada, currentUser } from "__ROOT__/lib/ambasada.ts";\n\n// Who is signed in comes from the request: render per request, not at build time.\nexport const dynamic = "force-dynamic";\n\n/**\n * The mini app\'s first screen. Build the in-app experience under this folder; the site stays as it is. Inside the\n * Ambasada app people are signed in before it shows (in-app.tsx); the button is for the web, and for an app the\n * person opened without installing it.\n */\nexport default async function InAppHome() {\n const user = await currentUser();\n return (\n <>\n <p className="ui-label">My App</p>\n <h1>{user ? `Hello, ${user.name ?? "you"}` : "Hello"}</h1>\n {!user && ambasada().auth.configured && (\n <a className="ui-button" href="/api/auth/login?returnTo=__ENTRY__">\n Sign in with Ambasada\n </a>\n )}\n <Card title="In the Ambasada app">\n <p>\n This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>app/__ENTRY_DIR__/page.tsx</code>.\n </p>\n </Card>\n </>\n );\n}\n', "spa/InApp.tsx": 'import { Card, Page } from "@ambasada/ui/react";\nimport { useEffect, useState } from "react";\nimport { startInApp, type InAppUser } from "./shell.ts";\n\n/** Where the mini app lives (`miniapp.entry` in ambasada.config.ts): src/main.tsx renders InApp there. */\nexport const IN_APP = "__ENTRY__";\nexport const isInApp = (path: string) => path === IN_APP || path.startsWith(`${IN_APP}/`);\n\n/**\n * The mini app: what the Ambasada app opens. No site header: the Ambasada app draws its own bar, and `Page`\n * (ui-page) keeps clear of it and the safe areas. Build the in-app experience here; the site stays as it is.\n * Nothing renders until startInApp() knows who it is: inside the app it signs the person in first (src/in-app/shell.ts).\n */\nexport function InApp() {\n const [me, setMe] = useState<{ user: InAppUser | null; configured: boolean } | null>(null);\n useEffect(() => {\n void startInApp().then(setMe);\n }, []);\n if (!me) return null;\n return (\n <Page>\n <p className="ui-label">My App</p>\n <h1>{me.user ? `Hello, ${me.user.name ?? "you"}` : "Hello"}</h1>\n {!me.user && me.configured && (\n // The web, or the app opened without installing it: inside the app with identity granted nobody sees this.\n <a className="ui-button" href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>\n Sign in with Ambasada\n </a>\n )}\n <Card title="In the Ambasada app">\n <p>\n This is <code>{IN_APP}</code>, the page the Ambasada app opens. Edit <code>src/in-app/InApp.tsx</code>.\n </p>\n </Card>\n </Page>\n );\n}\n', "spa/shell.ts": 'import { auth, mockEnv, ready } from "@ambasada/miniapp";\n\n/** What GET /api/auth/me says about the person (api/auth.ts). */\nexport type InAppUser = { id: string; name: string | null; avatar_url: string | null };\n\n/** Set before the reload that follows a silent sign-in, so a session that did not stick can\'t loop. */\nconst RELOADED = "ambasada_signed_in";\n\n/**\n * On open inside the Ambasada app (docs/MINI-APPS.md \xA75, decision 16): when the manifest asks for `identity` and\n * nobody is signed in, signs the person in before the page shows. `auth.signInSilently()` gets a shell token and\n * POSTs it to /api/auth/shell, which sets the same session as "Sign in with Ambasada". Nothing shows meanwhile (at\n * most 5 s; the app\'s splash stays until `ready()`). When the shell says no, the page shows with its "Sign in with\n * Ambasada" button. On the web: only `ready()`, a no-op. Under `ambasada dev`, `?ambasada_shell=mock` pretends to be\n * inside the app (the mock signs the token).\n *\n * Resolves who is signed in, and whether the app has Sign in with Ambasada at all. With `onSignedIn` (the whole site\n * is the mini app: `location.reload()`), a silent sign-in hands over to it and the page stays hidden.\n */\nexport function startInApp(onSignedIn?: () => void): Promise<{ user: InAppUser | null; configured: boolean }> {\n return (started ??= start(onSignedIn)); // once per page load (React runs effects twice in development)\n}\n\nlet started: ReturnType<typeof startInApp> | undefined;\n\nasync function start(onSignedIn?: () => void): Promise<{ user: InAppUser | null; configured: boolean }> {\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock";\n const page = document.documentElement.style;\n if (mock || auth.canSignInSilently()) page.visibility = "hidden";\n const me = (await fetch("/api/auth/me")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { user?: InAppUser | null; configured?: boolean; dev_issuer?: string } | null;\n if (mock && me?.dev_issuer) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n let user = me?.user ?? null;\n if (user) sessionStorage.removeItem(RELOADED);\n else if (!sessionStorage.getItem(RELOADED) && (await wantsIdentity())) {\n user = await auth.signInSilently<InAppUser>({ me: false });\n if (user && onSignedIn) {\n sessionStorage.setItem(RELOADED, "1");\n onSignedIn();\n return { user, configured: true };\n }\n }\n page.visibility = "";\n ready();\n return { user, configured: me?.configured ?? false };\n}\n\n/** Inside the app, and the manifest\'s `scopes` (`miniapp.scopes` in ambasada.config.ts) include identity. */\nasync function wantsIdentity(): Promise<boolean> {\n if (!auth.canSignInSilently()) return false;\n const manifest = (await fetch("/.well-known/ambasada.json")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { scopes?: unknown } | null;\n // Unreadable: try anyway, the shell knows what Install granted.\n return !Array.isArray(manifest?.scopes) || manifest.scopes.includes("identity");\n}\n', "worker/in-app.js": '// On open inside the Ambasada app (the mini app, `miniapp.entry` in ambasada.config.ts; docs/MINI-APPS.md \xA75,\n// decision 16): when the manifest asks for `identity` and nobody is signed in, sign the person in before the page\n// shows. `ambasada.auth.signInSilently()` gets a shell token and POSTs it to /api/auth/shell, which sets the same\n// session as "Sign in with Ambasada"; the page then reloads, rendered signed in. Nothing shows meanwhile (at most 5 s;\n// the app\'s splash stays until ready()). When the shell says no, the page shows with its "Sign in with Ambasada"\n// button. Needs https://hub.ambasada.id/miniapp.js before it; on the web it only fills in the name.\n// Under `ambasada dev`, ?ambasada_shell=mock pretends to be inside the app (the mock signs the token).\nconst RELOADED = "ambasada_signed_in"; // set before that reload, so a session that did not stick can\'t loop\n\naddEventListener("DOMContentLoaded", async () => {\n const a = window.ambasada;\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock";\n const page = document.documentElement.style;\n if (a && (mock || canSignIn(a))) page.visibility = "hidden";\n const me = await fetch("/api/auth/me").then((r) => r.json()).catch(() => ({}));\n if (a && mock && me.dev_issuer) {\n const token = await fetch(`${me.dev_issuer}/dev/shell-token?aud=${encodeURIComponent(location.origin)}`).then((r) => r.json());\n a.mockEnv({ answers: { "auth.getToken": token } });\n }\n if (me.user) sessionStorage.removeItem(RELOADED);\n else if (a && !sessionStorage.getItem(RELOADED) && (await wantsIdentity(a)) && (await signInSilently(a))) {\n sessionStorage.setItem(RELOADED, "1");\n return location.reload();\n }\n for (const el of document.querySelectorAll("[data-in-app-name]")) if (me.user) el.textContent = `, ${me.user.name ?? "you"}`;\n for (const el of document.querySelectorAll("[data-in-app-signin]")) el.hidden = !!me.user || !me.configured;\n page.visibility = "";\n a?.ready();\n});\n\n/** Inside the app, with a shell that mints tokens and a person to sign in (older miniapp.js builds: by hand). */\nfunction canSignIn(a) {\n if (a.auth.canSignInSilently) return a.auth.canSignInSilently();\n return a.capabilities().includes("auth.getToken") && !!a.context().user;\n}\n\n/** The manifest\'s `scopes` (`miniapp.scopes` in ambasada.config.ts) include identity; unreadable: try, the shell decides. */\nasync function wantsIdentity(a) {\n if (!canSignIn(a)) return false;\n const manifest = await fetch("/.well-known/ambasada.json").then((r) => (r.ok ? r.json() : null)).catch(() => null);\n return !Array.isArray(manifest?.scopes) || manifest.scopes.includes("identity");\n}\n\n/** The user, or null (the shell said no, the server refused, 5 s gone by). */\nasync function signInSilently(a) {\n if (a.auth.signInSilently) return a.auth.signInSilently({ me: false });\n const late = new Promise((resolve) => setTimeout(resolve, 5000, null));\n const run = async () => {\n const { token } = await a.auth.getToken();\n const res = await fetch("/api/auth/shell", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token }) });\n return res.ok ? (await res.json()).user : null;\n };\n return Promise.race([run().catch(() => null), late]);\n}\n', "worker/page.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n <!-- The mini app: what the Ambasada app opens (`miniapp.entry` in ambasada.config.ts). No site header: the\n Ambasada app draws its own bar, and ui-page keeps clear of it and the safe areas. -->\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />\n <link rel="stylesheet" href="/style.css" />\n <script src="https://hub.ambasada.id/miniapp.js" defer></script>\n <script src="/in-app.js" defer></script>\n </head>\n <body>\n <main class="ui-page">\n <p class="ui-label">My App</p>\n <h1>Hello<span data-in-app-name></span></h1>\n <!-- in-app.js shows it when nobody is signed in: on the web, or opened without installing. Inside the app with\n identity granted the person is signed in before the page shows. -->\n <a class="ui-button" href="/api/auth/login?returnTo=__ENTRY__" data-in-app-signin hidden>Sign in with Ambasada</a>\n <section class="ui-card">\n <h2 class="ui-card-title">In the Ambasada app</h2>\n <p>This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>web/__ENTRY_DIR__.html</code>.</p>\n </section>\n </main>\n </body>\n</html>\n' } };
100
+ `, "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: src/worker.ts answers /api/*, /files/* and /health (with the village add-on, /example, /thanks and /mcp too);\n // web/ is served as static assets.\n "name": "my-app",\n "main": "src/worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": "./web",\n "binding": "ASSETS",\n "run_worker_first": ["/api/*", "/files/*", "/example", "/thanks", "/health", "/mcp"]\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, miniapp: { "README.md": '# The mini-app pieces\n\nWhat `ambasada miniapp enable` and `ambasada init --miniapp` add to an app made from templates/{next,spa,worker}\n(docs/MINI-APPS.md \xA715, decision 15.6). Not a template on its own: packages/cli/src/miniapp.ts writes these files.\n\n- `__ENTRY__` is `miniapp.entry` ("/app"), `__ENTRY_DIR__` the same without the leading slash ("app"), `__ROOT__`\n the way back from the file to the app\'s root ("../..").\n- Entry `/`: only the glue (`in-app.tsx`, `shell.ts`, `in-app.js`), hooked into the root (app/layout.tsx,\n src/main.tsx, web/index.html). Any other entry: the in-app layout and page under that path too.\n- Inside the Ambasada app, at `/`, the glue pads `<body>` by the app\'s bar and the safe areas\n (`--ambasada-host-bar`, `--ambasada-safe-area-*`), so the builder\'s own unstyled pages aren\'t under the bar. Host\n chrome only; never on the web, never on a page with `ui-page` (the in-app layout pads itself).\n- `icon.png`: the 1024\xD71024 placeholder the manifest points at.\n- On open the glue signs the person in silently when the manifest\'s `scopes` include `identity` (docs/MINI-APPS.md\n \xA715, decision 16): the page stays hidden (\u2264 5 s, under the app\'s splash) while `auth.signInSilently()` runs, then\n reloads signed in, or shows with its "Sign in with Ambasada" button when the shell says no.\n- Inside the Ambasada app, the app\'s account is the one signed in: a session for someone else (an earlier "Sign in\n with Ambasada" in the same web view) is replaced the same way, once per account per web view session. A change to\n these sign-in lines also goes into `GLUE` in packages/cli/src/upgrade.ts, for apps made with an older CLI.\n', "icon.png": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAAACXBIWXMAAAsTAAALEwEAmpwYAAAalUlEQVR42u3Xuw3CQBRFwa2IZnACZeAAauejtSwCp8ScuZoKVsi8M+Y4AQAAEcMTAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAAAsArAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAAAsArAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAOAHy7rzDgACAID/d77N52u+P/P68BoAAgCAwPX/nQYAEAAAVK5/DQAgAABoXf8aAEAAANC6/jUAgAAAoHX9awAAAQBA6/rXAAACAIDW9a8BAAQAAK3rXwMACAAAWte/BgAQAAC0rn8NACAAAGhd/xoAQAAA0Lr+NQCAAACgdf1rAAABAEDr+tcAAAIAgNb1rwEABAAAretfAwAIAABa178GABAAALSufw0AIAAAaF3/GgBAAADQuv41AIAAAKB1/WsAAAEAQOv61wAAAgCA1vWvAQAEAACt618DAAgAAFrXvwYAEAAAtK5/DQAgAABoXf8aAEAAANC6/jUAgAAAoHX9awAAAQBA6/rXAAACAIDW9a8BAAQAAK3rXwMACAAAWte/BgAQAAC0rn8NACAAAGhd/xoAQAAA0Lr+NQCAAACgdf1rAAABAEDr+tcAAAIAgNb1rwEABACA6791/WsAAAEA4PovTgMACAAA178GAEAAALj+NQAAAgDA9a8BABAAAK5/DQCAAABw/WsAAAQAgOtfAwAIAABc/xoAQAAA4PrXAAACAADXvwYAEAAAuP41AIAAAHD9mwYAEAAArn/TAAACAMD1rwE0AIAAAHD9awAABACA618DACAAAFz/GgAAAQDg+tcAAAIAANe/BgAQAAC4/jUAgAAAwPWvAQAEAIDr3zQAgAAAcP2bBgAQAACuf9MAAAIAwPVvGgBAAAC4/k0DAAgAANe/BtAAAAIAwPWvAQAQAACufw0AIAAAcP1rAAABAIDrXwMACAAA179pAAABAOD6Nw0AIAAAXP+mAQAEAIDr3zQAgAAAcP2bBgAQAACuf9MAAAIAwPVvGgBAAAC4/k0DAAgAANe/BvBrBwSAJwBc/65/DQAgAABc/6YBAAQAgOvfNACAAABw/ZsGABAAAK5/0wAAAgDA9W8aAEAAALj+TQMACAAA179pAAABAOD6Nw0AIAAAXP+mAQAEAOD6d/2bBgAQAIDr30wDAAIAwPVvGgBAAAC4/k0DAAgAANe/aQAAAQDg+jcNACAAAFz/pgEABACA6980AIAAAHD9mwYAEAAArn/TAAACAHD9m2kAAAEAuP7NNACAAABc/2YaABAAAK5/Mw0ACAAA17+ZBgAEAIDr3zQAgAAAcP2bBgAQAACuf9MAAAIAwPVvGgBAAAC4/k0DAAgAwPVvpgEABADg+jfTAAACAHD9m2kAAAEAuP7NNAAgAABc/2YaABAAAK5/Mw0ACAAA17+ZBgAEAIDr30wDAAIAwPVvGgBAAAC4/k0DAAgAwPVvpgEABADg+jfTAAACAHD9m2kAAAEAuP7NNACAAABc/2YaABAAAK5/Mw0ACAAA17+ZBgAEAIDr30wDAAIAwPVvpgEAAQDg+jfTAIAAAFz/ZqYBAAEAuP7NNACAAABc/2YaAEAAAK5/Mw0AIAAA17+ZBgAQAIDr30wDAAgAwPVvpgEAAQDg+jfTAIAAAHD9m2kAQAAAuP7NNAAgAADXv5lpAEAAAK5/M9MAgAAAXP9mpgEAAQC4/s1MAwACAHD9m2kAAAEAuP7NNACAAABc/2YaAEAAAK5/Mw0ACAAA17+ZBgAEAIDr30wDAAIAcP2bmQYABADg+jczDQAIAMD1b2YaABAAgOvfzDQAIAAA17+ZaQBAAACufzPTAIAAAFz/ZqYBAAEAuP7NNACAAABc/2YaABAAAK5/Mw0ACADA9W9mGgAQAIDr38w0ACAAANe/mWkAQAAArn8z0wCAAABc/2amAQABALj+zUwDAAIAcP2bmQYABADg+jczDQAIAMD1b2YaABAAgOvfzDQACADA9W9mGgAQAIDr38w0ACAAANe/mWkAQAAArn8z0wCAAABc/2amAQABALj+zUwDAAIAcP2bmQYABADg+jczDQAIAMD1b2YaABAAgOvfzDQAIADA9W9mpgFAAHgFcP2bmWkAEACA69/MNIAGAAEAuP7NTAMAAgBw/ZuZBgAEAOD6NzMNAAgAwPVvZhoAEACA69/MNAAgAADXv5lpAEAAAK5/M9MAgAAA17+ZmQYABAC4/s3MNAAgAMD1b2amAUAAAK5/MzMNAAIAcP2bmWkAEACA69/MNIAGAAEAuP7NTAMAAgBw/ZuZBgAEAOD6NzMNAAgAwPVvZhoAEADg+jcz0wCAAADXv5mZBgAEALj+zcw0ACAAwPVvZqYBQAAArn8zMw0AAgBw/ZuZaQAQAIDr38xMA4AAAFz/ZmYaAAQA4Po3Mw3gmw8CAFz/rn8z0wCAAADXv5mZBgAEALj+zcw0ACAAwPVvZqYBAAEArn8zMw0ACABw/ZuZaQAQAIDr38xMA4AAAFz/ZmYaAAQA4Po3M9MAIAAA17+ZmQYAAQCuf9e/mZkGAAEArn8zM9MAIADA9W9mpgEAAQCufzMzDQAIAHD9m5lpAEAAgOvfzEwDAAIAXP9mZhoABADg+jcz0wAgAADXv5mZBgABALj+zcw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzPTAIAAANe/mZkGAAQAuP7NzDQAIADA9W9mpgFAAACufzMzDQACAFz/ZmamAUAAgOvfzMw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACAFz/ZmYaABAA4Po3M9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACAFz/ZmamAUAAgOvfzMw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAMD1b2ZmpgEQAOD6NzMzDQACAFz/ZmamAUAAgOvfzMw0AAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACANe/mZmZBgABgOvfzMxMAyAAwPVvZmamARAA4Po3MzPTAAgAcP2bmZkGAAEArn8zM9MAIADA9W9mZhoABAC4/s3MTAOAAADXv5mZaQAQAOD6NzMzDQACANe/mZmZBgABgOvfzMxMA4AAwPVvZmamARAAXgHXv5mZmQZAAIDr38zMTAMgAMD1b2ZmpgEQAOD6NzMz0wAIAHD9m5mZBgABAK5/MzPTACAAwPVvZmYaAAQArn8zMzMNAAIA17+ZmZkGAAGA69/MzEwDgADA9W9mZqYBQADg+jczM9MACABw/ZuZmWkABAC4/s3MzDQAAgBc/2ZmZhoAAQCufzMzMw2AAAABYGZmAuBydwkgANAAZmZmrn8QAGgAMzMz1z8IADSAmZmZ6x8EABrAzMzM9Q8CAA1gZmbm+gcBgAYwMzNz/SMAQAOYmZm5/hEAoAHMzMxc/wgA0ABmZmaufwQAaAAzM3P9+0NHAIAGMDMz1z8IANAAZmbm+gcBABrAzMxc/yAA0ABmZmaufxAAaAAzMzPXPwgANICZmZnrHwQAGsDMzMz1jwAADWBmZub6RwCABjAzM3P9IwBAA5iZmesfBABoADMzc/2DAAANYGZmrn8QAKABzMzM9Q8CADSAmZm5/kEAgAYwMzPXPwgA0ABmZub6BwGABjAzM3P9gwBAA5iZmbn+EQCgAczMzFz/CADQAGZm5voHAQAawMzMXP8gAEADmJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzMz1DwIANICZmbn+QQCABjAzM9c/CADQAGZm5voHAQAawMzMXP8IAK+ABjAzM9c/CADQAGZm5voHAQAawMzMXP8gAEADmJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzMz1DwIANICZmbn+QQCABjAzM9c/CADQAGZm5voHAQAawMzM9Q8IANAAZmaufxAAoAE0gJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzMz1DwIANICZmbn+QQCABjAzM9c/CADQAGZm5voHAQAawMzMXP8gAEADmJm5/gEBABrAzMz1DwgA0ABmZq5/EACABjAzc/2DAAA0gJmZ6x8EAGgADWBm5voHAQAawMzMXP8gAEADmJmZ6x8EAGgAMzNz/YMAAA1gZmaufxAAoAHMzFz/gAAADWBm5voHBABoADMz1z8gAEADmJm5/kEAABrAzMz1DwIA0ABmZq5/EACABjAzc/2DAAA0gJmZ6x8EAGgAf+tmZq5/EACgAczMzPUPAgA0gJmZ6x8QAKABzMxc/4AAAA1gZub6BwQAaAAzM9c/IABAA5iZuf5BAAAawMzM9Q8CANAAZmaufxAAgAYwM3P9gwAANICZmesfBACgAczMXP8gAAANYGauf0AAgAYwM3P9AwIANICZmesfEACgAczMXP+AAAANYGbm+gcEAGgAMzPXPwgATwAawMzM9Q8CANAAZmaufxAAgAYwM3P9gwAANICZmesfBACgAczM9Q8IAEADmJnrHxAAgAYwM9c/IAAADWBmrn9AAIAGMDNz/QMCADSAmZnrHxAAoAHMzFz/gAAADWBm5voHAQBoADMz1z8IAEADmJnr3/UPAgDQAGbm+gcEAKABzMz1DwgAQAOYmesfEACABjAz1z8gAAANYGauf0AAABrAzFz/gAAANICZuf4BAQAawMzM9Q8IANAAZmaufxAAgAYwM9e/6x8EAKABzMz1DwgAQAOYmesfEACABjAz1z8gAAANYGauf0AAABrAzFz/gAAANICZuf4BAQBoADNz/QMCANAAZub6BwQAoAHMzPUPCABAA5i5/n0DQQAAGsDMXP+AAAA0gJm5/gEBAGgAM3P9AwIA0ABm5voHBACgAczM9Q8IAEADmJnrHxAAgAYwM9c/IAAADWBmrn9AAAAawMxc/4AAADSAmevfpwwQAIAGMHP9AwIAQAOYuf4BAQCgAcxc/4AAADSAmbn+AQEAaAAzc/0DAgDQAGbm+gcEAKABzMz1DwgAQAOYmesfEACABjAz1z8gAAANYOb6BxAAgAYwc/0DCABAA5i5/gEEAKABzFz/gAAA0ABmrn9AAABoADPXPyAAADSAmesfEACABjAz1z8gAAANYGauf0AAABrAzFz/gAAANICZ6x9AAAAawMz1DyAAAA1g5voHEACABjBz/QMIAEADmLn+AQEAoAHMXP+AAADQAGauf0AAAGgAM9c/IAAANICZ6x8QAAAawMz1DwgAQAOYuf4BBACgAcxc/wACANAAZq5/AAEAaAAz1z+AAAA0gJnrH0AAABrAzPUPCAAADWDm+gcEAIAGMHP9AwIAQAOYuf4BAQCgAcxc/4AAANAA5voHEAAAGsBc/wACANAAGsBc/wACANAAZq5/AAEAaAAz1z+AAAA0gJnrH0AAABrAzPUPCAAADWDm+gcEAIAGMHP9AwIAQAOYuf4BAQCgAcz1DyAAADSAuf4BBACABjDXP4AAANAA5voHEAAAGsBc/wACANAAGsBc/wACANAAZq5/AAEAaAAz1z8gALwCoAHMXP+AAADQAGauf0AAAGgAc/0DCAAADWCufwABAKABzPUPIAAANIC5/gEEAIAGMNc/gAAA0ADm+gcQAAAawFz/AAIAQAOY6x9AAAAawHFrrn8AAQBoADPXPyAAADSAuf4BBACABjDXP4AAANAA5voHEAAAGsBc/wACAEADmOsfQAAAaABz/QMIAAANYK5/AAEAoAHM9Q8gAAA0gLn+AQQAgAYw1z+AAADQAK5/AAEAoAHM9Q8gAAA0gLn+AQQAgAYw1z+AAADQAOb6BxAAABrAXP8AAgBAA5jrH0AAAGgAc/0DCAAADWCufwABAKABzPUPIAAANIDrHwABAKABXP8AAgAADeD6BxAAAGgA1z+AAADQAOb6BxAAABrAXP8AAgBAA5jrH0AAAGgAc/0DCAAADWCufwABAKABXP+ufwABAKABXP8ACAAADeD6B0AAAGgA1z+AAABAA7j+AQQAABrA9Q8gAADQAK5/AAEAgAZw/QMIAAANYK5/AAEAoAHM9Q8gAAA0gOvf9Q8gAAA0gOsfAAEAoAFc/wAIAAAN4PoHQAAAaADXPwACAEADuP4BBAAAGsD1DyAAANAArn8AAQCABnD9AwgAADSA6x9AAACQbgDXP4AAANAArn8ABACABnD9AyAAADSA6x8AAQCgAVz/AAgAAA3g+gdAAABoANc/gAAAQAO4/gEEAAAawPUPIAAA0ACufwABAEC9AVz/AAIAgEoDuP4BBAAAlQZw/QMIAAAqDeD6BxAAAFQawPUPIAAAqDSA6x9AAABQaQDXP4AAAKDSAK5/AAEAQKUBXP8AAgCASgO4/gEEAACVBnD9AwgAACoN4PoHEAAAVBrA9Q8gAACoNIDrH0AAAFBpANc/gAAAoNIArn8AAQBApQFc/wACAIBKA7j+AQQAAJUGcP0DCAAAKg3g+gcQAABUGsD1DyAAAKg0gOsfQAAAUGkA1z+AAACg0gCufwABAEClAVz/AAIAgEoDuP4BBAAAlQZw/QMIAAAqDeD6BxAAAFQawPUPIAAAqDSA6x9AAABQaQDXP4AAAKBiWXfeAUAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgATwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgArwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAOGzzA06D16OwLgAAAABJRU5ErkJggg==", "next/in-app.tsx": '"use client";\n\nimport { auth, context, isInAmbasada, mockEnv, ready } from "@ambasada/miniapp";\nimport { useEffect } from "react";\n\n/** Set before the reload that follows a silent sign-in, to the app\'s account: a session that did not stick can\'t loop. */\nconst RELOADED = "ambasada_signed_in";\n\n/**\n * On open inside the Ambasada app (docs/MINI-APPS.md \xA75, decision 16): when the manifest asks for `identity` and\n * nobody is signed in, or someone other than the app\'s account is (an earlier "Sign in with Ambasada" in this web\n * view), signs the app\'s account in before the page shows. `auth.signInSilently()` gets a shell token and POSTs it to\n * /api/auth/shell, which sets the same session as "Sign in with Ambasada"; the page then reloads, rendered signed in.\n * Nothing shows meanwhile (at most 5 s; the app\'s splash stays until `ready()`). When the shell says no,\n * the page shows with its "Sign in with Ambasada" button. When the whole site is the mini app (entry `/`), the page is\n * kept clear of the app\'s bar (clearHostChrome). On the web: only `ready()`, a no-op. Under `ambasada dev`,\n * `?ambasada_shell=mock` pretends to be inside the app (the mock signs the token).\n */\nexport function InApp() {\n useEffect(() => {\n started ??= startInApp(); // once per page load (React runs effects twice in development)\n }, []);\n return null;\n}\n\nlet started: Promise<void> | undefined;\n\nasync function startInApp(): Promise<void> {\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock";\n const page = document.documentElement.style;\n if (mock || auth.canSignInSilently()) page.visibility = "hidden";\n const me = (await fetch("/api/auth/me")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { user?: unknown; dev_issuer?: string } | null;\n if (mock && me?.dev_issuer) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n // Inside the app, the app\'s account is the one signed in: a session for someone else is replaced, once per account.\n const inApp = context().user?.id ?? "1";\n if (auth.sessionMatches(me?.user)) sessionStorage.removeItem(RELOADED);\n else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n clearHostChrome();\n page.visibility = "";\n ready();\n}\n\n/**\n * Inside the Ambasada app its bar and the phone\'s safe areas sit over the page: pad <body> by them (the CSS variables\n * ready() sets). Host chrome only, no fonts, colours or widths. A page that keeps clear itself (ui-page: the in-app\n * layout, `Page` from @ambasada/ui/react) is left alone; on the web, nothing changes.\n */\nfunction clearHostChrome(): void {\n if (!isInAmbasada() || document.querySelector(".ui-page")) return;\n const body = document.body.style;\n body.paddingTop = "calc(var(--ambasada-host-bar, 0px) + var(--ambasada-safe-area-top, 0px))";\n for (const side of ["bottom", "left", "right"] as const) body.setProperty(`padding-${side}`, `var(--ambasada-safe-area-${side}, 0px)`);\n}\n\n/** Inside the app, and the manifest\'s `scopes` (`miniapp.scopes` in ambasada.config.ts) include identity. */\nasync function wantsIdentity(): Promise<boolean> {\n if (!auth.canSignInSilently()) return false;\n const manifest = (await fetch("/.well-known/ambasada.json")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { scopes?: unknown } | null;\n // Unreadable: try anyway, the shell knows what Install granted.\n return !Array.isArray(manifest?.scopes) || manifest.scopes.includes("identity");\n}\n', "next/layout.tsx": 'import { Page } from "@ambasada/ui/react";\nimport type { ReactNode } from "react";\nimport { InApp } from "./in-app.tsx";\n// The Ambasada app\'s look (@ambasada/ui: tokens, fonts, ui-page / ui-card / \u2026), for the mini app\'s pages only.\nimport "@ambasada/ui";\n\n/**\n * The mini app: what the Ambasada app opens (`miniapp.entry` in ambasada.config.ts). The Ambasada app draws its own\n * bar, and `Page` (ui-page) keeps clear of it and the safe areas.\n */\nexport default function InAppLayout({ children }: { children: ReactNode }) {\n return (\n <Page>\n {children}\n <InApp />\n </Page>\n );\n}\n', "next/page.tsx": 'import { Card } from "@ambasada/ui/react";\nimport { ambasada, currentUser } from "__ROOT__/lib/ambasada.ts";\n\n// Who is signed in comes from the request: render per request, not at build time.\nexport const dynamic = "force-dynamic";\n\n/**\n * The mini app\'s first screen. Build the in-app experience under this folder; the site stays as it is. Inside the\n * Ambasada app people are signed in before it shows (in-app.tsx); the button is for the web, and for an app the\n * person opened without installing it.\n */\nexport default async function InAppHome() {\n const user = await currentUser();\n return (\n <>\n <p className="ui-label">My App</p>\n <h1>{user ? `Hello, ${user.name ?? "you"}` : "Hello"}</h1>\n {!user && ambasada().auth.configured && (\n <a className="ui-button" href="/api/auth/login?returnTo=__ENTRY__">\n Sign in with Ambasada\n </a>\n )}\n <Card title="In the Ambasada app">\n <p>\n This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>app/__ENTRY_DIR__/page.tsx</code>.\n </p>\n </Card>\n </>\n );\n}\n', "spa/InApp.tsx": 'import { Card, Page } from "@ambasada/ui/react";\nimport { useEffect, useState } from "react";\nimport { startInApp, type InAppUser } from "./shell.ts";\n\n/** Where the mini app lives (`miniapp.entry` in ambasada.config.ts): src/main.tsx renders InApp there. */\nexport const IN_APP = "__ENTRY__";\nexport const isInApp = (path: string) => path === IN_APP || path.startsWith(`${IN_APP}/`);\n\n/** The Ambasada app\'s look (@ambasada/ui: tokens, fonts, ui-page / ui-card / \u2026): a chunk of its own, so the site doesn\'t get it. */\nconst look = () => Promise.all([import("@ambasada/ui/fonts.css"), import("@ambasada/ui/tokens.css"), import("@ambasada/ui/components.css")]);\n\n/**\n * The mini app: what the Ambasada app opens. The Ambasada app draws its own bar, and `Page` (ui-page) keeps clear of\n * it and the safe areas. Build the in-app experience here; the site stays as it is. Nothing renders until\n * startInApp() knows who it is (inside the app it signs the person in first: src/in-app/shell.ts) and the look is in.\n */\nexport function InApp() {\n const [me, setMe] = useState<{ user: InAppUser | null; configured: boolean } | null>(null);\n useEffect(() => {\n void Promise.all([startInApp(), look()]).then(([who]) => setMe(who));\n }, []);\n if (!me) return null;\n return (\n <Page>\n <p className="ui-label">My App</p>\n <h1>{me.user ? `Hello, ${me.user.name ?? "you"}` : "Hello"}</h1>\n {!me.user && me.configured && (\n // The web, or the app opened without installing it: inside the app with identity granted nobody sees this.\n <a className="ui-button" href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>\n Sign in with Ambasada\n </a>\n )}\n <Card title="In the Ambasada app">\n <p>\n This is <code>{IN_APP}</code>, the page the Ambasada app opens. Edit <code>src/in-app/InApp.tsx</code>.\n </p>\n </Card>\n </Page>\n );\n}\n', "spa/shell.ts": 'import { auth, context, isInAmbasada, mockEnv, ready } from "@ambasada/miniapp";\n\n/** What GET /api/auth/me says about the person (api/auth.ts). */\nexport type InAppUser = { id: string; name: string | null; avatar_url: string | null };\n\n/** Set before the reload that follows a silent sign-in, to the app\'s account: a session that did not stick can\'t loop. */\nconst RELOADED = "ambasada_signed_in";\n\n/**\n * On open inside the Ambasada app (docs/MINI-APPS.md \xA75, decision 16): when the manifest asks for `identity` and\n * nobody is signed in, or someone other than the app\'s account is (an earlier "Sign in with Ambasada" in this web\n * view), signs the app\'s account in before the page shows. `auth.signInSilently()` gets a shell token and POSTs it to\n * /api/auth/shell, which sets the same session as "Sign in with Ambasada". Nothing shows meanwhile (at most 5 s; the\n * app\'s splash stays until `ready()`). When the shell says no, the page shows with its "Sign in with Ambasada"\n * button. On the web: only `ready()`, a no-op. Under `ambasada dev`, `?ambasada_shell=mock` pretends to be inside\n * the app (the mock signs the token).\n *\n * Resolves who is signed in, and whether the app has Sign in with Ambasada at all. With `onSignedIn` (the whole site\n * is the mini app: `location.reload()`), a silent sign-in hands over to it and the page stays hidden; otherwise the\n * page is kept clear of the app\'s bar (clearHostChrome; the in-app page\'s `Page` does that itself).\n */\nexport function startInApp(onSignedIn?: () => void): Promise<{ user: InAppUser | null; configured: boolean }> {\n return (started ??= start(onSignedIn)); // once per page load (React runs effects twice in development)\n}\n\nlet started: ReturnType<typeof startInApp> | undefined;\n\nasync function start(onSignedIn?: () => void): Promise<{ user: InAppUser | null; configured: boolean }> {\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock";\n const page = document.documentElement.style;\n if (mock || auth.canSignInSilently()) page.visibility = "hidden";\n const me = (await fetch("/api/auth/me")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { user?: InAppUser | null; configured?: boolean; dev_issuer?: string } | null;\n if (mock && me?.dev_issuer) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n let user = me?.user ?? null;\n // Inside the app, the app\'s account is the one signed in: a session for someone else is replaced, once per account.\n const inApp = context().user?.id ?? "1";\n if (auth.sessionMatches(user)) sessionStorage.removeItem(RELOADED);\n else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity())) {\n const signedIn = await auth.signInSilently<InAppUser>({ me: false });\n user = signedIn ?? user;\n if (signedIn && onSignedIn) {\n sessionStorage.setItem(RELOADED, inApp);\n onSignedIn();\n return { user, configured: true };\n }\n }\n if (onSignedIn) clearHostChrome();\n page.visibility = "";\n ready();\n return { user, configured: me?.configured ?? false };\n}\n\n/**\n * Inside the Ambasada app its bar and the phone\'s safe areas sit over the page: pad <body> by them (the CSS variables\n * ready() sets). Host chrome only, no fonts, colours or widths. A page that keeps clear itself (ui-page: the in-app\n * layout, `Page` from @ambasada/ui/react) is left alone; on the web, nothing changes.\n */\nfunction clearHostChrome(): void {\n if (!isInAmbasada() || document.querySelector(".ui-page")) return;\n const body = document.body.style;\n body.paddingTop = "calc(var(--ambasada-host-bar, 0px) + var(--ambasada-safe-area-top, 0px))";\n for (const side of ["bottom", "left", "right"] as const) body.setProperty(`padding-${side}`, `var(--ambasada-safe-area-${side}, 0px)`);\n}\n\n/** Inside the app, and the manifest\'s `scopes` (`miniapp.scopes` in ambasada.config.ts) include identity. */\nasync function wantsIdentity(): Promise<boolean> {\n if (!auth.canSignInSilently()) return false;\n const manifest = (await fetch("/.well-known/ambasada.json")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { scopes?: unknown } | null;\n // Unreadable: try anyway, the shell knows what Install granted.\n return !Array.isArray(manifest?.scopes) || manifest.scopes.includes("identity");\n}\n', "worker/in-app.js": '// On open inside the Ambasada app (the mini app, `miniapp.entry` in ambasada.config.ts; docs/MINI-APPS.md \xA75,\n// decision 16): when the manifest asks for `identity` and nobody is signed in, or someone other than the app\'s account\n// is (an earlier "Sign in with Ambasada" in this web view), sign the app\'s account in before the page shows.\n// `ambasada.auth.signInSilently()` gets a shell token and POSTs it to /api/auth/shell, which sets the same session as\n// "Sign in with Ambasada"; the page then reloads, rendered signed in. Nothing shows meanwhile (at most 5 s; the app\'s\n// splash stays until ready()). When the shell says no, the page shows with its "Sign in with Ambasada" button. When\n// the whole site is the mini app (entry /), the page is kept clear of the app\'s bar (clearHostChrome).\n// Needs https://hub.ambasada.id/miniapp.js before it; on the web it only fills in the name.\n// Under `ambasada dev`, ?ambasada_shell=mock pretends to be inside the app (the mock signs the token).\nconst RELOADED = "ambasada_signed_in"; // set before that reload, to the app\'s account: a session that did not stick can\'t loop\n\naddEventListener("DOMContentLoaded", async () => {\n const a = window.ambasada;\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock";\n const page = document.documentElement.style;\n if (a && (mock || canSignIn(a))) page.visibility = "hidden";\n const me = await fetch("/api/auth/me").then((r) => r.json()).catch(() => ({}));\n if (a && mock && me.dev_issuer) {\n const token = await fetch(`${me.dev_issuer}/dev/shell-token?aud=${encodeURIComponent(location.origin)}`).then((r) => r.json());\n a.mockEnv({ ...(token.user ? { user: token.user } : {}), answers: { "auth.getToken": token } }); // the mock\'s visitor\n }\n // Inside the app, the app\'s account is the one signed in: a session for someone else is replaced, once per account.\n const inApp = a?.context().user?.id ?? "1";\n if (me.user && (!a?.context().user || me.user.id === inApp)) sessionStorage.removeItem(RELOADED);\n else if (a && sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity(a)) && (await signInSilently(a))) {\n sessionStorage.setItem(RELOADED, inApp);\n return location.reload();\n }\n for (const el of document.querySelectorAll("[data-in-app-name]")) if (me.user) el.textContent = `, ${me.user.name ?? "you"}`;\n for (const el of document.querySelectorAll("[data-in-app-signin]")) el.hidden = !!me.user || !me.configured;\n if (a) clearHostChrome(a);\n page.visibility = "";\n a?.ready();\n});\n\n/**\n * Inside the Ambasada app its bar and the phone\'s safe areas sit over the page: pad <body> by them (the CSS variables\n * ready() sets). Host chrome only, no fonts, colours or widths. A page that keeps clear itself (ui-page: web/app.html)\n * is left alone; on the web, nothing changes.\n */\nfunction clearHostChrome(a) {\n const inside = a.isInAmbasada ? a.isInAmbasada() : !!window.ambasadaShell || /AmbasadaChat\\//.test(navigator.userAgent);\n if (!inside || document.querySelector(".ui-page")) return;\n const body = document.body.style;\n body.paddingTop = "calc(var(--ambasada-host-bar, 0px) + var(--ambasada-safe-area-top, 0px))";\n for (const side of ["bottom", "left", "right"]) body.setProperty(`padding-${side}`, `var(--ambasada-safe-area-${side}, 0px)`);\n}\n\n/** Inside the app, with a shell that mints tokens and a person to sign in (older miniapp.js builds: by hand). */\nfunction canSignIn(a) {\n if (a.auth.canSignInSilently) return a.auth.canSignInSilently();\n return a.capabilities().includes("auth.getToken") && !!a.context().user;\n}\n\n/** The manifest\'s `scopes` (`miniapp.scopes` in ambasada.config.ts) include identity; unreadable: try, the shell decides. */\nasync function wantsIdentity(a) {\n if (!canSignIn(a)) return false;\n const manifest = await fetch("/.well-known/ambasada.json").then((r) => (r.ok ? r.json() : null)).catch(() => null);\n return !Array.isArray(manifest?.scopes) || manifest.scopes.includes("identity");\n}\n\n/** The user, or null (the shell said no, the server refused, 5 s gone by). */\nasync function signInSilently(a) {\n if (a.auth.signInSilently) return a.auth.signInSilently({ me: false });\n const late = new Promise((resolve) => setTimeout(resolve, 5000, null));\n const run = async () => {\n const { token } = await a.auth.getToken();\n const res = await fetch("/api/auth/shell", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token }) });\n return res.ok ? (await res.json()).user : null;\n };\n return Promise.race([run().catch(() => null), late]);\n}\n', "worker/page.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n <!-- The mini app: what the Ambasada app opens (`miniapp.entry` in ambasada.config.ts). The Ambasada app draws its\n own bar, and ui-page keeps clear of it and the safe areas. Its look: @ambasada/ui, for this page only. -->\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />\n <script src="https://hub.ambasada.id/miniapp.js" defer></script>\n <script src="/in-app.js" defer></script>\n </head>\n <body>\n <main class="ui-page">\n <p class="ui-label">My App</p>\n <h1>Hello<span data-in-app-name></span></h1>\n <!-- in-app.js shows it when nobody is signed in: on the web, or opened without installing. Inside the app with\n identity granted the person is signed in before the page shows. -->\n <a class="ui-button" href="/api/auth/login?returnTo=__ENTRY__" data-in-app-signin hidden>Sign in with Ambasada</a>\n <section class="ui-card">\n <h2 class="ui-card-title">In the Ambasada app</h2>\n <p>This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>web/__ENTRY_DIR__.html</code>.</p>\n </section>\n </main>\n </body>\n</html>\n' } };
317
101
 
318
102
  // src/main.ts
319
103
  import { existsSync as existsSync14 } from "node:fs";
@@ -1846,10 +1630,10 @@ var ambasada = new Ambasada();
1846
1630
 
1847
1631
  // src/commands.ts
1848
1632
  import { spawn as spawn4 } from "node:child_process";
1849
- import { existsSync as existsSync13, readFileSync as readFileSync8 } from "node:fs";
1633
+ import { existsSync as existsSync13, readdirSync as readdirSync6, readFileSync as readFileSync8 } from "node:fs";
1850
1634
  import { appendFile as appendFile2, mkdir as mkdir8, readdir as readdir4, readFile as readFile9, rm as rm8, writeFile as writeFile9 } from "node:fs/promises";
1851
1635
  import { hostname } from "node:os";
1852
- import { basename as basename4, dirname as dirname8, join as join14, resolve as resolvePath5 } from "node:path";
1636
+ import { basename as basename5, dirname as dirname8, join as join14, resolve as resolvePath5 } from "node:path";
1853
1637
  import { createInterface as createInterface2 } from "node:readline/promises";
1854
1638
 
1855
1639
  // src/config.ts
@@ -2193,7 +1977,8 @@ function authorizePage(p) {
2193
1977
  <h1>Sign in with Ambasada</h1><p><b>${esc(p.client)}</b> will see:</p><ul>${p.scopes.filter((s) => lines[s]).map((s) => `<li>${esc(lines[s])}</li>`).join("")}</ul>
2194
1978
  <form method="post" action="/oauth/authorize?${esc(p.query)}"><input type="hidden" name="decision" value="allow"><button>Continue as ${esc(p.user.name)}</button></form>
2195
1979
  <form method="post" action="/oauth/authorize?${esc(p.query)}" style="margin-top:.5rem"><input type="hidden" name="decision" value="deny"><button class="secondary">Cancel</button></form>
2196
- <p class="dim">${esc(p.user.email)} \xB7 visitor pass</p></div>`;
1980
+ <p class="dim">${esc(p.user.email)} \xB7 visitor pass</p>${p.other ? `<form method="post" action="/oauth/authorize?${esc(p.query)}" style="margin-top:1.5rem"><input type="hidden" name="decision" value="allow"><input type="hidden" name="user" value="${esc(p.other.id)}"><button class="secondary">Continue as ${esc(p.other.name)}</button></form>
1981
+ <p class="dim">Someone else on the web. Then open the page with <code>?ambasada_shell=mock</code>: inside the Ambasada app its account, ${esc(p.user.name)}, is the one signed in.</p>` : ""}</div>`;
2197
1982
  }
2198
1983
 
2199
1984
  // src/mock/server.ts
@@ -2202,6 +1987,7 @@ var DEV_AUTH_CLIENT_ID = "dev_client_ambasada";
2202
1987
  var DEV_AUTH_CLIENT_SECRET = "dev_secret_ambasada_sign_in";
2203
1988
  var DEV_APP_SECRET = "dev_app_secret_ambasada_notifications";
2204
1989
  var FAKE_USER = { id: "user_fake_visitor", name: "Fake Visitor", email: "visitor@example.com" };
1990
+ var SECOND_USER = { id: "user_second_visitor", name: "Second Visitor", email: "second@example.com" };
2205
1991
  var STORE_LIMIT = 1024 * 1024;
2206
1992
  var ApiError = class extends Error {
2207
1993
  constructor(code, message, status, details = {}) {
@@ -2335,19 +2121,21 @@ async function startMock(options = {}) {
2335
2121
  }
2336
2122
  const scopes = (q.get("scope") ?? "openid").split(/\s+/).filter(Boolean);
2337
2123
  if (method === "GET") {
2338
- return new Response(authorizePage({ query: q.toString(), client: new URL(redirect2).host, scopes, user: FAKE_USER }), { headers: { "content-type": "text/html; charset=utf-8" } });
2124
+ return new Response(authorizePage({ query: q.toString(), client: new URL(redirect2).host, scopes, user: FAKE_USER, other: SECOND_USER }), { headers: { "content-type": "text/html; charset=utf-8" } });
2339
2125
  }
2340
2126
  const back = new URL(redirect2);
2341
2127
  if (q.get("state")) back.searchParams.set("state", q.get("state"));
2342
- const decision = (req.headers.get("content-type") ?? "").includes("form") ? String((await req.formData()).get("decision") ?? "allow") : "allow";
2128
+ const form = (req.headers.get("content-type") ?? "").includes("form") ? await req.formData() : null;
2129
+ const decision = String(form?.get("decision") ?? "allow");
2130
+ const who = form?.get("user") === SECOND_USER.id ? SECOND_USER : FAKE_USER;
2343
2131
  if (decision !== "allow") {
2344
2132
  back.searchParams.set("error", "access_denied");
2345
2133
  return Response.redirect(back.toString(), 303);
2346
2134
  }
2347
2135
  const code = rid("code");
2348
- authCodes.set(code, { redirect_uri: redirect2, challenge: q.get("code_challenge"), scopes, expires: Date.now() + 6e5 });
2136
+ authCodes.set(code, { redirect_uri: redirect2, challenge: q.get("code_challenge"), scopes, expires: Date.now() + 6e5, user: who });
2349
2137
  back.searchParams.set("code", code);
2350
- say(`sign-in: ${FAKE_USER.name} \u2192 ${back.host}`);
2138
+ say(`sign-in: ${who.name} \u2192 ${back.host}`);
2351
2139
  return Response.redirect(back.toString(), 303);
2352
2140
  }
2353
2141
  if (path === "/oauth/token" && method === "POST") {
@@ -2362,21 +2150,21 @@ async function startMock(options = {}) {
2362
2150
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
2363
2151
  if (Buffer.from(digest).toString("base64url") !== code.challenge) return json2({ error: "invalid_grant", error_description: "PKCE verification failed" }, 400);
2364
2152
  const access_token = rid("oat_mock");
2365
- accessTokens.set(access_token, { scopes: code.scopes });
2153
+ accessTokens.set(access_token, { scopes: code.scopes, user: code.user });
2366
2154
  return json2({ access_token, token_type: "Bearer", expires_in: 86400, scope: code.scopes.join(" ") });
2367
2155
  }
2368
2156
  if (path === "/v1/auth/profile" && method === "GET") {
2369
2157
  const token2 = accessTokens.get(/^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? "");
2370
2158
  if (!token2) throw fail("UNAUTHORIZED", "invalid Ambasada ID access token");
2371
2159
  return json2({
2372
- id: FAKE_USER.id,
2373
- name: FAKE_USER.name,
2160
+ id: token2.user.id,
2161
+ name: token2.user.name,
2374
2162
  avatar_url: `${base}/files/mock-avatar.svg`,
2375
2163
  builder: false,
2376
2164
  builder_since: null,
2377
2165
  member_since: startedAt,
2378
2166
  pass: "visitor",
2379
- ...token2.scopes.includes("email") ? { email: FAKE_USER.email } : {}
2167
+ ...token2.scopes.includes("email") ? { email: token2.user.email } : {}
2380
2168
  });
2381
2169
  }
2382
2170
  if (path === "/dev/shell-token" && method === "GET") {
@@ -2385,7 +2173,8 @@ async function startMock(options = {}) {
2385
2173
  const token2 = await signShellToken(shellKey, { sub: FAKE_USER.id, aud, iss: base, name: FAKE_USER.name, avatar: `${base}/files/mock-avatar.svg` });
2386
2174
  shellTokens.add(token2);
2387
2175
  say(`silent sign-in: shell token for ${aud}`);
2388
- return json2({ token: token2, expires_at: new Date(Date.now() + SHELL_TOKEN_TTL_S * 1e3).toISOString() }, 200, { "access-control-allow-origin": "*", "cache-control": "no-store" });
2176
+ const user = { id: FAKE_USER.id, name: FAKE_USER.name, avatar: `${base}/files/mock-avatar.svg` };
2177
+ return json2({ token: token2, expires_at: new Date(Date.now() + SHELL_TOKEN_TTL_S * 1e3).toISOString(), user }, 200, { "access-control-allow-origin": "*", "cache-control": "no-store" });
2389
2178
  }
2390
2179
  if (path === "/api/apps/profile" && method === "GET") {
2391
2180
  const token2 = /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? "";
@@ -2530,7 +2319,7 @@ import { createRequire } from "node:module";
2530
2319
  import { dirname as dirname2, isAbsolute, join as join4, resolve as resolvePath2 } from "node:path";
2531
2320
  import { pathToFileURL as pathToFileURL2 } from "node:url";
2532
2321
  var PLATFORM_CONFIG_KEYS = ["id", "name", "product", "addons"];
2533
- var VERSION = true ? "0.8.18" : "0.1.0-dev";
2322
+ var VERSION = true ? "0.8.20" : "0.1.0-dev";
2534
2323
  var shortName = (pkg) => pkg.replace(/^@ambasada\//, "").replace(/^ambasada-(addon-)?/, "");
2535
2324
  var KNOWN_ADDONS = { village: "@ambasada/village" };
2536
2325
  function readJson(file) {
@@ -3218,6 +3007,10 @@ function miniappSpec(deps, flags, fallback) {
3218
3007
  const like = [deps["@ambasada/ui"], deps["@ambasada/sdk"]].find((v) => v && /^[\^~]?\d/.test(v));
3219
3008
  return like ?? fallback;
3220
3009
  }
3010
+ function uiSpec(deps, flags, fallback) {
3011
+ const given = typeof flags.ui === "string" ? flags.ui : process.env.AMBASADA_UI_SPEC;
3012
+ return given ?? [deps["@ambasada/miniapp"], deps["@ambasada/sdk"]].find((v) => v && /^[\^~]?\d/.test(v)) ?? fallback;
3013
+ }
3221
3014
  async function enableMiniapp(root, entry2, flags, opts) {
3222
3015
  if (!MINIAPP_ENTRY.test(entry2) || entry2.split("/").includes("..")) throw new CliError(`--entry must be a path like "/app" or "/" (got ${JSON.stringify(entry2)})`);
3223
3016
  const template = opts.template ?? templateOf(root);
@@ -3245,9 +3038,11 @@ async function enableMiniapp(root, entry2, flags, opts) {
3245
3038
  if (template !== "worker") {
3246
3039
  const pkgFile = join7(root, "package.json");
3247
3040
  const pkg = JSON.parse(await readFile3(pkgFile, "utf8"));
3248
- const deps = pkg.dependencies ?? {};
3249
- if (!deps["@ambasada/miniapp"]) {
3250
- pkg.dependencies = Object.fromEntries(Object.entries({ ...deps, "@ambasada/miniapp": miniappSpec(deps, flags, opts.releaseLine) }).sort(([a], [b]) => a.localeCompare(b)));
3041
+ const deps = { ...pkg.dependencies };
3042
+ if (!deps["@ambasada/miniapp"]) deps["@ambasada/miniapp"] = miniappSpec(deps, flags, opts.releaseLine);
3043
+ if (entry2 !== "/" && !deps["@ambasada/ui"]) deps["@ambasada/ui"] = uiSpec(deps, flags, opts.releaseLine);
3044
+ if (Object.keys(deps).length !== Object.keys(pkg.dependencies ?? {}).length) {
3045
+ pkg.dependencies = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
3251
3046
  await writeFile3(pkgFile, `${JSON.stringify(pkg, null, 2)}
3252
3047
  `);
3253
3048
  placed.patched.push("package.json");
@@ -3339,7 +3134,7 @@ async function enableInFolder(root, flags, releaseLine) {
3339
3134
  npmInstall(root);
3340
3135
  done.installNeeded = false;
3341
3136
  } catch {
3342
- out(c.yellow("! npm install failed: run it yourself (@ambasada/miniapp)"));
3137
+ out(c.yellow("! npm install failed: run it yourself (@ambasada/miniapp, @ambasada/ui)"));
3343
3138
  }
3344
3139
  }
3345
3140
  printEnabled(done, root);
@@ -3369,7 +3164,7 @@ function printEnabled(done, root) {
3369
3164
  out(` ${c.dim("/.well-known/ambasada.json: written from ambasada.config.ts by `ambasada dev` and `deploy`")}`);
3370
3165
  if (done.written.some((f) => f.endsWith("icon.png"))) out(` ${c.dim("icon.png is a placeholder: replace it with your 1024\xD71024 PNG, no transparency")}`);
3371
3166
  for (const m of done.manual) out(c.yellow(`! by hand: ${m}`));
3372
- if (done.installNeeded && existsSync6(join7(root, "node_modules"))) out(` then: npm install ${c.dim("(@ambasada/miniapp)")}`);
3167
+ if (done.installNeeded && existsSync6(join7(root, "node_modules"))) out(` then: npm install ${c.dim("(@ambasada/miniapp, @ambasada/ui)")}`);
3373
3168
  }
3374
3169
 
3375
3170
  // src/check.ts
@@ -3605,11 +3400,11 @@ function walk(dir, parts) {
3605
3400
  }
3606
3401
 
3607
3402
  // src/storage.ts
3608
- import { createHash } from "node:crypto";
3403
+ import { createHash as createHash2 } from "node:crypto";
3609
3404
  import { existsSync as existsSync11 } from "node:fs";
3610
3405
  import { mkdtemp, readdir as readdir3, readFile as readFile7, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
3611
3406
  import { tmpdir } from "node:os";
3612
- import { basename as basename3, extname as extname2, join as join12 } from "node:path";
3407
+ import { basename as basename4, extname as extname2, join as join12 } from "node:path";
3613
3408
  import { createInterface } from "node:readline";
3614
3409
 
3615
3410
  // src/deploy.ts
@@ -3617,14 +3412,15 @@ import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_proce
3617
3412
  import { existsSync as existsSync10 } from "node:fs";
3618
3413
  import { mkdir as mkdir6, readdir as readdir2, readFile as readFile6, rm as rm5, stat, writeFile as writeFile6 } from "node:fs/promises";
3619
3414
  import { createServer as createServer2 } from "node:net";
3620
- import { basename as basename2, dirname as dirname7, extname, join as join11, relative as relative5, resolve as resolvePath4, sep as sep2 } from "node:path";
3415
+ import { basename as basename3, dirname as dirname7, extname, join as join11, relative as relative5, resolve as resolvePath4, sep as sep2 } from "node:path";
3621
3416
 
3622
3417
  // src/upgrade.ts
3623
3418
  import { execFileSync as execFileSync3 } from "node:child_process";
3419
+ import { createHash } from "node:crypto";
3624
3420
  import { existsSync as existsSync9, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
3625
3421
  import { mkdir as mkdir5, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
3626
- import { join as join10, relative as relative4 } from "node:path";
3627
- var VERSION2 = true ? "0.8.18" : "0.1.0-dev";
3422
+ import { basename as basename2, join as join10, relative as relative4 } from "node:path";
3423
+ var VERSION2 = true ? "0.8.20" : "0.1.0-dev";
3628
3424
  var RELEASE_SPEC = /^\d+\.\d+\.\d+$/.test(VERSION2) ? `^${VERSION2}` : "latest";
3629
3425
  var out3 = (s = "") => process.stdout.write(`${s}
3630
3426
  `);
@@ -3731,6 +3527,92 @@ AMBASADA_VILLAGE_API_URL=${url}`);
3731
3527
  function rewriteUiLink(source, line) {
3732
3528
  return source.replace(/(unpkg\.com\/@ambasada\/ui@)(\d+\.\d+)(?=\/)/g, (m, prefix, at) => at === line ? m : `${prefix}${line}`);
3733
3529
  }
3530
+ var GLUE = {
3531
+ "in-app.tsx": {
3532
+ known: ["c0d6fb6d787ba5e8c10d05f0d535054ddc86eb0029f6b36dca21abaa03925e44", "31877e5a50022fe5803a5fdc957a895076d4e46c3e633e0628ff7b45bf2d8272"],
3533
+ edits: [
3534
+ ["import { auth, ", "import { auth, context, "],
3535
+ [
3536
+ ` if (me?.user) sessionStorage.removeItem(RELOADED);
3537
+ else if (!sessionStorage.getItem(RELOADED) && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {
3538
+ sessionStorage.setItem(RELOADED, "1");
3539
+ return location.reload();
3540
+ }
3541
+ `,
3542
+ ` // Inside the app, the app's account is the one signed in: a session for someone else is replaced, once per account.
3543
+ const inApp = context().user?.id ?? "1";
3544
+ if (auth.sessionMatches(me?.user)) sessionStorage.removeItem(RELOADED);
3545
+ else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity()) && (await auth.signInSilently({ me: false }))) {
3546
+ sessionStorage.setItem(RELOADED, inApp);
3547
+ return location.reload();
3548
+ }
3549
+ `
3550
+ ]
3551
+ ]
3552
+ },
3553
+ "shell.ts": {
3554
+ known: ["55fff9fe146abe73c82452712a997f264044e59f405843b627a86882849a1bdb", "b8785b33db408f646fd1693ae4b1a3b6e705097591f4a67f4cea84c533b12ed8"],
3555
+ edits: [
3556
+ ["import { auth, ", "import { auth, context, "],
3557
+ [
3558
+ ` if (user) sessionStorage.removeItem(RELOADED);
3559
+ else if (!sessionStorage.getItem(RELOADED) && (await wantsIdentity())) {
3560
+ user = await auth.signInSilently<InAppUser>({ me: false });
3561
+ if (user && onSignedIn) {
3562
+ sessionStorage.setItem(RELOADED, "1");
3563
+ `,
3564
+ ` // Inside the app, the app's account is the one signed in: a session for someone else is replaced, once per account.
3565
+ const inApp = context().user?.id ?? "1";
3566
+ if (auth.sessionMatches(user)) sessionStorage.removeItem(RELOADED);
3567
+ else if (sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity())) {
3568
+ const signedIn = await auth.signInSilently<InAppUser>({ me: false });
3569
+ user = signedIn ?? user;
3570
+ if (signedIn && onSignedIn) {
3571
+ sessionStorage.setItem(RELOADED, inApp);
3572
+ `
3573
+ ]
3574
+ ]
3575
+ },
3576
+ "in-app.js": {
3577
+ known: ["01afb8a109a3fa0eb034a3f0ea46ade509feae190c307069a627735b97259984", "30b53bc8603fd6b7c7cdc42149060897c7cceeb46c52937d96c503bb416be5e1"],
3578
+ edits: [
3579
+ [
3580
+ ` a.mockEnv({ answers: { "auth.getToken": token } });
3581
+ `,
3582
+ ` a.mockEnv({ ...(token.user ? { user: token.user } : {}), answers: { "auth.getToken": token } }); // the mock's visitor
3583
+ `
3584
+ ],
3585
+ [
3586
+ ` if (me.user) sessionStorage.removeItem(RELOADED);
3587
+ else if (a && !sessionStorage.getItem(RELOADED) && (await wantsIdentity(a)) && (await signInSilently(a))) {
3588
+ sessionStorage.setItem(RELOADED, "1");
3589
+ return location.reload();
3590
+ }
3591
+ `,
3592
+ ` // Inside the app, the app's account is the one signed in: a session for someone else is replaced, once per account.
3593
+ const inApp = a?.context().user?.id ?? "1";
3594
+ if (me.user && (!a?.context().user || me.user.id === inApp)) sessionStorage.removeItem(RELOADED);
3595
+ else if (a && sessionStorage.getItem(RELOADED) !== inApp && (await wantsIdentity(a)) && (await signInSilently(a))) {
3596
+ sessionStorage.setItem(RELOADED, inApp);
3597
+ return location.reload();
3598
+ }
3599
+ `
3600
+ ]
3601
+ ]
3602
+ }
3603
+ };
3604
+ var GLUE_DONE = "sessionStorage.getItem(RELOADED) !== inApp";
3605
+ function upgradeGlue(name, text) {
3606
+ const glue = GLUE[name];
3607
+ if (!glue || !text.includes("/api/auth/me") || !/auth\.(signIn|signInSilently|getToken)\b/.test(text) || text.includes(GLUE_DONE)) return "done";
3608
+ if (!glue.known.includes(createHash("sha256").update(text).digest("hex"))) return "edited";
3609
+ let next = text;
3610
+ for (const [from, to] of glue.edits) {
3611
+ if (!next.includes(from)) return "edited";
3612
+ next = next.replace(from, () => to);
3613
+ }
3614
+ return { next };
3615
+ }
3734
3616
  var SOURCE2 = /\.(m?[jt]sx?)$/;
3735
3617
  var WEB = /\.(m?[jt]sx?|html?)$/;
3736
3618
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".open-next", ".wrangler", "dist", ".git", ".ambasada", ".claude"]);
@@ -3802,6 +3684,16 @@ async function upgrade(_args, flags) {
3802
3684
  }
3803
3685
  }
3804
3686
  }
3687
+ const glueByHand = [];
3688
+ for (const file of sourceFiles(root).filter((f) => basename2(f) in GLUE)) {
3689
+ const glue = upgradeGlue(basename2(file), edits.get(file) ?? await readFile5(file, "utf8"));
3690
+ if (glue === "edited") glueByHand.push(rel(file));
3691
+ else if (glue !== "done") {
3692
+ edits.set(file, glue.next);
3693
+ changes.push(`${rel(file)}: inside the Ambasada app, the app's account is the one signed in`);
3694
+ }
3695
+ }
3696
+ const glueNotes = glueByHand.map((f) => `${f} is not the glue a 0.8.12 to 0.8.19 CLI wrote (you changed it, or it is older), so it is left alone: inside the Ambasada app it keeps another account's session. Make the change by hand (.claude/skills/ambasada-product/reference/upgrading.md, "the app's account is the one signed in")`);
3805
3697
  const configFile = join10(root, CONFIG_FILE);
3806
3698
  const configText = edits.get(configFile) ?? await readFile5(configFile, "utf8");
3807
3699
  const productUrl = PRODUCT_URL.exec(configText)?.[2];
@@ -3844,6 +3736,7 @@ async function upgrade(_args, flags) {
3844
3736
  const uniqueChanges = [...new Set(changes)];
3845
3737
  const from = info?.devkit ?? "0.7";
3846
3738
  if (checkOnly) {
3739
+ for (const note of glueNotes) out3(`${c.yellow("!")} ${note}`);
3847
3740
  if (!uniqueChanges.length && !stale.length) {
3848
3741
  out3(`${c.green("\u2713")} up to date with the devkit ${VERSION2}`);
3849
3742
  return;
@@ -3886,6 +3779,7 @@ async function upgrade(_args, flags) {
3886
3779
  for (const s of stale) out3(`${c.green("\u2713")} .claude/skills/${s}: removed (now ambasada-village)`);
3887
3780
  const report = await checkLocal(root).catch(() => null);
3888
3781
  for (const p of report?.problems ?? []) out3(`${c.yellow("!")} ${p.message} ${c.dim(`(${p.code}, \`ambasada check\`)`)}`);
3782
+ for (const note of glueNotes) out3(`${c.yellow("!")} ${note}`);
3889
3783
  out3(`${c.green("\u2713")} devkit ${from} \u2192 ${VERSION2}`);
3890
3784
  if (before08) {
3891
3785
  out3("");
@@ -4082,7 +3976,7 @@ async function bundle(dir, template) {
4082
3976
  if (template === "next" && existsSync10(nextEnv)) await writeFile6(nextEnv, EMPTY_NEXT_ENV);
4083
3977
  await run(bin(dir, "wrangler"), ["deploy", "--dry-run", "--outdir", outdir], dir, { capture: true });
4084
3978
  const modules = (await walk2(outdir)).map((path) => ({ name: relative5(outdir, path).split(sep2).join("/"), path })).filter((m) => !m.name.endsWith(".map") && m.name !== "README.md");
4085
- const wanted = config.main ? `${basename2(config.main, extname(config.main))}.js` : "index.js";
3979
+ const wanted = config.main ? `${basename3(config.main, extname(config.main))}.js` : "index.js";
4086
3980
  const main2 = modules.find((m) => m.name === wanted) ?? modules.find((m) => m.name === "index.js") ?? (modules.filter((m) => /\.m?js$/.test(m.name)).length === 1 ? modules.find((m) => /\.m?js$/.test(m.name)) : void 0);
4087
3981
  if (!main2) throw new CliError(`wrangler wrote ${modules.map((m) => m.name).join(", ") || "nothing"} to ${outdir}: can't tell which is the main module`);
4088
3982
  const assetsDir = config.assets?.directory ? resolvePath4(dirname7(configFile), config.assets.directory) : null;
@@ -4179,7 +4073,7 @@ async function upload(apiUrl, apiKey, built, metadata) {
4179
4073
  const form = new FormData();
4180
4074
  form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
4181
4075
  for (const m of built.modules) form.append(`module:${m.name}`, new Blob([await readFile6(m.path)], { type: /\.m?js$/.test(m.name) ? "application/javascript+module" : "" }), m.name);
4182
- for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile6(a.file)]), basename2(a.file));
4076
+ for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile6(a.file)]), basename3(a.file));
4183
4077
  out4(c.dim(`uploading to ${apiUrl} \u2026`));
4184
4078
  for (let attempt = 1; ; attempt++) {
4185
4079
  let res;
@@ -4349,7 +4243,7 @@ async function collectAssets(dir) {
4349
4243
  const files2 = [];
4350
4244
  for (const file of await walk2(dir)) {
4351
4245
  const rel = relative5(dir, file).split(sep2).join("/");
4352
- const name = basename2(rel);
4246
+ const name = basename3(rel);
4353
4247
  if (name === ".assetsignore" || name === ".DS_Store" || name === "_headers" || name === "_redirects" || rel.startsWith("node_modules/")) continue;
4354
4248
  if (patterns.some((p) => p.test(rel) || p.test(name))) continue;
4355
4249
  files2.push({ path: `/${rel}`, file, size: (await stat(file)).size });
@@ -4404,7 +4298,7 @@ function run(command, args, cwd, opts = {}) {
4404
4298
  child.once("error", (error) => reject(new CliError(`could not run ${file}: ${error.message}`)));
4405
4299
  child.once("close", (code) => {
4406
4300
  if (code === 0) resolveRun(output);
4407
- else reject(new CliError(`\`${basename2(file)} ${[...pre, ...args].join(" ")}\` failed (exit ${code})${output && opts.capture ? `
4301
+ else reject(new CliError(`\`${basename3(file)} ${[...pre, ...args].join(" ")}\` failed (exit ${code})${output && opts.capture ? `
4408
4302
  ${output.slice(-3e3)}` : ""}`));
4409
4303
  });
4410
4304
  });
@@ -4492,7 +4386,7 @@ async function readMigrations(root) {
4492
4386
  const names2 = (await readdir3(dir)).filter((f) => f.endsWith(".sql")).sort();
4493
4387
  return Promise.all(names2.map(async (name) => ({ name, sql: await readFile7(join12(dir, name), "utf8") })));
4494
4388
  }
4495
- var sha2562 = (s) => createHash("sha256").update(s).digest("hex");
4389
+ var sha2562 = (s) => createHash2("sha256").update(s).digest("hex");
4496
4390
  var names = (list) => Array.isArray(list) ? list.map((x) => typeof x === "string" ? x : String(x?.name ?? "")).filter(Boolean) : [];
4497
4391
  var sqlString = (s) => `'${s.replace(/'/g, "''")}'`;
4498
4392
  async function localQuery(t, sql, extra = {}) {
@@ -4815,7 +4709,7 @@ async function files(args, flags) {
4815
4709
  case "upload": {
4816
4710
  if (!a) throw new CliError("usage: ambasada files put <path> [key] [--content-type type]");
4817
4711
  if (!existsSync11(a)) throw new CliError(`no file ${a}`);
4818
- const key = checkKey(b ?? basename3(a));
4712
+ const key = checkKey(b ?? basename4(a));
4819
4713
  const type = str2(flags["content-type"]) ?? contentTypeOf(a);
4820
4714
  if (t.local) {
4821
4715
  await wrangler(t, ["r2", "object", "put", `${await localBucket(t)}/${key}`, "--file", a, "--content-type", type]);
@@ -4844,7 +4738,7 @@ async function files(args, flags) {
4844
4738
 
4845
4739
  // src/tunnel.ts
4846
4740
  import { spawn as spawn3, spawnSync } from "node:child_process";
4847
- import { createHash as createHash2 } from "node:crypto";
4741
+ import { createHash as createHash3 } from "node:crypto";
4848
4742
  import { existsSync as existsSync12 } from "node:fs";
4849
4743
  import { chmod as chmod2, mkdir as mkdir7, mkdtemp as mkdtemp2, readFile as readFile8, rename, rm as rm7, writeFile as writeFile8 } from "node:fs/promises";
4850
4744
  import { tmpdir as tmpdir2 } from "node:os";
@@ -4882,7 +4776,7 @@ async function downloadCloudflared(dest, opts = {}) {
4882
4776
  } catch (error) {
4883
4777
  throw new CliError(`could not download cloudflared from ${url}: ${error.message}`);
4884
4778
  }
4885
- const digest = createHash2("sha256").update(bytes).digest("hex");
4779
+ const digest = createHash3("sha256").update(bytes).digest("hex");
4886
4780
  if (digest !== asset.sha256) throw new CliError(`cloudflared download from ${url} has SHA-256 ${digest}, expected ${asset.sha256}: not running it`);
4887
4781
  const dir = join13(dest, "..");
4888
4782
  await mkdir7(dir, { recursive: true });
@@ -5085,7 +4979,7 @@ async function tunnel(_args, flags) {
5085
4979
  }
5086
4980
 
5087
4981
  // src/commands.ts
5088
- var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.18") ? `^${"0.8.18"}` : "latest";
4982
+ var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.20") ? `^${"0.8.20"}` : "latest";
5089
4983
  var DEV_PORT = 8788;
5090
4984
  var out7 = (s = "") => process.stdout.write(`${s}
5091
4985
  `);
@@ -5282,7 +5176,7 @@ async function writeFiles(root, files2, map, force = true) {
5282
5176
  }
5283
5177
  async function init(args, flags) {
5284
5178
  const target2 = resolvePath5(args[0] ?? ".");
5285
- const name = str3(flags.name) ?? (args[0] ? basename4(target2) : "My App");
5179
+ const name = str3(flags.name) ?? (args[0] ? basename5(target2) : "My App");
5286
5180
  const id = slug(str3(flags.id) ?? name);
5287
5181
  if (existsSync13(target2) && (await readdir4(target2)).filter((f) => f !== ".git").length && !flags.force) {
5288
5182
  throw new CliError(`${target2} is not empty (use --force to scaffold anyway)`);
@@ -5377,7 +5271,8 @@ async function applyAddon(root, template, short, flags, fresh) {
5377
5271
  const overlay = addon.overlay;
5378
5272
  if (overlay) {
5379
5273
  const id = await loadIdName(root) ?? { id: "my-app", name: "My App" };
5380
- await writeFiles(root, overlay.files(template), (_rel, text) => text.replaceAll("my-app", id.id).replaceAll("My App", id.name), fresh || flags.force === true);
5274
+ const files2 = Object.fromEntries(Object.entries(overlay.files(template)).filter(([rel]) => fresh || !pageInGroup(root, rel)));
5275
+ await writeFiles(root, files2, (_rel, text) => text.replaceAll("my-app", id.id).replaceAll("My App", id.name), fresh || flags.force === true);
5381
5276
  if (overlay.config) {
5382
5277
  const file = join14(root, CONFIG_FILE);
5383
5278
  await writeFile9(file, overlay.config(await readFile9(file, "utf8"), template));
@@ -5407,6 +5302,11 @@ ${text.trim()}
5407
5302
  await writeFiles(join14(root, ".claude/skills"), addon.skills, (_rel, text) => text, true);
5408
5303
  return addon;
5409
5304
  }
5305
+ function pageInGroup(root, rel) {
5306
+ const page = /^app\/(?:(.+)\/)?page\.tsx$/.exec(rel);
5307
+ if (!page || !existsSync13(join14(root, "app"))) return false;
5308
+ return readdirSync6(join14(root, "app")).some((dir) => /^\(.+\)$/.test(dir) && existsSync13(join14(root, "app", dir, page[1] ?? "", "page.tsx")));
5309
+ }
5410
5310
  function readJsonName(dir) {
5411
5311
  try {
5412
5312
  return JSON.parse(readFileSync8(join14(dir, "package.json"), "utf8")).name;
@@ -5741,7 +5641,7 @@ async function logs(_args, flags) {
5741
5641
  }
5742
5642
 
5743
5643
  // src/main.ts
5744
- var VERSION3 = true ? "0.8.18" : "0.1.0-dev";
5644
+ var VERSION3 = true ? "0.8.20" : "0.1.0-dev";
5745
5645
  var MAJOR_MINOR = VERSION3.split(".").slice(0, 2).join(".");
5746
5646
  var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasada
5747
5647