@ambasada/cli 0.8.7 → 0.8.9
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 +392 -180
- 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, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "AGENTS.md": "# Agents\n\nThis is an Ambasada app: Next.js that runs as one Cloudflare Worker (OpenNext), with `@ambasada/sdk` for\nsign-in, notifications and the store. Read `README.md` first, and `.claude/skills/ambasada-product/`.\nRun it with `npm run dev` (`ambasada dev`: the mock API + `next dev`). Data and realtime (`env.DB`, `env.KV`,\n`env.FILES`, live rooms): `.claude/skills/ambasada-product/reference/storage.md`.\n\n<!-- BEGIN:nextjs-agent-rules -->\n\n## This is NOT the Next.js you know\n\nThis version has breaking changes \u2014 APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.\n\nThis block is written and re-added by `next dev` \u2014 verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.\n\n<!-- END:nextjs-agent-rules -->\n", "README.md": "# My App\n\nAn app on Ambasada, Next.js on one Cloudflare Worker (template `next`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\napp/(site)/, app/notes.tsx the site: header, landing page, room, privacy; the notes example\napp/api/auth/* Sign in with Ambasada (login, callback, logout, me, shell); app/account.tsx is the header\napp/api/ambasada/webhook the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napp/api/notes, app/api/files env.DB + env.KV, uploads to env.FILES; app/files/[...key] serves them\nlib/ambasada.ts the SDK client from the Worker's env (getCloudflareContext), process.env under `next dev`\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables; lib/bindings.ts reaches them\nsrc/realtime/ the live room (a Durable Object) and its React hook; worker.ts routes its WebSockets\nnext.config.ts, open-next.config.ts, wrangler.jsonc, worker.ts Next.js \u2192 one Worker (OpenNext, nodejs_compat)\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `next dev` on http://localhost:3000\n```\n\n`npm install` warns that `node-domexception` and `glob@10.3.3` are deprecated: dependencies of OpenNext's, harmless,\nand gone when it updates them.\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `app/api/<name>/route.ts`. Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nStay inside what runs on a Worker: pages, server components, route handlers, server actions and `fetch`.\nNot: the `next/image` default loader, ISR / `revalidate`, `fs`, `child_process` or native packages, a custom\nserver. `npm run preview` builds the Worker (`opennextjs-cloudflare build`) and runs it in workerd, as in production.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # 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, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n *\n * Durable Objects don\'t run under `next dev`: there a job runs in the dev server\'s process, with the same retries\n * and an env like the object\'s: wrangler\'s local DB, KV and FILES, and the vars (`npm run preview` and the deployed app\n * use the Durable Object).\n */\nimport { createAmbasada } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\ninterface JobStub {\n start(kind: string, input: unknown, options?: JobOptions): Promise<JobState>;\n state(): Promise<JobState | null>;\n}\n\nconst stub = (name: string): JobStub => (getCloudflareContext().env as unknown as { JOBS: { getByName(name: string): JobStub } }).JOBS.getByName(name);\nconst local = process.env.NODE_ENV === "development";\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return local ? startLocal(name, kind, input, options) : stub(name).start(kind, input, { ...options, name });\n}\n\nexport async function jobState(name: string): Promise<JobState | null> {\n return local ? (devJobs.get(name) ?? null) : stub(name).state();\n}\n\n// \u2500\u2500 under `next dev` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * One registry for the dev server\'s process: Next.js loads this file once for route handlers and once more for server\n * components, so a module-level map gave /thanks its own copy, and it ran the webhook\'s job a second time (village dry\n * run 5 #5). On globalThis, both see the same job.\n */\nconst devJobs: Map<string, JobState> = ((globalThis as { __ambasadaDevJobs?: Map<string, JobState> }).__ambasadaDevJobs ??= new Map());\n\nasync function startLocal(name: string, kind: string, input: unknown, options: JobOptions): Promise<JobState> {\n // Loaded here, not at the top: the handlers import what imports this file. From here on nothing awaits until the\n // job is in devJobs, so the same name twice starts it once.\n const { HANDLERS } = await import("../src/jobs/handlers.ts");\n const current = devJobs.get(name);\n if (current) return current;\n const handler = HANDLERS[kind];\n if (!handler) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const job: JobState = { kind, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: Date.now(), deadline: deadlineMs(options.deadline), failures: [], updated_at: Date.now() };\n const retry = { lag_retries: 0, unauthorized_since: null as number | null };\n devJobs.set(name, job);\n let env: JobEnv = {};\n const attempt = async (): Promise<void> => {\n Object.assign(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null, updated_at: Date.now() });\n try {\n env = await devEnv();\n Object.assign(job, { status: "done", result: (await handler.run(input, env, { attempt: job.attempts })) ?? null, error: null, updated_at: Date.now() });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n // What the Durable Object reports to `ambasada logs` (the mock\'s, under `ambasada dev`).\n await createAmbasada({ env: env as Record<string, unknown> })\n .logs.report({ level: "error", message: `${kind} attempt ${job.attempts}: ${error}`, stack, job: name })\n .catch(() => {});\n const plan = nextRetry({ ...job, ...retry }, e, now);\n if (isUnauthorized(e)) retry.unauthorized_since ??= now;\n if (plan.lag) retry.lag_retries++;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n Object.assign(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error, updated_at: Date.now() });\n await handler.failed?.(input, env, error).catch(() => {});\n return;\n }\n Object.assign(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs, updated_at: Date.now() });\n await handler.retrying?.(input, env, { attempt: job.attempts, error, retryInMs }).catch(() => {});\n setTimeout(() => void attempt(), retryInMs);\n }\n };\n // What the Durable Object answers: the job as queued, its first attempt right after.\n const queued = { ...job };\n void attempt();\n return queued;\n}\n\n/**\n * What the Durable Object\'s env holds, under `next dev`: the bindings from wrangler\'s local copies (next.config.ts),\n * over the vars `ambasada dev` sets. Async mode: a retry runs from a timer, outside any request.\n */\nasync function devEnv(): Promise<JobEnv> {\n const { env } = await getCloudflareContext({ async: true });\n return { ...process.env, ...(env as unknown as JobEnv) };\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "next.config.ts": 'import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";\nimport type { NextConfig } from "next";\nimport { PHASE_DEVELOPMENT_SERVER } from "next/constants.js";\n\n// The product runs as one Cloudflare Worker (OpenNext). Stick to pages, server components, route\n// handlers, server actions and fetch: no next/image default loader, ISR, fs or native dependencies.\nconst nextConfig: NextConfig = {\n // `ambasada dev --tunnel` serves `next dev` at https://<slug>-dev.ambasada.dev: let it load dev assets and HMR from there.\n allowedDevOrigins: ["*.ambasada.dev"],\n};\n\nexport default function config(phase: string): NextConfig {\n // `next dev` reaches env.DB, env.KV and env.FILES (wrangler\'s local copies) through getCloudflareContext()\n // (lib/bindings.ts). Not in `next build`: the deployed Worker has its own, and wrangler\'s stand-in Worker for the\n // local copies doesn\'t export Room and Jobs (worker.ts does), so it warns they "will fail at runtime".\n if (phase === PHASE_DEVELOPMENT_SERVER) initOpenNextCloudflareForDev();\n return nextConfig;\n}\n', "open-next.config.ts": "import { defineCloudflareConfig } from \"@opennextjs/cloudflare\";\n\n// `opennextjs-cloudflare build` turns the Next.js app into one Worker (.open-next/worker.js + assets).\n// No source maps for Next's routes: OpenNext (1.20) bundles the server with esbuild and no `sourcemap` option, and\n// this config has none to set, so `ambasada logs` names a route's file (`\u2192 in app/api/boom/route.ts`), not its line.\n// lib/, src/jobs and worker.ts map to their lines.\nexport default defineCloudflareConfig();\n", "package.json": '{\n "name": "@ambasada/template-next",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Next.js as one Cloudflare Worker (OpenNext), with sign-in, notifications and storage.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "next build",\n "build:worker": "opennextjs-cloudflare build",\n "preview": "opennextjs-cloudflare build && wrangler dev --port 3000",\n "typecheck": "next typegen && tsc --noEmit && wrangler types node_modules/.worker-env.d.ts && tsc -p tsconfig.worker.json",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "@ambasada/ui": "workspace:*",\n "@opennextjs/cloudflare": "~1.20.15",\n "next": "~16.4.3",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/realtime/use-room.ts": '/**\n * `useRoom(id)`: a live room in a React component (src/realtime/room.ts on the server). Reconnects on its own.\n *\n * const { messages, online, send, status } = useRoom("lobby", { name: "Ana" });\n */\n"use client";\nimport { useCallback, useEffect, useRef, useState } from "react";\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\ntype RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport function useRoom(id: string, options: { name?: string } = {}) {\n const [messages, setMessages] = useState<RoomMessage[]>([]);\n const [online, setOnline] = useState(0);\n const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");\n const socket = useRef<WebSocket | null>(null);\n\n useEffect(() => {\n let stopped = false;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let tries = 0;\n const connect = () => {\n const url = new URL(`/api/rooms/${encodeURIComponent(id)}/ws`, location.href);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n if (options.name) url.searchParams.set("name", options.name);\n const ws = new WebSocket(url);\n socket.current = ws;\n setStatus("connecting");\n const timeout = setTimeout(() => ws.close(), 10_000); // no answer (e.g. `next dev`, which can\'t serve rooms): retry later\n ws.onopen = () => {\n clearTimeout(timeout);\n tries = 0;\n setStatus("open");\n };\n ws.onmessage = (e) => {\n const event = JSON.parse(String(e.data)) as RoomEvent;\n if (event.type === "history") setMessages(event.messages);\n else if (event.type === "message") setMessages((m) => [...m.slice(-49), event.message]);\n else setOnline(event.online);\n };\n ws.onclose = () => {\n clearTimeout(timeout);\n setStatus("closed");\n if (!stopped) retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); // back off; give up never\n };\n };\n connect();\n return () => {\n stopped = true;\n clearTimeout(retry);\n socket.current?.close();\n };\n }, [id, options.name]);\n\n const send = useCallback((text: string) => {\n if (socket.current?.readyState !== WebSocket.OPEN) return false;\n socket.current.send(JSON.stringify({ text }));\n return true;\n }, []);\n\n return { messages, online, status, send };\n}\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "lib": [\n "DOM",\n "DOM.Iterable",\n "ES2022"\n ],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "jsx": "react-jsx",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "esModuleInterop": true,\n "incremental": true,\n "plugins": [\n {\n "name": "next"\n }\n ],\n "allowJs": true\n },\n "include": [\n "next-env.d.ts",\n "**/*.ts",\n "**/*.tsx",\n ".next/types/**/*.ts",\n ".next/dev/types/**/*.ts"\n ],\n "exclude": [\n "node_modules",\n ".open-next",\n ".wrangler",\n "worker.ts",\n "async-hooks.d.ts",\n "src/realtime/room.ts",\n "src/jobs/jobs.ts",\n "examples"\n ]\n}\n', "tsconfig.worker.json": '{\n // worker.ts and the Durable Objects run in workerd, not in Next.js: their types come from\n // `wrangler types` (npm run typecheck writes them to node_modules/.worker-env.d.ts).\n "compilerOptions": {\n "target": "ES2022",\n "lib": ["ES2022"],\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "allowImportingTsExtensions": true,\n "types": []\n },\n "files": ["node_modules/.worker-env.d.ts", "async-hooks.d.ts", "worker.ts", "src/realtime/room.ts", "src/jobs/jobs.ts"]\n}\n', "worker.ts": '/**\n * The Worker\'s entry (wrangler.jsonc `main`): the live rooms\' WebSockets, which Next.js can\'t answer\n * (/api/rooms/<id>/ws \u2192 src/realtime/room.ts), then everything else to the Next.js app OpenNext built.\n * Durable Object classes are exported from here. A request that fails keeps what Next.js logged about it (the error\n * and its stack, your console.error lines) for `ambasada logs` (lib/errors.ts).\n */\n// @ts-ignore: .open-next/worker.js is written by `opennextjs-cloudflare build`\nimport { default as nextApp } from "./.open-next/worker.js";\nimport { withRequestLog } from "./lib/errors.ts";\n\nexport { Room } from "./src/realtime/room.ts";\nexport { Jobs } from "./src/jobs/jobs.ts";\n\nexport default {\n fetch(req, env, ctx) {\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(new URL(req.url).pathname);\n if (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n return withRequestLog(() => nextApp.fetch(req, env, ctx));\n },\n} satisfies ExportedHandler<Env>;\n', "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: worker.ts (live rooms, then the Next.js app built by OpenNext into .open-next/) plus static assets.\n "name": "my-app",\n "main": "worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": ".open-next/assets",\n "binding": "ASSETS"\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "JOBS", "class_name": "Jobs" }] },\n // Every Durable Object class needs a migration; add new ones at the end, never edit or remove an applied tag.\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Jobs"] }],\n "observability": { "enabled": true }\n}\n' }, spa: { ".claude/skills/ambasada-product/SKILL.md": "---\nname: ambasada-product\ndescription: Build an app on Ambasada and put it online. Use when the user wants to make an app, tool, game or website, start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy` at <slug>.ambasada.dev, show it in the Ambasada app (mini app, notifications, sign-in), store data in the app's SQLite database, KV or file storage, make it realtime with Durable Objects or an agent (Agents SDK), bring a site built in ChatGPT, Lovable, Bolt or v0, or upgrade a project made with an older CLI (`ambasada upgrade`). Default stack: Next.js on Cloudflare.\n---\n\n# Build an app on Ambasada and put it online\n\nAn app is **one website** (one Cloudflare Worker) at `https://<your-slug>.ambasada.dev`. It comes with\nSign in with Ambasada (Ambasada ID), a SQLite database, key-value, file storage and Durable Objects for\nrealtime. If you want, it also gets a mini app inside the Ambasada app, with push notifications (off by default). No servers, no\nCloudflare account, no keys to configure.\n\n## Start\n\n```sh\nnpm create ambasada@latest my-app # Next.js (default)\ncd my-app && npm install\nnpx ambasada dev # the mock API + your site, no login\n```\n\nOther templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or\n`-- --template worker` (plain HTML + small fetch handlers; the smallest possible app). All three have the\nsame `ambasada.config.ts`, sign-in routes, notifications webhook and storage helpers, and a notes example.\n\n## What to read\n\n| The user wants to\u2026 | Read |\n|---|---|\n| know where files go, what Next.js features are allowed, secrets, who is signed in | [reference/stack.md](reference/stack.md) |\n| store data (tables, migrations), sessions or caches, uploads; make it live between people (chat, presence, a shared board, counters); build an AI agent | [reference/storage.md](reference/storage.md) |\n| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |\n| make it look good and easy to use | [reference/design.md](reference/design.md) |\n| show up in the Ambasada app (optional: `npx ambasada miniapp enable`), send push notifications, sign people in inside the app | [reference/mini-app.md](reference/mini-app.md) |\n| build in ChatGPT, Lovable, Bolt or v0 and bring it here | [reference/build-elsewhere.md](reference/build-elsewhere.md) |\n| upgrade a project made with an older CLI | [reference/upgrading.md](reference/upgrading.md) |\n\nFor the Hack A Ton (TON payments, offerings, the agent that runs a shop): `npx ambasada add village`, then\nthe **ambasada-village** skill.\n\n## Rules (break one and the app does not work)\n\n1. **One app = one Worker.** No separate backend, no Docker. State lives in the app's SQLite (`env.DB`),\n key-value (`env.KV`), file storage (`env.FILES`) or a Durable Object, never in module variables.\n See [reference/storage.md](reference/storage.md).\n2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.\n No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom\n server, no `middleware.ts`/`proxy.ts`, no `runtime = \"edge\"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).\n3. **Keys never reach the browser.** They live in `.env` locally and `ambasada env set` online; only server\n code reads them. Never prefix one with `NEXT_PUBLIC_`.\n4. **Sign-in is Ambasada ID.** Use the template's `/api/auth/*` routes and `auth.user(req)`; don't add\n Clerk, Auth.js or Supabase Auth, and don't build an accounts table. Key per-person data by `user.id`.\n5. **Tables change through migrations**: a new `db/migrations/NNNN_name.sql` per change, never edits to an\n applied one; `npx ambasada db migrate` after deploying.\n\n## When the user is not a developer\n\nDo the work; don't ask them to edit code. Ask at most: what it does and for whom. Then build one page, run\n`npx ambasada dev`, open it, use it as the fake visitor, and show them it worked. Explain in one sentence\nper step, no jargon.\n", ".claude/skills/ambasada-product/reference/build-elsewhere.md": "# Built in ChatGPT, Lovable, Bolt or v0\n\nThree ways, easiest first.\n\n## A. Keep it hosted there, link it\n\nGood for: a site that is finished where it is and only needs to show up in the Ambasada app.\n\n1. On a laptop with Node 22+, once:\n\n ```sh\n npm create ambasada@latest my-app -- --miniapp --entry / # its ambasada.config.ts and the manifest\n cd my-app && npm install\n npx ambasada login\n npx ambasada link https://YOUR-SITE\n ```\n\n2. The site must serve `/.well-known/ambasada.json` and `/icon.png` (copy them from the new project's\n `public/`; `npx ambasada dev` writes the manifest there) and pass `npx ambasada check https://YOUR-SITE`\n ([mini-app.md](mini-app.md)).\n\nLimit: Sign in with Ambasada, notifications and the app's database, KV and files come with hosting on\nambasada.dev. A site hosted elsewhere keeps its own backend; use B or C to get them.\n\n## B. Bring the code home (recommended for Lovable and Bolt)\n\nGood for: anything with real work behind it. You get hosting on ambasada.dev and the full rules.\n\n1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.\n2. `npm create ambasada@latest my-app -- --template spa` and copy the exported `src/` (components, pages,\n styles) into the new project's `src/`, replacing the starter UI. Keep the template's `api/`, `lib/`,\n `db/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.\n3. Open the project in Claude Code, Cursor or Codex and say:\n\n > This is an Ambasada app (see .claude/skills/ambasada-product). Replace Supabase/Firebase with the\n > app's env.DB (tables in db/migrations), env.KV and env.FILES, and any login with Sign in with Ambasada\n > (api/auth.ts, /api/auth/me). Then run it with `npx ambasada dev`.\n\n4. `npx ambasada dev`, try it, then `npx ambasada deploy`.\n\n## C. ChatGPT (or any chat) writes the code, you paste it\n\n1. `npm create ambasada@latest my-app`, `cd my-app && npm install`, `npx ambasada dev`.\n2. Paste this into ChatGPT, then the files it mentions, then your idea:\n\n > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no `fs`, no\n > native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`, no other login than the\n > one in `app/api/auth`. Data goes in SQLite through `lib/db.ts` (tables in `db/migrations/*.sql`), the\n > signed-in person comes from `currentUser()` in `lib/ambasada.ts`. Below are my `app/page.tsx`,\n > `app/api/notes/route.ts` and `db/migrations/0001_init.sql`. Rewrite them for my idea: the main action\n > visible on a phone without scrolling. Write complete files, one code block per file, with the file path\n > above each. My idea: \u2026\n\n3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page. New tables: a new\n file in `db/migrations/` (never edit an applied one). When it works: `npx ambasada deploy` and\n `npx ambasada db migrate`.\n\nUse ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,\n`next/image` or a `middleware.ts`, tell it \"that's banned in my project, do it without\".\n", ".claude/skills/ambasada-product/reference/design.md": '# Design for people who decide in 10 seconds\n\nPeople open your app on a phone, often inside the Ambasada app, between two other things. They decide in\nseconds whether it is for them. Build for that.\n\n## One page, one action\n\n- **One page first.** What it is, and the one thing to do, fit on the first screen of a phone (375 \xD7 667).\n Extra pages only when the action needs them.\n- **One action.** One button that does the main thing. Add a second only after people use the first.\n- **Few words.** A headline of up to 8 words, one sentence under it, the button. Cut every adjective you can.\n- **Show the result.** One example of what people get (an image, a sample text) beats any description.\n- **No walls.** No signup before the first use, no cookie banner, no tour. Ask for sign-in when the app\n needs to remember someone (Sign in with Ambasada is one tap inside the Ambasada app).\n\n## The button\n\n- Label it with the action: **Make my poem**, not **Submit**.\n- Big (at least 48 px tall), full width on phones, one strong colour, nothing else in that colour.\n- If people must type something first, put that one field right above the button.\n- After the action, show the result, or "Working on it\u2026" and the result appearing when ready (a live room\n or polling). Never a blank page.\n\n## Look\n\n- The templates use `@ambasada/ui` (the Ambasada app\'s look: tokens, `ui-card`, `ui-button`); start there\n ([mini-app.md](mini-app.md)). Otherwise plain CSS: one font, two colours plus grey, generous spacing.\n- Large text: 18 px body on phones, a headline twice that. Light theme.\n- Works with one thumb: tap targets 48 px, no hover-only menus.\n- Loads fast: no video backgrounds, no 3 MB hero images, no animation libraries.\n\n## Words\n\n- Say what they get, not how it works. "A logo in 1 minute", not "AI-powered logo generation".\n- Write in the language of your users; one is fine.\n\n## Before and after\n\n```\nBad: Welcome to PoemForge\u2122, the next-generation AI-powered creative writing platform\n [Features] [Pricing] [About] [Sign up] \u2026scroll\u2026 [Get started]\n\nGood: Your name as a folk poem\n Type a name. Get a 4-line Romanian folk poem in 10 seconds.\n [ Name: _________ ]\n [ Make my poem ]\n "Ana, floare de la munte, / \u2026" \u2190 one real example\n```\n', ".claude/skills/ambasada-product/reference/dev-and-deploy.md": "# Run it, put it online\n\n## `ambasada dev`: everything on your laptop\n\n```sh\nnpx ambasada dev\n```\n\nStarts two things:\n\n1. a **mock Ambasada API** on `http://127.0.0.1:8788`: same routes and signatures as the real one. Sign in\n with Ambasada signs you in as \"Fake Visitor\"; `apps.notify` lands in a fake hub; `ambasada.store` and\n `ambasada.files` work in memory. Open it in a browser for a page with what happened.\n2. **your site** with its normal dev server (`next dev`, `vite` or `wrangler dev`), pointed at the mock.\n\nNo login. The app's database, KV, files and Durable Objects run locally too (wrangler, `.wrangler/state`);\n`ambasada dev` applies `db/migrations/` to the local database before it starts the site.\n\nTry it: open your site, sign in, add a note. With the mini app on, `http://localhost:3000/app?ambasada_shell=mock`\n(your `miniapp.entry`) pretends to be inside the Ambasada app (host bar insets, silent sign-in).\n\n`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:\n`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).\n\n`product.url` in `ambasada.config.ts` is where `ambasada dev` serves the site (`http://localhost:3000`); `deploy`\nnever changes it, so `ambasada dev` works the same before and after a deploy. Port taken? `ambasada dev` says who\nholds it and stops; `--app-port 3001` moves the site (`--port` moves the mock, `:8788`).\n\n## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, in the app's folder:\n\n```sh\nnpx ambasada login # opens 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, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "README.md": "# My App\n\nAn app on Ambasada, Vite + React on one Cloudflare Worker (template `spa`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/ the React app (drop a Lovable/Bolt export here); src/App.tsx has the header and the notes example\nworker/index.ts the Worker: /api/<name> \u2192 api/<name>.ts, everything else is the app\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker's env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/ the live room (a Durable Object) and its React hook\nvite.config.ts, wrangler.jsonc @cloudflare/vite-plugin: `vite` runs the app and the Worker together\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `vite` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `api/<name>.ts` (and a line in `worker/index.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # 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, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobOptions, JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(env: Env, name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input, { ...options, name })) as JobState;\n}\n\nexport async function jobState(env: Env, name: string): Promise<JobState | null> {\n return (await env.JOBS.getByName(name).state()) as JobState | null;\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-spa",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Vite + React (Lovable/Bolt exports) and api/*.ts handlers as one Cloudflare Worker.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "vite build",\n "preview": "vite build && vite preview --port 3000",\n "typecheck": "tsc -b",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "@ambasada/ui": "workspace:*",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/vite-plugin": "^1.67.0",\n "@cloudflare/workers-types": "^5.20261102.1",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "@vitejs/plugin-react": "^6.1.2",\n "typescript": "~5.9.3",\n "vite": "^8.3.5",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "public/privacy.html": `<!doctype html>
|
|
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">
|
|
@@ -155,7 +155,7 @@ export const bindings = (): Bindings => getCloudflareContext().env as unknown as
|
|
|
155
155
|
</main>
|
|
156
156
|
</body>
|
|
157
157
|
</html>
|
|
158
|
-
`, "src/App.tsx": 'import { useEffect, useState } from "react";\nimport { RoomDemo } from "./realtime/room-demo.tsx";\n\n/** Two pages: the landing page and /room (a live room). */\nexport function App() {\n return (\n <>\n <Header />\n <main className="page">{location.pathname === "/room" ? <RoomDemo /> : <Home />}</main>\n </>\n );\n}\n\n/** What GET /api/auth/me returns (api/auth.ts): the person signed in with Ambasada, or null. */\ntype User = { id: string; name: string | null; avatar_url: string | null; builder: boolean; pass: string | null };\n\ntype Me = { user: User | null; configured: boolean; dev_issuer?: string };\n\n/** "Sign in with Ambasada", or the person\'s name and avatar once signed in. */\nfunction Header() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n }, []);\n return (\n <header className="topbar">\n <a className="brand" href="/">\n My App\n </a>\n {me?.user ? (\n <span className="account">\n {me.user.avatar_url && <img src={me.user.avatar_url} alt="" />}\n <span>{me.user.name ?? "Signed in"}</span>\n <a href="/api/auth/logout">Sign out</a>\n </span>\n ) : me?.configured ? (\n <a className="ui-button secondary signin" href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>\n Sign in with Ambasada\n </a>\n ) : null}\n </header>\n );\n}\n\nfunction Home() {\n return (\n <>\n <p className="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p className="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>\n <Notes />\n <p>\n <a href="/room">Open the live room</a> in two tabs and type.\n </p>\n </>\n );\n}\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. */\nfunction Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "src/env.d.ts": '/// <reference types="vite/client" />\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * 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/main.tsx": `import { StrictMode } from "react";
|
|
158
|
+
`, "src/App.tsx": 'import { useEffect, useState } from "react";\nimport { RoomDemo } from "./realtime/room-demo.tsx";\n\n/** Two pages: the landing page and /room (a live room). */\nexport function App() {\n return (\n <>\n <Header />\n <main className="page">{location.pathname === "/room" ? <RoomDemo /> : <Home />}</main>\n </>\n );\n}\n\n/** What GET /api/auth/me returns (api/auth.ts): the person signed in with Ambasada, or null. */\ntype User = { id: string; name: string | null; avatar_url: string | null; builder: boolean; pass: string | null };\n\ntype Me = { user: User | null; configured: boolean; dev_issuer?: string };\n\n/** "Sign in with Ambasada", or the person\'s name and avatar once signed in. */\nfunction Header() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n }, []);\n return (\n <header className="topbar">\n <a className="brand" href="/">\n My App\n </a>\n {me?.user ? (\n <span className="account">\n {me.user.avatar_url && <img src={me.user.avatar_url} alt="" />}\n <span>{me.user.name ?? "Signed in"}</span>\n <a href="/api/auth/logout">Sign out</a>\n </span>\n ) : me?.configured ? (\n <a className="ui-button secondary signin" href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>\n Sign in with Ambasada\n </a>\n ) : null}\n </header>\n );\n}\n\nfunction Home() {\n return (\n <>\n <p className="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p className="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>\n <Notes />\n <p>\n <a href="/room">Open the live room</a> in two tabs and type.\n </p>\n </>\n );\n}\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. */\nfunction Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "src/env.d.ts": '/// <reference types="vite/client" />\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/main.tsx": `import { StrictMode } from "react";
|
|
159
159
|
import { createRoot } from "react-dom/client";
|
|
160
160
|
import { App } from "./App.tsx";
|
|
161
161
|
// The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this product's own styles.
|
|
@@ -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, failures }\n```\n\nEach failed attempt is kept on the job (`failures`: when, the error, where it was thrown) and shows in `ambasada logs`\nas `job report:42 attempt 1: \u2026` with its source line: a job runs from an alarm, outside any request, so its\n`console.error` reaches nobody online. A 401 from the API in the minutes after a deploy is retried on a short clock\nwithout counting as an attempt.\n\nNext.js: `startJob(name, kind, input)` (no `env`); under `next dev` the job runs in the dev server\'s process, with the\nsame `env` (wrangler\'s local DB, KV and FILES, and the vars). A handler runs outside Next.js: build clients from its\n`env` argument, not from `lib/ambasada.ts`.\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": '# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI\'s version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project\'s own skills are left alone), the code\nchanges listed below as "done by `upgrade`", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project\'s `typecheck` script.\n\n## 0.8.7 \u2192 the next release\n\nBy hand, for a coding agent (copy from a fresh `npm create ambasada@latest` of the same template):\n\n1. **next: a job reaches the app\'s storage under `ambasada dev`.** Copy `lib/jobs.ts`: under `next dev` it gave the\n handler `process.env`, so `env.DB`, `env.KV` and `env.FILES` were undefined there (online it was right).\n2. **next: no "will fail at runtime" on deploy.** Copy `next.config.ts` (or call `initOpenNextCloudflareForDev()` only\n when `phase === PHASE_DEVELOPMENT_SERVER`): in `next build` it started wrangler\'s local stand-in, which said the\n Durable Objects aren\'t exported. They are, from `worker.ts`; nothing changes online.\n3. **`ambasada logs` names your source.** Copy `lib/errors.ts` (the same file in every template): it keeps the stack\n down to the first frame in your code, past built-ins like `JSON.parse`. `logs` maps it once you deploy with this CLI.\n\n## 0.8.6 \u2192 0.8.7: the live URL leaves `ambasada.config.ts`\n\nDone by `upgrade`: up to 0.8.6 `ambasada deploy` wrote the live URL (`https://<slug>.ambasada.dev`) into `product.url`,\nand `ambasada dev` then refused to start. `product.url` is the local dev server\'s URL now (`http://localhost:3000`)\nand the live one is `live_url` in `.ambasada/project.json`, written by `deploy` and `link`: `upgrade` moves it there\nand puts the local URL back. Nothing by hand, unless your own code read `product.url` from the config as the live URL.\n\nBy hand, for a coding agent (the templates have these from 0.8.7; copy them from a fresh `npm create ambasada@latest`\nof the same template):\n\n1. **Jobs** (work that outlives a request): `src/jobs/jobs.ts`, `src/jobs/types.ts`, `src/jobs/handlers.ts`,\n `lib/jobs.ts`; in `wrangler.jsonc` the binding `{ "name": "JOBS", "class_name": "Jobs" }` and a new migration\n after your last one, `{ "tag": "v<next>", "new_sqlite_classes": ["Jobs"] }`; `export { Jobs } from \u2026` in the\n Worker\'s entry (worker.ts / src/worker.ts / worker/index.ts); `JOBS` in the `Env` type (worker, spa); spa:\n `src/jobs` in tsconfig.worker.json\'s `include` and tsconfig.app.json\'s `exclude`; next: `src/jobs/jobs.ts` in\n tsconfig.worker.json\'s `files` and tsconfig.json\'s `exclude`.\n2. **A village app that sells with Checkout**: the webhook must not do the work. Move it into a job (the village skill\'s\n reference/checkout.md): the webhook calls `startJob(\u2026, "checkout:" + id, "deliver-checkout", { session: id })` and\n answers 200; the job does the work and `deliverCheckout`; `/thanks` reads `jobState` (working / the result / failed).\n3. **`lib/errors.ts`** (`ambasada logs` shows why a request failed): copy it, wrap the Worker\'s `fetch` in\n `withRequestLog(\u2026)` and answer the catch-all with `serverError(error)` (next: in worker.ts, plus `async-hooks.d.ts`\n in tsconfig.worker.json\'s `files`; worker and spa: `lib/async-hooks.d.ts`).\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest\'s subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client\'s `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform\'s from 0.8.\n5. **Login.** The CLI\'s login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n', "README.md": "# My App\n\nAn app on Ambasada, one bare Cloudflare Worker (template `worker`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/worker.ts the Worker: /api/<name> \u2192 api/<name>.ts, / (landing page), the rest from web/\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker's env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/room.ts the live room (a Durable Object); web/room.html is the demo\nweb/ the landing page, static assets\nwrangler.jsonc one Worker + assets, nodejs_compat, the storage bindings\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `wrangler dev` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `api/<name>.ts` (and a line in `src/worker.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nNo framework, no build step: `wrangler` bundles `src/worker.ts`, and `web/` is served as is.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # 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, failures }\n * jobAnswer(job) // what an async offering\'s GET \u2026/jobs/<id> answers\n */\nimport type { Env } from "./ambasada.ts";\nimport type { JobOptions, JobState } from "../src/jobs/types.ts";\n\nexport { jobAnswer } from "../src/jobs/types.ts";\nexport type { JobOptions, JobState };\n\n/** `options.deadline`: an async offering\'s job passes `call.deadline`, and is tried until then (never after). */\nexport async function startJob(env: Env, name: string, kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n return (await env.JOBS.getByName(name).start(kind, input, { ...options, name })) as JobState;\n}\n\nexport async function jobState(env: Env, name: string): Promise<JobState | null> {\n return (await env.JOBS.getByName(name).state()) as JobState | null;\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-worker",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: one bare Cloudflare Worker, web/ + api/*.ts fetch handlers.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "preview": "wrangler dev --port 3000",\n "typecheck": "tsc -p .",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/workers-types": "^5.20261102.1",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n },\n "allowScripts": {\n "esbuild": true,\n "fsevents": true,\n "workerd": true\n }\n}\n', "src/jobs/handlers.ts": '/**\n * What each kind of job does (src/jobs/jobs.ts runs them, lib/jobs.ts starts them). Use a job for work that takes\n * longer than a request should, or must not be lost when one is cut: a model call, a report, a batch of pushes.\n * `run` is tried again after 10 s, 30 s, 2 min, 10 min and 30 min when it throws; after that the job has failed.\n *\n * "send-digest": {\n * async run(input, env) {\n * const { user } = input as { user: string };\n * \u2026 // anything: fetch, env.DB, the SDK with ambasada(env)\n * return { sent: true }; // kept as the job\'s result\n * },\n * },\n */\nimport type { JobHandler } from "./types.ts";\n\nexport const HANDLERS: Record<string, JobHandler> = {};\n', "src/jobs/jobs.ts": '/**\n * Work that outlives a request (Durable Object `Jobs`, bound as env.JOBS): one object per job, named by whoever starts\n * it (`checkout:cs_\u2026`), run from an alarm with the object\'s own limits (minutes of wall time, not a request\'s 30 s or a\n * webhook\'s 10 s), retried with backoff when it throws.\n *\n * startJob(env, "checkout:cs_\u2026", "deliver-checkout", { session: "cs_\u2026" }) lib/jobs.ts: answers at once\n * startJob(env, `story:${call.call_id}`, "story", input, { deadline: call.deadline }) an async offering\'s: tried until then\n * jobState(env, "checkout:cs_\u2026") \u2192 { status: "queued" | "working" | "retrying" | "done" | "failed", attempts, error, result }\n *\n * A 401 from the API within 5 minutes of a deploy is retried on a short clock (15 s \u2026 2 min) without counting as an\n * attempt: the deploy\'s new version reaches running objects with a lag (src/jobs/types.ts nextRetry).\n *\n * Each failed attempt is kept on the job (`failures`: when, the error, its stack\'s first frames) and reported to\n * `ambasada logs` (`job <name>`): a job runs from an alarm, outside any request, so its console.error lines go nowhere\n * you can read them online.\n *\n * What each kind does: src/jobs/handlers.ts. Starting a job again under the same name is a no-op (a webhook retried,\n * the thanks page opened first), so name a job after the thing it is for.\n */\nimport { DurableObject } from "cloudflare:workers";\nimport { createAmbasada } from "@ambasada/sdk";\nimport { HANDLERS } from "./handlers.ts";\nimport { deadlineMs, isUnauthorized, MAX_FAILURES, nextRetry, stackHead, type JobEnv, type JobOptions, type JobState } from "./types.ts";\n\ninterface Stored extends JobState {\n input: unknown;\n name?: string;\n lag_retries?: number;\n unauthorized_since?: number | null;\n}\n\nexport class Jobs extends DurableObject<JobEnv> {\n /** Starts the job unless this object has one already; the first attempt runs right after this answers. */\n async start(kind: string, input: unknown, options: JobOptions = {}): Promise<JobState> {\n const current = await this.ctx.storage.get<Stored>("job");\n if (current) return view(current);\n if (!HANDLERS[kind]) throw new Error(`no job kind "${kind}" in src/jobs/handlers.ts`);\n const now = Date.now();\n const job: Stored = { kind, input, status: "queued", attempts: 0, error: null, result: null, next_attempt_at: now, deadline: deadlineMs(options.deadline), failures: [], name: options.name, updated_at: now };\n await this.ctx.storage.put("job", job);\n await this.ctx.storage.setAlarm(now);\n return view(job);\n }\n\n async state(): Promise<JobState | null> {\n const job = await this.ctx.storage.get<Stored>("job");\n return job ? view(job) : null;\n }\n\n async alarm(): Promise<void> {\n const job = await this.ctx.storage.get<Stored>("job");\n if (!job || job.status === "done" || job.status === "failed") return;\n const handler = HANDLERS[job.kind];\n if (!handler) return void (await this.save(job, { status: "failed", error: `no job kind "${job.kind}" in src/jobs/handlers.ts` }));\n if (job.deadline && Date.now() >= job.deadline) return void (await this.save(job, { status: "failed", error: job.error ?? "the buyer\'s max_wait_s is over (refunded)" }));\n await this.save(job, { status: "working", attempts: job.attempts + 1, next_attempt_at: null });\n try {\n const result = await handler.run(job.input, this.env, { attempt: job.attempts });\n await this.save(job, { status: "done", result: result ?? null, error: null });\n } catch (e) {\n const error = e instanceof Error ? e.message : String(e);\n console.error(`job ${job.kind} attempt ${job.attempts} failed:`, e);\n const now = Date.now();\n const stack = stackHead(e);\n job.failures = [...(job.failures ?? []), { at: now, attempt: job.attempts, error, stack }].slice(-MAX_FAILURES);\n await this.report(job, `attempt ${job.attempts}: ${error}`, stack);\n const plan = nextRetry(job, e, now, Number(this.env.AMBASADA_DEPLOYED_AT) || undefined);\n if (isUnauthorized(e)) job.unauthorized_since ??= now;\n if (plan.lag) job.lag_retries = (job.lag_retries ?? 0) + 1;\n const retryInMs = plan.retryInMs;\n if (retryInMs === null) {\n await this.save(job, { status: "failed", error: plan.why ? `${error} (${plan.why})` : error });\n await handler.failed?.(job.input, this.env, error).catch((f) => console.error(`job ${job.kind}: failed() threw`, f));\n return;\n }\n await this.save(job, { status: "retrying", error, next_attempt_at: Date.now() + retryInMs });\n await this.ctx.storage.setAlarm(Date.now() + retryInMs);\n await handler.retrying?.(job.input, this.env, { attempt: job.attempts, error, retryInMs }).catch((f) => console.error(`job ${job.kind}: retrying() threw`, f));\n }\n }\n\n /** A failed attempt in `ambasada logs` (through the AMBASADA_API binding online, the mock under `ambasada dev`). */\n private async report(job: Stored, message: string, stack: string | null): Promise<void> {\n try {\n await createAmbasada({ env: this.env as Record<string, unknown> }).logs.report({ level: "error", message: `${job.kind} ${message}`, stack, job: job.name ?? job.kind });\n } catch (e) {\n console.warn(`job ${job.kind}: could not report to ambasada logs:`, e instanceof Error ? e.message : e);\n }\n }\n\n private async save(job: Stored, change: Partial<Stored>): Promise<void> {\n Object.assign(job, change, { updated_at: Date.now() });\n await this.ctx.storage.put("job", job);\n }\n}\n\nfunction view({ input: _input, name: _name, lag_retries: _lag, unauthorized_since: _since, ...state }: Stored): JobState {\n return state;\n}\n', "src/jobs/types.ts": '/** The job runner\'s shapes (src/jobs/jobs.ts runs jobs, lib/jobs.ts starts them, src/jobs/handlers.ts says what they do). */\n\nexport type JobStatus = "queued" | "working" | "retrying" | "done" | "failed";\n\nexport interface JobState {\n kind: string;\n status: JobStatus;\n /** Attempts started so far. */\n attempts: number;\n /** What the last failed attempt threw (`retrying`, `failed`). */\n error: string | null;\n /** What `run` returned (`done`), as JSON. */\n result: unknown;\n /** When the next attempt starts (`queued`, `retrying`), ms since the epoch. */\n next_attempt_at: number | null;\n /** An async offering\'s: the buyer\'s max_wait_s runs out then (ms); retried until it, never after. */\n deadline?: number | null;\n /** Each failed attempt (the last 10): when, what it threw and where (also in `ambasada logs`). */\n failures?: JobFailure[];\n updated_at: number;\n}\n\nexport interface JobFailure {\n at: number;\n attempt: number;\n error: string;\n /** The stack\'s first frames, down to the first one in a file (`ambasada logs` maps it to your source). */\n stack: string | null;\n}\n\n/** Failed attempts a job keeps. */\nexport const MAX_FAILURES = 10;\n\n/** An error\'s stack down to its first frame in a file (past built-ins like `at JSON.parse (<anonymous>)`), or null. */\nexport function stackHead(error: unknown): string | null {\n if (!(error instanceof Error) || !error.stack) return null;\n const frames = error.stack.split("\\n").map((line) => line.trim()).filter((line) => line.startsWith("at "));\n const inFile = frames.findIndex((line) => /:\\d+:\\d+\\)?$/.test(line));\n const head = frames.slice(0, inFile < 0 ? 1 : Math.min(inFile + 1, 3));\n return head.length ? head.join("\\n") : null;\n}\n\n/**\n * What an async offering\'s `GET <endpoint>/jobs/<id>` answers for this job (`village.jobStatus`): done, failed, or\n * working with the attempts that failed so far, which the buyer sees as "working (attempt 2, last error: \u2026)".\n */\nexport function jobAnswer(job: JobState): { status: "working"; attempts: { at: string; error: string }[] } | { status: "done"; output: unknown } | { status: "failed"; error: string } {\n if (job.status === "done") return { status: "done", output: job.result };\n if (job.status === "failed") return { status: "failed", error: job.error ?? "failed" };\n return { status: "working", attempts: (job.failures ?? []).map((f) => ({ at: new Date(f.at).toISOString(), error: f.error })) };\n}\n\n/** What `startJob` may say besides the input. */\nexport interface JobOptions {\n /** An async offering\'s job passes `call.deadline` (the receipt\'s, unix seconds; ms work too): the Gateway refunds the buyer then. */\n deadline?: number | null;\n /** The job\'s name (lib/jobs.ts passes it): how `ambasada logs` names its failed attempts. */\n name?: string;\n}\n\n/** A deadline in ms since the epoch (seconds, as a receipt has it, are converted). */\nexport function deadlineMs(deadline: number | null | undefined): number | null {\n if (!deadline || !Number.isFinite(deadline)) return null;\n return deadline < 1e12 ? deadline * 1000 : deadline;\n}\n\n/** The Worker\'s env as the job sees it (its bindings and vars). */\nexport type JobEnv = Record<string, unknown>;\n\nexport interface JobHandler {\n /** Does the work: may take minutes, may throw (then it is tried again). Make it safe to run twice. */\n run(input: unknown, env: JobEnv, info: { attempt: number }): Promise<unknown>;\n /** An attempt failed and another follows in `retryInMs`: say so somewhere, if you like. */\n retrying?(input: unknown, env: JobEnv, info: { attempt: number; error: string; retryInMs: number }): Promise<void>;\n /** The last attempt failed: the job is over. */\n failed?(input: unknown, env: JobEnv, error: string): Promise<void>;\n}\n\n/** After a failed attempt, wait this long and try again; after the last one the job has failed (6 attempts, ~43 min). */\nexport const RETRY_AFTER_MS = [10_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];\n\n/**\n * A 401 from the API within this long of a deploy (or of the job\'s first 401) is the new version still on its way, not\n * a bad key: retried on its own short clock and not counted (village dry run 5 #2: retries failed "a valid company API\n * key is required" for minutes after a deploy).\n */\nexport const DEPLOY_LAG_MS = 5 * 60_000;\nexport const LAG_RETRY_MS = [15_000, 30_000, 60_000, 120_000];\n\n/** What a job remembers between attempts to plan the next one. */\nexport interface RetryState {\n attempts: number;\n /** Retries after a 401 in the deploy window (not counted against RETRY_AFTER_MS). */\n lag_retries?: number;\n /** The first 401 of this job (ms). */\n unauthorized_since?: number | null;\n deadline?: number | null;\n}\n\n/** The API refused the app\'s key (401): the SDK\'s error has status 401 / code UNAUTHORIZED. */\nexport function isUnauthorized(e: unknown): boolean {\n const x = e as { status?: unknown; code?: unknown; message?: unknown } | null;\n return x?.status === 401 || x?.code === "UNAUTHORIZED" || /valid company API key is required/.test(String(x?.message ?? ""));\n}\n\n/**\n * After a failed attempt: when to try again, or `retryInMs: null` when the job has failed. `deployedAt`: the app\'s\n * last deploy (`AMBASADA_DEPLOYED_AT`). With a deadline the job is tried until it and never after (the buyer is\n * refunded then, so more attempts would be work nobody pays for).\n */\nexport function nextRetry(job: RetryState, error: unknown, now: number, deployedAt?: number): { retryInMs: number | null; lag: boolean; why?: string } {\n const lag = isUnauthorized(error) && (now - (job.unauthorized_since ?? now) < DEPLOY_LAG_MS || (deployedAt !== undefined && now - deployedAt >= 0 && now - deployedAt < DEPLOY_LAG_MS));\n let retryInMs: number | undefined = lag\n ? LAG_RETRY_MS[Math.min(job.lag_retries ?? 0, LAG_RETRY_MS.length - 1)]\n : RETRY_AFTER_MS[job.attempts - 1 - (job.lag_retries ?? 0)];\n if (job.deadline !== null && job.deadline !== undefined) {\n const left = job.deadline - now;\n if (left < 3_000) return { retryInMs: null, lag, why: "the buyer\'s max_wait_s is over (refunded)" };\n // Never give up before the deadline, and never wait past it.\n retryInMs = Math.min(retryInMs ?? RETRY_AFTER_MS[RETRY_AFTER_MS.length - 1]!, left - 2_000);\n }\n return { retryInMs: retryInMs ?? null, lag };\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/worker.ts": '/**\n * The app: one Cloudflare Worker (`wrangler dev`, or `ambasada dev` with the mock API).\n *\n * /api/<name> \u2192 api/<name>.ts (fetch handlers; add yours to API below)\n * /api/auth/* \u2192 api/auth.ts: Sign in with Ambasada (login, callback, logout, me, shell)\n * /api/ambasada/webhook \u2192 api/ambasada-webhook.ts: the Ambasada app\'s events (notifications on/off, ping)\n * /api/notes, /api/files \u2192 api/notes.ts (env.DB + env.KV), api/files.ts (uploads to env.FILES)\n * /api/rooms/<id>/ws \u2192 the live room <id> (src/realtime/room.ts, a Durable Object); demo at /room\n * /files/* \u2192 files from env.FILES (lib/files.ts)\n * / \u2192 web/index.html with who is signed in filled in\n * the rest \u2192 static files from web/ (wrangler.jsonc \u2192 assets)\n */\nimport appWebhook from "../api/ambasada-webhook.ts";\nimport auth from "../api/auth.ts";\nimport files from "../api/files.ts";\nimport notes from "../api/notes.ts";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { FILES_PATH, serveFile } from "../lib/files.ts";\nimport { serverError, withRequestLog } from "../lib/errors.ts";\n\nexport { Room } from "./realtime/room.ts";\nexport { Jobs } from "./jobs/jobs.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\n/** The landing page, with the header\'s "Sign in with Ambasada" button or, once signed in, the person\'s name and avatar. */\nasync function landing(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n const user = await auth.user(req);\n return new HTMLRewriter()\n .on("[data-auth-signin]", { element: (el) => void ((user || !auth.configured) && el.remove()) })\n .on("[data-auth-user]", { element: (el) => void (user ? el.removeAttribute("hidden") : el.remove()) })\n .on("[data-auth-avatar]", { element: (el) => void (user?.avatar_url ? el.setAttribute("src", user.avatar_url) : el.remove()) })\n .on("[data-auth-name]", { element: (el) => void el.setInnerContent(user?.name ?? "Signed in") })\n .transform(await env.ASSETS.fetch(req));\n}\n\nexport default {\n fetch(req, env) {\n // A failing request\'s console.error lines and error go to `ambasada logs` (lib/errors.ts).\n return withRequestLog(() => handle(req, env));\n },\n} satisfies ExportedHandler<Env>;\n\n/** Every route; a throw anywhere answers 500 and is logged with its stack. */\nasync function handle(req: Request, env: Env): Promise<Response> {\n const { pathname } = new URL(req.url);\n try {\n if (pathname === "/health") return Response.json({ ok: true });\n if (pathname.startsWith("/api/auth/")) return await auth(req, env);\n if (pathname === "/api/ambasada/webhook") return await appWebhook(req, env);\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\n if (room) return await env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n if (pathname.startsWith(FILES_PATH)) return await serveFile(env.FILES, req);\n const api = /^\\/api\\/([^/]+)\\/?$/.exec(pathname);\n if (api) {\n const handler = API[api[1]!];\n return handler ? await handler(req, env) : Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n }\n if (pathname === "/") return await landing(req, env);\n return env.ASSETS.fetch(req);\n } catch (error) {\n return serverError(error);\n }\n}\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "lib": ["ES2022"],\n "types": ["@cloudflare/workers-types"],\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["ambasada.config.ts", "src", "api", "lib"]\n}\n', "web/index.html": `<!doctype html>
|
|
208
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;
|
|
@@ -1783,6 +1783,15 @@ var Ambasada = class {
|
|
|
1783
1783
|
/** Keys (up to 1000, optionally under a prefix) and how much of the 1 MB is used. */
|
|
1784
1784
|
list: (prefix) => this.call("GET", "/v1/store", { query: { prefix } })
|
|
1785
1785
|
};
|
|
1786
|
+
/**
|
|
1787
|
+
* The app's error log (`ambasada logs`, the Developer app's Logs). Errors in a request are recorded on their own (a
|
|
1788
|
+
* 5xx); `report` is for work outside one, like a job's failed attempt (the templates' Jobs object reports each).
|
|
1789
|
+
*/
|
|
1790
|
+
logs = {
|
|
1791
|
+
report: (entry2) => this.call("POST", "/v1/product/logs", { body: entry2 }),
|
|
1792
|
+
/** The last deploy and recent errors (what `ambasada logs` prints). */
|
|
1793
|
+
recent: () => this.call("GET", "/v1/product/logs")
|
|
1794
|
+
};
|
|
1786
1795
|
};
|
|
1787
1796
|
function cachedKeys(load, ttlMs = 10 * 6e4) {
|
|
1788
1797
|
let cached;
|
|
@@ -1813,9 +1822,9 @@ var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
|
|
|
1813
1822
|
var ambasada = new Ambasada();
|
|
1814
1823
|
|
|
1815
1824
|
// src/commands.ts
|
|
1816
|
-
import { existsSync as
|
|
1817
|
-
import { appendFile as appendFile2, mkdir as
|
|
1818
|
-
import { basename as
|
|
1825
|
+
import { existsSync as existsSync13, readFileSync as readFileSync8 } from "node:fs";
|
|
1826
|
+
import { appendFile as appendFile2, mkdir as mkdir8, readdir as readdir4, readFile as readFile9, rm as rm8, writeFile as writeFile9 } from "node:fs/promises";
|
|
1827
|
+
import { basename as basename4, dirname as dirname8, join as join14, resolve as resolvePath5 } from "node:path";
|
|
1819
1828
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1820
1829
|
|
|
1821
1830
|
// src/config.ts
|
|
@@ -2182,6 +2191,7 @@ async function startMock(options = {}) {
|
|
|
2182
2191
|
const shellKey = await generateShellKey("ins_mock_shell");
|
|
2183
2192
|
const files2 = /* @__PURE__ */ new Map();
|
|
2184
2193
|
const stores = /* @__PURE__ */ new Map();
|
|
2194
|
+
const productLog = [];
|
|
2185
2195
|
const devices = /* @__PURE__ */ new Map();
|
|
2186
2196
|
const authCodes = /* @__PURE__ */ new Map();
|
|
2187
2197
|
const accessTokens = /* @__PURE__ */ new Map();
|
|
@@ -2417,6 +2427,15 @@ async function startMock(options = {}) {
|
|
|
2417
2427
|
if (method === "DELETE") return json2({ deleted: store.delete(key) });
|
|
2418
2428
|
}
|
|
2419
2429
|
if (path === "/v1/product" && method === "GET") return json2({ deployed: false, url: `https://${me}.ambasada.dev`, tunnel: null, mock: true });
|
|
2430
|
+
if (path === "/v1/product/logs" && method === "POST") {
|
|
2431
|
+
const b = await body();
|
|
2432
|
+
if (typeof b.message !== "string" || !b.message.trim()) throw fail("INVALID_REQUEST", "message is required");
|
|
2433
|
+
const job = typeof b.job === "string" ? b.job : null;
|
|
2434
|
+
productLog.unshift({ at: (/* @__PURE__ */ new Date()).toISOString(), status: 0, method: job ? "JOB" : "LOG", path: job ?? "", message: [b.message, typeof b.stack === "string" ? b.stack : ""].filter(Boolean).join("\n") });
|
|
2435
|
+
productLog.splice(50);
|
|
2436
|
+
return json2({ ok: true });
|
|
2437
|
+
}
|
|
2438
|
+
if (path === "/v1/product/logs" && method === "GET") return json2({ last_deploy: null, errors: { hourly: [], total_24h: productLog.length, recent: productLog }, mock: true });
|
|
2420
2439
|
if (path === "/v1/product/deploy" && method === "POST") throw fail("INVALID_REQUEST", "ambasada dev does not host apps: deploy against the real API (drop --api-url)");
|
|
2421
2440
|
if (path.startsWith("/v1/product/tunnel")) throw fail("INVALID_REQUEST", "ambasada dev does not open tunnels: `ambasada tunnel` and `ambasada dev --tunnel` talk to the real API (drop --api-url)");
|
|
2422
2441
|
if (/^\/v1\/dev\/(storage|db|kv|files)(\/|$)/.test(path)) {
|
|
@@ -2447,9 +2466,9 @@ async function startMock(options = {}) {
|
|
|
2447
2466
|
res.end(Buffer.from(await response.arrayBuffer()));
|
|
2448
2467
|
});
|
|
2449
2468
|
server.keepAliveTimeout = 1e3;
|
|
2450
|
-
await new Promise((
|
|
2469
|
+
await new Promise((resolve3, reject) => {
|
|
2451
2470
|
server.once("error", reject);
|
|
2452
|
-
server.listen(options.port ?? 8788, options.host ?? "127.0.0.1", () =>
|
|
2471
|
+
server.listen(options.port ?? 8788, options.host ?? "127.0.0.1", () => resolve3());
|
|
2453
2472
|
});
|
|
2454
2473
|
const address = server.address();
|
|
2455
2474
|
base = `http://${address.address === "::" || address.address === "0.0.0.0" ? "localhost" : address.address}:${address.port}`;
|
|
@@ -2460,9 +2479,9 @@ async function startMock(options = {}) {
|
|
|
2460
2479
|
server,
|
|
2461
2480
|
notifications,
|
|
2462
2481
|
appEvent,
|
|
2463
|
-
close: () => new Promise((
|
|
2482
|
+
close: () => new Promise((resolve3) => {
|
|
2464
2483
|
server.closeAllConnections();
|
|
2465
|
-
server.close(() =>
|
|
2484
|
+
server.close(() => resolve3());
|
|
2466
2485
|
})
|
|
2467
2486
|
};
|
|
2468
2487
|
}
|
|
@@ -2473,7 +2492,7 @@ import { createRequire } from "node:module";
|
|
|
2473
2492
|
import { dirname as dirname2, isAbsolute, join as join4, resolve as resolvePath2 } from "node:path";
|
|
2474
2493
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
2475
2494
|
var PLATFORM_CONFIG_KEYS = ["id", "name", "product", "addons"];
|
|
2476
|
-
var VERSION = true ? "0.8.
|
|
2495
|
+
var VERSION = true ? "0.8.9" : "0.1.0-dev";
|
|
2477
2496
|
var shortName = (pkg) => pkg.replace(/^@ambasada\//, "").replace(/^ambasada-(addon-)?/, "");
|
|
2478
2497
|
var KNOWN_ADDONS = { village: "@ambasada/village" };
|
|
2479
2498
|
function readJson(file) {
|
|
@@ -2620,7 +2639,7 @@ function quietDurableObjects() {
|
|
|
2620
2639
|
const once = () => {
|
|
2621
2640
|
if (said) return null;
|
|
2622
2641
|
said = true;
|
|
2623
|
-
return "Durable Objects
|
|
2642
|
+
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
2643
|
};
|
|
2625
2644
|
return (line) => {
|
|
2626
2645
|
const text = line.replace(ANSI, "");
|
|
@@ -2628,7 +2647,7 @@ function quietDurableObjects() {
|
|
|
2628
2647
|
inBlock = true;
|
|
2629
2648
|
return once();
|
|
2630
2649
|
}
|
|
2631
|
-
if (inBlock && /^\s
|
|
2650
|
+
if (inBlock && /^\s/.test(text)) return null;
|
|
2632
2651
|
inBlock = false;
|
|
2633
2652
|
if (/A DurableObjectNamespace in the config referenced the class .* but no such Durable Object class is exported/.test(text)) return once();
|
|
2634
2653
|
return line;
|
|
@@ -2693,7 +2712,10 @@ function startApp(root, stack, bin2, url, vars, print, prefix) {
|
|
|
2693
2712
|
const port = portOf(url);
|
|
2694
2713
|
const child = spawn(process.execPath, [bin2, ...stack.args(url.hostname, port, vars)], {
|
|
2695
2714
|
cwd: root,
|
|
2696
|
-
|
|
2715
|
+
// wrangler and the Cloudflare Vite plugin print a dozen lines about their Local Explorer API when an AI agent runs
|
|
2716
|
+
// them; X_LOCAL_EXPLORER=false (which both honour) turns the explorer and those lines off. `ambasada dev` has its
|
|
2717
|
+
// own dashboard (the mock's URL); X_LOCAL_EXPLORER=true in your environment keeps the explorer.
|
|
2718
|
+
env: { X_LOCAL_EXPLORER: "false", ...process.env, ...stack.env, ...vars },
|
|
2697
2719
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2698
2720
|
});
|
|
2699
2721
|
const filter = stack.output?.() ?? ((line) => line);
|
|
@@ -2780,15 +2802,15 @@ var BINARY = /\.(png|ico|jpe?g|gif|webp|woff2?)$/i;
|
|
|
2780
2802
|
var isBinary = (path) => BINARY.test(path);
|
|
2781
2803
|
function readTemplateDir(dir) {
|
|
2782
2804
|
const out8 = {};
|
|
2783
|
-
const
|
|
2805
|
+
const walk3 = (d) => {
|
|
2784
2806
|
for (const name of readdirSync2(d)) {
|
|
2785
2807
|
if (SKIP.has(name)) continue;
|
|
2786
2808
|
const path = join6(d, name);
|
|
2787
|
-
if (statSync2(path).isDirectory())
|
|
2809
|
+
if (statSync2(path).isDirectory()) walk3(path);
|
|
2788
2810
|
else out8[relative(dir, path).split("\\").join("/")] = readFileSync4(path, isBinary(name) ? "base64" : "utf8");
|
|
2789
2811
|
}
|
|
2790
2812
|
};
|
|
2791
|
-
|
|
2813
|
+
walk3(dir);
|
|
2792
2814
|
return out8;
|
|
2793
2815
|
}
|
|
2794
2816
|
function templateFiles(name) {
|
|
@@ -3334,40 +3356,202 @@ async function platformCheck(apiUrl, apiKey) {
|
|
|
3334
3356
|
}
|
|
3335
3357
|
}
|
|
3336
3358
|
|
|
3359
|
+
// src/sourcemap.ts
|
|
3360
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync7, realpathSync, statSync as statSync3 } from "node:fs";
|
|
3361
|
+
import { mkdir as mkdir4, readFile as readFile4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
3362
|
+
import { basename, dirname as dirname6, join as join9, relative as relative3, resolve as resolve2, sep } from "node:path";
|
|
3363
|
+
var MAPS_DIR = join9(".ambasada", "maps");
|
|
3364
|
+
var KEEP = 3;
|
|
3365
|
+
async function keepMaps(root, modules, configFile, version, deployedAt = /* @__PURE__ */ new Date()) {
|
|
3366
|
+
const maps = {};
|
|
3367
|
+
for (const m of modules) {
|
|
3368
|
+
if (!/\.m?js$/.test(m.name)) continue;
|
|
3369
|
+
const file = [`${m.path}.map`, join9(dirname6(configFile), `${m.name}.map`)].find((f) => existsSync8(f));
|
|
3370
|
+
if (!file) continue;
|
|
3371
|
+
try {
|
|
3372
|
+
const raw = JSON.parse(readFileSync7(file, "utf8"));
|
|
3373
|
+
const base = resolve2(dirname6(file), raw.sourceRoot ?? "");
|
|
3374
|
+
const real = (p) => existsSync8(p) ? realpathSync(p) : p;
|
|
3375
|
+
const source = (s) => /^[a-z-]+:/.test(s) ? s : relative3(real(root), real(resolve2(base, s))).split(sep).join("/");
|
|
3376
|
+
maps[m.name] = { version: 3, sources: raw.sources.map(source), mappings: raw.mappings };
|
|
3377
|
+
} catch {
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
const dir = join9(root, MAPS_DIR);
|
|
3381
|
+
await mkdir4(dir, { recursive: true });
|
|
3382
|
+
if (Object.keys(maps).length) await writeFile4(join9(dir, `v${version}.json`), JSON.stringify({ version, deployed_at: deployedAt.toISOString(), maps }));
|
|
3383
|
+
const old = keptVersions(root).slice(0, -KEEP);
|
|
3384
|
+
for (const v of old) await rm3(join9(dir, `v${v}.json`), { force: true });
|
|
3385
|
+
return Object.keys(maps).length;
|
|
3386
|
+
}
|
|
3387
|
+
function keptVersions(root) {
|
|
3388
|
+
const dir = join9(root, MAPS_DIR);
|
|
3389
|
+
if (!existsSync8(dir)) return [];
|
|
3390
|
+
return readdirSync3(dir).map((f) => /^v(\d+)\.json$/.exec(f)?.[1]).filter((v) => !!v).map(Number).sort((a, b) => a - b);
|
|
3391
|
+
}
|
|
3392
|
+
async function mapsAt(root, at, lastDeploy) {
|
|
3393
|
+
const versions = keptVersions(root);
|
|
3394
|
+
if (!versions.length) return null;
|
|
3395
|
+
const kept = /* @__PURE__ */ new Map();
|
|
3396
|
+
const load = async (v) => {
|
|
3397
|
+
if (!kept.has(v)) kept.set(v, JSON.parse(await readFile4(join9(root, MAPS_DIR, `v${v}.json`), "utf8")));
|
|
3398
|
+
return kept.get(v);
|
|
3399
|
+
};
|
|
3400
|
+
const time = Date.parse(at);
|
|
3401
|
+
if (lastDeploy && Date.parse(lastDeploy.deployed_at) <= time) return versions.includes(lastDeploy.version) ? (await load(lastDeploy.version)).maps : null;
|
|
3402
|
+
for (let i = versions.length - 1; i >= 0; i--) {
|
|
3403
|
+
const v = versions[i];
|
|
3404
|
+
const record = await load(v);
|
|
3405
|
+
if (Date.parse(record.deployed_at) > time) continue;
|
|
3406
|
+
const next = versions[i + 1] ?? lastDeploy?.version;
|
|
3407
|
+
return next === void 0 || next === v || next === v + 1 ? record.maps : null;
|
|
3408
|
+
}
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
var FRAME = /^(\s*at\s+)(?:(.+?)\s+\()?([^\s()]+?):(\d+):(\d+)\)?\s*$/;
|
|
3412
|
+
function mapMessage(message, maps) {
|
|
3413
|
+
const decoded = /* @__PURE__ */ new Map();
|
|
3414
|
+
return message.split("\n").map((line) => {
|
|
3415
|
+
const frame = FRAME.exec(line);
|
|
3416
|
+
if (!frame) return line;
|
|
3417
|
+
const [, lead, fn, file, row, col] = frame;
|
|
3418
|
+
const map = maps[file] ?? maps[basename(file)];
|
|
3419
|
+
if (!map) return line;
|
|
3420
|
+
if (!decoded.has(file)) decoded.set(file, decode(map));
|
|
3421
|
+
const pos = lookup(decoded.get(file), Number(row) - 1, Number(col) - 1);
|
|
3422
|
+
if (!pos) return line;
|
|
3423
|
+
const where = `${map.sources[pos.source] ?? file}:${pos.line + 1}:${pos.column + 1}`;
|
|
3424
|
+
return `${lead}${fn ? `${fn} (${where})` : where}`;
|
|
3425
|
+
}).join("\n");
|
|
3426
|
+
}
|
|
3427
|
+
var BUILD = /(^|\/)(\.open-next|\.next|\.wrangler|dist)\//;
|
|
3428
|
+
var buildFrame = (line) => BUILD.test(FRAME.exec(line)?.[3] ?? "");
|
|
3429
|
+
var ownFrame = (line) => {
|
|
3430
|
+
const file = FRAME.exec(line)?.[3];
|
|
3431
|
+
return !!file && !BUILD.test(file) && !/(^|\/)node_modules\//.test(file) && !/^[a-z-]+:/.test(file) && /\.(t|j)sx?$/.test(file);
|
|
3432
|
+
};
|
|
3433
|
+
var B64 = new Map([..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].map((ch, i) => [ch, i]));
|
|
3434
|
+
function decode(map) {
|
|
3435
|
+
const lines = [];
|
|
3436
|
+
let source = 0;
|
|
3437
|
+
let line = 0;
|
|
3438
|
+
let column = 0;
|
|
3439
|
+
for (const text of map.mappings.split(";")) {
|
|
3440
|
+
const segments = [];
|
|
3441
|
+
let gen = 0;
|
|
3442
|
+
for (const seg of text.split(",")) {
|
|
3443
|
+
if (!seg) continue;
|
|
3444
|
+
const v = vlq(seg);
|
|
3445
|
+
gen += v[0];
|
|
3446
|
+
if (v.length < 4) continue;
|
|
3447
|
+
source += v[1];
|
|
3448
|
+
line += v[2];
|
|
3449
|
+
column += v[3];
|
|
3450
|
+
segments.push([gen, source, line, column]);
|
|
3451
|
+
}
|
|
3452
|
+
lines.push(segments);
|
|
3453
|
+
}
|
|
3454
|
+
return lines;
|
|
3455
|
+
}
|
|
3456
|
+
function vlq(seg) {
|
|
3457
|
+
const out8 = [];
|
|
3458
|
+
let value = 0;
|
|
3459
|
+
let shift = 0;
|
|
3460
|
+
for (const ch of seg) {
|
|
3461
|
+
const digit = B64.get(ch);
|
|
3462
|
+
if (digit === void 0) break;
|
|
3463
|
+
value += (digit & 31) << shift;
|
|
3464
|
+
if (digit & 32) shift += 5;
|
|
3465
|
+
else {
|
|
3466
|
+
out8.push(value & 1 ? -(value >>> 1) : value >>> 1);
|
|
3467
|
+
value = 0;
|
|
3468
|
+
shift = 0;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
return out8;
|
|
3472
|
+
}
|
|
3473
|
+
function lookup(lines, line, column) {
|
|
3474
|
+
const segments = lines[line];
|
|
3475
|
+
if (!segments?.length) return null;
|
|
3476
|
+
let found = segments[0];
|
|
3477
|
+
for (const s of segments) {
|
|
3478
|
+
if (s[0] > column) break;
|
|
3479
|
+
found = s;
|
|
3480
|
+
}
|
|
3481
|
+
return { source: found[1], line: found[2], column: found[3] };
|
|
3482
|
+
}
|
|
3483
|
+
function nextRouteFile(root, path) {
|
|
3484
|
+
const appDir = ["app", "src/app"].map((d) => join9(root, d)).find((d) => existsSync8(d));
|
|
3485
|
+
if (!appDir) return null;
|
|
3486
|
+
const parts = path.split("?")[0].split("/").filter(Boolean).map((p) => decodeURIComponent(p));
|
|
3487
|
+
const found = walk(appDir, parts);
|
|
3488
|
+
return found ? relative3(root, found).split(sep).join("/") : null;
|
|
3489
|
+
}
|
|
3490
|
+
var ENTRY = /^(route|page)\.(t|j)sx?$/;
|
|
3491
|
+
function walk(dir, parts) {
|
|
3492
|
+
const entries = readdirSync3(dir).sort();
|
|
3493
|
+
const dirs = entries.filter((e) => !e.startsWith("_") && statSync3(join9(dir, e)).isDirectory());
|
|
3494
|
+
if (!parts.length) {
|
|
3495
|
+
const own = ["route", "page"].flatMap((n) => entries.filter((e) => ENTRY.test(e) && e.startsWith(`${n}.`)))[0];
|
|
3496
|
+
if (own) return join9(dir, own);
|
|
3497
|
+
}
|
|
3498
|
+
const [head, ...rest] = parts;
|
|
3499
|
+
for (const d of dirs.filter((d2) => /^\(.+\)$/.test(d2))) {
|
|
3500
|
+
const hit = walk(join9(dir, d), parts);
|
|
3501
|
+
if (hit) return hit;
|
|
3502
|
+
}
|
|
3503
|
+
if (head !== void 0) {
|
|
3504
|
+
if (dirs.includes(head)) {
|
|
3505
|
+
const hit = walk(join9(dir, head), rest);
|
|
3506
|
+
if (hit) return hit;
|
|
3507
|
+
}
|
|
3508
|
+
for (const d of dirs.filter((d2) => /^\[[^.[\]]+\]$/.test(d2))) {
|
|
3509
|
+
const hit = walk(join9(dir, d), rest);
|
|
3510
|
+
if (hit) return hit;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
for (const d of dirs.filter((d2) => /^\[\.\.\.[^\]]+\]$/.test(d2) || /^\[\[\.\.\.[^\]]+\]\]$/.test(d2))) {
|
|
3514
|
+
if (head === void 0 && !d.startsWith("[[")) continue;
|
|
3515
|
+
const hit = walk(join9(dir, d), []);
|
|
3516
|
+
if (hit) return hit;
|
|
3517
|
+
}
|
|
3518
|
+
return null;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3337
3521
|
// src/storage.ts
|
|
3338
3522
|
import { createHash } from "node:crypto";
|
|
3339
|
-
import { existsSync as
|
|
3340
|
-
import { mkdtemp, readdir as readdir3, readFile as
|
|
3523
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
3524
|
+
import { mkdtemp, readdir as readdir3, readFile as readFile7, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
|
|
3341
3525
|
import { tmpdir } from "node:os";
|
|
3342
|
-
import { basename as
|
|
3526
|
+
import { basename as basename3, extname as extname2, join as join12 } from "node:path";
|
|
3343
3527
|
import { createInterface } from "node:readline";
|
|
3344
3528
|
|
|
3345
3529
|
// src/deploy.ts
|
|
3346
3530
|
import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_process";
|
|
3347
|
-
import { existsSync as
|
|
3348
|
-
import { mkdir as
|
|
3531
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
3532
|
+
import { mkdir as mkdir6, readdir as readdir2, readFile as readFile6, rm as rm5, stat, writeFile as writeFile6 } from "node:fs/promises";
|
|
3349
3533
|
import { createServer as createServer2 } from "node:net";
|
|
3350
|
-
import { basename, dirname as
|
|
3534
|
+
import { basename as basename2, dirname as dirname7, extname, join as join11, relative as relative5, resolve as resolvePath4, sep as sep2 } from "node:path";
|
|
3351
3535
|
|
|
3352
3536
|
// src/upgrade.ts
|
|
3353
3537
|
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.
|
|
3538
|
+
import { existsSync as existsSync9, readdirSync as readdirSync4, statSync as statSync4 } from "node:fs";
|
|
3539
|
+
import { mkdir as mkdir5, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
|
|
3540
|
+
import { join as join10, relative as relative4 } from "node:path";
|
|
3541
|
+
var VERSION2 = true ? "0.8.9" : "0.1.0-dev";
|
|
3358
3542
|
var RELEASE_SPEC = /^\d+\.\d+\.\d+$/.test(VERSION2) ? `^${VERSION2}` : "latest";
|
|
3359
3543
|
var out3 = (s = "") => process.stdout.write(`${s}
|
|
3360
3544
|
`);
|
|
3361
3545
|
async function readProjectInfo(root) {
|
|
3362
3546
|
try {
|
|
3363
|
-
return JSON.parse(await
|
|
3547
|
+
return JSON.parse(await readFile5(join10(root, PROJECT_FILE), "utf8"));
|
|
3364
3548
|
} catch {
|
|
3365
3549
|
return null;
|
|
3366
3550
|
}
|
|
3367
3551
|
}
|
|
3368
3552
|
async function writeProjectInfo(root, info) {
|
|
3369
|
-
await
|
|
3370
|
-
await
|
|
3553
|
+
await mkdir5(join10(root, ".ambasada"), { recursive: true });
|
|
3554
|
+
await writeFile5(join10(root, PROJECT_FILE), `${JSON.stringify(info, null, 2)}
|
|
3371
3555
|
`);
|
|
3372
3556
|
}
|
|
3373
3557
|
var devkitVersion = () => VERSION2;
|
|
@@ -3466,25 +3650,25 @@ var WEB = /\.(m?[jt]sx?|html?)$/;
|
|
|
3466
3650
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".open-next", ".wrangler", "dist", ".git", ".ambasada", ".claude"]);
|
|
3467
3651
|
function sourceFiles(root, pattern = SOURCE) {
|
|
3468
3652
|
const found = [];
|
|
3469
|
-
const
|
|
3470
|
-
for (const name of
|
|
3653
|
+
const walk3 = (dir) => {
|
|
3654
|
+
for (const name of readdirSync4(dir)) {
|
|
3471
3655
|
if (SKIP_DIRS.has(name)) continue;
|
|
3472
|
-
const path =
|
|
3473
|
-
if (
|
|
3656
|
+
const path = join10(dir, name);
|
|
3657
|
+
if (statSync4(path).isDirectory()) walk3(path);
|
|
3474
3658
|
else if (pattern.test(name) && !name.endsWith(".d.ts")) found.push(path);
|
|
3475
3659
|
}
|
|
3476
3660
|
};
|
|
3477
|
-
|
|
3661
|
+
walk3(root);
|
|
3478
3662
|
return found;
|
|
3479
3663
|
}
|
|
3480
3664
|
async function usesVillage(root, pkg) {
|
|
3481
3665
|
if (pkg.dependencies?.["@ambasada/village"] || pkg.devDependencies?.["@ambasada/village"]) return true;
|
|
3482
|
-
if (
|
|
3483
|
-
const config = await
|
|
3666
|
+
if (existsSync9(join10(root, "offerings.ts")) || existsSync9(join10(root, "product/offerings.ts"))) return true;
|
|
3667
|
+
const config = await readFile5(join10(root, CONFIG_FILE), "utf8").catch(() => "");
|
|
3484
3668
|
return /\bdefineCompany\b|\bidea\s*:/.test(config);
|
|
3485
3669
|
}
|
|
3486
3670
|
function templateOf2(root) {
|
|
3487
|
-
return detectStack(root)?.name ?? (
|
|
3671
|
+
return detectStack(root)?.name ?? (existsSync9(join10(root, "src/worker.ts")) ? "worker" : "next");
|
|
3488
3672
|
}
|
|
3489
3673
|
var PRODUCT_URL = /(\bproduct\s*:\s*\{\s*url\s*:\s*)(?:["'`])([^"'`]*)["'`]/;
|
|
3490
3674
|
var specFor = (name) => process.env[`AMBASADA_${name.replace(/^@ambasada\//, "").toUpperCase()}_SPEC`] ?? RELEASE_SPEC;
|
|
@@ -3493,16 +3677,16 @@ async function upgrade(_args, flags) {
|
|
|
3493
3677
|
if (!root) throw new CliError(`run \`ambasada upgrade\` inside your app (no ${CONFIG_FILE} found)`);
|
|
3494
3678
|
const checkOnly = flags.check === true;
|
|
3495
3679
|
const info = await readProjectInfo(root);
|
|
3496
|
-
const pkgFile =
|
|
3497
|
-
const pkg = JSON.parse(await
|
|
3680
|
+
const pkgFile = join10(root, "package.json");
|
|
3681
|
+
const pkg = JSON.parse(await readFile5(pkgFile, "utf8"));
|
|
3498
3682
|
const village = await usesVillage(root, pkg);
|
|
3499
3683
|
const template = info?.template && TEMPLATES.includes(info.template) ? info.template : templateOf2(root);
|
|
3500
3684
|
const changes = [];
|
|
3501
|
-
const rel = (p) =>
|
|
3685
|
+
const rel = (p) => relative4(root, p);
|
|
3502
3686
|
const before08 = !info || /^0\.[0-7]\./.test(info.devkit) && !info.devkit.includes("-dev");
|
|
3503
3687
|
const edits = /* @__PURE__ */ new Map();
|
|
3504
3688
|
for (const file of before08 ? sourceFiles(root) : []) {
|
|
3505
|
-
const text = await
|
|
3689
|
+
const text = await readFile5(file, "utf8");
|
|
3506
3690
|
if (!text.includes("@ambasada/sdk")) continue;
|
|
3507
3691
|
const next = rewriteImports(text, village);
|
|
3508
3692
|
if (next !== text) {
|
|
@@ -3511,9 +3695,9 @@ async function upgrade(_args, flags) {
|
|
|
3511
3695
|
}
|
|
3512
3696
|
}
|
|
3513
3697
|
for (const name of before08 ? [".env", ".dev.vars"] : []) {
|
|
3514
|
-
const file =
|
|
3515
|
-
if (!
|
|
3516
|
-
const text = await
|
|
3698
|
+
const file = join10(root, name);
|
|
3699
|
+
if (!existsSync9(file)) continue;
|
|
3700
|
+
const text = await readFile5(file, "utf8");
|
|
3517
3701
|
const next = rewriteEnv(text, village);
|
|
3518
3702
|
if (next !== text) {
|
|
3519
3703
|
edits.set(file, next);
|
|
@@ -3523,7 +3707,7 @@ async function upgrade(_args, flags) {
|
|
|
3523
3707
|
const uiLine = VERSION2.split(".").slice(0, 2).join(".");
|
|
3524
3708
|
if (/^\d+\.\d+$/.test(uiLine) && !VERSION2.includes("-dev")) {
|
|
3525
3709
|
for (const file of sourceFiles(root, WEB)) {
|
|
3526
|
-
const text = edits.get(file) ?? await
|
|
3710
|
+
const text = edits.get(file) ?? await readFile5(file, "utf8");
|
|
3527
3711
|
if (!text.includes("@ambasada/ui@")) continue;
|
|
3528
3712
|
const next = rewriteUiLink(text, uiLine);
|
|
3529
3713
|
if (next !== text) {
|
|
@@ -3532,8 +3716,8 @@ async function upgrade(_args, flags) {
|
|
|
3532
3716
|
}
|
|
3533
3717
|
}
|
|
3534
3718
|
}
|
|
3535
|
-
const configFile =
|
|
3536
|
-
const configText = edits.get(configFile) ?? await
|
|
3719
|
+
const configFile = join10(root, CONFIG_FILE);
|
|
3720
|
+
const configText = edits.get(configFile) ?? await readFile5(configFile, "utf8");
|
|
3537
3721
|
const productUrl = PRODUCT_URL.exec(configText)?.[2];
|
|
3538
3722
|
let movedLiveUrl;
|
|
3539
3723
|
if (productUrl && URL.canParse(productUrl) && !isLocalUrl(productUrl)) {
|
|
@@ -3561,10 +3745,10 @@ async function upgrade(_args, flags) {
|
|
|
3561
3745
|
const skills = {};
|
|
3562
3746
|
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
3747
|
const stale = [];
|
|
3564
|
-
const skillsDir =
|
|
3565
|
-
if (
|
|
3748
|
+
const skillsDir = join10(root, ".claude/skills");
|
|
3749
|
+
if (existsSync9(join10(skillsDir, "ambasada-payments"))) stale.push("ambasada-payments");
|
|
3566
3750
|
for (const [path, text] of Object.entries(skills)) {
|
|
3567
|
-
const current2 = await
|
|
3751
|
+
const current2 = await readFile5(join10(root, path), "utf8").catch(() => null);
|
|
3568
3752
|
if (current2 !== text) changes.push(`${path.split("/").slice(0, 3).join("/")}: re-synced`);
|
|
3569
3753
|
}
|
|
3570
3754
|
const nextInfo = { ...info, template, devkit: VERSION2, addons: village ? ["village"] : info?.addons ?? [], ...movedLiveUrl ? { live_url: movedLiveUrl } : {} };
|
|
@@ -3583,16 +3767,16 @@ async function upgrade(_args, flags) {
|
|
|
3583
3767
|
for (const s of stale) out3(` .claude/skills/${s}: removed`);
|
|
3584
3768
|
throw new CliError("`ambasada upgrade` would change the above", 1);
|
|
3585
3769
|
}
|
|
3586
|
-
for (const [file, text] of edits) await
|
|
3770
|
+
for (const [file, text] of edits) await writeFile5(file, text);
|
|
3587
3771
|
pkg.dependencies = deps;
|
|
3588
3772
|
if (pkg.devDependencies) pkg.devDependencies = devDeps;
|
|
3589
|
-
await
|
|
3773
|
+
await writeFile5(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
3590
3774
|
`);
|
|
3591
3775
|
for (const [path, text] of Object.entries(skills)) {
|
|
3592
|
-
await
|
|
3593
|
-
await
|
|
3776
|
+
await mkdir5(join10(root, path, ".."), { recursive: true });
|
|
3777
|
+
await writeFile5(join10(root, path), text);
|
|
3594
3778
|
}
|
|
3595
|
-
for (const s of stale) await
|
|
3779
|
+
for (const s of stale) await rm4(join10(skillsDir, s), { recursive: true, force: true });
|
|
3596
3780
|
if (!flags["no-install"]) {
|
|
3597
3781
|
out3(c.dim("npm install \u2026"));
|
|
3598
3782
|
try {
|
|
@@ -3605,9 +3789,9 @@ async function upgrade(_args, flags) {
|
|
|
3605
3789
|
setAddons(addons2);
|
|
3606
3790
|
for (const addon of addons2.list) {
|
|
3607
3791
|
for (const [path, text] of Object.entries(addon.skills)) {
|
|
3608
|
-
const file =
|
|
3609
|
-
await
|
|
3610
|
-
await
|
|
3792
|
+
const file = join10(skillsDir, path);
|
|
3793
|
+
await mkdir5(join10(file, ".."), { recursive: true });
|
|
3794
|
+
await writeFile5(file, text);
|
|
3611
3795
|
}
|
|
3612
3796
|
}
|
|
3613
3797
|
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"));
|
|
@@ -3686,7 +3870,7 @@ async function runDeploy(flags) {
|
|
|
3686
3870
|
for (const warning of storageWarnings(built.config)) out4(c.yellow(`! ${warning}`));
|
|
3687
3871
|
out4(`${c.green("\u2713")} built ${c.bold(template)}: ${built.modules.length} module(s), ${built.assets.length} static file(s)`);
|
|
3688
3872
|
const smoked = flags["no-smoke"] ? { ok: false, skipped: true, checks: [], ms: null } : await smoke(built, project.config.id, project.root);
|
|
3689
|
-
const metadata = { ...deployMetadata(built, project.root), report: { template, devkit: devkitVersion(), smoke: smoked, git: gitInfo(project.root) } };
|
|
3873
|
+
const metadata = { ...deployMetadata(built, project.root), ...flags.rotate ? { rotate_key: true } : {}, report: { template, devkit: devkitVersion(), smoke: smoked, git: gitInfo(project.root) } };
|
|
3690
3874
|
if (flags["dry-run"]) {
|
|
3691
3875
|
const would = { ...metadata, modules: built.modules.map((m) => m.name), assets: built.assets.length };
|
|
3692
3876
|
out4(`would POST ${r.apiUrl}/v1/product/deploy ${JSON.stringify(would)}`);
|
|
@@ -3695,6 +3879,7 @@ async function runDeploy(flags) {
|
|
|
3695
3879
|
}
|
|
3696
3880
|
const deployment = await upload(r.apiUrl, r.apiKey, built, metadata);
|
|
3697
3881
|
out4(`${c.green("\u2713")} live at ${c.bold(c.cyan(deployment.url))} ${c.dim(`v${deployment.version}, ${kb(deployment.bundle_bytes)} gzipped`)}`);
|
|
3882
|
+
await keepMaps(project.root, built.modules, built.configFile, deployment.version).catch(() => 0);
|
|
3698
3883
|
for (const line of resourceLines(deployment.resources)) out4(line);
|
|
3699
3884
|
if (deployment.offerings_moved?.length) out4(` offerings moved to it: ${deployment.offerings_moved.join(", ")}`);
|
|
3700
3885
|
if (deployment.webhook) out4(` webhooks \u2192 ${deployment.webhook}`);
|
|
@@ -3734,24 +3919,29 @@ async function isMockApi(apiUrl) {
|
|
|
3734
3919
|
}
|
|
3735
3920
|
}
|
|
3736
3921
|
function findProductDir(root) {
|
|
3737
|
-
for (const d of [root,
|
|
3922
|
+
for (const d of [root, join11(root, "product")]) if (["wrangler.jsonc", "wrangler.json"].some((f) => existsSync10(join11(d, f)))) return d;
|
|
3738
3923
|
return null;
|
|
3739
3924
|
}
|
|
3740
3925
|
function productDir(root) {
|
|
3741
3926
|
const dir = findProductDir(root);
|
|
3742
3927
|
if (dir) return dir;
|
|
3743
|
-
if (
|
|
3928
|
+
if (existsSync10(join11(root, "wrangler.toml"))) throw new CliError("wrangler.toml is not supported by `ambasada deploy`: use wrangler.jsonc");
|
|
3744
3929
|
throw new CliError("no wrangler.jsonc here, so `ambasada deploy` can't host this app. Host it anywhere and `ambasada link <url>`");
|
|
3745
3930
|
}
|
|
3746
3931
|
function detectTemplate(dir) {
|
|
3747
|
-
const has = (...files2) => files2.some((f) =>
|
|
3932
|
+
const has = (...files2) => files2.some((f) => existsSync10(join11(dir, f)));
|
|
3748
3933
|
if (has("open-next.config.ts", "open-next.config.js", "next.config.ts", "next.config.js", "next.config.mjs")) return "next";
|
|
3749
3934
|
if (has("vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts")) return "spa";
|
|
3750
3935
|
return "worker";
|
|
3751
3936
|
}
|
|
3752
3937
|
var NEXT_BUILD_ENV = { __NEXT_PROCESSED_ENV: "true" };
|
|
3753
3938
|
var EMPTY_NEXT_ENV = "export const production = {};\nexport const development = {};\nexport const test = {};\n";
|
|
3754
|
-
var VITE_BUILD =
|
|
3939
|
+
var VITE_BUILD = [
|
|
3940
|
+
"const { createBuilder } = await import('vite');",
|
|
3941
|
+
"const noDevVars = { name: 'ambasada:no-dev-vars', enforce: 'post', generateBundle(_, bundle) { delete bundle['.dev.vars']; } };",
|
|
3942
|
+
"const builder = await createBuilder({ envDir: false, plugins: [noDevVars] }, null);",
|
|
3943
|
+
"await builder.buildApp();"
|
|
3944
|
+
].join(" ");
|
|
3755
3945
|
var ENV_NOISE = /^\s*(?:-\s*Environments:\s*\.env|Using (?:secrets|vars) defined in \.(?:env|dev\.vars))/;
|
|
3756
3946
|
function quietEnv() {
|
|
3757
3947
|
let dropped = false;
|
|
@@ -3765,32 +3955,30 @@ async function build(dir, template) {
|
|
|
3765
3955
|
if (template === "next") {
|
|
3766
3956
|
out4(c.bold("opennextjs-cloudflare build"));
|
|
3767
3957
|
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
|
|
3958
|
+
const server = join11(dir, ".open-next", "server-functions", "default");
|
|
3959
|
+
if (existsSync10(server)) {
|
|
3960
|
+
for (const f of await readdir2(server)) if (/^\.env/.test(f)) await rm5(join11(server, f), { force: true });
|
|
3771
3961
|
}
|
|
3772
3962
|
} else if (template === "spa") {
|
|
3773
3963
|
out4(c.bold("vite build"));
|
|
3774
3964
|
if (bin(dir, "vite").startsWith("npx ")) throw new CliError(`vite is not installed in ${dir}: run \`npm install\``);
|
|
3775
3965
|
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
3966
|
}
|
|
3779
|
-
if (env2.dropped()) out4(c.dim(".env files stay local:
|
|
3967
|
+
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
3968
|
}
|
|
3781
3969
|
async function bundle(dir, template) {
|
|
3782
3970
|
const { file: configFile, config } = await readWranglerConfig(dir);
|
|
3783
|
-
const outdir =
|
|
3784
|
-
await
|
|
3785
|
-
const nextEnv =
|
|
3786
|
-
if (template === "next" &&
|
|
3971
|
+
const outdir = join11(dir, ".ambasada", "deploy");
|
|
3972
|
+
await rm5(outdir, { recursive: true, force: true });
|
|
3973
|
+
const nextEnv = join11(dir, ".open-next", "cloudflare", "next-env.mjs");
|
|
3974
|
+
if (template === "next" && existsSync10(nextEnv)) await writeFile6(nextEnv, EMPTY_NEXT_ENV);
|
|
3787
3975
|
await run(bin(dir, "wrangler"), ["deploy", "--dry-run", "--outdir", outdir], dir, { capture: true });
|
|
3788
|
-
const modules = (await
|
|
3789
|
-
const wanted = config.main ? `${
|
|
3976
|
+
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");
|
|
3977
|
+
const wanted = config.main ? `${basename2(config.main, extname(config.main))}.js` : "index.js";
|
|
3790
3978
|
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
3979
|
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 &&
|
|
3980
|
+
const assetsDir = config.assets?.directory ? resolvePath4(dirname7(configFile), config.assets.directory) : null;
|
|
3981
|
+
const assets = assetsDir && existsSync10(assetsDir) ? await collectAssets(assetsDir) : [];
|
|
3794
3982
|
if (assets.length > LIMITS.assets) throw new CliError(`${assets.length} static files in ${assetsDir}; ambasada.dev hosts up to ${LIMITS.assets}`);
|
|
3795
3983
|
const big = assets.find((a) => a.size > LIMITS.assetBytes);
|
|
3796
3984
|
if (big) throw new CliError(`${big.file} is ${kb(big.size)}; static files are at most 25 MB`);
|
|
@@ -3800,7 +3988,7 @@ function deployMetadata(built, root) {
|
|
|
3800
3988
|
const a = built.config.assets ?? {};
|
|
3801
3989
|
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
3990
|
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) =>
|
|
3991
|
+
const webhook = WEBHOOK_FILES.some((f) => existsSync10(join11(root, f)) || existsSync10(join11(built.dir, f)));
|
|
3804
3992
|
return {
|
|
3805
3993
|
main_module: built.mainModule,
|
|
3806
3994
|
compatibility_date: built.config.compatibility_date,
|
|
@@ -3882,8 +4070,8 @@ function resourceLines(r) {
|
|
|
3882
4070
|
async function upload(apiUrl, apiKey, built, metadata) {
|
|
3883
4071
|
const form = new FormData();
|
|
3884
4072
|
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
|
|
4073
|
+
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);
|
|
4074
|
+
for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile6(a.file)]), basename2(a.file));
|
|
3887
4075
|
out4(c.dim(`uploading to ${apiUrl} \u2026`));
|
|
3888
4076
|
for (let attempt = 1; ; attempt++) {
|
|
3889
4077
|
let res;
|
|
@@ -3916,14 +4104,14 @@ function uploadFailure(status, error) {
|
|
|
3916
4104
|
}
|
|
3917
4105
|
async function smoke(built, company, root = built.dir) {
|
|
3918
4106
|
const started = Date.now();
|
|
3919
|
-
const smokeDir =
|
|
3920
|
-
await
|
|
3921
|
-
await
|
|
3922
|
-
const outdir =
|
|
4107
|
+
const smokeDir = join11(built.dir, ".ambasada", "smoke");
|
|
4108
|
+
await rm5(smokeDir, { recursive: true, force: true });
|
|
4109
|
+
await mkdir6(smokeDir, { recursive: true });
|
|
4110
|
+
const outdir = dirname7(built.modules.find((m) => m.name === built.mainModule).path);
|
|
3923
4111
|
const a = built.config.assets;
|
|
3924
4112
|
const config = {
|
|
3925
4113
|
name: built.config.name ?? company,
|
|
3926
|
-
main:
|
|
4114
|
+
main: join11(outdir, built.mainModule),
|
|
3927
4115
|
compatibility_date: built.config.compatibility_date,
|
|
3928
4116
|
compatibility_flags: built.config.compatibility_flags ?? [],
|
|
3929
4117
|
no_bundle: true,
|
|
@@ -3935,9 +4123,9 @@ async function smoke(built, company, root = built.dir) {
|
|
|
3935
4123
|
// DB, KV, FILES and the Durable Objects as wrangler simulates them locally, in a fresh state under .ambasada/smoke.
|
|
3936
4124
|
...localBindings(built.config)
|
|
3937
4125
|
};
|
|
3938
|
-
const configPath2 =
|
|
3939
|
-
await
|
|
3940
|
-
const persistTo =
|
|
4126
|
+
const configPath2 = join11(smokeDir, "wrangler.json");
|
|
4127
|
+
await writeFile6(configPath2, JSON.stringify(config, null, 2));
|
|
4128
|
+
const persistTo = join11(smokeDir, "state");
|
|
3941
4129
|
if (config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) {
|
|
3942
4130
|
const root2 = findRoot(built.dir) ?? built.dir;
|
|
3943
4131
|
try {
|
|
@@ -3948,7 +4136,7 @@ async function smoke(built, company, root = built.dir) {
|
|
|
3948
4136
|
}
|
|
3949
4137
|
const port = await freePort();
|
|
3950
4138
|
out4(c.dim(`smoke test: wrangler dev on :${port} \u2026`));
|
|
3951
|
-
const child = spawn2(bin(built.dir, "wrangler"), ["dev", "--config",
|
|
4139
|
+
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
4140
|
cwd: built.dir,
|
|
3953
4141
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3954
4142
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false", CI: "1" }
|
|
@@ -3974,7 +4162,7 @@ ${(await res.text()).slice(0, 500)}`);
|
|
|
3974
4162
|
return { ok: true, skipped: false, checks, ms: Date.now() - started };
|
|
3975
4163
|
} finally {
|
|
3976
4164
|
child.kill("SIGTERM");
|
|
3977
|
-
await
|
|
4165
|
+
await rm5(smokeDir, { recursive: true, force: true });
|
|
3978
4166
|
}
|
|
3979
4167
|
}
|
|
3980
4168
|
function gitInfo(dir) {
|
|
@@ -4014,16 +4202,16 @@ function freePort() {
|
|
|
4014
4202
|
});
|
|
4015
4203
|
}
|
|
4016
4204
|
async function readWranglerConfig(dir) {
|
|
4017
|
-
const redirect2 =
|
|
4205
|
+
const redirect2 = join11(dir, ".wrangler", "deploy", "config.json");
|
|
4018
4206
|
let file;
|
|
4019
|
-
if (
|
|
4020
|
-
const target2 = JSON.parse(await
|
|
4021
|
-
if (target2) file = resolvePath4(
|
|
4207
|
+
if (existsSync10(redirect2)) {
|
|
4208
|
+
const target2 = JSON.parse(await readFile6(redirect2, "utf8")).configPath;
|
|
4209
|
+
if (target2) file = resolvePath4(dirname7(redirect2), target2);
|
|
4022
4210
|
}
|
|
4023
|
-
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) =>
|
|
4211
|
+
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) => join11(dir, f)).find((f) => existsSync10(f));
|
|
4024
4212
|
if (!file) throw new CliError(`no wrangler.jsonc in ${dir}`);
|
|
4025
4213
|
try {
|
|
4026
|
-
return { file, config: JSON.parse(stripJsonc(await
|
|
4214
|
+
return { file, config: JSON.parse(stripJsonc(await readFile6(file, "utf8"))) };
|
|
4027
4215
|
} catch (error) {
|
|
4028
4216
|
throw new CliError(`${file} is not valid JSON(C): ${error.message}`);
|
|
4029
4217
|
}
|
|
@@ -4048,32 +4236,32 @@ function stripJsonc(text) {
|
|
|
4048
4236
|
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
4049
4237
|
}
|
|
4050
4238
|
async function collectAssets(dir) {
|
|
4051
|
-
const ignore =
|
|
4239
|
+
const ignore = existsSync10(join11(dir, ".assetsignore")) ? (await readFile6(join11(dir, ".assetsignore"), "utf8")).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) : [];
|
|
4052
4240
|
const patterns = ignore.map((p) => new RegExp(`^${p.replace(/^\//, "").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*")}(/.*)?$`));
|
|
4053
4241
|
const files2 = [];
|
|
4054
|
-
for (const file of await
|
|
4055
|
-
const rel =
|
|
4056
|
-
const name =
|
|
4242
|
+
for (const file of await walk2(dir)) {
|
|
4243
|
+
const rel = relative5(dir, file).split(sep2).join("/");
|
|
4244
|
+
const name = basename2(rel);
|
|
4057
4245
|
if (name === ".assetsignore" || name === ".DS_Store" || name === "_headers" || name === "_redirects" || rel.startsWith("node_modules/")) continue;
|
|
4058
4246
|
if (patterns.some((p) => p.test(rel) || p.test(name))) continue;
|
|
4059
4247
|
files2.push({ path: `/${rel}`, file, size: (await stat(file)).size });
|
|
4060
4248
|
}
|
|
4061
4249
|
return files2;
|
|
4062
4250
|
}
|
|
4063
|
-
async function
|
|
4251
|
+
async function walk2(dir) {
|
|
4064
4252
|
const files2 = [];
|
|
4065
4253
|
for (const entry2 of await readdir2(dir, { withFileTypes: true })) {
|
|
4066
|
-
const path =
|
|
4067
|
-
if (entry2.isDirectory()) files2.push(...await
|
|
4254
|
+
const path = join11(dir, entry2.name);
|
|
4255
|
+
if (entry2.isDirectory()) files2.push(...await walk2(path));
|
|
4068
4256
|
else if (entry2.isFile()) files2.push(path);
|
|
4069
4257
|
}
|
|
4070
4258
|
return files2.sort();
|
|
4071
4259
|
}
|
|
4072
4260
|
function bin(dir, name, pkg = name) {
|
|
4073
|
-
for (let d = dir; ; d =
|
|
4074
|
-
const local =
|
|
4075
|
-
if (
|
|
4076
|
-
if (
|
|
4261
|
+
for (let d = dir; ; d = dirname7(d)) {
|
|
4262
|
+
const local = join11(d, "node_modules", ".bin", name);
|
|
4263
|
+
if (existsSync10(local)) return local;
|
|
4264
|
+
if (dirname7(d) === d) return `npx --yes ${pkg}`;
|
|
4077
4265
|
}
|
|
4078
4266
|
}
|
|
4079
4267
|
function run(command, args, cwd, opts = {}) {
|
|
@@ -4108,7 +4296,7 @@ function run(command, args, cwd, opts = {}) {
|
|
|
4108
4296
|
child.once("error", (error) => reject(new CliError(`could not run ${file}: ${error.message}`)));
|
|
4109
4297
|
child.once("close", (code) => {
|
|
4110
4298
|
if (code === 0) resolveRun(output);
|
|
4111
|
-
else reject(new CliError(`\`${
|
|
4299
|
+
else reject(new CliError(`\`${basename2(file)} ${[...pre, ...args].join(" ")}\` failed (exit ${code})${output && opts.capture ? `
|
|
4112
4300
|
${output.slice(-3e3)}` : ""}`));
|
|
4113
4301
|
});
|
|
4114
4302
|
});
|
|
@@ -4119,7 +4307,7 @@ var kb = (n) => n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math
|
|
|
4119
4307
|
var out5 = (s = "") => void process.stdout.write(`${s}
|
|
4120
4308
|
`);
|
|
4121
4309
|
var str2 = (v) => typeof v === "string" && v ? v : void 0;
|
|
4122
|
-
var MIGRATIONS_DIR =
|
|
4310
|
+
var MIGRATIONS_DIR = join12("db", "migrations");
|
|
4123
4311
|
var MIGRATIONS_TABLE = "_ambasada_migrations";
|
|
4124
4312
|
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
4313
|
var PRODUCTS_DOMAIN = "ambasada.dev";
|
|
@@ -4191,23 +4379,23 @@ ${output.slice(-1e3)}`);
|
|
|
4191
4379
|
}
|
|
4192
4380
|
}
|
|
4193
4381
|
async function readMigrations(root) {
|
|
4194
|
-
const dir =
|
|
4195
|
-
if (!
|
|
4382
|
+
const dir = join12(root, MIGRATIONS_DIR);
|
|
4383
|
+
if (!existsSync11(dir)) return [];
|
|
4196
4384
|
const names2 = (await readdir3(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
4197
|
-
return Promise.all(names2.map(async (name) => ({ name, sql: await
|
|
4385
|
+
return Promise.all(names2.map(async (name) => ({ name, sql: await readFile7(join12(dir, name), "utf8") })));
|
|
4198
4386
|
}
|
|
4199
4387
|
var sha2562 = (s) => createHash("sha256").update(s).digest("hex");
|
|
4200
4388
|
var names = (list) => Array.isArray(list) ? list.map((x) => typeof x === "string" ? x : String(x?.name ?? "")).filter(Boolean) : [];
|
|
4201
4389
|
var sqlString = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
4202
4390
|
async function localQuery(t, sql, extra = {}) {
|
|
4203
|
-
const dir = await mkdtemp(
|
|
4391
|
+
const dir = await mkdtemp(join12(tmpdir(), "ambasada-sql-"));
|
|
4204
4392
|
let rows;
|
|
4205
4393
|
try {
|
|
4206
|
-
const file =
|
|
4207
|
-
await
|
|
4394
|
+
const file = join12(dir, "query.sql");
|
|
4395
|
+
await writeFile7(file, sql);
|
|
4208
4396
|
rows = wranglerJson(await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--json", "--yes"], extra));
|
|
4209
4397
|
} finally {
|
|
4210
|
-
await
|
|
4398
|
+
await rm6(dir, { recursive: true, force: true });
|
|
4211
4399
|
}
|
|
4212
4400
|
return rows.map((r) => {
|
|
4213
4401
|
const results = r.results ?? [];
|
|
@@ -4220,13 +4408,13 @@ async function localQuery(t, sql, extra = {}) {
|
|
|
4220
4408
|
});
|
|
4221
4409
|
}
|
|
4222
4410
|
async function localExec(t, sql, extra = {}) {
|
|
4223
|
-
const dir = await mkdtemp(
|
|
4411
|
+
const dir = await mkdtemp(join12(tmpdir(), "ambasada-sql-"));
|
|
4224
4412
|
try {
|
|
4225
|
-
const file =
|
|
4226
|
-
await
|
|
4413
|
+
const file = join12(dir, "migration.sql");
|
|
4414
|
+
await writeFile7(file, sql);
|
|
4227
4415
|
await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--yes"], extra);
|
|
4228
4416
|
} finally {
|
|
4229
|
-
await
|
|
4417
|
+
await rm6(dir, { recursive: true, force: true });
|
|
4230
4418
|
}
|
|
4231
4419
|
}
|
|
4232
4420
|
async function migrateLocal(opts) {
|
|
@@ -4266,7 +4454,7 @@ INSERT INTO ${MIGRATIONS_TABLE} ${hasSha ? `(name, sha256) VALUES (${sqlString(m
|
|
|
4266
4454
|
}
|
|
4267
4455
|
async function migrateBeforeDev(root, print) {
|
|
4268
4456
|
const dir = findProductDir(root);
|
|
4269
|
-
if (!dir || !
|
|
4457
|
+
if (!dir || !existsSync11(join12(root, MIGRATIONS_DIR))) return;
|
|
4270
4458
|
try {
|
|
4271
4459
|
const { config } = await readWranglerConfig(dir);
|
|
4272
4460
|
if (!config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) return;
|
|
@@ -4435,7 +4623,7 @@ async function kv(args, flags) {
|
|
|
4435
4623
|
await wrangler(t, ["kv", "key", "put", key, ...fromFile ? ["--path", fromFile] : [value], "--binding", PLATFORM_STORAGE.kv, ...ttl ? ["--ttl", ttl] : []]);
|
|
4436
4624
|
return out5(`${c.green("\u2713")} ${key} (local)`);
|
|
4437
4625
|
}
|
|
4438
|
-
const raw = fromFile ? new Uint8Array(await
|
|
4626
|
+
const raw = fromFile ? new Uint8Array(await readFile7(fromFile)) : value;
|
|
4439
4627
|
const r = await api(t, "PUT", `${kvPath(key)}${ttl ? `?ttl=${ttl}` : ""}`, { raw, type: fromFile ? "application/octet-stream" : "text/plain; charset=utf-8" });
|
|
4440
4628
|
if (flags.json) return out5(JSON.stringify(r, null, 2));
|
|
4441
4629
|
return out5(`${c.green("\u2713")} ${r.key} ${c.dim(`${r.size} bytes${ttl ? `, expires in ${ttl} s` : ""}`)}`);
|
|
@@ -4518,14 +4706,14 @@ async function files(args, flags) {
|
|
|
4518
4706
|
case "put":
|
|
4519
4707
|
case "upload": {
|
|
4520
4708
|
if (!a) throw new CliError("usage: ambasada files put <path> [key] [--content-type type]");
|
|
4521
|
-
if (!
|
|
4522
|
-
const key = checkKey(b ??
|
|
4709
|
+
if (!existsSync11(a)) throw new CliError(`no file ${a}`);
|
|
4710
|
+
const key = checkKey(b ?? basename3(a));
|
|
4523
4711
|
const type = str2(flags["content-type"]) ?? contentTypeOf(a);
|
|
4524
4712
|
if (t.local) {
|
|
4525
4713
|
await wrangler(t, ["r2", "object", "put", `${await localBucket(t)}/${key}`, "--file", a, "--content-type", type]);
|
|
4526
4714
|
return out5(`${c.green("\u2713")} ${key} (local) \u2192 ${await fileUrl(t, key, flags)}`);
|
|
4527
4715
|
}
|
|
4528
|
-
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await
|
|
4716
|
+
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await readFile7(a)), type });
|
|
4529
4717
|
if (flags.json) return out5(JSON.stringify(r, null, 2));
|
|
4530
4718
|
return out5(`${c.green("\u2713")} ${r.key} ${c.dim(size(r.size))} \u2192 ${c.cyan(r.url ?? await fileUrl(t, key, flags))}`);
|
|
4531
4719
|
}
|
|
@@ -4549,10 +4737,10 @@ async function files(args, flags) {
|
|
|
4549
4737
|
// src/tunnel.ts
|
|
4550
4738
|
import { spawn as spawn3, spawnSync } from "node:child_process";
|
|
4551
4739
|
import { createHash as createHash2 } from "node:crypto";
|
|
4552
|
-
import { existsSync as
|
|
4553
|
-
import { chmod as chmod2, mkdir as
|
|
4740
|
+
import { existsSync as existsSync12 } from "node:fs";
|
|
4741
|
+
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
4742
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
4555
|
-
import { join as
|
|
4743
|
+
import { join as join13 } from "node:path";
|
|
4556
4744
|
var out6 = (s = "") => process.stdout.write(`${s}
|
|
4557
4745
|
`);
|
|
4558
4746
|
var CLOUDFLARED_VERSION = "2026.10.7";
|
|
@@ -4567,8 +4755,8 @@ var RELEASES = "https://github.com/cloudflare/cloudflared/releases/download";
|
|
|
4567
4755
|
async function cloudflaredBin(print = out6) {
|
|
4568
4756
|
if (process.env.AMBASADA_CLOUDFLARED) return process.env.AMBASADA_CLOUDFLARED;
|
|
4569
4757
|
if (spawnSync("cloudflared", ["--version"], { stdio: "ignore" }).status === 0) return "cloudflared";
|
|
4570
|
-
const dest =
|
|
4571
|
-
if (
|
|
4758
|
+
const dest = join13(configDir(), "bin", `cloudflared-${CLOUDFLARED_VERSION}${process.platform === "win32" ? ".exe" : ""}`);
|
|
4759
|
+
if (existsSync12(dest)) return dest;
|
|
4572
4760
|
print(c.dim(`downloading cloudflared ${CLOUDFLARED_VERSION} (once, ~20\u201340 MB) \u2026`));
|
|
4573
4761
|
await downloadCloudflared(dest);
|
|
4574
4762
|
return dest;
|
|
@@ -4588,22 +4776,22 @@ async function downloadCloudflared(dest, opts = {}) {
|
|
|
4588
4776
|
}
|
|
4589
4777
|
const digest = createHash2("sha256").update(bytes).digest("hex");
|
|
4590
4778
|
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(
|
|
4779
|
+
const dir = join13(dest, "..");
|
|
4780
|
+
await mkdir7(dir, { recursive: true });
|
|
4781
|
+
const work = await mkdtemp2(join13(tmpdir2(), "ambasada-cloudflared-"));
|
|
4594
4782
|
try {
|
|
4595
|
-
const binary =
|
|
4783
|
+
const binary = join13(work, "cloudflared");
|
|
4596
4784
|
if (asset.file.endsWith(".tgz")) {
|
|
4597
|
-
await
|
|
4598
|
-
const tar = spawnSync("tar", ["-xzf",
|
|
4599
|
-
if (tar.status !== 0 || !
|
|
4600
|
-
} else await
|
|
4785
|
+
await writeFile8(join13(work, asset.file), bytes);
|
|
4786
|
+
const tar = spawnSync("tar", ["-xzf", join13(work, asset.file), "-C", work], { encoding: "utf8" });
|
|
4787
|
+
if (tar.status !== 0 || !existsSync12(binary)) throw new CliError(`could not unpack ${asset.file}: ${tar.stderr || "no cloudflared inside"}`);
|
|
4788
|
+
} else await writeFile8(binary, bytes);
|
|
4601
4789
|
await chmod2(binary, 493);
|
|
4602
4790
|
const tmp = `${dest}.${process.pid}`;
|
|
4603
|
-
await
|
|
4791
|
+
await writeFile8(tmp, await readFile8(binary), { mode: 493 });
|
|
4604
4792
|
await rename(tmp, dest);
|
|
4605
4793
|
} finally {
|
|
4606
|
-
await
|
|
4794
|
+
await rm7(work, { recursive: true, force: true });
|
|
4607
4795
|
}
|
|
4608
4796
|
}
|
|
4609
4797
|
async function call(opts, method, path, body) {
|
|
@@ -4760,7 +4948,7 @@ async function tunnelPort(flags) {
|
|
|
4760
4948
|
return 3e3;
|
|
4761
4949
|
}
|
|
4762
4950
|
function webhookPath(root) {
|
|
4763
|
-
return root && WEBHOOK_FILES.some((f) =>
|
|
4951
|
+
return root && WEBHOOK_FILES.some((f) => existsSync12(join13(root, f))) ? "/api/webhook" : void 0;
|
|
4764
4952
|
}
|
|
4765
4953
|
async function tunnel(_args, flags) {
|
|
4766
4954
|
const r = await resolveForApp(flags, "open the tunnel");
|
|
@@ -4773,7 +4961,7 @@ async function tunnel(_args, flags) {
|
|
|
4773
4961
|
out6(c.yellow(`! nothing answers on ${local} yet: start your dev server there (or pass --port)`));
|
|
4774
4962
|
}
|
|
4775
4963
|
const root = findRoot();
|
|
4776
|
-
if (root &&
|
|
4964
|
+
if (root && existsSync12(join13(root, ".env")) && /AMBASADA_API_URL=http:\/\/(127\.0\.0\.1|localhost)/.test(await readFile8(join13(root, ".env"), "utf8"))) {
|
|
4777
4965
|
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
4966
|
}
|
|
4779
4967
|
const register = flags["no-register"] !== true;
|
|
@@ -4788,7 +4976,7 @@ async function tunnel(_args, flags) {
|
|
|
4788
4976
|
}
|
|
4789
4977
|
|
|
4790
4978
|
// src/commands.ts
|
|
4791
|
-
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.
|
|
4979
|
+
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.9") ? `^${"0.8.9"}` : "latest";
|
|
4792
4980
|
var DEV_PORT = 8788;
|
|
4793
4981
|
var out7 = (s = "") => process.stdout.write(`${s}
|
|
4794
4982
|
`);
|
|
@@ -4887,20 +5075,20 @@ var addonSpec = (short, flags) => str3(flags[`${short}-spec`]) ?? process.env[`A
|
|
|
4887
5075
|
async function writeFiles(root, files2, map, force = true) {
|
|
4888
5076
|
const written = [];
|
|
4889
5077
|
for (const [rel, content] of Object.entries(files2)) {
|
|
4890
|
-
const dest =
|
|
4891
|
-
if (!force &&
|
|
4892
|
-
await
|
|
4893
|
-
if (isBinary(rel)) await
|
|
4894
|
-
else await
|
|
5078
|
+
const dest = join14(root, rel === "gitignore" ? ".gitignore" : rel);
|
|
5079
|
+
if (!force && existsSync13(dest)) continue;
|
|
5080
|
+
await mkdir8(dirname8(dest), { recursive: true });
|
|
5081
|
+
if (isBinary(rel)) await writeFile9(dest, Buffer.from(content, "base64"));
|
|
5082
|
+
else await writeFile9(dest, map(rel, content));
|
|
4895
5083
|
written.push(rel);
|
|
4896
5084
|
}
|
|
4897
5085
|
return written;
|
|
4898
5086
|
}
|
|
4899
5087
|
async function init(args, flags) {
|
|
4900
5088
|
const target2 = resolvePath5(args[0] ?? ".");
|
|
4901
|
-
const name = str3(flags.name) ?? (args[0] ?
|
|
5089
|
+
const name = str3(flags.name) ?? (args[0] ? basename4(target2) : "My App");
|
|
4902
5090
|
const id = slug(str3(flags.id) ?? name);
|
|
4903
|
-
if (
|
|
5091
|
+
if (existsSync13(target2) && (await readdir4(target2)).filter((f) => f !== ".git").length && !flags.force) {
|
|
4904
5092
|
throw new CliError(`${target2} is not empty (use --force to scaffold anyway)`);
|
|
4905
5093
|
}
|
|
4906
5094
|
const template = str3(flags.template) ?? TEMPLATES[0];
|
|
@@ -4919,9 +5107,9 @@ async function init(args, flags) {
|
|
|
4919
5107
|
return `${JSON.stringify(pkg, null, 2)}
|
|
4920
5108
|
`;
|
|
4921
5109
|
});
|
|
4922
|
-
const env2 =
|
|
4923
|
-
if (!
|
|
4924
|
-
await
|
|
5110
|
+
const env2 = join14(target2, ".env");
|
|
5111
|
+
if (!existsSync13(env2)) {
|
|
5112
|
+
await writeFile9(
|
|
4925
5113
|
env2,
|
|
4926
5114
|
[
|
|
4927
5115
|
"# Local development against `ambasada dev`. On ambasada.dev the deploy sets the real values.",
|
|
@@ -4945,7 +5133,7 @@ async function init(args, flags) {
|
|
|
4945
5133
|
const entry2 = flags.miniapp ? str3(flags.entry) ?? DEFAULT_ENTRY : addon?.overlay?.miniapp?.entry;
|
|
4946
5134
|
const miniapp2 = entry2 ? await enableMiniapp(target2, entry2, flags, { template, releaseLine: /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec }) : null;
|
|
4947
5135
|
await writeProjectInfo(target2, { template, devkit: devkitVersion(), addons: addon ? [addon.short] : [] });
|
|
4948
|
-
let installed =
|
|
5136
|
+
let installed = existsSync13(join14(target2, "node_modules"));
|
|
4949
5137
|
const missing = installed ? missingDependencies(target2) : [];
|
|
4950
5138
|
if (missing.length && !flags["no-install"]) {
|
|
4951
5139
|
out7(c.dim(`npm install (${missing.join(", ")}) \u2026`));
|
|
@@ -4972,10 +5160,10 @@ async function init(args, flags) {
|
|
|
4972
5160
|
}
|
|
4973
5161
|
async function applyAddon(root, template, short, flags, fresh) {
|
|
4974
5162
|
const pkgName = KNOWN_ADDONS[short] ?? short;
|
|
4975
|
-
const pkgFile =
|
|
4976
|
-
const pkg = JSON.parse(await
|
|
5163
|
+
const pkgFile = join14(root, "package.json");
|
|
5164
|
+
const pkg = JSON.parse(await readFile9(pkgFile, "utf8"));
|
|
4977
5165
|
pkg.dependencies = { ...pkg.dependencies, [pkgName]: pkg.dependencies?.[pkgName] ?? addonSpec(shortName(pkgName), flags) };
|
|
4978
|
-
await
|
|
5166
|
+
await writeFile9(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
4979
5167
|
`);
|
|
4980
5168
|
const find = () => findAddons(root).find((dir2) => readJsonName(dir2) === pkgName) ?? besideCli(pkgName);
|
|
4981
5169
|
let dir = find() ?? packageDir(pkgName, root);
|
|
@@ -4995,37 +5183,37 @@ async function applyAddon(root, template, short, flags, fresh) {
|
|
|
4995
5183
|
const id = await loadIdName(root) ?? { id: "my-app", name: "My App" };
|
|
4996
5184
|
await writeFiles(root, overlay.files(template), (_rel, text) => text.replaceAll("my-app", id.id).replaceAll("My App", id.name), fresh || flags.force === true);
|
|
4997
5185
|
if (overlay.config) {
|
|
4998
|
-
const file =
|
|
4999
|
-
await
|
|
5186
|
+
const file = join14(root, CONFIG_FILE);
|
|
5187
|
+
await writeFile9(file, overlay.config(await readFile9(file, "utf8"), template));
|
|
5000
5188
|
}
|
|
5001
5189
|
if (overlay.env?.length) {
|
|
5002
|
-
const env2 =
|
|
5003
|
-
const current2 =
|
|
5190
|
+
const env2 = join14(root, ".env");
|
|
5191
|
+
const current2 = existsSync13(env2) ? await readFile9(env2, "utf8") : "";
|
|
5004
5192
|
const missing = overlay.env.filter((line) => line.startsWith("#") || !current2.includes(`${line.split("=")[0]}=`));
|
|
5005
5193
|
if (missing.some((l) => !l.startsWith("#"))) await appendFile2(env2, `${current2 && !current2.endsWith("\n") ? "\n" : ""}${missing.join("\n")}
|
|
5006
5194
|
`);
|
|
5007
5195
|
}
|
|
5008
5196
|
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
|
|
5197
|
+
const file = join14(root, rel);
|
|
5198
|
+
const current2 = existsSync13(file) ? await readFile9(file, "utf8") : "";
|
|
5199
|
+
if (!current2.includes(text.trim().split("\n")[0])) await writeFile9(file, `${current2.trimEnd()}
|
|
5012
5200
|
|
|
5013
5201
|
${text.trim()}
|
|
5014
5202
|
`);
|
|
5015
5203
|
}
|
|
5016
5204
|
if (overlay.dependencies) {
|
|
5017
|
-
const next = JSON.parse(await
|
|
5205
|
+
const next = JSON.parse(await readFile9(pkgFile, "utf8"));
|
|
5018
5206
|
next.dependencies = { ...overlay.dependencies, ...next.dependencies };
|
|
5019
|
-
await
|
|
5207
|
+
await writeFile9(pkgFile, `${JSON.stringify(next, null, 2)}
|
|
5020
5208
|
`);
|
|
5021
5209
|
}
|
|
5022
5210
|
}
|
|
5023
|
-
await writeFiles(
|
|
5211
|
+
await writeFiles(join14(root, ".claude/skills"), addon.skills, (_rel, text) => text, true);
|
|
5024
5212
|
return addon;
|
|
5025
5213
|
}
|
|
5026
5214
|
function readJsonName(dir) {
|
|
5027
5215
|
try {
|
|
5028
|
-
return JSON.parse(
|
|
5216
|
+
return JSON.parse(readFileSync8(join14(dir, "package.json"), "utf8")).name;
|
|
5029
5217
|
} catch {
|
|
5030
5218
|
return void 0;
|
|
5031
5219
|
}
|
|
@@ -5058,7 +5246,7 @@ async function check(args, flags) {
|
|
|
5058
5246
|
const dir = target2 ? resolvePath5(target2) : findRoot();
|
|
5059
5247
|
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
5248
|
report = await checkLocal(dir);
|
|
5061
|
-
const url = await liveUrl(dir, PRODUCT_URL2.exec(
|
|
5249
|
+
const url = await liveUrl(dir, PRODUCT_URL2.exec(existsSync13(join14(dir, CONFIG_FILE)) ? readFileSync8(join14(dir, CONFIG_FILE), "utf8") : "")?.[1]);
|
|
5062
5250
|
if (!report.miniapp?.on && url?.startsWith("https://")) report = await checkRemote(url, void 0, { miniapp: false });
|
|
5063
5251
|
}
|
|
5064
5252
|
const on = report.miniapp?.on !== false;
|
|
@@ -5101,6 +5289,7 @@ function configuredAppUrl(project, flags, print) {
|
|
|
5101
5289
|
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
5290
|
return DEFAULT_APP_URL;
|
|
5103
5291
|
}
|
|
5292
|
+
var DEV_SESSION_FILE = join14(".ambasada", "dev.json");
|
|
5104
5293
|
async function dev(_args, flags) {
|
|
5105
5294
|
const port = Number(str3(flags.port) ?? DEV_PORT);
|
|
5106
5295
|
if (flags.tunnel) return devTunnel(flags);
|
|
@@ -5120,6 +5309,11 @@ async function dev(_args, flags) {
|
|
|
5120
5309
|
extensions: addons().mocks,
|
|
5121
5310
|
log: (line) => out7(`${c.dim((/* @__PURE__ */ new Date()).toISOString().slice(11, 19))} ${line}`)
|
|
5122
5311
|
});
|
|
5312
|
+
const session = project ? join14(project.root, DEV_SESSION_FILE) : null;
|
|
5313
|
+
if (session) await mkdir8(dirname8(session), { recursive: true }).then(() => writeFile9(session, `${JSON.stringify({ mock_url: mock.url, company, pid: process.pid }, null, 2)}
|
|
5314
|
+
`));
|
|
5315
|
+
const forget = () => session ? rm8(session, { force: true }).catch(() => {
|
|
5316
|
+
}) : Promise.resolve();
|
|
5123
5317
|
out7(`${c.bold("ambasada dev")} \xB7 mock Ambasada API on ${c.cyan(mock.url)} ${c.dim("(dashboard: open it in a browser)")}`);
|
|
5124
5318
|
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
5319
|
out7(` in your app: AMBASADA_API_URL=${mock.url} AMBASADA_API_KEY=${DEV_KEY}`);
|
|
@@ -5144,6 +5338,7 @@ async function dev(_args, flags) {
|
|
|
5144
5338
|
if (flags.once) {
|
|
5145
5339
|
hints();
|
|
5146
5340
|
await mock.close();
|
|
5341
|
+
await forget();
|
|
5147
5342
|
return;
|
|
5148
5343
|
}
|
|
5149
5344
|
if (stack && project) await migrateBeforeDev(project.root, out7);
|
|
@@ -5160,7 +5355,7 @@ async function dev(_args, flags) {
|
|
|
5160
5355
|
});
|
|
5161
5356
|
} else hints();
|
|
5162
5357
|
let closing;
|
|
5163
|
-
const close = () => closing ??= mock.close();
|
|
5358
|
+
const close = () => closing ??= mock.close().then(forget);
|
|
5164
5359
|
const code = await new Promise((resolveStop) => {
|
|
5165
5360
|
const stop2 = () => {
|
|
5166
5361
|
out7("\nstopping ambasada dev");
|
|
@@ -5223,7 +5418,7 @@ function manifestWebhook(root, productUrl) {
|
|
|
5223
5418
|
const file = findLocalManifest(root);
|
|
5224
5419
|
if (!file) return null;
|
|
5225
5420
|
try {
|
|
5226
|
-
const m = JSON.parse(
|
|
5421
|
+
const m = JSON.parse(readFileSync8(file, "utf8"));
|
|
5227
5422
|
const path = m.notifications?.webhook;
|
|
5228
5423
|
return Array.isArray(m.scopes) && m.scopes.includes("notifications") && typeof path === "string" && path ? new URL(path, productUrl).toString() : null;
|
|
5229
5424
|
} catch {
|
|
@@ -5298,11 +5493,27 @@ function messageLines(message, keep = 6) {
|
|
|
5298
5493
|
const lines = (message ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
5299
5494
|
return lines.length > keep ? [lines[0], `\u2026 ${lines.length - (keep - 1)} more`, ...lines.slice(-(keep - 2))] : lines;
|
|
5300
5495
|
}
|
|
5496
|
+
async function locate(root, e, lastDeploy) {
|
|
5497
|
+
if (!root || !e.message) return e;
|
|
5498
|
+
const maps = await mapsAt(root, e.at, lastDeploy).catch(() => null);
|
|
5499
|
+
const mapped = maps ? mapMessage(e.message, maps) : e.message;
|
|
5500
|
+
const lines = mapped === e.message ? [] : mapped.split("\n").filter((l, i) => l !== e.message.split("\n")[i] && !buildFrame(l));
|
|
5501
|
+
const route = detectStack(root)?.name === "next" && e.path && !lines.some(ownFrame) ? nextRouteFile(root, e.path) : null;
|
|
5502
|
+
const source = [...lines.map((l) => l.trim()), ...route ? [`in ${route}`] : []].join("\n");
|
|
5503
|
+
return source ? { ...e, source } : e;
|
|
5504
|
+
}
|
|
5505
|
+
function errorLabel(e) {
|
|
5506
|
+
if (e.method === "JOB") return `${c.red("job")} ${e.path ?? ""}`;
|
|
5507
|
+
if (e.method === "LOG") return c.red("error");
|
|
5508
|
+
return `${c.red(String(e.status ?? "error"))} ${e.method ?? ""} ${e.path ?? ""}`.trimEnd();
|
|
5509
|
+
}
|
|
5301
5510
|
async function logs(_args, flags) {
|
|
5302
5511
|
const t = await hosted(flags);
|
|
5512
|
+
const root = findRoot();
|
|
5303
5513
|
const seen = /* @__PURE__ */ new Set();
|
|
5304
5514
|
for (let first = true; ; first = false) {
|
|
5305
5515
|
const res = await api(t, "GET", "/v1/product/logs");
|
|
5516
|
+
res.errors.recent = await Promise.all(res.errors.recent.map((e) => locate(root, e, res.last_deploy)));
|
|
5306
5517
|
if (json(flags, res) && !flags.follow) return;
|
|
5307
5518
|
if (first) {
|
|
5308
5519
|
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`"));
|
|
@@ -5313,8 +5524,9 @@ async function logs(_args, flags) {
|
|
|
5313
5524
|
if (seen.has(key)) continue;
|
|
5314
5525
|
seen.add(key);
|
|
5315
5526
|
const [first2 = "", ...more] = messageLines(e.message);
|
|
5316
|
-
out7(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${
|
|
5527
|
+
out7(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${errorLabel(e)} ${first2}`.trimEnd());
|
|
5317
5528
|
for (const line of more) out7(` ${c.dim(line)}`);
|
|
5529
|
+
for (const line of e.source?.split("\n") ?? []) out7(` ${c.cyan(`\u2192 ${line}`)}`);
|
|
5318
5530
|
}
|
|
5319
5531
|
if (!flags.follow) return;
|
|
5320
5532
|
await sleep(5e3);
|
|
@@ -5322,7 +5534,7 @@ async function logs(_args, flags) {
|
|
|
5322
5534
|
}
|
|
5323
5535
|
|
|
5324
5536
|
// src/main.ts
|
|
5325
|
-
var VERSION3 = true ? "0.8.
|
|
5537
|
+
var VERSION3 = true ? "0.8.9" : "0.1.0-dev";
|
|
5326
5538
|
var MAJOR_MINOR = VERSION3.split(".").slice(0, 2).join(".");
|
|
5327
5539
|
var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasada
|
|
5328
5540
|
|
|
@@ -5332,7 +5544,7 @@ var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasad
|
|
|
5332
5544
|
dev [--app-port 3000] [--port 8788] [--tunnel] your dev server (product.url, :3000) + the platform mock
|
|
5333
5545
|
[--mock-only] (:8788): sign-in as a fake visitor, notifications, store,
|
|
5334
5546
|
local D1/KV/R2/DO
|
|
5335
|
-
deploy [--own] [--no-smoke] [--dry-run]
|
|
5547
|
+
deploy [--own] [--no-smoke] [--dry-run] [--rotate] host it at https://<slug>.ambasada.dev
|
|
5336
5548
|
tunnel [--port] | link <url> | check [url|dir] your laptop at https://<slug>-dev.ambasada.dev; hosted
|
|
5337
5549
|
elsewhere; the product (and mini-app) checks
|
|
5338
5550
|
miniapp [enable [--entry /app] | disable [--force]] the Ambasada mini app at a path of your app: off by default
|
|
@@ -5374,7 +5586,7 @@ ${ns.help}`)).join("\n");
|
|
|
5374
5586
|
return `${HELP}${extra || VILLAGE_HINT}
|
|
5375
5587
|
${FOOTER}`;
|
|
5376
5588
|
}
|
|
5377
|
-
var BOOLEAN = /* @__PURE__ */ new Set(["village", "miniapp", "no-install", "check", "json", "force", "dry-run", "agent-only", "offerings-only", "follow", "agent", "gateway", "no-sync", "no-device", "rotate", "once", "mock-only", "help", "version", "own", "no-smoke", "no-build", "tunnel", "no-register", "local"]);
|
|
5589
|
+
var BOOLEAN = /* @__PURE__ */ new Set(["village", "miniapp", "no-install", "check", "json", "force", "dry-run", "agent-only", "offerings-only", "follow", "agent", "gateway", "no-sync", "no-device", "rotate", "once", "mock-only", "help", "version", "own", "no-smoke", "no-build", "tunnel", "no-register", "local", "no-wait", "offerings"]);
|
|
5378
5590
|
function parseArgs(argv) {
|
|
5379
5591
|
const args = [];
|
|
5380
5592
|
const flags = {};
|
|
@@ -5417,7 +5629,7 @@ var COMMANDS = {
|
|
|
5417
5629
|
var VILLAGE_COMMANDS = /* @__PURE__ */ new Set(["wallet", "ledger", "offerings", "market", "steer", "webhook", "links", "village"]);
|
|
5418
5630
|
async function villageHint(root, addons2) {
|
|
5419
5631
|
if (!root || addons2.get("village")) return;
|
|
5420
|
-
let uses =
|
|
5632
|
+
let uses = existsSync14(join15(root, "offerings.ts"));
|
|
5421
5633
|
if (!uses) {
|
|
5422
5634
|
try {
|
|
5423
5635
|
uses = (await loadProject(root)).config.idea !== void 0;
|