@ambasada/cli 0.8.7 → 0.8.8
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.
- package/dist/ambasada.mjs +363 -175
- package/package.json +3 -3
package/dist/ambasada.mjs
CHANGED
|
@@ -1,7 +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 a page: sign in with Ambasada ID\n```\n\n(If it asks for a key instead: https://developer.ambasada.dev \u2192 your app \u2192 a project key, `vk_\u2026`.)\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.\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. `.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. More: docs/TUNNEL.md.\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`). Keep that wrapper if you rewrite the Worker's entry.\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\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 without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\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 }\n```\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process. A handler\nruns outside Next.js: build clients from its `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.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\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 # Ambasada ID, or a project key from https://developer.ambasada.dev\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";
|
|
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 a page: sign in with Ambasada ID\n```\n\n(If it asks for a key instead: https://developer.ambasada.dev \u2192 your app \u2192 a project key, `vk_\u2026`.)\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.\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. `.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. More: docs/TUNNEL.md.\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`). 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\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 without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\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 }\n```\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 # Ambasada ID, or a project key from https://developer.ambasada.dev\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
5
|
import { Account } from "../account.tsx";
|
|
6
6
|
|
|
7
7
|
/** The site's pages (the route group adds nothing to the URL): the header with sign-in, then the page. */
|
|
@@ -132,7 +132,7 @@ export interface Bindings {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
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 * Locally (`ambasada dev`) the header is harmless; the 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/** An error as one line plus the first line of its stack (where it was thrown); anything else as text. */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const at = value.stack?.split("\\n").find((line) => line.trim().startsWith("at "))?.trim();\n return `${value.name}: ${value.message}${at ? `\\n ${at}` : ""}`;\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/** Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`. */\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 || !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 }\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 * (`npm run preview` and the deployed app use the Durable Object).\n */\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { RETRY_AFTER_MS, type JobEnv, type JobState } from "../src/jobs/types.ts";\n\nexport type { JobState };\n\ninterface JobStub {\n start(kind: string, input: unknown): 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\nexport async function startJob(name: string, kind: string, input: unknown): Promise<JobState> {\n return local ? startLocal(name, kind, input) : stub(name).start(kind, input);\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\nconst devJobs = new Map<string, JobState>();\n\nasync function startLocal(name: string, kind: string, input: unknown): 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(), updated_at: Date.now() };\n devJobs.set(name, job);\n const env = process.env as 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 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 retryInMs = RETRY_AFTER_MS[job.attempts - 1];\n if (retryInMs === undefined) {\n Object.assign(job, { status: "failed", 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 void attempt();\n return { ...job };\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";\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 nextConfig;\n\n// `next dev` reaches env.DB, env.KV and env.FILES (wrangler\'s local copies) through getCloudflareContext() (lib/bindings.ts).\ninitOpenNextCloudflareForDev();\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).\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 * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\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 { HANDLERS } from "./handlers.ts";\nimport { RETRY_AFTER_MS, type JobEnv, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\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): 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, 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 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 retryInMs = RETRY_AFTER_MS[job.attempts - 1];\n if (retryInMs === undefined) {\n await this.save(job, { status: "failed", 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 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, ...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 updated_at: number;\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', "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 a page: sign in with Ambasada ID\n```\n\n(If it asks for a key instead: https://developer.ambasada.dev \u2192 your app \u2192 a project key, `vk_\u2026`.)\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.\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. `.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. More: docs/TUNNEL.md.\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`). Keep that wrapper if you rewrite the Worker's entry.\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\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 without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\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 }\n```\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process. A handler\nruns outside Next.js: build clients from its `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.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 # Ambasada ID, or a project key from https://developer.ambasada.dev\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.signIn()` 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 * Locally (`ambasada dev`) the header is harmless; the 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/** An error as one line plus the first line of its stack (where it was thrown); anything else as text. */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const at = value.stack?.split("\\n").find((line) => line.trim().startsWith("at "))?.trim();\n return `${value.name}: ${value.message}${at ? `\\n ${at}` : ""}`;\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/** Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`. */\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 || !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 }\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobState } from "../src/jobs/types.ts";\n\nexport type { JobState };\n\nexport async function startJob(env: Env, name: string, kind: string, input: unknown): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input)) 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>
|
|
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 * Locally (`ambasada dev`) the header is harmless; the 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/** Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`. */\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 || !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 }\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 { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { RETRY_AFTER_MS, type JobEnv, type JobState } from "../src/jobs/types.ts";\n\nexport type { JobState };\n\ninterface JobStub {\n start(kind: string, input: unknown): 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\nexport async function startJob(name: string, kind: string, input: unknown): Promise<JobState> {\n return local ? startLocal(name, kind, input) : stub(name).start(kind, input);\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\nconst devJobs = new Map<string, JobState>();\n\nasync function startLocal(name: string, kind: string, input: unknown): 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(), updated_at: Date.now() };\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 retryInMs = RETRY_AFTER_MS[job.attempts - 1];\n if (retryInMs === undefined) {\n Object.assign(job, { status: "failed", 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).\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 * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\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 { HANDLERS } from "./handlers.ts";\nimport { RETRY_AFTER_MS, type JobEnv, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\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): 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, 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 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 retryInMs = RETRY_AFTER_MS[job.attempts - 1];\n if (retryInMs === undefined) {\n await this.save(job, { status: "failed", 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 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, ...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 updated_at: number;\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', "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 a page: sign in with Ambasada ID\n```\n\n(If it asks for a key instead: https://developer.ambasada.dev \u2192 your app \u2192 a project key, `vk_\u2026`.)\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.\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. `.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. More: docs/TUNNEL.md.\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`). 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\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 without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\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 }\n```\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 # Ambasada ID, or a project key from https://developer.ambasada.dev\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.signIn()` 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 * Locally (`ambasada dev`) the header is harmless; the 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/** Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`. */\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 || !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 }\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobState } from "../src/jobs/types.ts";\n\nexport type { JobState };\n\nexport async function startJob(env: Env, name: string, kind: string, input: unknown): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input)) 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>
|
|
136
136
|
<!-- /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
|
|
137
137
|
(docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes. -->
|
|
138
138
|
<html lang="en">
|
|
@@ -204,7 +204,7 @@ a { color: var(--color-accent-text); }
|
|
|
204
204
|
.account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
|
|
205
205
|
.account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
|
|
206
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"]\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 a page: sign in with Ambasada ID\n```\n\n(If it asks for a key instead: https://developer.ambasada.dev \u2192 your app \u2192 a project key, `vk_\u2026`.)\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.\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. `.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. More: docs/TUNNEL.md.\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`). Keep that wrapper if you rewrite the Worker's entry.\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\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 without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\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 }\n```\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process. A handler\nruns outside Next.js: build clients from its `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.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 # Ambasada ID, or a project key from https://developer.ambasada.dev\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.signIn()` 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 * Locally (`ambasada dev`) the header is harmless; the 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/** An error as one line plus the first line of its stack (where it was thrown); anything else as text. */\nexport function describeError(value: unknown): string {\n if (!(value instanceof Error)) return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);\n const at = value.stack?.split("\\n").find((line) => line.trim().startsWith("at "))?.trim();\n return `${value.name}: ${value.message}${at ? `\\n ${at}` : ""}`;\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/** Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`. */\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 || !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 }\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobState } from "../src/jobs/types.ts";\n\nexport type { JobState };\n\nexport async function startJob(env: Env, name: string, kind: string, input: unknown): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input)) 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 * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\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 { HANDLERS } from "./handlers.ts";\nimport { RETRY_AFTER_MS, type JobEnv, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\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): 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, 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 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 retryInMs = RETRY_AFTER_MS[job.attempts - 1];\n if (retryInMs === undefined) {\n await this.save(job, { status: "failed", 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 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, ...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 updated_at: number;\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', "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>
|
|
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"]\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 a page: sign in with Ambasada ID\n```\n\n(If it asks for a key instead: https://developer.ambasada.dev \u2192 your app \u2192 a project key, `vk_\u2026`.)\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.\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. `.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. More: docs/TUNNEL.md.\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`). 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\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 without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\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 }\n```\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 # Ambasada ID, or a project key from https://developer.ambasada.dev\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.signIn()` 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 * Locally (`ambasada dev`) the header is harmless; the 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/** Runs one request keeping its console.error / console.warn lines; a 5xx answer carries them to `ambasada logs`. */\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 || !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 }\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobState } from "../src/jobs/types.ts";\n\nexport type { JobState };\n\nexport async function startJob(env: Env, name: string, kind: string, input: unknown): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input)) 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 * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\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 { HANDLERS } from "./handlers.ts";\nimport { RETRY_AFTER_MS, type JobEnv, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\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): 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, 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 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 retryInMs = RETRY_AFTER_MS[job.attempts - 1];\n if (retryInMs === undefined) {\n await this.save(job, { status: "failed", 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 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, ...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 updated_at: number;\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', "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
208
|
<html lang="en">
|
|
209
209
|
<head>
|
|
210
210
|
<meta charset="utf-8" />
|
|
@@ -316,8 +316,8 @@ a { color: var(--color-accent-text); }
|
|
|
316
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"]\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', "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 { useRouter } from "next/navigation";\nimport { useEffect } from "react";\n\n/**\n * Inside the Ambasada app: drops the splash (`ready()`), then signs in without a consent screen\n * (docs/MINI-APPS.md \xA75): `auth.signIn()` gets a shell token and POSTs it to /api/auth/shell, which sets the same\n * session as "Sign in with Ambasada". On the web: a no-op. Under `ambasada dev`, `?ambasada_shell=mock` pretends to\n * be inside the app (the mock signs the token).\n */\nexport function InApp() {\n const router = useRouter();\n useEffect(() => {\n void (async () => {\n const me = (await fetch("/api/auth/me")\n .then((r) => r.json())\n .catch(() => null)) as { user?: unknown; dev_issuer?: string } | null;\n if (me?.dev_issuer && (new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock")) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n ready();\n if (!me?.user && (await auth.signIn().catch(() => null))) router.refresh();\n })();\n }, [router]);\n return null;\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 { 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/** The mini app\'s first screen. Build the in-app experience under this folder; the site stays as it is. */\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 <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 } 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\ntype Me = { user: { name: string | null } | null };\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 */\nexport function InApp() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n const load = () =>\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n void load();\n void startInApp(() => void load());\n }, []);\n return (\n <Page>\n <p className="ui-label">My App</p>\n <h1>{me?.user ? `Hello, ${me.user.name ?? "you"}` : "Hello"}</h1>\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/**\n * Inside the Ambasada app: drops the splash (`ready()`), then signs in without a consent screen\n * (docs/MINI-APPS.md \xA75): `auth.signIn()` gets a shell token and POSTs it to /api/auth/shell, which sets the same\n * session as "Sign in with Ambasada"; `onSignedIn` runs after that. On the web: a no-op. Under `ambasada dev`,\n * `?ambasada_shell=mock` pretends to be inside the app (the mock signs the token).\n */\nexport async function startInApp(onSignedIn: () => void): Promise<void> {\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 (me?.dev_issuer && (new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock")) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n ready();\n if (!me?.user && (await auth.signIn().catch(() => null))) onSignedIn();\n}\n', "worker/in-app.js": '// Inside the Ambasada app (the mini app, `miniapp.entry` in ambasada.config.ts): drop the splash, then sign in without\n// a consent screen (docs/MINI-APPS.md \xA75): a shell token, POSTed to /api/auth/shell, gives the same session as\n// "Sign in with Ambasada". Needs https://hub.ambasada.id/miniapp.js before it; on the web it does nothing.\n// Under `ambasada dev`, ?ambasada_shell=mock pretends to be inside the app (the mock signs the token).\naddEventListener("DOMContentLoaded", async () => {\n const a = window.ambasada;\n const me = await fetch("/api/auth/me").then((r) => r.json()).catch(() => ({}));\n for (const el of document.querySelectorAll("[data-in-app-name]")) if (me.user) el.textContent = `, ${me.user.name ?? "you"}`;\n if (!a) return;\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock";\n if (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 a.ready();\n if (me.user || !a.capabilities().includes("auth.getToken")) return;\n const shell = await a.auth.getToken().catch(() => null);\n if (!shell) return;\n const res = await fetch("/api/auth/shell", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: shell.token }) });\n if (res.ok) location.replace(location.pathname);\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 <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
317
|
|
|
318
318
|
// src/main.ts
|
|
319
|
-
import { existsSync as
|
|
320
|
-
import { join as
|
|
319
|
+
import { existsSync as existsSync14 } from "node:fs";
|
|
320
|
+
import { join as join15 } from "node:path";
|
|
321
321
|
|
|
322
322
|
// ../protocol/src/money.ts
|
|
323
323
|
var NANO_PER_TON = 1e9;
|
|
@@ -1813,9 +1813,9 @@ var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
|
|
|
1813
1813
|
var ambasada = new Ambasada();
|
|
1814
1814
|
|
|
1815
1815
|
// src/commands.ts
|
|
1816
|
-
import { existsSync as
|
|
1817
|
-
import { appendFile as appendFile2, mkdir as
|
|
1818
|
-
import { basename as
|
|
1816
|
+
import { existsSync as existsSync13, readFileSync as readFileSync8 } from "node:fs";
|
|
1817
|
+
import { appendFile as appendFile2, mkdir as mkdir8, readdir as readdir4, readFile as readFile9, rm as rm8, writeFile as writeFile9 } from "node:fs/promises";
|
|
1818
|
+
import { basename as basename4, dirname as dirname8, join as join14, resolve as resolvePath5 } from "node:path";
|
|
1819
1819
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1820
1820
|
|
|
1821
1821
|
// src/config.ts
|
|
@@ -2447,9 +2447,9 @@ async function startMock(options = {}) {
|
|
|
2447
2447
|
res.end(Buffer.from(await response.arrayBuffer()));
|
|
2448
2448
|
});
|
|
2449
2449
|
server.keepAliveTimeout = 1e3;
|
|
2450
|
-
await new Promise((
|
|
2450
|
+
await new Promise((resolve3, reject) => {
|
|
2451
2451
|
server.once("error", reject);
|
|
2452
|
-
server.listen(options.port ?? 8788, options.host ?? "127.0.0.1", () =>
|
|
2452
|
+
server.listen(options.port ?? 8788, options.host ?? "127.0.0.1", () => resolve3());
|
|
2453
2453
|
});
|
|
2454
2454
|
const address = server.address();
|
|
2455
2455
|
base = `http://${address.address === "::" || address.address === "0.0.0.0" ? "localhost" : address.address}:${address.port}`;
|
|
@@ -2460,9 +2460,9 @@ async function startMock(options = {}) {
|
|
|
2460
2460
|
server,
|
|
2461
2461
|
notifications,
|
|
2462
2462
|
appEvent,
|
|
2463
|
-
close: () => new Promise((
|
|
2463
|
+
close: () => new Promise((resolve3) => {
|
|
2464
2464
|
server.closeAllConnections();
|
|
2465
|
-
server.close(() =>
|
|
2465
|
+
server.close(() => resolve3());
|
|
2466
2466
|
})
|
|
2467
2467
|
};
|
|
2468
2468
|
}
|
|
@@ -2473,7 +2473,7 @@ import { createRequire } from "node:module";
|
|
|
2473
2473
|
import { dirname as dirname2, isAbsolute, join as join4, resolve as resolvePath2 } from "node:path";
|
|
2474
2474
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
2475
2475
|
var PLATFORM_CONFIG_KEYS = ["id", "name", "product", "addons"];
|
|
2476
|
-
var VERSION = true ? "0.8.
|
|
2476
|
+
var VERSION = true ? "0.8.8" : "0.1.0-dev";
|
|
2477
2477
|
var shortName = (pkg) => pkg.replace(/^@ambasada\//, "").replace(/^ambasada-(addon-)?/, "");
|
|
2478
2478
|
var KNOWN_ADDONS = { village: "@ambasada/village" };
|
|
2479
2479
|
function readJson(file) {
|
|
@@ -2620,7 +2620,7 @@ function quietDurableObjects() {
|
|
|
2620
2620
|
const once = () => {
|
|
2621
2621
|
if (said) return null;
|
|
2622
2622
|
said = true;
|
|
2623
|
-
return "Durable Objects
|
|
2623
|
+
return "Durable Objects don't run under next dev: jobs run in the dev server, the live rooms under `npm run preview`; online they just work";
|
|
2624
2624
|
};
|
|
2625
2625
|
return (line) => {
|
|
2626
2626
|
const text = line.replace(ANSI, "");
|
|
@@ -2628,7 +2628,7 @@ function quietDurableObjects() {
|
|
|
2628
2628
|
inBlock = true;
|
|
2629
2629
|
return once();
|
|
2630
2630
|
}
|
|
2631
|
-
if (inBlock && /^\s
|
|
2631
|
+
if (inBlock && /^\s/.test(text)) return null;
|
|
2632
2632
|
inBlock = false;
|
|
2633
2633
|
if (/A DurableObjectNamespace in the config referenced the class .* but no such Durable Object class is exported/.test(text)) return once();
|
|
2634
2634
|
return line;
|
|
@@ -2693,7 +2693,10 @@ function startApp(root, stack, bin2, url, vars, print, prefix) {
|
|
|
2693
2693
|
const port = portOf(url);
|
|
2694
2694
|
const child = spawn(process.execPath, [bin2, ...stack.args(url.hostname, port, vars)], {
|
|
2695
2695
|
cwd: root,
|
|
2696
|
-
|
|
2696
|
+
// wrangler and the Cloudflare Vite plugin print a dozen lines about their Local Explorer API when an AI agent runs
|
|
2697
|
+
// them; X_LOCAL_EXPLORER=false (which both honour) turns the explorer and those lines off. `ambasada dev` has its
|
|
2698
|
+
// own dashboard (the mock's URL); X_LOCAL_EXPLORER=true in your environment keeps the explorer.
|
|
2699
|
+
env: { X_LOCAL_EXPLORER: "false", ...process.env, ...stack.env, ...vars },
|
|
2697
2700
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2698
2701
|
});
|
|
2699
2702
|
const filter = stack.output?.() ?? ((line) => line);
|
|
@@ -2780,15 +2783,15 @@ var BINARY = /\.(png|ico|jpe?g|gif|webp|woff2?)$/i;
|
|
|
2780
2783
|
var isBinary = (path) => BINARY.test(path);
|
|
2781
2784
|
function readTemplateDir(dir) {
|
|
2782
2785
|
const out8 = {};
|
|
2783
|
-
const
|
|
2786
|
+
const walk3 = (d) => {
|
|
2784
2787
|
for (const name of readdirSync2(d)) {
|
|
2785
2788
|
if (SKIP.has(name)) continue;
|
|
2786
2789
|
const path = join6(d, name);
|
|
2787
|
-
if (statSync2(path).isDirectory())
|
|
2790
|
+
if (statSync2(path).isDirectory()) walk3(path);
|
|
2788
2791
|
else out8[relative(dir, path).split("\\").join("/")] = readFileSync4(path, isBinary(name) ? "base64" : "utf8");
|
|
2789
2792
|
}
|
|
2790
2793
|
};
|
|
2791
|
-
|
|
2794
|
+
walk3(dir);
|
|
2792
2795
|
return out8;
|
|
2793
2796
|
}
|
|
2794
2797
|
function templateFiles(name) {
|
|
@@ -3334,40 +3337,202 @@ async function platformCheck(apiUrl, apiKey) {
|
|
|
3334
3337
|
}
|
|
3335
3338
|
}
|
|
3336
3339
|
|
|
3340
|
+
// src/sourcemap.ts
|
|
3341
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync7, realpathSync, statSync as statSync3 } from "node:fs";
|
|
3342
|
+
import { mkdir as mkdir4, readFile as readFile4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
3343
|
+
import { basename, dirname as dirname6, join as join9, relative as relative3, resolve as resolve2, sep } from "node:path";
|
|
3344
|
+
var MAPS_DIR = join9(".ambasada", "maps");
|
|
3345
|
+
var KEEP = 3;
|
|
3346
|
+
async function keepMaps(root, modules, configFile, version, deployedAt = /* @__PURE__ */ new Date()) {
|
|
3347
|
+
const maps = {};
|
|
3348
|
+
for (const m of modules) {
|
|
3349
|
+
if (!/\.m?js$/.test(m.name)) continue;
|
|
3350
|
+
const file = [`${m.path}.map`, join9(dirname6(configFile), `${m.name}.map`)].find((f) => existsSync8(f));
|
|
3351
|
+
if (!file) continue;
|
|
3352
|
+
try {
|
|
3353
|
+
const raw = JSON.parse(readFileSync7(file, "utf8"));
|
|
3354
|
+
const base = resolve2(dirname6(file), raw.sourceRoot ?? "");
|
|
3355
|
+
const real = (p) => existsSync8(p) ? realpathSync(p) : p;
|
|
3356
|
+
const source = (s) => /^[a-z-]+:/.test(s) ? s : relative3(real(root), real(resolve2(base, s))).split(sep).join("/");
|
|
3357
|
+
maps[m.name] = { version: 3, sources: raw.sources.map(source), mappings: raw.mappings };
|
|
3358
|
+
} catch {
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
const dir = join9(root, MAPS_DIR);
|
|
3362
|
+
await mkdir4(dir, { recursive: true });
|
|
3363
|
+
if (Object.keys(maps).length) await writeFile4(join9(dir, `v${version}.json`), JSON.stringify({ version, deployed_at: deployedAt.toISOString(), maps }));
|
|
3364
|
+
const old = keptVersions(root).slice(0, -KEEP);
|
|
3365
|
+
for (const v of old) await rm3(join9(dir, `v${v}.json`), { force: true });
|
|
3366
|
+
return Object.keys(maps).length;
|
|
3367
|
+
}
|
|
3368
|
+
function keptVersions(root) {
|
|
3369
|
+
const dir = join9(root, MAPS_DIR);
|
|
3370
|
+
if (!existsSync8(dir)) return [];
|
|
3371
|
+
return readdirSync3(dir).map((f) => /^v(\d+)\.json$/.exec(f)?.[1]).filter((v) => !!v).map(Number).sort((a, b) => a - b);
|
|
3372
|
+
}
|
|
3373
|
+
async function mapsAt(root, at, lastDeploy) {
|
|
3374
|
+
const versions = keptVersions(root);
|
|
3375
|
+
if (!versions.length) return null;
|
|
3376
|
+
const kept = /* @__PURE__ */ new Map();
|
|
3377
|
+
const load = async (v) => {
|
|
3378
|
+
if (!kept.has(v)) kept.set(v, JSON.parse(await readFile4(join9(root, MAPS_DIR, `v${v}.json`), "utf8")));
|
|
3379
|
+
return kept.get(v);
|
|
3380
|
+
};
|
|
3381
|
+
const time = Date.parse(at);
|
|
3382
|
+
if (lastDeploy && Date.parse(lastDeploy.deployed_at) <= time) return versions.includes(lastDeploy.version) ? (await load(lastDeploy.version)).maps : null;
|
|
3383
|
+
for (let i = versions.length - 1; i >= 0; i--) {
|
|
3384
|
+
const v = versions[i];
|
|
3385
|
+
const record = await load(v);
|
|
3386
|
+
if (Date.parse(record.deployed_at) > time) continue;
|
|
3387
|
+
const next = versions[i + 1] ?? lastDeploy?.version;
|
|
3388
|
+
return next === void 0 || next === v || next === v + 1 ? record.maps : null;
|
|
3389
|
+
}
|
|
3390
|
+
return null;
|
|
3391
|
+
}
|
|
3392
|
+
var FRAME = /^(\s*at\s+)(?:(.+?)\s+\()?([^\s()]+?):(\d+):(\d+)\)?\s*$/;
|
|
3393
|
+
function mapMessage(message, maps) {
|
|
3394
|
+
const decoded = /* @__PURE__ */ new Map();
|
|
3395
|
+
return message.split("\n").map((line) => {
|
|
3396
|
+
const frame = FRAME.exec(line);
|
|
3397
|
+
if (!frame) return line;
|
|
3398
|
+
const [, lead, fn, file, row, col] = frame;
|
|
3399
|
+
const map = maps[file] ?? maps[basename(file)];
|
|
3400
|
+
if (!map) return line;
|
|
3401
|
+
if (!decoded.has(file)) decoded.set(file, decode(map));
|
|
3402
|
+
const pos = lookup(decoded.get(file), Number(row) - 1, Number(col) - 1);
|
|
3403
|
+
if (!pos) return line;
|
|
3404
|
+
const where = `${map.sources[pos.source] ?? file}:${pos.line + 1}:${pos.column + 1}`;
|
|
3405
|
+
return `${lead}${fn ? `${fn} (${where})` : where}`;
|
|
3406
|
+
}).join("\n");
|
|
3407
|
+
}
|
|
3408
|
+
var BUILD = /(^|\/)(\.open-next|\.next|\.wrangler|dist)\//;
|
|
3409
|
+
var buildFrame = (line) => BUILD.test(FRAME.exec(line)?.[3] ?? "");
|
|
3410
|
+
var ownFrame = (line) => {
|
|
3411
|
+
const file = FRAME.exec(line)?.[3];
|
|
3412
|
+
return !!file && !BUILD.test(file) && !/(^|\/)node_modules\//.test(file) && !/^[a-z-]+:/.test(file) && /\.(t|j)sx?$/.test(file);
|
|
3413
|
+
};
|
|
3414
|
+
var B64 = new Map([..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].map((ch, i) => [ch, i]));
|
|
3415
|
+
function decode(map) {
|
|
3416
|
+
const lines = [];
|
|
3417
|
+
let source = 0;
|
|
3418
|
+
let line = 0;
|
|
3419
|
+
let column = 0;
|
|
3420
|
+
for (const text of map.mappings.split(";")) {
|
|
3421
|
+
const segments = [];
|
|
3422
|
+
let gen = 0;
|
|
3423
|
+
for (const seg of text.split(",")) {
|
|
3424
|
+
if (!seg) continue;
|
|
3425
|
+
const v = vlq(seg);
|
|
3426
|
+
gen += v[0];
|
|
3427
|
+
if (v.length < 4) continue;
|
|
3428
|
+
source += v[1];
|
|
3429
|
+
line += v[2];
|
|
3430
|
+
column += v[3];
|
|
3431
|
+
segments.push([gen, source, line, column]);
|
|
3432
|
+
}
|
|
3433
|
+
lines.push(segments);
|
|
3434
|
+
}
|
|
3435
|
+
return lines;
|
|
3436
|
+
}
|
|
3437
|
+
function vlq(seg) {
|
|
3438
|
+
const out8 = [];
|
|
3439
|
+
let value = 0;
|
|
3440
|
+
let shift = 0;
|
|
3441
|
+
for (const ch of seg) {
|
|
3442
|
+
const digit = B64.get(ch);
|
|
3443
|
+
if (digit === void 0) break;
|
|
3444
|
+
value += (digit & 31) << shift;
|
|
3445
|
+
if (digit & 32) shift += 5;
|
|
3446
|
+
else {
|
|
3447
|
+
out8.push(value & 1 ? -(value >>> 1) : value >>> 1);
|
|
3448
|
+
value = 0;
|
|
3449
|
+
shift = 0;
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
return out8;
|
|
3453
|
+
}
|
|
3454
|
+
function lookup(lines, line, column) {
|
|
3455
|
+
const segments = lines[line];
|
|
3456
|
+
if (!segments?.length) return null;
|
|
3457
|
+
let found = segments[0];
|
|
3458
|
+
for (const s of segments) {
|
|
3459
|
+
if (s[0] > column) break;
|
|
3460
|
+
found = s;
|
|
3461
|
+
}
|
|
3462
|
+
return { source: found[1], line: found[2], column: found[3] };
|
|
3463
|
+
}
|
|
3464
|
+
function nextRouteFile(root, path) {
|
|
3465
|
+
const appDir = ["app", "src/app"].map((d) => join9(root, d)).find((d) => existsSync8(d));
|
|
3466
|
+
if (!appDir) return null;
|
|
3467
|
+
const parts = path.split("?")[0].split("/").filter(Boolean).map((p) => decodeURIComponent(p));
|
|
3468
|
+
const found = walk(appDir, parts);
|
|
3469
|
+
return found ? relative3(root, found).split(sep).join("/") : null;
|
|
3470
|
+
}
|
|
3471
|
+
var ENTRY = /^(route|page)\.(t|j)sx?$/;
|
|
3472
|
+
function walk(dir, parts) {
|
|
3473
|
+
const entries = readdirSync3(dir).sort();
|
|
3474
|
+
const dirs = entries.filter((e) => !e.startsWith("_") && statSync3(join9(dir, e)).isDirectory());
|
|
3475
|
+
if (!parts.length) {
|
|
3476
|
+
const own = ["route", "page"].flatMap((n) => entries.filter((e) => ENTRY.test(e) && e.startsWith(`${n}.`)))[0];
|
|
3477
|
+
if (own) return join9(dir, own);
|
|
3478
|
+
}
|
|
3479
|
+
const [head, ...rest] = parts;
|
|
3480
|
+
for (const d of dirs.filter((d2) => /^\(.+\)$/.test(d2))) {
|
|
3481
|
+
const hit = walk(join9(dir, d), parts);
|
|
3482
|
+
if (hit) return hit;
|
|
3483
|
+
}
|
|
3484
|
+
if (head !== void 0) {
|
|
3485
|
+
if (dirs.includes(head)) {
|
|
3486
|
+
const hit = walk(join9(dir, head), rest);
|
|
3487
|
+
if (hit) return hit;
|
|
3488
|
+
}
|
|
3489
|
+
for (const d of dirs.filter((d2) => /^\[[^.[\]]+\]$/.test(d2))) {
|
|
3490
|
+
const hit = walk(join9(dir, d), rest);
|
|
3491
|
+
if (hit) return hit;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
for (const d of dirs.filter((d2) => /^\[\.\.\.[^\]]+\]$/.test(d2) || /^\[\[\.\.\.[^\]]+\]\]$/.test(d2))) {
|
|
3495
|
+
if (head === void 0 && !d.startsWith("[[")) continue;
|
|
3496
|
+
const hit = walk(join9(dir, d), []);
|
|
3497
|
+
if (hit) return hit;
|
|
3498
|
+
}
|
|
3499
|
+
return null;
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3337
3502
|
// src/storage.ts
|
|
3338
3503
|
import { createHash } from "node:crypto";
|
|
3339
|
-
import { existsSync as
|
|
3340
|
-
import { mkdtemp, readdir as readdir3, readFile as
|
|
3504
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
3505
|
+
import { mkdtemp, readdir as readdir3, readFile as readFile7, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
|
|
3341
3506
|
import { tmpdir } from "node:os";
|
|
3342
|
-
import { basename as
|
|
3507
|
+
import { basename as basename3, extname as extname2, join as join12 } from "node:path";
|
|
3343
3508
|
import { createInterface } from "node:readline";
|
|
3344
3509
|
|
|
3345
3510
|
// src/deploy.ts
|
|
3346
3511
|
import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_process";
|
|
3347
|
-
import { existsSync as
|
|
3348
|
-
import { mkdir as
|
|
3512
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
3513
|
+
import { mkdir as mkdir6, readdir as readdir2, readFile as readFile6, rm as rm5, stat, writeFile as writeFile6 } from "node:fs/promises";
|
|
3349
3514
|
import { createServer as createServer2 } from "node:net";
|
|
3350
|
-
import { basename, dirname as
|
|
3515
|
+
import { basename as basename2, dirname as dirname7, extname, join as join11, relative as relative5, resolve as resolvePath4, sep as sep2 } from "node:path";
|
|
3351
3516
|
|
|
3352
3517
|
// src/upgrade.ts
|
|
3353
3518
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3354
|
-
import { existsSync as
|
|
3355
|
-
import { mkdir as
|
|
3356
|
-
import { join as
|
|
3357
|
-
var VERSION2 = true ? "0.8.
|
|
3519
|
+
import { existsSync as existsSync9, readdirSync as readdirSync4, statSync as statSync4 } from "node:fs";
|
|
3520
|
+
import { mkdir as mkdir5, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
|
|
3521
|
+
import { join as join10, relative as relative4 } from "node:path";
|
|
3522
|
+
var VERSION2 = true ? "0.8.8" : "0.1.0-dev";
|
|
3358
3523
|
var RELEASE_SPEC = /^\d+\.\d+\.\d+$/.test(VERSION2) ? `^${VERSION2}` : "latest";
|
|
3359
3524
|
var out3 = (s = "") => process.stdout.write(`${s}
|
|
3360
3525
|
`);
|
|
3361
3526
|
async function readProjectInfo(root) {
|
|
3362
3527
|
try {
|
|
3363
|
-
return JSON.parse(await
|
|
3528
|
+
return JSON.parse(await readFile5(join10(root, PROJECT_FILE), "utf8"));
|
|
3364
3529
|
} catch {
|
|
3365
3530
|
return null;
|
|
3366
3531
|
}
|
|
3367
3532
|
}
|
|
3368
3533
|
async function writeProjectInfo(root, info) {
|
|
3369
|
-
await
|
|
3370
|
-
await
|
|
3534
|
+
await mkdir5(join10(root, ".ambasada"), { recursive: true });
|
|
3535
|
+
await writeFile5(join10(root, PROJECT_FILE), `${JSON.stringify(info, null, 2)}
|
|
3371
3536
|
`);
|
|
3372
3537
|
}
|
|
3373
3538
|
var devkitVersion = () => VERSION2;
|
|
@@ -3466,25 +3631,25 @@ var WEB = /\.(m?[jt]sx?|html?)$/;
|
|
|
3466
3631
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".open-next", ".wrangler", "dist", ".git", ".ambasada", ".claude"]);
|
|
3467
3632
|
function sourceFiles(root, pattern = SOURCE) {
|
|
3468
3633
|
const found = [];
|
|
3469
|
-
const
|
|
3470
|
-
for (const name of
|
|
3634
|
+
const walk3 = (dir) => {
|
|
3635
|
+
for (const name of readdirSync4(dir)) {
|
|
3471
3636
|
if (SKIP_DIRS.has(name)) continue;
|
|
3472
|
-
const path =
|
|
3473
|
-
if (
|
|
3637
|
+
const path = join10(dir, name);
|
|
3638
|
+
if (statSync4(path).isDirectory()) walk3(path);
|
|
3474
3639
|
else if (pattern.test(name) && !name.endsWith(".d.ts")) found.push(path);
|
|
3475
3640
|
}
|
|
3476
3641
|
};
|
|
3477
|
-
|
|
3642
|
+
walk3(root);
|
|
3478
3643
|
return found;
|
|
3479
3644
|
}
|
|
3480
3645
|
async function usesVillage(root, pkg) {
|
|
3481
3646
|
if (pkg.dependencies?.["@ambasada/village"] || pkg.devDependencies?.["@ambasada/village"]) return true;
|
|
3482
|
-
if (
|
|
3483
|
-
const config = await
|
|
3647
|
+
if (existsSync9(join10(root, "offerings.ts")) || existsSync9(join10(root, "product/offerings.ts"))) return true;
|
|
3648
|
+
const config = await readFile5(join10(root, CONFIG_FILE), "utf8").catch(() => "");
|
|
3484
3649
|
return /\bdefineCompany\b|\bidea\s*:/.test(config);
|
|
3485
3650
|
}
|
|
3486
3651
|
function templateOf2(root) {
|
|
3487
|
-
return detectStack(root)?.name ?? (
|
|
3652
|
+
return detectStack(root)?.name ?? (existsSync9(join10(root, "src/worker.ts")) ? "worker" : "next");
|
|
3488
3653
|
}
|
|
3489
3654
|
var PRODUCT_URL = /(\bproduct\s*:\s*\{\s*url\s*:\s*)(?:["'`])([^"'`]*)["'`]/;
|
|
3490
3655
|
var specFor = (name) => process.env[`AMBASADA_${name.replace(/^@ambasada\//, "").toUpperCase()}_SPEC`] ?? RELEASE_SPEC;
|
|
@@ -3493,16 +3658,16 @@ async function upgrade(_args, flags) {
|
|
|
3493
3658
|
if (!root) throw new CliError(`run \`ambasada upgrade\` inside your app (no ${CONFIG_FILE} found)`);
|
|
3494
3659
|
const checkOnly = flags.check === true;
|
|
3495
3660
|
const info = await readProjectInfo(root);
|
|
3496
|
-
const pkgFile =
|
|
3497
|
-
const pkg = JSON.parse(await
|
|
3661
|
+
const pkgFile = join10(root, "package.json");
|
|
3662
|
+
const pkg = JSON.parse(await readFile5(pkgFile, "utf8"));
|
|
3498
3663
|
const village = await usesVillage(root, pkg);
|
|
3499
3664
|
const template = info?.template && TEMPLATES.includes(info.template) ? info.template : templateOf2(root);
|
|
3500
3665
|
const changes = [];
|
|
3501
|
-
const rel = (p) =>
|
|
3666
|
+
const rel = (p) => relative4(root, p);
|
|
3502
3667
|
const before08 = !info || /^0\.[0-7]\./.test(info.devkit) && !info.devkit.includes("-dev");
|
|
3503
3668
|
const edits = /* @__PURE__ */ new Map();
|
|
3504
3669
|
for (const file of before08 ? sourceFiles(root) : []) {
|
|
3505
|
-
const text = await
|
|
3670
|
+
const text = await readFile5(file, "utf8");
|
|
3506
3671
|
if (!text.includes("@ambasada/sdk")) continue;
|
|
3507
3672
|
const next = rewriteImports(text, village);
|
|
3508
3673
|
if (next !== text) {
|
|
@@ -3511,9 +3676,9 @@ async function upgrade(_args, flags) {
|
|
|
3511
3676
|
}
|
|
3512
3677
|
}
|
|
3513
3678
|
for (const name of before08 ? [".env", ".dev.vars"] : []) {
|
|
3514
|
-
const file =
|
|
3515
|
-
if (!
|
|
3516
|
-
const text = await
|
|
3679
|
+
const file = join10(root, name);
|
|
3680
|
+
if (!existsSync9(file)) continue;
|
|
3681
|
+
const text = await readFile5(file, "utf8");
|
|
3517
3682
|
const next = rewriteEnv(text, village);
|
|
3518
3683
|
if (next !== text) {
|
|
3519
3684
|
edits.set(file, next);
|
|
@@ -3523,7 +3688,7 @@ async function upgrade(_args, flags) {
|
|
|
3523
3688
|
const uiLine = VERSION2.split(".").slice(0, 2).join(".");
|
|
3524
3689
|
if (/^\d+\.\d+$/.test(uiLine) && !VERSION2.includes("-dev")) {
|
|
3525
3690
|
for (const file of sourceFiles(root, WEB)) {
|
|
3526
|
-
const text = edits.get(file) ?? await
|
|
3691
|
+
const text = edits.get(file) ?? await readFile5(file, "utf8");
|
|
3527
3692
|
if (!text.includes("@ambasada/ui@")) continue;
|
|
3528
3693
|
const next = rewriteUiLink(text, uiLine);
|
|
3529
3694
|
if (next !== text) {
|
|
@@ -3532,8 +3697,8 @@ async function upgrade(_args, flags) {
|
|
|
3532
3697
|
}
|
|
3533
3698
|
}
|
|
3534
3699
|
}
|
|
3535
|
-
const configFile =
|
|
3536
|
-
const configText = edits.get(configFile) ?? await
|
|
3700
|
+
const configFile = join10(root, CONFIG_FILE);
|
|
3701
|
+
const configText = edits.get(configFile) ?? await readFile5(configFile, "utf8");
|
|
3537
3702
|
const productUrl = PRODUCT_URL.exec(configText)?.[2];
|
|
3538
3703
|
let movedLiveUrl;
|
|
3539
3704
|
if (productUrl && URL.canParse(productUrl) && !isLocalUrl(productUrl)) {
|
|
@@ -3561,10 +3726,10 @@ async function upgrade(_args, flags) {
|
|
|
3561
3726
|
const skills = {};
|
|
3562
3727
|
for (const [path, text] of Object.entries(templateFiles(template))) if (path.startsWith(".claude/skills/ambasada-")) skills[path] = text.replaceAll("my-app", idName.id).replaceAll("My App", idName.name);
|
|
3563
3728
|
const stale = [];
|
|
3564
|
-
const skillsDir =
|
|
3565
|
-
if (
|
|
3729
|
+
const skillsDir = join10(root, ".claude/skills");
|
|
3730
|
+
if (existsSync9(join10(skillsDir, "ambasada-payments"))) stale.push("ambasada-payments");
|
|
3566
3731
|
for (const [path, text] of Object.entries(skills)) {
|
|
3567
|
-
const current2 = await
|
|
3732
|
+
const current2 = await readFile5(join10(root, path), "utf8").catch(() => null);
|
|
3568
3733
|
if (current2 !== text) changes.push(`${path.split("/").slice(0, 3).join("/")}: re-synced`);
|
|
3569
3734
|
}
|
|
3570
3735
|
const nextInfo = { ...info, template, devkit: VERSION2, addons: village ? ["village"] : info?.addons ?? [], ...movedLiveUrl ? { live_url: movedLiveUrl } : {} };
|
|
@@ -3583,16 +3748,16 @@ async function upgrade(_args, flags) {
|
|
|
3583
3748
|
for (const s of stale) out3(` .claude/skills/${s}: removed`);
|
|
3584
3749
|
throw new CliError("`ambasada upgrade` would change the above", 1);
|
|
3585
3750
|
}
|
|
3586
|
-
for (const [file, text] of edits) await
|
|
3751
|
+
for (const [file, text] of edits) await writeFile5(file, text);
|
|
3587
3752
|
pkg.dependencies = deps;
|
|
3588
3753
|
if (pkg.devDependencies) pkg.devDependencies = devDeps;
|
|
3589
|
-
await
|
|
3754
|
+
await writeFile5(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
3590
3755
|
`);
|
|
3591
3756
|
for (const [path, text] of Object.entries(skills)) {
|
|
3592
|
-
await
|
|
3593
|
-
await
|
|
3757
|
+
await mkdir5(join10(root, path, ".."), { recursive: true });
|
|
3758
|
+
await writeFile5(join10(root, path), text);
|
|
3594
3759
|
}
|
|
3595
|
-
for (const s of stale) await
|
|
3760
|
+
for (const s of stale) await rm4(join10(skillsDir, s), { recursive: true, force: true });
|
|
3596
3761
|
if (!flags["no-install"]) {
|
|
3597
3762
|
out3(c.dim("npm install \u2026"));
|
|
3598
3763
|
try {
|
|
@@ -3605,9 +3770,9 @@ async function upgrade(_args, flags) {
|
|
|
3605
3770
|
setAddons(addons2);
|
|
3606
3771
|
for (const addon of addons2.list) {
|
|
3607
3772
|
for (const [path, text] of Object.entries(addon.skills)) {
|
|
3608
|
-
const file =
|
|
3609
|
-
await
|
|
3610
|
-
await
|
|
3773
|
+
const file = join10(skillsDir, path);
|
|
3774
|
+
await mkdir5(join10(file, ".."), { recursive: true });
|
|
3775
|
+
await writeFile5(file, text);
|
|
3611
3776
|
}
|
|
3612
3777
|
}
|
|
3613
3778
|
if (village && !addons2.get("village")) out3(c.yellow("! @ambasada/village is in package.json but not installed yet: run `npm install`, then `ambasada upgrade` again for its skill"));
|
|
@@ -3695,6 +3860,7 @@ async function runDeploy(flags) {
|
|
|
3695
3860
|
}
|
|
3696
3861
|
const deployment = await upload(r.apiUrl, r.apiKey, built, metadata);
|
|
3697
3862
|
out4(`${c.green("\u2713")} live at ${c.bold(c.cyan(deployment.url))} ${c.dim(`v${deployment.version}, ${kb(deployment.bundle_bytes)} gzipped`)}`);
|
|
3863
|
+
await keepMaps(project.root, built.modules, built.configFile, deployment.version).catch(() => 0);
|
|
3698
3864
|
for (const line of resourceLines(deployment.resources)) out4(line);
|
|
3699
3865
|
if (deployment.offerings_moved?.length) out4(` offerings moved to it: ${deployment.offerings_moved.join(", ")}`);
|
|
3700
3866
|
if (deployment.webhook) out4(` webhooks \u2192 ${deployment.webhook}`);
|
|
@@ -3734,24 +3900,29 @@ async function isMockApi(apiUrl) {
|
|
|
3734
3900
|
}
|
|
3735
3901
|
}
|
|
3736
3902
|
function findProductDir(root) {
|
|
3737
|
-
for (const d of [root,
|
|
3903
|
+
for (const d of [root, join11(root, "product")]) if (["wrangler.jsonc", "wrangler.json"].some((f) => existsSync10(join11(d, f)))) return d;
|
|
3738
3904
|
return null;
|
|
3739
3905
|
}
|
|
3740
3906
|
function productDir(root) {
|
|
3741
3907
|
const dir = findProductDir(root);
|
|
3742
3908
|
if (dir) return dir;
|
|
3743
|
-
if (
|
|
3909
|
+
if (existsSync10(join11(root, "wrangler.toml"))) throw new CliError("wrangler.toml is not supported by `ambasada deploy`: use wrangler.jsonc");
|
|
3744
3910
|
throw new CliError("no wrangler.jsonc here, so `ambasada deploy` can't host this app. Host it anywhere and `ambasada link <url>`");
|
|
3745
3911
|
}
|
|
3746
3912
|
function detectTemplate(dir) {
|
|
3747
|
-
const has = (...files2) => files2.some((f) =>
|
|
3913
|
+
const has = (...files2) => files2.some((f) => existsSync10(join11(dir, f)));
|
|
3748
3914
|
if (has("open-next.config.ts", "open-next.config.js", "next.config.ts", "next.config.js", "next.config.mjs")) return "next";
|
|
3749
3915
|
if (has("vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts")) return "spa";
|
|
3750
3916
|
return "worker";
|
|
3751
3917
|
}
|
|
3752
3918
|
var NEXT_BUILD_ENV = { __NEXT_PROCESSED_ENV: "true" };
|
|
3753
3919
|
var EMPTY_NEXT_ENV = "export const production = {};\nexport const development = {};\nexport const test = {};\n";
|
|
3754
|
-
var VITE_BUILD =
|
|
3920
|
+
var VITE_BUILD = [
|
|
3921
|
+
"const { createBuilder } = await import('vite');",
|
|
3922
|
+
"const noDevVars = { name: 'ambasada:no-dev-vars', enforce: 'post', generateBundle(_, bundle) { delete bundle['.dev.vars']; } };",
|
|
3923
|
+
"const builder = await createBuilder({ envDir: false, plugins: [noDevVars] }, null);",
|
|
3924
|
+
"await builder.buildApp();"
|
|
3925
|
+
].join(" ");
|
|
3755
3926
|
var ENV_NOISE = /^\s*(?:-\s*Environments:\s*\.env|Using (?:secrets|vars) defined in \.(?:env|dev\.vars))/;
|
|
3756
3927
|
function quietEnv() {
|
|
3757
3928
|
let dropped = false;
|
|
@@ -3765,32 +3936,30 @@ async function build(dir, template) {
|
|
|
3765
3936
|
if (template === "next") {
|
|
3766
3937
|
out4(c.bold("opennextjs-cloudflare build"));
|
|
3767
3938
|
await run(bin(dir, "opennextjs-cloudflare", "@opennextjs/cloudflare"), ["build"], dir, { env: NEXT_BUILD_ENV, filter: env2.filter });
|
|
3768
|
-
const server =
|
|
3769
|
-
if (
|
|
3770
|
-
for (const f of await readdir2(server)) if (/^\.env/.test(f)) await
|
|
3939
|
+
const server = join11(dir, ".open-next", "server-functions", "default");
|
|
3940
|
+
if (existsSync10(server)) {
|
|
3941
|
+
for (const f of await readdir2(server)) if (/^\.env/.test(f)) await rm5(join11(server, f), { force: true });
|
|
3771
3942
|
}
|
|
3772
3943
|
} else if (template === "spa") {
|
|
3773
3944
|
out4(c.bold("vite build"));
|
|
3774
3945
|
if (bin(dir, "vite").startsWith("npx ")) throw new CliError(`vite is not installed in ${dir}: run \`npm install\``);
|
|
3775
3946
|
await run([process.execPath, "--input-type=module", "-e", VITE_BUILD], [], dir, { filter: env2.filter });
|
|
3776
|
-
const { file } = await readWranglerConfig(dir);
|
|
3777
|
-
if (dirname6(file) !== dir) for (const local of [".dev.vars", ".env"]) await rm4(join10(dirname6(file), local), { force: true });
|
|
3778
3947
|
}
|
|
3779
|
-
if (env2.dropped()) out4(c.dim(".env files stay local:
|
|
3948
|
+
if (env2.dropped()) out4(c.dim(".env files stay local: no value from them is in the Worker or its files (`ambasada env set` for online values)"));
|
|
3780
3949
|
}
|
|
3781
3950
|
async function bundle(dir, template) {
|
|
3782
3951
|
const { file: configFile, config } = await readWranglerConfig(dir);
|
|
3783
|
-
const outdir =
|
|
3784
|
-
await
|
|
3785
|
-
const nextEnv =
|
|
3786
|
-
if (template === "next" &&
|
|
3952
|
+
const outdir = join11(dir, ".ambasada", "deploy");
|
|
3953
|
+
await rm5(outdir, { recursive: true, force: true });
|
|
3954
|
+
const nextEnv = join11(dir, ".open-next", "cloudflare", "next-env.mjs");
|
|
3955
|
+
if (template === "next" && existsSync10(nextEnv)) await writeFile6(nextEnv, EMPTY_NEXT_ENV);
|
|
3787
3956
|
await run(bin(dir, "wrangler"), ["deploy", "--dry-run", "--outdir", outdir], dir, { capture: true });
|
|
3788
|
-
const modules = (await
|
|
3789
|
-
const wanted = config.main ? `${
|
|
3957
|
+
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");
|
|
3958
|
+
const wanted = config.main ? `${basename2(config.main, extname(config.main))}.js` : "index.js";
|
|
3790
3959
|
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);
|
|
3791
3960
|
if (!main2) throw new CliError(`wrangler wrote ${modules.map((m) => m.name).join(", ") || "nothing"} to ${outdir}: can't tell which is the main module`);
|
|
3792
|
-
const assetsDir = config.assets?.directory ? resolvePath4(
|
|
3793
|
-
const assets = assetsDir &&
|
|
3961
|
+
const assetsDir = config.assets?.directory ? resolvePath4(dirname7(configFile), config.assets.directory) : null;
|
|
3962
|
+
const assets = assetsDir && existsSync10(assetsDir) ? await collectAssets(assetsDir) : [];
|
|
3794
3963
|
if (assets.length > LIMITS.assets) throw new CliError(`${assets.length} static files in ${assetsDir}; ambasada.dev hosts up to ${LIMITS.assets}`);
|
|
3795
3964
|
const big = assets.find((a) => a.size > LIMITS.assetBytes);
|
|
3796
3965
|
if (big) throw new CliError(`${big.file} is ${kb(big.size)}; static files are at most 25 MB`);
|
|
@@ -3800,7 +3969,7 @@ function deployMetadata(built, root) {
|
|
|
3800
3969
|
const a = built.config.assets ?? {};
|
|
3801
3970
|
const assetsConfig = Object.fromEntries(Object.entries({ html_handling: a.html_handling, not_found_handling: a.not_found_handling, run_worker_first: a.run_worker_first }).filter(([, v]) => v !== void 0));
|
|
3802
3971
|
const vars = Object.fromEntries(Object.entries(built.config.vars ?? {}).filter(([k, v]) => typeof v === "string" && !/^(AMBASADA|VILLAGE)_/.test(k)));
|
|
3803
|
-
const webhook = WEBHOOK_FILES.some((f) =>
|
|
3972
|
+
const webhook = WEBHOOK_FILES.some((f) => existsSync10(join11(root, f)) || existsSync10(join11(built.dir, f)));
|
|
3804
3973
|
return {
|
|
3805
3974
|
main_module: built.mainModule,
|
|
3806
3975
|
compatibility_date: built.config.compatibility_date,
|
|
@@ -3882,8 +4051,8 @@ function resourceLines(r) {
|
|
|
3882
4051
|
async function upload(apiUrl, apiKey, built, metadata) {
|
|
3883
4052
|
const form = new FormData();
|
|
3884
4053
|
form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
|
|
3885
|
-
for (const m of built.modules) form.append(`module:${m.name}`, new Blob([await
|
|
3886
|
-
for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await
|
|
4054
|
+
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);
|
|
4055
|
+
for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile6(a.file)]), basename2(a.file));
|
|
3887
4056
|
out4(c.dim(`uploading to ${apiUrl} \u2026`));
|
|
3888
4057
|
for (let attempt = 1; ; attempt++) {
|
|
3889
4058
|
let res;
|
|
@@ -3916,14 +4085,14 @@ function uploadFailure(status, error) {
|
|
|
3916
4085
|
}
|
|
3917
4086
|
async function smoke(built, company, root = built.dir) {
|
|
3918
4087
|
const started = Date.now();
|
|
3919
|
-
const smokeDir =
|
|
3920
|
-
await
|
|
3921
|
-
await
|
|
3922
|
-
const outdir =
|
|
4088
|
+
const smokeDir = join11(built.dir, ".ambasada", "smoke");
|
|
4089
|
+
await rm5(smokeDir, { recursive: true, force: true });
|
|
4090
|
+
await mkdir6(smokeDir, { recursive: true });
|
|
4091
|
+
const outdir = dirname7(built.modules.find((m) => m.name === built.mainModule).path);
|
|
3923
4092
|
const a = built.config.assets;
|
|
3924
4093
|
const config = {
|
|
3925
4094
|
name: built.config.name ?? company,
|
|
3926
|
-
main:
|
|
4095
|
+
main: join11(outdir, built.mainModule),
|
|
3927
4096
|
compatibility_date: built.config.compatibility_date,
|
|
3928
4097
|
compatibility_flags: built.config.compatibility_flags ?? [],
|
|
3929
4098
|
no_bundle: true,
|
|
@@ -3935,9 +4104,9 @@ async function smoke(built, company, root = built.dir) {
|
|
|
3935
4104
|
// DB, KV, FILES and the Durable Objects as wrangler simulates them locally, in a fresh state under .ambasada/smoke.
|
|
3936
4105
|
...localBindings(built.config)
|
|
3937
4106
|
};
|
|
3938
|
-
const configPath2 =
|
|
3939
|
-
await
|
|
3940
|
-
const persistTo =
|
|
4107
|
+
const configPath2 = join11(smokeDir, "wrangler.json");
|
|
4108
|
+
await writeFile6(configPath2, JSON.stringify(config, null, 2));
|
|
4109
|
+
const persistTo = join11(smokeDir, "state");
|
|
3941
4110
|
if (config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) {
|
|
3942
4111
|
const root2 = findRoot(built.dir) ?? built.dir;
|
|
3943
4112
|
try {
|
|
@@ -3948,7 +4117,7 @@ async function smoke(built, company, root = built.dir) {
|
|
|
3948
4117
|
}
|
|
3949
4118
|
const port = await freePort();
|
|
3950
4119
|
out4(c.dim(`smoke test: wrangler dev on :${port} \u2026`));
|
|
3951
|
-
const child = spawn2(bin(built.dir, "wrangler"), ["dev", "--config",
|
|
4120
|
+
const child = spawn2(bin(built.dir, "wrangler"), ["dev", "--config", join11(smokeDir, "wrangler.json"), "--port", String(port), "--ip", "127.0.0.1", "--inspector-port", String(await freePort()), "--persist-to", persistTo, "--show-interactive-dev-session=false", "--log-level", "error"], {
|
|
3952
4121
|
cwd: built.dir,
|
|
3953
4122
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3954
4123
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false", CI: "1" }
|
|
@@ -3974,7 +4143,7 @@ ${(await res.text()).slice(0, 500)}`);
|
|
|
3974
4143
|
return { ok: true, skipped: false, checks, ms: Date.now() - started };
|
|
3975
4144
|
} finally {
|
|
3976
4145
|
child.kill("SIGTERM");
|
|
3977
|
-
await
|
|
4146
|
+
await rm5(smokeDir, { recursive: true, force: true });
|
|
3978
4147
|
}
|
|
3979
4148
|
}
|
|
3980
4149
|
function gitInfo(dir) {
|
|
@@ -4014,16 +4183,16 @@ function freePort() {
|
|
|
4014
4183
|
});
|
|
4015
4184
|
}
|
|
4016
4185
|
async function readWranglerConfig(dir) {
|
|
4017
|
-
const redirect2 =
|
|
4186
|
+
const redirect2 = join11(dir, ".wrangler", "deploy", "config.json");
|
|
4018
4187
|
let file;
|
|
4019
|
-
if (
|
|
4020
|
-
const target2 = JSON.parse(await
|
|
4021
|
-
if (target2) file = resolvePath4(
|
|
4188
|
+
if (existsSync10(redirect2)) {
|
|
4189
|
+
const target2 = JSON.parse(await readFile6(redirect2, "utf8")).configPath;
|
|
4190
|
+
if (target2) file = resolvePath4(dirname7(redirect2), target2);
|
|
4022
4191
|
}
|
|
4023
|
-
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) =>
|
|
4192
|
+
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) => join11(dir, f)).find((f) => existsSync10(f));
|
|
4024
4193
|
if (!file) throw new CliError(`no wrangler.jsonc in ${dir}`);
|
|
4025
4194
|
try {
|
|
4026
|
-
return { file, config: JSON.parse(stripJsonc(await
|
|
4195
|
+
return { file, config: JSON.parse(stripJsonc(await readFile6(file, "utf8"))) };
|
|
4027
4196
|
} catch (error) {
|
|
4028
4197
|
throw new CliError(`${file} is not valid JSON(C): ${error.message}`);
|
|
4029
4198
|
}
|
|
@@ -4048,32 +4217,32 @@ function stripJsonc(text) {
|
|
|
4048
4217
|
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
4049
4218
|
}
|
|
4050
4219
|
async function collectAssets(dir) {
|
|
4051
|
-
const ignore =
|
|
4220
|
+
const ignore = existsSync10(join11(dir, ".assetsignore")) ? (await readFile6(join11(dir, ".assetsignore"), "utf8")).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) : [];
|
|
4052
4221
|
const patterns = ignore.map((p) => new RegExp(`^${p.replace(/^\//, "").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*")}(/.*)?$`));
|
|
4053
4222
|
const files2 = [];
|
|
4054
|
-
for (const file of await
|
|
4055
|
-
const rel =
|
|
4056
|
-
const name =
|
|
4223
|
+
for (const file of await walk2(dir)) {
|
|
4224
|
+
const rel = relative5(dir, file).split(sep2).join("/");
|
|
4225
|
+
const name = basename2(rel);
|
|
4057
4226
|
if (name === ".assetsignore" || name === ".DS_Store" || name === "_headers" || name === "_redirects" || rel.startsWith("node_modules/")) continue;
|
|
4058
4227
|
if (patterns.some((p) => p.test(rel) || p.test(name))) continue;
|
|
4059
4228
|
files2.push({ path: `/${rel}`, file, size: (await stat(file)).size });
|
|
4060
4229
|
}
|
|
4061
4230
|
return files2;
|
|
4062
4231
|
}
|
|
4063
|
-
async function
|
|
4232
|
+
async function walk2(dir) {
|
|
4064
4233
|
const files2 = [];
|
|
4065
4234
|
for (const entry2 of await readdir2(dir, { withFileTypes: true })) {
|
|
4066
|
-
const path =
|
|
4067
|
-
if (entry2.isDirectory()) files2.push(...await
|
|
4235
|
+
const path = join11(dir, entry2.name);
|
|
4236
|
+
if (entry2.isDirectory()) files2.push(...await walk2(path));
|
|
4068
4237
|
else if (entry2.isFile()) files2.push(path);
|
|
4069
4238
|
}
|
|
4070
4239
|
return files2.sort();
|
|
4071
4240
|
}
|
|
4072
4241
|
function bin(dir, name, pkg = name) {
|
|
4073
|
-
for (let d = dir; ; d =
|
|
4074
|
-
const local =
|
|
4075
|
-
if (
|
|
4076
|
-
if (
|
|
4242
|
+
for (let d = dir; ; d = dirname7(d)) {
|
|
4243
|
+
const local = join11(d, "node_modules", ".bin", name);
|
|
4244
|
+
if (existsSync10(local)) return local;
|
|
4245
|
+
if (dirname7(d) === d) return `npx --yes ${pkg}`;
|
|
4077
4246
|
}
|
|
4078
4247
|
}
|
|
4079
4248
|
function run(command, args, cwd, opts = {}) {
|
|
@@ -4108,7 +4277,7 @@ function run(command, args, cwd, opts = {}) {
|
|
|
4108
4277
|
child.once("error", (error) => reject(new CliError(`could not run ${file}: ${error.message}`)));
|
|
4109
4278
|
child.once("close", (code) => {
|
|
4110
4279
|
if (code === 0) resolveRun(output);
|
|
4111
|
-
else reject(new CliError(`\`${
|
|
4280
|
+
else reject(new CliError(`\`${basename2(file)} ${[...pre, ...args].join(" ")}\` failed (exit ${code})${output && opts.capture ? `
|
|
4112
4281
|
${output.slice(-3e3)}` : ""}`));
|
|
4113
4282
|
});
|
|
4114
4283
|
});
|
|
@@ -4119,7 +4288,7 @@ var kb = (n) => n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math
|
|
|
4119
4288
|
var out5 = (s = "") => void process.stdout.write(`${s}
|
|
4120
4289
|
`);
|
|
4121
4290
|
var str2 = (v) => typeof v === "string" && v ? v : void 0;
|
|
4122
|
-
var MIGRATIONS_DIR =
|
|
4291
|
+
var MIGRATIONS_DIR = join12("db", "migrations");
|
|
4123
4292
|
var MIGRATIONS_TABLE = "_ambasada_migrations";
|
|
4124
4293
|
var CREATE_MIGRATIONS = `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, sha256 TEXT);`;
|
|
4125
4294
|
var PRODUCTS_DOMAIN = "ambasada.dev";
|
|
@@ -4191,23 +4360,23 @@ ${output.slice(-1e3)}`);
|
|
|
4191
4360
|
}
|
|
4192
4361
|
}
|
|
4193
4362
|
async function readMigrations(root) {
|
|
4194
|
-
const dir =
|
|
4195
|
-
if (!
|
|
4363
|
+
const dir = join12(root, MIGRATIONS_DIR);
|
|
4364
|
+
if (!existsSync11(dir)) return [];
|
|
4196
4365
|
const names2 = (await readdir3(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
4197
|
-
return Promise.all(names2.map(async (name) => ({ name, sql: await
|
|
4366
|
+
return Promise.all(names2.map(async (name) => ({ name, sql: await readFile7(join12(dir, name), "utf8") })));
|
|
4198
4367
|
}
|
|
4199
4368
|
var sha2562 = (s) => createHash("sha256").update(s).digest("hex");
|
|
4200
4369
|
var names = (list) => Array.isArray(list) ? list.map((x) => typeof x === "string" ? x : String(x?.name ?? "")).filter(Boolean) : [];
|
|
4201
4370
|
var sqlString = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
4202
4371
|
async function localQuery(t, sql, extra = {}) {
|
|
4203
|
-
const dir = await mkdtemp(
|
|
4372
|
+
const dir = await mkdtemp(join12(tmpdir(), "ambasada-sql-"));
|
|
4204
4373
|
let rows;
|
|
4205
4374
|
try {
|
|
4206
|
-
const file =
|
|
4207
|
-
await
|
|
4375
|
+
const file = join12(dir, "query.sql");
|
|
4376
|
+
await writeFile7(file, sql);
|
|
4208
4377
|
rows = wranglerJson(await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--json", "--yes"], extra));
|
|
4209
4378
|
} finally {
|
|
4210
|
-
await
|
|
4379
|
+
await rm6(dir, { recursive: true, force: true });
|
|
4211
4380
|
}
|
|
4212
4381
|
return rows.map((r) => {
|
|
4213
4382
|
const results = r.results ?? [];
|
|
@@ -4220,13 +4389,13 @@ async function localQuery(t, sql, extra = {}) {
|
|
|
4220
4389
|
});
|
|
4221
4390
|
}
|
|
4222
4391
|
async function localExec(t, sql, extra = {}) {
|
|
4223
|
-
const dir = await mkdtemp(
|
|
4392
|
+
const dir = await mkdtemp(join12(tmpdir(), "ambasada-sql-"));
|
|
4224
4393
|
try {
|
|
4225
|
-
const file =
|
|
4226
|
-
await
|
|
4394
|
+
const file = join12(dir, "migration.sql");
|
|
4395
|
+
await writeFile7(file, sql);
|
|
4227
4396
|
await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--yes"], extra);
|
|
4228
4397
|
} finally {
|
|
4229
|
-
await
|
|
4398
|
+
await rm6(dir, { recursive: true, force: true });
|
|
4230
4399
|
}
|
|
4231
4400
|
}
|
|
4232
4401
|
async function migrateLocal(opts) {
|
|
@@ -4266,7 +4435,7 @@ INSERT INTO ${MIGRATIONS_TABLE} ${hasSha ? `(name, sha256) VALUES (${sqlString(m
|
|
|
4266
4435
|
}
|
|
4267
4436
|
async function migrateBeforeDev(root, print) {
|
|
4268
4437
|
const dir = findProductDir(root);
|
|
4269
|
-
if (!dir || !
|
|
4438
|
+
if (!dir || !existsSync11(join12(root, MIGRATIONS_DIR))) return;
|
|
4270
4439
|
try {
|
|
4271
4440
|
const { config } = await readWranglerConfig(dir);
|
|
4272
4441
|
if (!config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) return;
|
|
@@ -4435,7 +4604,7 @@ async function kv(args, flags) {
|
|
|
4435
4604
|
await wrangler(t, ["kv", "key", "put", key, ...fromFile ? ["--path", fromFile] : [value], "--binding", PLATFORM_STORAGE.kv, ...ttl ? ["--ttl", ttl] : []]);
|
|
4436
4605
|
return out5(`${c.green("\u2713")} ${key} (local)`);
|
|
4437
4606
|
}
|
|
4438
|
-
const raw = fromFile ? new Uint8Array(await
|
|
4607
|
+
const raw = fromFile ? new Uint8Array(await readFile7(fromFile)) : value;
|
|
4439
4608
|
const r = await api(t, "PUT", `${kvPath(key)}${ttl ? `?ttl=${ttl}` : ""}`, { raw, type: fromFile ? "application/octet-stream" : "text/plain; charset=utf-8" });
|
|
4440
4609
|
if (flags.json) return out5(JSON.stringify(r, null, 2));
|
|
4441
4610
|
return out5(`${c.green("\u2713")} ${r.key} ${c.dim(`${r.size} bytes${ttl ? `, expires in ${ttl} s` : ""}`)}`);
|
|
@@ -4518,14 +4687,14 @@ async function files(args, flags) {
|
|
|
4518
4687
|
case "put":
|
|
4519
4688
|
case "upload": {
|
|
4520
4689
|
if (!a) throw new CliError("usage: ambasada files put <path> [key] [--content-type type]");
|
|
4521
|
-
if (!
|
|
4522
|
-
const key = checkKey(b ??
|
|
4690
|
+
if (!existsSync11(a)) throw new CliError(`no file ${a}`);
|
|
4691
|
+
const key = checkKey(b ?? basename3(a));
|
|
4523
4692
|
const type = str2(flags["content-type"]) ?? contentTypeOf(a);
|
|
4524
4693
|
if (t.local) {
|
|
4525
4694
|
await wrangler(t, ["r2", "object", "put", `${await localBucket(t)}/${key}`, "--file", a, "--content-type", type]);
|
|
4526
4695
|
return out5(`${c.green("\u2713")} ${key} (local) \u2192 ${await fileUrl(t, key, flags)}`);
|
|
4527
4696
|
}
|
|
4528
|
-
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await
|
|
4697
|
+
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await readFile7(a)), type });
|
|
4529
4698
|
if (flags.json) return out5(JSON.stringify(r, null, 2));
|
|
4530
4699
|
return out5(`${c.green("\u2713")} ${r.key} ${c.dim(size(r.size))} \u2192 ${c.cyan(r.url ?? await fileUrl(t, key, flags))}`);
|
|
4531
4700
|
}
|
|
@@ -4549,10 +4718,10 @@ async function files(args, flags) {
|
|
|
4549
4718
|
// src/tunnel.ts
|
|
4550
4719
|
import { spawn as spawn3, spawnSync } from "node:child_process";
|
|
4551
4720
|
import { createHash as createHash2 } from "node:crypto";
|
|
4552
|
-
import { existsSync as
|
|
4553
|
-
import { chmod as chmod2, mkdir as
|
|
4721
|
+
import { existsSync as existsSync12 } from "node:fs";
|
|
4722
|
+
import { chmod as chmod2, mkdir as mkdir7, mkdtemp as mkdtemp2, readFile as readFile8, rename, rm as rm7, writeFile as writeFile8 } from "node:fs/promises";
|
|
4554
4723
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
4555
|
-
import { join as
|
|
4724
|
+
import { join as join13 } from "node:path";
|
|
4556
4725
|
var out6 = (s = "") => process.stdout.write(`${s}
|
|
4557
4726
|
`);
|
|
4558
4727
|
var CLOUDFLARED_VERSION = "2026.10.7";
|
|
@@ -4567,8 +4736,8 @@ var RELEASES = "https://github.com/cloudflare/cloudflared/releases/download";
|
|
|
4567
4736
|
async function cloudflaredBin(print = out6) {
|
|
4568
4737
|
if (process.env.AMBASADA_CLOUDFLARED) return process.env.AMBASADA_CLOUDFLARED;
|
|
4569
4738
|
if (spawnSync("cloudflared", ["--version"], { stdio: "ignore" }).status === 0) return "cloudflared";
|
|
4570
|
-
const dest =
|
|
4571
|
-
if (
|
|
4739
|
+
const dest = join13(configDir(), "bin", `cloudflared-${CLOUDFLARED_VERSION}${process.platform === "win32" ? ".exe" : ""}`);
|
|
4740
|
+
if (existsSync12(dest)) return dest;
|
|
4572
4741
|
print(c.dim(`downloading cloudflared ${CLOUDFLARED_VERSION} (once, ~20\u201340 MB) \u2026`));
|
|
4573
4742
|
await downloadCloudflared(dest);
|
|
4574
4743
|
return dest;
|
|
@@ -4588,22 +4757,22 @@ async function downloadCloudflared(dest, opts = {}) {
|
|
|
4588
4757
|
}
|
|
4589
4758
|
const digest = createHash2("sha256").update(bytes).digest("hex");
|
|
4590
4759
|
if (digest !== asset.sha256) throw new CliError(`cloudflared download from ${url} has SHA-256 ${digest}, expected ${asset.sha256}: not running it`);
|
|
4591
|
-
const dir =
|
|
4592
|
-
await
|
|
4593
|
-
const work = await mkdtemp2(
|
|
4760
|
+
const dir = join13(dest, "..");
|
|
4761
|
+
await mkdir7(dir, { recursive: true });
|
|
4762
|
+
const work = await mkdtemp2(join13(tmpdir2(), "ambasada-cloudflared-"));
|
|
4594
4763
|
try {
|
|
4595
|
-
const binary =
|
|
4764
|
+
const binary = join13(work, "cloudflared");
|
|
4596
4765
|
if (asset.file.endsWith(".tgz")) {
|
|
4597
|
-
await
|
|
4598
|
-
const tar = spawnSync("tar", ["-xzf",
|
|
4599
|
-
if (tar.status !== 0 || !
|
|
4600
|
-
} else await
|
|
4766
|
+
await writeFile8(join13(work, asset.file), bytes);
|
|
4767
|
+
const tar = spawnSync("tar", ["-xzf", join13(work, asset.file), "-C", work], { encoding: "utf8" });
|
|
4768
|
+
if (tar.status !== 0 || !existsSync12(binary)) throw new CliError(`could not unpack ${asset.file}: ${tar.stderr || "no cloudflared inside"}`);
|
|
4769
|
+
} else await writeFile8(binary, bytes);
|
|
4601
4770
|
await chmod2(binary, 493);
|
|
4602
4771
|
const tmp = `${dest}.${process.pid}`;
|
|
4603
|
-
await
|
|
4772
|
+
await writeFile8(tmp, await readFile8(binary), { mode: 493 });
|
|
4604
4773
|
await rename(tmp, dest);
|
|
4605
4774
|
} finally {
|
|
4606
|
-
await
|
|
4775
|
+
await rm7(work, { recursive: true, force: true });
|
|
4607
4776
|
}
|
|
4608
4777
|
}
|
|
4609
4778
|
async function call(opts, method, path, body) {
|
|
@@ -4760,7 +4929,7 @@ async function tunnelPort(flags) {
|
|
|
4760
4929
|
return 3e3;
|
|
4761
4930
|
}
|
|
4762
4931
|
function webhookPath(root) {
|
|
4763
|
-
return root && WEBHOOK_FILES.some((f) =>
|
|
4932
|
+
return root && WEBHOOK_FILES.some((f) => existsSync12(join13(root, f))) ? "/api/webhook" : void 0;
|
|
4764
4933
|
}
|
|
4765
4934
|
async function tunnel(_args, flags) {
|
|
4766
4935
|
const r = await resolveForApp(flags, "open the tunnel");
|
|
@@ -4773,7 +4942,7 @@ async function tunnel(_args, flags) {
|
|
|
4773
4942
|
out6(c.yellow(`! nothing answers on ${local} yet: start your dev server there (or pass --port)`));
|
|
4774
4943
|
}
|
|
4775
4944
|
const root = findRoot();
|
|
4776
|
-
if (root &&
|
|
4945
|
+
if (root && existsSync12(join13(root, ".env")) && /AMBASADA_API_URL=http:\/\/(127\.0\.0\.1|localhost)/.test(await readFile8(join13(root, ".env"), "utf8"))) {
|
|
4777
4946
|
out6(c.yellow("! your .env points AMBASADA_API_URL at the mock: real buyers' receipts won't verify. `ambasada dev --tunnel` runs your dev server against the village instead"));
|
|
4778
4947
|
}
|
|
4779
4948
|
const register = flags["no-register"] !== true;
|
|
@@ -4788,7 +4957,7 @@ async function tunnel(_args, flags) {
|
|
|
4788
4957
|
}
|
|
4789
4958
|
|
|
4790
4959
|
// src/commands.ts
|
|
4791
|
-
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.
|
|
4960
|
+
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.8") ? `^${"0.8.8"}` : "latest";
|
|
4792
4961
|
var DEV_PORT = 8788;
|
|
4793
4962
|
var out7 = (s = "") => process.stdout.write(`${s}
|
|
4794
4963
|
`);
|
|
@@ -4887,20 +5056,20 @@ var addonSpec = (short, flags) => str3(flags[`${short}-spec`]) ?? process.env[`A
|
|
|
4887
5056
|
async function writeFiles(root, files2, map, force = true) {
|
|
4888
5057
|
const written = [];
|
|
4889
5058
|
for (const [rel, content] of Object.entries(files2)) {
|
|
4890
|
-
const dest =
|
|
4891
|
-
if (!force &&
|
|
4892
|
-
await
|
|
4893
|
-
if (isBinary(rel)) await
|
|
4894
|
-
else await
|
|
5059
|
+
const dest = join14(root, rel === "gitignore" ? ".gitignore" : rel);
|
|
5060
|
+
if (!force && existsSync13(dest)) continue;
|
|
5061
|
+
await mkdir8(dirname8(dest), { recursive: true });
|
|
5062
|
+
if (isBinary(rel)) await writeFile9(dest, Buffer.from(content, "base64"));
|
|
5063
|
+
else await writeFile9(dest, map(rel, content));
|
|
4895
5064
|
written.push(rel);
|
|
4896
5065
|
}
|
|
4897
5066
|
return written;
|
|
4898
5067
|
}
|
|
4899
5068
|
async function init(args, flags) {
|
|
4900
5069
|
const target2 = resolvePath5(args[0] ?? ".");
|
|
4901
|
-
const name = str3(flags.name) ?? (args[0] ?
|
|
5070
|
+
const name = str3(flags.name) ?? (args[0] ? basename4(target2) : "My App");
|
|
4902
5071
|
const id = slug(str3(flags.id) ?? name);
|
|
4903
|
-
if (
|
|
5072
|
+
if (existsSync13(target2) && (await readdir4(target2)).filter((f) => f !== ".git").length && !flags.force) {
|
|
4904
5073
|
throw new CliError(`${target2} is not empty (use --force to scaffold anyway)`);
|
|
4905
5074
|
}
|
|
4906
5075
|
const template = str3(flags.template) ?? TEMPLATES[0];
|
|
@@ -4919,9 +5088,9 @@ async function init(args, flags) {
|
|
|
4919
5088
|
return `${JSON.stringify(pkg, null, 2)}
|
|
4920
5089
|
`;
|
|
4921
5090
|
});
|
|
4922
|
-
const env2 =
|
|
4923
|
-
if (!
|
|
4924
|
-
await
|
|
5091
|
+
const env2 = join14(target2, ".env");
|
|
5092
|
+
if (!existsSync13(env2)) {
|
|
5093
|
+
await writeFile9(
|
|
4925
5094
|
env2,
|
|
4926
5095
|
[
|
|
4927
5096
|
"# Local development against `ambasada dev`. On ambasada.dev the deploy sets the real values.",
|
|
@@ -4945,7 +5114,7 @@ async function init(args, flags) {
|
|
|
4945
5114
|
const entry2 = flags.miniapp ? str3(flags.entry) ?? DEFAULT_ENTRY : addon?.overlay?.miniapp?.entry;
|
|
4946
5115
|
const miniapp2 = entry2 ? await enableMiniapp(target2, entry2, flags, { template, releaseLine: /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec }) : null;
|
|
4947
5116
|
await writeProjectInfo(target2, { template, devkit: devkitVersion(), addons: addon ? [addon.short] : [] });
|
|
4948
|
-
let installed =
|
|
5117
|
+
let installed = existsSync13(join14(target2, "node_modules"));
|
|
4949
5118
|
const missing = installed ? missingDependencies(target2) : [];
|
|
4950
5119
|
if (missing.length && !flags["no-install"]) {
|
|
4951
5120
|
out7(c.dim(`npm install (${missing.join(", ")}) \u2026`));
|
|
@@ -4972,10 +5141,10 @@ async function init(args, flags) {
|
|
|
4972
5141
|
}
|
|
4973
5142
|
async function applyAddon(root, template, short, flags, fresh) {
|
|
4974
5143
|
const pkgName = KNOWN_ADDONS[short] ?? short;
|
|
4975
|
-
const pkgFile =
|
|
4976
|
-
const pkg = JSON.parse(await
|
|
5144
|
+
const pkgFile = join14(root, "package.json");
|
|
5145
|
+
const pkg = JSON.parse(await readFile9(pkgFile, "utf8"));
|
|
4977
5146
|
pkg.dependencies = { ...pkg.dependencies, [pkgName]: pkg.dependencies?.[pkgName] ?? addonSpec(shortName(pkgName), flags) };
|
|
4978
|
-
await
|
|
5147
|
+
await writeFile9(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
4979
5148
|
`);
|
|
4980
5149
|
const find = () => findAddons(root).find((dir2) => readJsonName(dir2) === pkgName) ?? besideCli(pkgName);
|
|
4981
5150
|
let dir = find() ?? packageDir(pkgName, root);
|
|
@@ -4995,37 +5164,37 @@ async function applyAddon(root, template, short, flags, fresh) {
|
|
|
4995
5164
|
const id = await loadIdName(root) ?? { id: "my-app", name: "My App" };
|
|
4996
5165
|
await writeFiles(root, overlay.files(template), (_rel, text) => text.replaceAll("my-app", id.id).replaceAll("My App", id.name), fresh || flags.force === true);
|
|
4997
5166
|
if (overlay.config) {
|
|
4998
|
-
const file =
|
|
4999
|
-
await
|
|
5167
|
+
const file = join14(root, CONFIG_FILE);
|
|
5168
|
+
await writeFile9(file, overlay.config(await readFile9(file, "utf8"), template));
|
|
5000
5169
|
}
|
|
5001
5170
|
if (overlay.env?.length) {
|
|
5002
|
-
const env2 =
|
|
5003
|
-
const current2 =
|
|
5171
|
+
const env2 = join14(root, ".env");
|
|
5172
|
+
const current2 = existsSync13(env2) ? await readFile9(env2, "utf8") : "";
|
|
5004
5173
|
const missing = overlay.env.filter((line) => line.startsWith("#") || !current2.includes(`${line.split("=")[0]}=`));
|
|
5005
5174
|
if (missing.some((l) => !l.startsWith("#"))) await appendFile2(env2, `${current2 && !current2.endsWith("\n") ? "\n" : ""}${missing.join("\n")}
|
|
5006
5175
|
`);
|
|
5007
5176
|
}
|
|
5008
5177
|
for (const [rel, text] of Object.entries(overlay.append ?? {})) {
|
|
5009
|
-
const file =
|
|
5010
|
-
const current2 =
|
|
5011
|
-
if (!current2.includes(text.trim().split("\n")[0])) await
|
|
5178
|
+
const file = join14(root, rel);
|
|
5179
|
+
const current2 = existsSync13(file) ? await readFile9(file, "utf8") : "";
|
|
5180
|
+
if (!current2.includes(text.trim().split("\n")[0])) await writeFile9(file, `${current2.trimEnd()}
|
|
5012
5181
|
|
|
5013
5182
|
${text.trim()}
|
|
5014
5183
|
`);
|
|
5015
5184
|
}
|
|
5016
5185
|
if (overlay.dependencies) {
|
|
5017
|
-
const next = JSON.parse(await
|
|
5186
|
+
const next = JSON.parse(await readFile9(pkgFile, "utf8"));
|
|
5018
5187
|
next.dependencies = { ...overlay.dependencies, ...next.dependencies };
|
|
5019
|
-
await
|
|
5188
|
+
await writeFile9(pkgFile, `${JSON.stringify(next, null, 2)}
|
|
5020
5189
|
`);
|
|
5021
5190
|
}
|
|
5022
5191
|
}
|
|
5023
|
-
await writeFiles(
|
|
5192
|
+
await writeFiles(join14(root, ".claude/skills"), addon.skills, (_rel, text) => text, true);
|
|
5024
5193
|
return addon;
|
|
5025
5194
|
}
|
|
5026
5195
|
function readJsonName(dir) {
|
|
5027
5196
|
try {
|
|
5028
|
-
return JSON.parse(
|
|
5197
|
+
return JSON.parse(readFileSync8(join14(dir, "package.json"), "utf8")).name;
|
|
5029
5198
|
} catch {
|
|
5030
5199
|
return void 0;
|
|
5031
5200
|
}
|
|
@@ -5058,7 +5227,7 @@ async function check(args, flags) {
|
|
|
5058
5227
|
const dir = target2 ? resolvePath5(target2) : findRoot();
|
|
5059
5228
|
if (!dir) throw new CliError("no ambasada.config.ts here or in a parent folder: pass a folder or a URL (ambasada check https://your.app)");
|
|
5060
5229
|
report = await checkLocal(dir);
|
|
5061
|
-
const url = await liveUrl(dir, PRODUCT_URL2.exec(
|
|
5230
|
+
const url = await liveUrl(dir, PRODUCT_URL2.exec(existsSync13(join14(dir, CONFIG_FILE)) ? readFileSync8(join14(dir, CONFIG_FILE), "utf8") : "")?.[1]);
|
|
5062
5231
|
if (!report.miniapp?.on && url?.startsWith("https://")) report = await checkRemote(url, void 0, { miniapp: false });
|
|
5063
5232
|
}
|
|
5064
5233
|
const on = report.miniapp?.on !== false;
|
|
@@ -5101,6 +5270,7 @@ function configuredAppUrl(project, flags, print) {
|
|
|
5101
5270
|
print(c.yellow(`! product.url in ${CONFIG_FILE} is ${configured}, not this laptop: serving on ${DEFAULT_APP_URL} (\`ambasada upgrade\` moves the live URL to .ambasada/project.json)`));
|
|
5102
5271
|
return DEFAULT_APP_URL;
|
|
5103
5272
|
}
|
|
5273
|
+
var DEV_SESSION_FILE = join14(".ambasada", "dev.json");
|
|
5104
5274
|
async function dev(_args, flags) {
|
|
5105
5275
|
const port = Number(str3(flags.port) ?? DEV_PORT);
|
|
5106
5276
|
if (flags.tunnel) return devTunnel(flags);
|
|
@@ -5120,6 +5290,11 @@ async function dev(_args, flags) {
|
|
|
5120
5290
|
extensions: addons().mocks,
|
|
5121
5291
|
log: (line) => out7(`${c.dim((/* @__PURE__ */ new Date()).toISOString().slice(11, 19))} ${line}`)
|
|
5122
5292
|
});
|
|
5293
|
+
const session = project ? join14(project.root, DEV_SESSION_FILE) : null;
|
|
5294
|
+
if (session) await mkdir8(dirname8(session), { recursive: true }).then(() => writeFile9(session, `${JSON.stringify({ mock_url: mock.url, company, pid: process.pid }, null, 2)}
|
|
5295
|
+
`));
|
|
5296
|
+
const forget = () => session ? rm8(session, { force: true }).catch(() => {
|
|
5297
|
+
}) : Promise.resolve();
|
|
5123
5298
|
out7(`${c.bold("ambasada dev")} \xB7 mock Ambasada API on ${c.cyan(mock.url)} ${c.dim("(dashboard: open it in a browser)")}`);
|
|
5124
5299
|
out7(` app ${c.bold(company)} \xB7 key ${c.bold(DEV_KEY)} \xB7 sign-in as a fake visitor \xB7 notifications to the mock hub`);
|
|
5125
5300
|
out7(` in your app: AMBASADA_API_URL=${mock.url} AMBASADA_API_KEY=${DEV_KEY}`);
|
|
@@ -5144,6 +5319,7 @@ async function dev(_args, flags) {
|
|
|
5144
5319
|
if (flags.once) {
|
|
5145
5320
|
hints();
|
|
5146
5321
|
await mock.close();
|
|
5322
|
+
await forget();
|
|
5147
5323
|
return;
|
|
5148
5324
|
}
|
|
5149
5325
|
if (stack && project) await migrateBeforeDev(project.root, out7);
|
|
@@ -5160,7 +5336,7 @@ async function dev(_args, flags) {
|
|
|
5160
5336
|
});
|
|
5161
5337
|
} else hints();
|
|
5162
5338
|
let closing;
|
|
5163
|
-
const close = () => closing ??= mock.close();
|
|
5339
|
+
const close = () => closing ??= mock.close().then(forget);
|
|
5164
5340
|
const code = await new Promise((resolveStop) => {
|
|
5165
5341
|
const stop2 = () => {
|
|
5166
5342
|
out7("\nstopping ambasada dev");
|
|
@@ -5223,7 +5399,7 @@ function manifestWebhook(root, productUrl) {
|
|
|
5223
5399
|
const file = findLocalManifest(root);
|
|
5224
5400
|
if (!file) return null;
|
|
5225
5401
|
try {
|
|
5226
|
-
const m = JSON.parse(
|
|
5402
|
+
const m = JSON.parse(readFileSync8(file, "utf8"));
|
|
5227
5403
|
const path = m.notifications?.webhook;
|
|
5228
5404
|
return Array.isArray(m.scopes) && m.scopes.includes("notifications") && typeof path === "string" && path ? new URL(path, productUrl).toString() : null;
|
|
5229
5405
|
} catch {
|
|
@@ -5298,11 +5474,22 @@ function messageLines(message, keep = 6) {
|
|
|
5298
5474
|
const lines = (message ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
5299
5475
|
return lines.length > keep ? [lines[0], `\u2026 ${lines.length - (keep - 1)} more`, ...lines.slice(-(keep - 2))] : lines;
|
|
5300
5476
|
}
|
|
5477
|
+
async function locate(root, e, lastDeploy) {
|
|
5478
|
+
if (!root || !e.message) return e;
|
|
5479
|
+
const maps = await mapsAt(root, e.at, lastDeploy).catch(() => null);
|
|
5480
|
+
const mapped = maps ? mapMessage(e.message, maps) : e.message;
|
|
5481
|
+
const lines = mapped === e.message ? [] : mapped.split("\n").filter((l, i) => l !== e.message.split("\n")[i] && !buildFrame(l));
|
|
5482
|
+
const route = detectStack(root)?.name === "next" && e.path && !lines.some(ownFrame) ? nextRouteFile(root, e.path) : null;
|
|
5483
|
+
const source = [...lines.map((l) => l.trim()), ...route ? [`in ${route}`] : []].join("\n");
|
|
5484
|
+
return source ? { ...e, source } : e;
|
|
5485
|
+
}
|
|
5301
5486
|
async function logs(_args, flags) {
|
|
5302
5487
|
const t = await hosted(flags);
|
|
5488
|
+
const root = findRoot();
|
|
5303
5489
|
const seen = /* @__PURE__ */ new Set();
|
|
5304
5490
|
for (let first = true; ; first = false) {
|
|
5305
5491
|
const res = await api(t, "GET", "/v1/product/logs");
|
|
5492
|
+
res.errors.recent = await Promise.all(res.errors.recent.map((e) => locate(root, e, res.last_deploy)));
|
|
5306
5493
|
if (json(flags, res) && !flags.follow) return;
|
|
5307
5494
|
if (first) {
|
|
5308
5495
|
out7(res.last_deploy ? `last deploy v${res.last_deploy.version} at ${res.last_deploy.deployed_at.slice(0, 19).replace("T", " ")}` : c.dim("not deployed yet: `ambasada deploy`"));
|
|
@@ -5315,6 +5502,7 @@ async function logs(_args, flags) {
|
|
|
5315
5502
|
const [first2 = "", ...more] = messageLines(e.message);
|
|
5316
5503
|
out7(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${c.red(String(e.status ?? "error"))} ${e.method ?? ""} ${e.path ?? ""} ${first2}`.trimEnd());
|
|
5317
5504
|
for (const line of more) out7(` ${c.dim(line)}`);
|
|
5505
|
+
for (const line of e.source?.split("\n") ?? []) out7(` ${c.cyan(`\u2192 ${line}`)}`);
|
|
5318
5506
|
}
|
|
5319
5507
|
if (!flags.follow) return;
|
|
5320
5508
|
await sleep(5e3);
|
|
@@ -5322,7 +5510,7 @@ async function logs(_args, flags) {
|
|
|
5322
5510
|
}
|
|
5323
5511
|
|
|
5324
5512
|
// src/main.ts
|
|
5325
|
-
var VERSION3 = true ? "0.8.
|
|
5513
|
+
var VERSION3 = true ? "0.8.8" : "0.1.0-dev";
|
|
5326
5514
|
var MAJOR_MINOR = VERSION3.split(".").slice(0, 2).join(".");
|
|
5327
5515
|
var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasada
|
|
5328
5516
|
|
|
@@ -5417,7 +5605,7 @@ var COMMANDS = {
|
|
|
5417
5605
|
var VILLAGE_COMMANDS = /* @__PURE__ */ new Set(["wallet", "ledger", "offerings", "market", "steer", "webhook", "links", "village"]);
|
|
5418
5606
|
async function villageHint(root, addons2) {
|
|
5419
5607
|
if (!root || addons2.get("village")) return;
|
|
5420
|
-
let uses =
|
|
5608
|
+
let uses = existsSync14(join15(root, "offerings.ts"));
|
|
5421
5609
|
if (!uses) {
|
|
5422
5610
|
try {
|
|
5423
5611
|
uses = (await loadProject(root)).config.idea !== void 0;
|