@ambasada/cli 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/ambasada.mjs +683 -290
  2. 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## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, per laptop:\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`.)\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\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.\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\nSets `product.url` in `ambasada.config.ts` and checks that `<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 https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\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\n```\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app" }` | `ambasada.config.ts`; optional `icon`, `category`, `accent`, `scopes`, `visibility` next to `entry` |\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 `/`.\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- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only.\n- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.\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| 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## 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.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 the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\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## `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\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\nSets `product.url` in `ambasada.config.ts` and checks that `<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 https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\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| 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## 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.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 the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\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. */
@@ -81,14 +81,11 @@ body { margin: 0; }
81
81
  .eyebrow::before { content: "// "; }
82
82
  h1 { margin: var(--space-2) 0 var(--space-3); font-size: clamp(2.2rem, 7vw, 3.4rem); font-weight: var(--weight-semibold); line-height: 1.05; letter-spacing: -0.03em; }
83
83
  .lead { margin: 0 0 var(--space-8); font-size: 1.125rem; color: var(--color-ink-2); }
84
- .checkout { display: grid; gap: var(--space-4); }
85
84
  textarea, select { font: inherit; font-size: var(--text-base); color: inherit; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface); }
86
85
  textarea:focus, select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-soft); }
87
86
  textarea { resize: vertical; min-height: 7rem; }
88
87
  .row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; }
89
88
  label { display: flex; gap: var(--space-2); align-items: center; color: var(--color-ink-2); }
90
- .pay::before { content: "\u25C6 "; opacity: 0.8; }
91
- .tip { margin-top: var(--space-10); padding-top: var(--space-6); border-top: 1px solid var(--color-line); }
92
89
  .small, footer { color: var(--color-ink-3); font-size: var(--text-sm); }
93
90
  footer { margin-top: calc(var(--space-10) + var(--space-2)); }
94
91
  code { font-family: var(--font-mono); font-size: 0.9em; background: var(--color-surface-2); padding: 0.1em 0.35em; border-radius: var(--radius-xs); }
@@ -118,7 +115,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
118
115
  </html>
119
116
  );
120
117
  }
121
- `, "app/notes.tsx": '"use client";\n\nimport { useEffect, useState } from "react";\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** The notes example: GET and POST /api/notes (app/api/notes/route.ts). */\nexport function Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\n.next/\n.open-next/\n.wrangler/\nnext-env.d.ts\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": 'import { createAmbasada, type Ambasada, type AmbasadaUser } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { headers } from "next/headers";\n\n/**\n * The Worker\'s env (secrets injected at deploy, `.env` locally). Under `next dev` there is no\n * Cloudflare context: the SDK then reads process.env, which `ambasada dev` points at the mock.\n */\nfunction cloudflareEnv(): Record<string, unknown> | undefined {\n try {\n return getCloudflareContext().env as unknown as Record<string, unknown>;\n } catch {\n return undefined;\n }\n}\n\n/** A variable from the Worker\'s env, else process.env. */\nexport function env(name: string): string | undefined {\n const value = cloudflareEnv()?.[name] ?? process.env[name];\n return typeof value === "string" && value ? value : undefined;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API\'s signing keys. */\nexport function ambasada(): Ambasada {\n return (client ??= createAmbasada({ env: cloudflareEnv() }));\n}\n\n/** Who is signed in with Ambasada (app/api/auth/*), in server components and route handlers; null when signed out. */\nexport async function currentUser(): Promise<AmbasadaUser | null> {\n return ambasada().auth.user({ headers: await headers() });\n}\n', "lib/bindings.ts": `import { getCloudflareContext } from "@opennextjs/cloudflare";
118
+ `, "app/notes.tsx": '"use client";\n\nimport { useEffect, useState } from "react";\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** The notes example: GET and POST /api/notes (app/api/notes/route.ts). */\nexport function Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\n.next/\n.open-next/\n.wrangler/\nnext-env.d.ts\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": 'import { createAmbasada, type Ambasada, type AmbasadaUser } from "@ambasada/sdk";\nimport { getCloudflareContext } from "@opennextjs/cloudflare";\nimport { headers } from "next/headers";\n\n/**\n * The Worker\'s env (secrets injected at deploy, `.env` locally). Under `next dev` there is no\n * Cloudflare context: the SDK then reads process.env, which `ambasada dev` points at the mock.\n */\nfunction cloudflareEnv(): Record<string, unknown> | undefined {\n try {\n return getCloudflareContext().env as unknown as Record<string, unknown>;\n } catch {\n return undefined;\n }\n}\n\n/** A variable from the Worker\'s env, else process.env. */\nexport function env(name: string): string | undefined {\n const value = cloudflareEnv()?.[name] ?? process.env[name];\n return typeof value === "string" && value ? value : undefined;\n}\n\n/**\n * This app\'s own origin (https://<slug>.ambasada.dev online, the dev server locally): AMBASADA_APP_ORIGIN, which\n * `ambasada deploy` and `ambasada dev` set, else the request\'s. Not `req.url` alone: a call the platform forwards (an\n * agent\'s purchase, a webhook) reaches Next.js without the Host it builds `req.url` from (`https://undefined`).\n */\nexport function appOrigin(req?: Request): string {\n return env("AMBASADA_APP_ORIGIN") ?? (req ? new URL(req.url).origin : "http://localhost:3000");\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API\'s signing keys. */\nexport function ambasada(): Ambasada {\n return (client ??= createAmbasada({ env: cloudflareEnv() }));\n}\n\n/** Who is signed in with Ambasada (app/api/auth/*), in server components and route handlers; null when signed out. */\nexport async function currentUser(): Promise<AmbasadaUser | null> {\n return ambasada().auth.user({ headers: await headers() });\n}\n', "lib/bindings.ts": `import { getCloudflareContext } from "@opennextjs/cloudflare";
122
119
  import type { Database } from "./db.ts";
123
120
  import type { Bucket } from "./files.ts";
124
121
  import type { KeyValue } from "./kv.ts";
@@ -135,7 +132,7 @@ export interface Bindings {
135
132
  }
136
133
 
137
134
  export const bindings = (): Bindings => getCloudflareContext().env as unknown as Bindings;
138
- `, "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/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/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}\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 "src/realtime/room.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", "worker.ts", "src/realtime/room.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.\n */\n// @ts-ignore: .open-next/worker.js is written by `opennextjs-cloudflare build`\nimport { default as nextApp } from "./.open-next/worker.js";\n\nexport { Room } from "./src/realtime/room.ts";\n\nexport default {\n async 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 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" }] },\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"] }],\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## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, per laptop:\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`.)\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\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.\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\nSets `product.url` in `ambasada.config.ts` and checks that `<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 https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\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\n```\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app" }` | `ambasada.config.ts`; optional `icon`, `category`, `accent`, `scopes`, `visibility` next to `entry` |\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 `/`.\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- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only.\n- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.\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| 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## 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.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 the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\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 { 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 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 AMBASADA_APP_ORIGIN?: string;\n /** Your public URL, if the Worker sees another host (behind a proxy). */\n PUBLIC_URL?: string;\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/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/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/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}\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/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/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/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 "src/realtime/room.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", "worker.ts", "src/realtime/room.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.\n */\n// @ts-ignore: .open-next/worker.js is written by `opennextjs-cloudflare build`\nimport { default as nextApp } from "./.open-next/worker.js";\n\nexport { Room } from "./src/realtime/room.ts";\n\nexport default {\n async 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 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" }] },\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"] }],\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## `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\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\nSets `product.url` in `ambasada.config.ts` and checks that `<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 https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\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| 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## 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.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 the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\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 { 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 /** 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/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/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/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>
139
136
  <!-- /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
140
137
  (docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes. -->
141
138
  <html lang="en">
@@ -191,14 +188,11 @@ body { margin: 0; }
191
188
  .eyebrow::before { content: "// "; }
192
189
  h1 { margin: var(--space-2) 0 var(--space-3); font-size: clamp(2.2rem, 7vw, 3.4rem); font-weight: var(--weight-semibold); line-height: 1.05; letter-spacing: -0.03em; }
193
190
  .lead { margin: 0 0 var(--space-8); font-size: 1.125rem; color: var(--color-ink-2); }
194
- .checkout { display: grid; gap: var(--space-4); }
195
191
  textarea, select { font: inherit; font-size: var(--text-base); color: inherit; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface); }
196
192
  textarea:focus, select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-soft); }
197
193
  textarea { resize: vertical; min-height: 7rem; }
198
194
  .row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; }
199
195
  label { display: flex; gap: var(--space-2); align-items: center; color: var(--color-ink-2); }
200
- .pay::before { content: "\u25C6 "; opacity: 0.8; }
201
- .tip { margin-top: var(--space-10); padding-top: var(--space-6); border-top: 1px solid var(--color-line); }
202
196
  .small, footer { color: var(--color-ink-3); font-size: var(--text-sm); }
203
197
  footer { margin-top: calc(var(--space-10) + var(--space-2)); }
204
198
  code { font-family: var(--font-mono); font-size: 0.9em; background: var(--color-surface-2); padding: 0.1em 0.35em; border-radius: var(--radius-xs); }
@@ -210,7 +204,7 @@ a { color: var(--color-accent-text); }
210
204
  .account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
211
205
  .account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
212
206
  [hidden] { display: none !important; }
213
- `, "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"]\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", "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";\n\nexport { Room } from "../src/realtime/room.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\nexport default {\n async fetch(req, env) {\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 console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n }\n },\n} satisfies ExportedHandler<Env>;\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" }] },\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"] }],\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## `ambasada deploy`: online at `<slug>.ambasada.dev`\n\nOnce, per laptop:\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`.)\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\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.\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\nSets `product.url` in `ambasada.config.ts` and checks that `<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 https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\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\n```\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app" }` | `ambasada.config.ts`; optional `icon`, `category`, `accent`, `scopes`, `visibility` next to `entry` |\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 `/`.\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- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only.\n- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.\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| 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## 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.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 the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\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 { 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 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 AMBASADA_APP_ORIGIN?: string;\n /** Your public URL, if the Worker sees another host (behind a proxy). */\n PUBLIC_URL?: string;\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/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/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/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}\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";\n\nexport { Room } from "./realtime/room.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 async fetch(req, env) {\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 console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n }\n },\n} satisfies ExportedHandler<Env>;\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"]\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", "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";\n\nexport { Room } from "../src/realtime/room.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\nexport default {\n async fetch(req, env) {\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 console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n }\n },\n} satisfies ExportedHandler<Env>;\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" }] },\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"] }],\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## `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\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\nSets `product.url` in `ambasada.config.ts` and checks that `<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 https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\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| 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## 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.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 the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\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 { 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 /** 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/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/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/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/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";\n\nexport { Room } from "./realtime/room.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 async fetch(req, env) {\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 console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n }\n },\n} satisfies ExportedHandler<Env>;\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>
214
208
  <html lang="en">
215
209
  <head>
216
210
  <meta charset="utf-8" />
@@ -303,14 +297,11 @@ body { margin: 0; }
303
297
  .eyebrow::before { content: "// "; }
304
298
  h1 { margin: var(--space-2) 0 var(--space-3); font-size: clamp(2.2rem, 7vw, 3.4rem); font-weight: var(--weight-semibold); line-height: 1.05; letter-spacing: -0.03em; }
305
299
  .lead { margin: 0 0 var(--space-8); font-size: 1.125rem; color: var(--color-ink-2); }
306
- .checkout { display: grid; gap: var(--space-4); }
307
300
  textarea, select { font: inherit; font-size: var(--text-base); color: inherit; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface); }
308
301
  textarea:focus, select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-soft); }
309
302
  textarea { resize: vertical; min-height: 7rem; }
310
303
  .row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; }
311
304
  label { display: flex; gap: var(--space-2); align-items: center; color: var(--color-ink-2); }
312
- .pay::before { content: "\u25C6 "; opacity: 0.8; }
313
- .tip { margin-top: var(--space-10); padding-top: var(--space-6); border-top: 1px solid var(--color-line); }
314
305
  .small, footer { color: var(--color-ink-3); font-size: var(--text-sm); }
315
306
  footer { margin-top: calc(var(--space-10) + var(--space-2)); }
316
307
  code { font-family: var(--font-mono); font-size: 0.9em; background: var(--color-surface-2); padding: 0.1em 0.35em; border-radius: var(--radius-xs); }
@@ -395,10 +386,10 @@ function aexists(instance, checkFinished = true) {
395
386
  if (checkFinished && instance.finished)
396
387
  throw new Error("digest() was already called");
397
388
  }
398
- function aoutput(out7, instance) {
399
- abytes(out7, void 0, "output");
389
+ function aoutput(out8, instance) {
390
+ abytes(out8, void 0, "output");
400
391
  const min = instance.outputLen;
401
- if (!(out7.length >= min)) {
392
+ if (!(out8.length >= min)) {
402
393
  throw new RangeError('"output" expected length >= ' + min);
403
394
  }
404
395
  }
@@ -496,9 +487,9 @@ var HashMD = class {
496
487
  this.roundClean();
497
488
  return this;
498
489
  }
499
- digestInto(out7) {
490
+ digestInto(out8) {
500
491
  aexists(this);
501
- aoutput(out7, this);
492
+ aoutput(out8, this);
502
493
  this.finished = true;
503
494
  const { buffer, view, blockLen, isLE } = this;
504
495
  let { pos } = this;
@@ -511,7 +502,7 @@ var HashMD = class {
511
502
  setU64FromNum(view, blockLen - 8, this.length * 8, isLE);
512
503
  this.process(view, 0);
513
504
  this.roundClean();
514
- const oview = out7 === buffer ? view : createView(out7);
505
+ const oview = out8 === buffer ? view : createView(out8);
515
506
  const len = this.outputLen;
516
507
  const outLen = len / 4;
517
508
  const state = this.get();
@@ -865,6 +856,7 @@ var MANIFEST_SCHEMA = 2;
865
856
  var MANIFEST_KINDS = ["product", "event", "tool", "assistant"];
866
857
  var MANIFEST_CATEGORIES = ["shopping", "food", "events", "tools", "learning", "community", "games", "finance", "other"];
867
858
  var MANIFEST_VISIBILITY = ["public", "builders", "unlisted"];
859
+ var MANIFEST_AGE_RATINGS = ["4+", "9+", "12+", "17+"];
868
860
  var MANIFEST_LIMITS = { id: 32, name: 32, subtitle: 40, description: 170, tags: 5, screenshots: 3 };
869
861
  var MANIFEST_ID = /^[a-z0-9-]{3,32}$/;
870
862
  var HOSTED_MANIFEST = /^https:\/\/hub\.ambasada\.id\/api\/apps\/([a-z0-9-]+)\/manifest$/;
@@ -925,6 +917,15 @@ function validateManifest(json2) {
925
917
  const color = splash?.color;
926
918
  if (!splash || color !== void 0 && (typeof color !== "string" || !HEX_COLOR.test(color))) problem("splash_color", `"splash.color" must be a #RRGGBB colour (got ${JSON.stringify(color ?? m.splash)})`);
927
919
  }
920
+ const age = str4("age_rating");
921
+ if (m.age_rating === void 0) {
922
+ if (m.schema === MANIFEST_SCHEMA) problem("age_rating", `"age_rating" is required: one of ${MANIFEST_AGE_RATINGS.join(", ")} (the hub refuses to register an app without it)`);
923
+ } else if (!age || !MANIFEST_AGE_RATINGS.includes(age)) {
924
+ problem("age_rating", `"age_rating" must be one of ${MANIFEST_AGE_RATINGS.join(", ")} (got ${JSON.stringify(m.age_rating)})`);
925
+ }
926
+ if (m.support !== void 0 && (typeof m.support !== "string" || !/^(mailto:[^@\s]+@[^@\s]+|https:\/\/\S+)$/.test(m.support))) {
927
+ problem("support", `"support" must be a mailto: address or an https URL (got ${JSON.stringify(m.support)})`);
928
+ }
928
929
  const kind = str4("kind");
929
930
  if (m.kind !== void 0 && (!kind || !MANIFEST_KINDS.includes(kind))) warn("kind", `unknown "kind" ${JSON.stringify(m.kind)}: the app knows ${MANIFEST_KINDS.join(", ")} and shows this one as a product`);
930
931
  const category = str4("category");
@@ -965,9 +966,9 @@ function checkIcon(bytes) {
965
966
  function metaTags(html) {
966
967
  const tags = [];
967
968
  for (const [, attrs] of html.matchAll(/<meta\b([^>]*)>/gi)) {
968
- const out7 = {};
969
- for (const [, key, dq, sq, bare] of attrs.matchAll(/([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) out7[key.toLowerCase()] = (dq ?? sq ?? bare ?? "").trim();
970
- tags.push(out7);
969
+ const out8 = {};
970
+ for (const [, key, dq, sq, bare] of attrs.matchAll(/([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) out8[key.toLowerCase()] = (dq ?? sq ?? bare ?? "").trim();
971
+ tags.push(out8);
971
972
  }
972
973
  return tags;
973
974
  }
@@ -983,7 +984,7 @@ function checkHtml(html) {
983
984
  const darkStyle = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].concat([...html.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)]).some(([, css]) => [...css.matchAll(/color-scheme\s*:\s*([^;}"]+)/gi)].some(([, v]) => /\bdark\b/i.test(v) && !/\blight\b/i.test(v)));
984
985
  if (darkMeta || darkStyle) f.problems.push({ code: "dark_only", message: "the entry page declares a dark-only color-scheme; the Ambasada app is light (use light, or light dark)" });
985
986
  if (!SDK_MARKERS.some((m) => html.includes(m))) {
986
- f.warnings.push({ code: "no_miniapp_sdk", message: "no @ambasada/miniapp, miniapp.js or ambasadaShell in the entry HTML: a bundled import is fine too, the registry crawler confirms ready() is called at runtime" });
987
+ f.warnings.push({ code: "no_miniapp_sdk", message: "no @ambasada/miniapp, miniapp.js or ambasadaShell in the entry HTML or its scripts: the Ambasada app waits for ready() to drop the splash" });
987
988
  }
988
989
  return f;
989
990
  }
@@ -991,6 +992,23 @@ var USER_AGENT = "ambasada-check/1 (+https://ambasada.id)";
991
992
  async function get(fetchImpl, url) {
992
993
  return fetchImpl(url, { redirect: "manual", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { "user-agent": USER_AGENT, accept: "*/*" } });
993
994
  }
995
+ var SDK_SCRIPT_LIMIT = 16;
996
+ var BUNDLED_SDK = /ambasadaShell|@ambasada\/miniapp/;
997
+ async function bundlesMiniappSdk(fetchImpl, pageUrl, html) {
998
+ const origin = new URL(pageUrl).origin;
999
+ const srcs = [...html.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)].map(([, src]) => URL.canParse(src, pageUrl) ? new URL(src, pageUrl) : null).filter((u) => !!u && u.origin === origin).slice(0, SDK_SCRIPT_LIMIT);
1000
+ const found = await Promise.all(
1001
+ srcs.map(async (u) => {
1002
+ try {
1003
+ const res = await get(fetchImpl, u.toString());
1004
+ return res.status === 200 && BUNDLED_SDK.test(await res.text());
1005
+ } catch {
1006
+ return false;
1007
+ }
1008
+ })
1009
+ );
1010
+ return found.includes(true);
1011
+ }
994
1012
  async function checkPage(fetchImpl, url, report, html) {
995
1013
  try {
996
1014
  const started = Date.now();
@@ -999,7 +1017,11 @@ async function checkPage(fetchImpl, url, report, html) {
999
1017
  if (!html) await res.body?.cancel();
1000
1018
  const ms = Date.now() - started;
1001
1019
  if (res.status !== 200) report.problems.push({ code: "entry_status", message: `${url} answered ${res.status}${res.headers.get("location") ? ` \u2192 ${res.headers.get("location")}` : ""}; ${html ? "the entry" : "the app"} must answer 200 without redirects` });
1002
- else if (html) merge(report, checkHtml(text));
1020
+ else if (html) {
1021
+ const found = checkHtml(text);
1022
+ if (found.warnings.some((w) => w.code === "no_miniapp_sdk") && await bundlesMiniappSdk(fetchImpl, url, text)) found.warnings = found.warnings.filter((w) => w.code !== "no_miniapp_sdk");
1023
+ merge(report, found);
1024
+ }
1003
1025
  if (ms > SLOW_ENTRY_MS) report.warnings.push({ code: "slow_entry", message: `${url} took ${ms} ms; aim for under ${SLOW_ENTRY_MS} ms${html ? " (the splash stays until ready())" : ""}` });
1004
1026
  } catch (error) {
1005
1027
  report.problems.push({ code: "entry_fetch", message: `could not fetch ${url}: ${error.message}` });
@@ -1104,10 +1126,13 @@ async function checkRemote(url, fetchImpl = fetch, options = {}) {
1104
1126
  report.ok = report.problems.length === 0;
1105
1127
  return report;
1106
1128
  }
1107
- function listingLine(tier, check2) {
1129
+ var checkPending = (check2) => check2?.status === "pending";
1130
+ function listingLine(tier, check2, kind = "company") {
1108
1131
  if (check2?.miniapp === "off") return "Mini app: off";
1132
+ const listed = kind === "project" ? "Listed" : "Listed for the event";
1133
+ if (checkPending(check2)) return `${tier === "verified" ? "Verified" : tier === "listed" ? listed : tier === "unlisted" ? "Unlisted" : "Not registered yet"} \xB7 checking\u2026`;
1109
1134
  if (tier === "verified") return "Verified";
1110
- if (tier === "listed") return "Listed for the event";
1135
+ if (tier === "listed") return listed;
1111
1136
  if (tier === "unlisted") return check2?.needs.length ? `Unlisted \xB7 needs: ${check2.needs.join(", ")}` : "Unlisted";
1112
1137
  return check2?.needs.length ? `Not registered yet \xB7 needs: ${check2.needs.join(", ")}` : "Not registered yet";
1113
1138
  }
@@ -1314,9 +1339,9 @@ function b64url(bytes) {
1314
1339
  }
1315
1340
  function fromB64url(text) {
1316
1341
  const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
1317
- const out7 = new Uint8Array(s.length);
1318
- for (let i = 0; i < s.length; i++) out7[i] = s.charCodeAt(i);
1319
- return out7;
1342
+ const out8 = new Uint8Array(s.length);
1343
+ for (let i = 0; i < s.length; i++) out8[i] = s.charCodeAt(i);
1344
+ return out8;
1320
1345
  }
1321
1346
  var random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
1322
1347
  async function pkce() {
@@ -1581,6 +1606,7 @@ var AmbasadaAuth = class {
1581
1606
 
1582
1607
  // ../sdk/src/client.ts
1583
1608
  var DEFAULT_API_URL = "https://api.ambasada.dev";
1609
+ var isBinding = (v) => typeof v === "object" && v !== null && typeof v.fetch === "function";
1584
1610
  function processEnv() {
1585
1611
  const p = globalThis.process;
1586
1612
  return p?.env ?? {};
@@ -1596,6 +1622,22 @@ var Ambasada = class {
1596
1622
  const v = this.options.env?.[name];
1597
1623
  return typeof v === "string" ? v : processEnv()[name];
1598
1624
  }
1625
+ /**
1626
+ * How this client fetches: `options.fetch`; else, with an `AMBASADA_API` service binding in `env` (hosted on
1627
+ * ambasada.dev), the API's own URLs go through it and everything else (the hub, the issuer) through `fetch`.
1628
+ */
1629
+ get fetcher() {
1630
+ if (this.options.fetch) return this.options.fetch;
1631
+ const binding = this.options.env?.AMBASADA_API;
1632
+ if (!isBinding(binding)) return (input, init2) => fetch(input, init2);
1633
+ const api2 = new Set(
1634
+ [this.apiUrl, this.env("AMBASADA_API_URL"), this.env("AMBASADA_VILLAGE_API_URL")].filter((u) => !!u && URL.canParse(u)).map((u) => new URL(u).origin)
1635
+ );
1636
+ return (input, init2) => {
1637
+ const request = new Request(input, init2);
1638
+ return api2.has(new URL(request.url).origin) ? binding.fetch(request) : fetch(request);
1639
+ };
1640
+ }
1599
1641
  get apiUrl() {
1600
1642
  return (this.options.apiUrl ?? this.env("AMBASADA_API_URL") ?? this.env("VILLAGE_API_URL") ?? DEFAULT_API_URL).replace(/\/+$/, "");
1601
1643
  }
@@ -1617,7 +1659,7 @@ var Ambasada = class {
1617
1659
  // Silent sign-in: a shell token must be for this app, by id or origin.
1618
1660
  audiences: [this.appOrigin, this.options.app?.id ?? this.env("AMBASADA_APP_ID") ?? this.env("AMBASADA_COMPANY")].filter((a) => !!a),
1619
1661
  hubUrl: this.hubUrl,
1620
- fetch: this.options.fetch
1662
+ fetch: this.fetcher
1621
1663
  }));
1622
1664
  get appOrigin() {
1623
1665
  const url = this.options.app?.origin ?? this.env("AMBASADA_APP_ORIGIN") ?? this.options.auth?.publicUrl ?? this.env("PUBLIC_URL");
@@ -1634,7 +1676,7 @@ var Ambasada = class {
1634
1676
  appSecret: this.options.app?.secret ?? this.env("AMBASADA_APP_SECRET"),
1635
1677
  hubUrl: this.hubUrl,
1636
1678
  origin: this.appOrigin,
1637
- fetch: this.options.fetch
1679
+ fetch: this.fetcher
1638
1680
  }));
1639
1681
  // ── HTTP ──────────────────────────────────────────────────────────────────
1640
1682
  async send(method, path, opts = {}) {
@@ -1648,7 +1690,7 @@ var Ambasada = class {
1648
1690
  headers["content-type"] = "application/json";
1649
1691
  }
1650
1692
  for (const [k, v] of Object.entries(opts.headers ?? {})) if (v !== void 0) headers[k.toLowerCase()] = v;
1651
- const doFetch = this.options.fetch ?? fetch;
1693
+ const doFetch = this.fetcher;
1652
1694
  const attempt = () => {
1653
1695
  const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 45e3);
1654
1696
  const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
@@ -1758,15 +1800,21 @@ function cachedKeys(load, ttlMs = 10 * 6e4) {
1758
1800
  }
1759
1801
 
1760
1802
  // ../sdk/src/define.ts
1803
+ function supportUrl(value) {
1804
+ const v = value?.trim();
1805
+ if (!v) return void 0;
1806
+ if (/^mailto:[^@\s]+@[^@\s]+$/.test(v) || /^https:\/\/\S+$/.test(v)) return v;
1807
+ if (/^[^@\s:/]+@[^@\s]+\.[^@\s]+$/.test(v)) return `mailto:${v}`;
1808
+ return void 0;
1809
+ }
1761
1810
  var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
1762
1811
 
1763
1812
  // ../sdk/src/platform.ts
1764
1813
  var ambasada = new Ambasada();
1765
1814
 
1766
1815
  // src/commands.ts
1767
- import { execFileSync as execFileSync4 } from "node:child_process";
1768
- import { existsSync as existsSync12, readFileSync as readFileSync6 } from "node:fs";
1769
- import { appendFile as appendFile2, mkdir as mkdir6, readdir as readdir3, readFile as readFile8, writeFile as writeFile8 } from "node:fs/promises";
1816
+ import { existsSync as existsSync12, readFileSync as readFileSync7 } from "node:fs";
1817
+ import { appendFile as appendFile2, mkdir as mkdir6, readdir as readdir4, readFile as readFile8, writeFile as writeFile8 } from "node:fs/promises";
1770
1818
  import { basename as basename3, dirname as dirname7, join as join13, resolve as resolvePath5 } from "node:path";
1771
1819
  import { createInterface as createInterface2 } from "node:readline/promises";
1772
1820
 
@@ -1797,54 +1845,79 @@ function findLocalManifest(dir) {
1797
1845
  return candidates.find((c2) => existsSync(c2)) ?? null;
1798
1846
  }
1799
1847
  var APP_WEBHOOK_PATH = "/api/ambasada/webhook";
1848
+ var MINIAPP_DEFAULTS = { age_rating: "4+", kind: "tool" };
1800
1849
  var oneLine = (s, max) => {
1801
1850
  const line = s.replace(/\s+/g, " ").trim();
1802
- return line.length > max ? `${line.slice(0, max - 1).trimEnd()}\u2026` : line;
1851
+ if (line.length <= max) return line;
1852
+ const room = line.slice(0, max - 1);
1853
+ const space = line[max - 1] === " " ? max - 1 : room.lastIndexOf(" ");
1854
+ const cut = space > 0 ? room.slice(0, space) : room;
1855
+ return `${cut.replace(/[\s,;:.\-–—]+$/, "")}\u2026`;
1803
1856
  };
1804
1857
  function appManifest(app, miniapp2 = { entry: "/" }) {
1805
- const description = app.description || app.name;
1858
+ const description = miniapp2.description || app.description || app.name;
1859
+ const support = supportUrl(miniapp2.support ?? app.support);
1806
1860
  const icon = miniapp2.icon ?? "/icon.png";
1807
1861
  const scopes = miniapp2.scopes ?? ["identity", "notifications"];
1808
1862
  return {
1809
1863
  schema: 2,
1810
1864
  id: app.id,
1811
1865
  name: oneLine(app.name, MANIFEST_LIMITS.name),
1812
- subtitle: oneLine(description, MANIFEST_LIMITS.subtitle),
1866
+ subtitle: oneLine(miniapp2.subtitle || description, MANIFEST_LIMITS.subtitle),
1813
1867
  description: description.length > MANIFEST_LIMITS.description ? oneLine(description, MANIFEST_LIMITS.description) : description,
1814
1868
  icon,
1815
1869
  splash: { image: icon, color: "#FFFFFF" },
1816
1870
  accent: miniapp2.accent ?? "#FF002E",
1817
1871
  entry: miniapp2.entry,
1818
- kind: "product",
1872
+ kind: miniapp2.kind ?? MINIAPP_DEFAULTS.kind,
1819
1873
  category: miniapp2.category ?? "other",
1874
+ ...miniapp2.tags?.length ? { tags: miniapp2.tags.slice(0, MANIFEST_LIMITS.tags) } : {},
1820
1875
  scopes,
1821
1876
  ...scopes.includes("notifications") ? { notifications: { webhook: APP_WEBHOOK_PATH } } : {},
1822
1877
  ...scopes.length ? { privacy_url: "/privacy" } : {},
1823
- visibility: miniapp2.visibility ?? "public"
1878
+ ...support ? { support } : {},
1879
+ visibility: miniapp2.visibility ?? "public",
1880
+ age_rating: miniapp2.age_rating ?? MINIAPP_DEFAULTS.age_rating
1824
1881
  };
1825
1882
  }
1826
1883
  var manifestJson = (app, miniapp2) => `${JSON.stringify(appManifest(app, miniapp2), null, 2)}
1827
1884
  `;
1828
1885
  function readCompany(configSource) {
1886
+ const source = stripComments(configSource).replace(MINIAPP_BLOCK, "");
1829
1887
  const str4 = (re) => {
1830
- const m = re.exec(configSource);
1888
+ const m = re.exec(source);
1831
1889
  return m ? JSON.parse(`"${m[1]}"`) : void 0;
1832
1890
  };
1833
1891
  const id = str4(/\bid\s*:\s*"((?:[^"\\]|\\.)*)"/);
1834
1892
  const name = str4(/\bname\s*:\s*"((?:[^"\\]|\\.)*)"/);
1835
1893
  const description = str4(/\bdescription\s*:\s*"((?:[^"\\]|\\.)*)"/) ?? str4(/\bidea\s*:\s*\{\s*title\s*:\s*"((?:[^"\\]|\\.)*)"/);
1836
- return id && name ? { id, name, description: description ?? "" } : null;
1894
+ const support = str4(/\bsupport\s*:\s*"((?:[^"\\]|\\.)*)"/);
1895
+ return id && name ? { id, name, description: description ?? "", ...support ? { support } : {} } : null;
1837
1896
  }
1897
+ var MINIAPP_BLOCK = /\bminiapp\s*:\s*\{([^}]*)\}/;
1898
+ var stripComments = (source) => source.replace(/(?<![:"'`])\/\/[^\n]*|\/\*[\s\S]*?\*\//g, "");
1838
1899
  function readMiniapp(configSource) {
1839
- const block = /\bminiapp\s*:\s*\{([^}]*)\}/.exec(configSource.replace(/(?<!:)\/\/[^\n]*|\/\*[\s\S]*?\*\//g, ""));
1900
+ const block = MINIAPP_BLOCK.exec(stripComments(configSource));
1840
1901
  if (!block) return null;
1841
- const entry2 = /\bentry\s*:\s*["'`]([^"'`]*)["'`]/.exec(block[1])?.[1];
1842
- const icon = /\bicon\s*:\s*["'`]([^"'`]*)["'`]/.exec(block[1])?.[1];
1843
- return entry2 === void 0 ? null : { entry: entry2, ...icon ? { icon } : {} };
1902
+ const text = block[1];
1903
+ const str4 = (key) => new RegExp(`\\b${key}\\s*:\\s*(["'\`])((?:(?!\\1)[^\\\\]|\\\\.)*)\\1`).exec(text)?.[2];
1904
+ const entry2 = str4("entry");
1905
+ if (entry2 === void 0) return null;
1906
+ const out8 = { entry: entry2 };
1907
+ for (const key of ["icon", "category", "accent", "visibility", "age_rating", "kind", "support", "subtitle", "description"]) {
1908
+ const v = str4(key);
1909
+ if (v !== void 0) out8[key] = v;
1910
+ }
1911
+ for (const key of ["tags", "scopes"]) {
1912
+ const list = new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`).exec(text);
1913
+ if (list) out8[key] = [...list[1].matchAll(/(["'`])((?:(?!\1).)*)\1/g)].map((m) => m[2]);
1914
+ }
1915
+ return out8;
1844
1916
  }
1845
1917
 
1846
1918
  // src/project.ts
1847
- import { existsSync as existsSync2 } from "node:fs";
1919
+ import { execFileSync } from "node:child_process";
1920
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
1848
1921
  import { readFile, rm, writeFile } from "node:fs/promises";
1849
1922
  import * as nodeModule from "node:module";
1850
1923
  import { dirname, join as join2, resolve as resolvePath } from "node:path";
@@ -1886,8 +1959,9 @@ function findRoot(from = process.cwd()) {
1886
1959
  dir = parent;
1887
1960
  }
1888
1961
  }
1962
+ var imports = 0;
1889
1963
  async function importTs(file) {
1890
- const url = `${pathToFileURL(file).href}?t=${Date.now()}`;
1964
+ const url = `${pathToFileURL(file).href}?t=${Date.now()}-${++imports}`;
1891
1965
  try {
1892
1966
  return (await import(url)).default;
1893
1967
  } catch (error) {
@@ -1929,6 +2003,19 @@ async function loadIdName(root) {
1929
2003
  const app = readCompany(await readFile(join2(root, CONFIG_FILE), "utf8"));
1930
2004
  return app ? { id: app.id, name: app.name } : null;
1931
2005
  }
2006
+ function npmInstall(root) {
2007
+ const npm = process.env.AMBASADA_NPM ?? (process.platform === "win32" ? "npm.cmd" : "npm");
2008
+ execFileSync(npm, ["install"], { cwd: root, stdio: "inherit" });
2009
+ }
2010
+ function missingDependencies(root) {
2011
+ let pkg;
2012
+ try {
2013
+ pkg = JSON.parse(readFileSync(join2(root, "package.json"), "utf8"));
2014
+ } catch {
2015
+ return [];
2016
+ }
2017
+ return Object.keys({ ...pkg.devDependencies, ...pkg.dependencies }).filter((name) => !existsSync2(join2(root, "node_modules", name, "package.json")));
2018
+ }
1932
2019
 
1933
2020
  // src/config.ts
1934
2021
  var OLD_DEFAULT_API_URL = "https://hackaton.ambasada.id";
@@ -2003,15 +2090,22 @@ async function whoseKey(apiUrl, key) {
2003
2090
  }
2004
2091
  throw new CliError(`${apiUrl}/v1/key answered ${res.status}: is --api-url an Ambasada API?`);
2005
2092
  }
2006
- async function resolveForApp(flags) {
2093
+ async function assertFolderKey(opts) {
2094
+ const owner = opts.owner ?? (await whoseKey(opts.apiUrl, opts.apiKey)).id;
2095
+ if (owner === opts.here) return owner;
2096
+ if (str(opts.flags.company) === owner) {
2097
+ process.stderr.write(`! this folder is ${opts.here}; going on as ${owner} (--company)
2098
+ `);
2099
+ return owner;
2100
+ }
2101
+ throw new CliError(
2102
+ `this folder is ${opts.here}, you are logged in as ${owner}: run \`ambasada login --key \u2026\` here with ${opts.here}'s key (https://developer.ambasada.dev \u2192 ${opts.here} \u2192 Settings \u2192 New key), or pass --company ${owner} to ${opts.action} anyway`
2103
+ );
2104
+ }
2105
+ async function resolveForApp(flags, action = "go on") {
2007
2106
  const r = await resolve(flags);
2008
2107
  if (!r.apiKey || !r.here) return r;
2009
- const owner = r.project ?? (await whoseKey(r.apiUrl, r.apiKey)).id;
2010
- if (owner !== r.here) {
2011
- throw new CliError(
2012
- `this folder is the app ${r.here} (ambasada.config.ts), but you are logged in as ${owner}: run \`ambasada login --key vk_\u2026\` here with ${r.here}'s key (https://developer.ambasada.dev \u2192 ${r.here} \u2192 Settings \u2192 New key)`
2013
- );
2014
- }
2108
+ const owner = await assertFolderKey({ here: r.here, apiUrl: r.apiUrl, apiKey: r.apiKey, flags, action, owner: r.project });
2015
2109
  return { ...r, project: owner };
2016
2110
  }
2017
2111
 
@@ -2254,8 +2348,8 @@ async function startMock(options = {}) {
2254
2348
  }
2255
2349
  if (path === "/dev/app/event" && method === "POST") {
2256
2350
  const b = await body();
2257
- const out7 = await appEvent(String(b.event ?? "ping"), { user: typeof b.user === "string" ? b.user : void 0, webhook: typeof b.webhook === "string" ? b.webhook : void 0 });
2258
- return json2(out7, out7.error ? 502 : 200);
2351
+ const out8 = await appEvent(String(b.event ?? "ping"), { user: typeof b.user === "string" ? b.user : void 0, webhook: typeof b.webhook === "string" ? b.webhook : void 0 });
2352
+ return json2(out8, out8.error ? 502 : 200);
2259
2353
  }
2260
2354
  if (path === "/dev/state") return json2(Object.assign({ company, notifications, events }, ...extensions.map((e) => e.state?.() ?? {})));
2261
2355
  if (!path.startsWith("/v1/")) throw fail("NOT_FOUND", "no such route");
@@ -2359,17 +2453,17 @@ async function startMock(options = {}) {
2359
2453
  }
2360
2454
 
2361
2455
  // src/plugin.ts
2362
- import { existsSync as existsSync4, readFileSync } from "node:fs";
2456
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
2363
2457
  import { createRequire } from "node:module";
2364
2458
  import { dirname as dirname2, isAbsolute, join as join4, resolve as resolvePath2 } from "node:path";
2365
2459
  import { pathToFileURL as pathToFileURL2 } from "node:url";
2366
2460
  var PLATFORM_CONFIG_KEYS = ["id", "name", "product", "addons"];
2367
- var VERSION = true ? "0.8.4" : "0.1.0-dev";
2461
+ var VERSION = true ? "0.8.6" : "0.1.0-dev";
2368
2462
  var shortName = (pkg) => pkg.replace(/^@ambasada\//, "").replace(/^ambasada-(addon-)?/, "");
2369
2463
  var KNOWN_ADDONS = { village: "@ambasada/village" };
2370
2464
  function readJson(file) {
2371
2465
  try {
2372
- return JSON.parse(readFileSync(file, "utf8"));
2466
+ return JSON.parse(readFileSync2(file, "utf8"));
2373
2467
  } catch {
2374
2468
  return null;
2375
2469
  }
@@ -2416,7 +2510,7 @@ async function loadAddon(dir) {
2416
2510
  return registerAddon(pkg.name, dir, { register });
2417
2511
  }
2418
2512
  async function registerAddon(name, dir, plugin) {
2419
- const addon = { name, short: shortName(name), dir, namespaces: [], aliases: [], mocks: [], devHooks: [], deployHooks: [], smoke: [], configKeys: [], overlay: null, skills: {} };
2513
+ const addon = { name, short: shortName(name), dir, namespaces: [], aliases: [], mocks: [], devHooks: [], deployHooks: [], preDeployHooks: [], smoke: [], configKeys: [], overlay: null, skills: {} };
2420
2514
  await plugin.register(apiFor(addon));
2421
2515
  return addon;
2422
2516
  }
@@ -2428,6 +2522,7 @@ function apiFor(addon) {
2428
2522
  mock: (f) => void addon.mocks.push(f),
2429
2523
  dev: (h) => void addon.devHooks.push(h),
2430
2524
  afterDeploy: (h) => void addon.deployHooks.push(h),
2525
+ beforeDeploy: (h) => void addon.preDeployHooks.push(h),
2431
2526
  smoke: (check2) => void addon.smoke.push(check2),
2432
2527
  configKeys: (keys3) => void addon.configKeys.push(...keys3),
2433
2528
  initOverlay: (o) => void (addon.overlay = o),
@@ -2435,7 +2530,7 @@ function apiFor(addon) {
2435
2530
  ui: { c, table, ton, out: (line = "") => process.stdout.write(`${line}
2436
2531
  `) },
2437
2532
  CliError,
2438
- config: { read: readConfig, write: writeConfig, resolve },
2533
+ config: { read: readConfig, write: writeConfig, resolve, assertFolderKey },
2439
2534
  project: { findRoot, load: loadProject, importTs }
2440
2535
  };
2441
2536
  }
@@ -2479,6 +2574,9 @@ var Addons = class _Addons {
2479
2574
  async afterDeploy(ctx) {
2480
2575
  for (const a of this.list) for (const hook of a.deployHooks) await hook(ctx);
2481
2576
  }
2577
+ async beforeDeploy(ctx) {
2578
+ for (const a of this.list) for (const hook of a.preDeployHooks ?? []) await hook(ctx);
2579
+ }
2482
2580
  get smoke() {
2483
2581
  return this.list.flatMap((a) => a.smoke);
2484
2582
  }
@@ -2497,8 +2595,29 @@ function setAddons(a) {
2497
2595
 
2498
2596
  // src/stack.ts
2499
2597
  import { spawn } from "node:child_process";
2500
- import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
2598
+ import { existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
2501
2599
  import { dirname as dirname3, join as join5 } from "node:path";
2600
+ var ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
2601
+ function quietDurableObjects() {
2602
+ let said = false;
2603
+ let inBlock = false;
2604
+ const once = () => {
2605
+ if (said) return null;
2606
+ said = true;
2607
+ return "Durable Objects (the live rooms) don't run under next dev: `npm run preview` runs them; online they just work";
2608
+ };
2609
+ return (line) => {
2610
+ const text = line.replace(ANSI, "");
2611
+ if (/You have defined bindings to the following internal Durable Objects/.test(text)) {
2612
+ inBlock = true;
2613
+ return once();
2614
+ }
2615
+ if (inBlock && /^\s*\t/.test(text)) return null;
2616
+ inBlock = false;
2617
+ if (/A DurableObjectNamespace in the config referenced the class .* but no such Durable Object class is exported/.test(text)) return once();
2618
+ return line;
2619
+ };
2620
+ }
2502
2621
  var STACKS = [
2503
2622
  {
2504
2623
  files: ["next.config.ts", "next.config.mjs", "next.config.js", "open-next.config.ts"],
@@ -2508,7 +2627,8 @@ var STACKS = [
2508
2627
  bin: "next",
2509
2628
  label: "next dev",
2510
2629
  // Next loads .env without overriding what is already in process.env.
2511
- args: (host, port) => ["dev", "--hostname", host, "--port", String(port)]
2630
+ args: (host, port) => ["dev", "--hostname", host, "--port", String(port)],
2631
+ output: quietDurableObjects
2512
2632
  }
2513
2633
  },
2514
2634
  {
@@ -2541,7 +2661,7 @@ function resolveBin(root, pkg, bin2) {
2541
2661
  for (let dir = root; ; dir = dirname3(dir)) {
2542
2662
  const manifest = join5(dir, "node_modules", pkg, "package.json");
2543
2663
  if (existsSync5(manifest)) {
2544
- const json2 = JSON.parse(readFileSync2(manifest, "utf8"));
2664
+ const json2 = JSON.parse(readFileSync3(manifest, "utf8"));
2545
2665
  const rel = typeof json2.bin === "string" ? json2.bin : json2.bin?.[bin2];
2546
2666
  return rel ? join5(dirname3(manifest), rel) : null;
2547
2667
  }
@@ -2560,12 +2680,16 @@ function startApp(root, stack, bin2, url, vars, print, prefix) {
2560
2680
  env: { ...process.env, ...stack.env, ...vars },
2561
2681
  stdio: ["ignore", "pipe", "pipe"]
2562
2682
  });
2683
+ const filter = stack.output?.() ?? ((line) => line);
2563
2684
  for (const stream of [child.stdout, child.stderr]) {
2564
2685
  let rest = "";
2565
2686
  stream.on("data", (chunk) => {
2566
2687
  const lines = (rest + chunk.toString()).split("\n");
2567
2688
  rest = lines.pop() ?? "";
2568
- for (const line of lines) if (line.trim()) print(`${prefix} ${line}`);
2689
+ for (const line of lines) {
2690
+ const shown = line.trim() ? filter(line) : null;
2691
+ if (shown) print(`${prefix} ${shown}`);
2692
+ }
2569
2693
  });
2570
2694
  }
2571
2695
  const ready = (async () => {
@@ -2584,17 +2708,16 @@ function startApp(root, stack, bin2, url, vars, print, prefix) {
2584
2708
  }
2585
2709
 
2586
2710
  // src/check.ts
2587
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
2711
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
2588
2712
  import { dirname as dirname5, join as join8, resolve as resolvePath3 } from "node:path";
2589
2713
 
2590
2714
  // src/miniapp.ts
2591
- import { execFileSync } from "node:child_process";
2592
- import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
2593
- import { appendFile, mkdir as mkdir2, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
2715
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "node:fs";
2716
+ import { appendFile, mkdir as mkdir2, readdir, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
2594
2717
  import { dirname as dirname4, join as join7, relative as relative2 } from "node:path";
2595
2718
 
2596
2719
  // src/template.ts
2597
- import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
2720
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
2598
2721
  import { join as join6, relative } from "node:path";
2599
2722
  import { fileURLToPath } from "node:url";
2600
2723
  var TEMPLATES = ["next", "spa", "worker"];
@@ -2602,17 +2725,17 @@ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".next", ".open-next
2602
2725
  var BINARY = /\.(png|ico|jpe?g|gif|webp|woff2?)$/i;
2603
2726
  var isBinary = (path) => BINARY.test(path);
2604
2727
  function readTemplateDir(dir) {
2605
- const out7 = {};
2728
+ const out8 = {};
2606
2729
  const walk2 = (d) => {
2607
2730
  for (const name of readdirSync2(d)) {
2608
2731
  if (SKIP.has(name)) continue;
2609
2732
  const path = join6(d, name);
2610
2733
  if (statSync2(path).isDirectory()) walk2(path);
2611
- else out7[relative(dir, path).split("\\").join("/")] = readFileSync3(path, isBinary(name) ? "base64" : "utf8");
2734
+ else out8[relative(dir, path).split("\\").join("/")] = readFileSync4(path, isBinary(name) ? "base64" : "utf8");
2612
2735
  }
2613
2736
  };
2614
2737
  walk2(dir);
2615
- return out7;
2738
+ return out8;
2616
2739
  }
2617
2740
  function templateFiles(name) {
2618
2741
  if (typeof define_AMBASADA_TEMPLATES_default !== "undefined") return define_AMBASADA_TEMPLATES_default[name] ?? {};
@@ -2638,7 +2761,7 @@ function appInfo(config) {
2638
2761
  }
2639
2762
  function readRecord(root) {
2640
2763
  try {
2641
- return JSON.parse(readFileSync4(join7(root, GENERATED_RECORD), "utf8"));
2764
+ return JSON.parse(readFileSync5(join7(root, GENERATED_RECORD), "utf8"));
2642
2765
  } catch {
2643
2766
  return null;
2644
2767
  }
@@ -2646,7 +2769,7 @@ function readRecord(root) {
2646
2769
  function isOurs(root, file) {
2647
2770
  const record = readRecord(root);
2648
2771
  if (!record || join7(root, record.path) !== file || !existsSync6(file)) return false;
2649
- return readFileSync4(file, "utf8") === record.content;
2772
+ return readFileSync5(file, "utf8") === record.content;
2650
2773
  }
2651
2774
  function handWritten(root) {
2652
2775
  const file = findLocalManifest(root);
@@ -2654,7 +2777,7 @@ function handWritten(root) {
2654
2777
  }
2655
2778
  function fileEntry(file) {
2656
2779
  try {
2657
- const entry2 = JSON.parse(readFileSync4(file, "utf8")).entry;
2780
+ const entry2 = JSON.parse(readFileSync5(file, "utf8")).entry;
2658
2781
  return typeof entry2 === "string" && entry2.startsWith("/") ? entry2 : "/";
2659
2782
  } catch {
2660
2783
  return "/";
@@ -2667,7 +2790,7 @@ function miniappState(root, miniapp2) {
2667
2790
  }
2668
2791
  function miniappOfSource(root) {
2669
2792
  const file = join7(root, CONFIG_FILE);
2670
- const source = existsSync6(file) ? readFileSync4(file, "utf8") : "";
2793
+ const source = existsSync6(file) ? readFileSync5(file, "utf8") : "";
2671
2794
  return { app: readCompany(source), miniapp: readMiniapp(source) };
2672
2795
  }
2673
2796
  async function ensureGitignored(root, rel) {
@@ -2683,7 +2806,7 @@ async function writeGenerated(root, template, app, miniapp2) {
2683
2806
  const rel = MANIFEST_FILES[template];
2684
2807
  const text = manifestJson(app, miniapp2);
2685
2808
  await mkdir2(dirname4(join7(root, rel)), { recursive: true });
2686
- if (!existsSync6(join7(root, rel)) || readFileSync4(join7(root, rel), "utf8") !== text) await writeFile3(join7(root, rel), text);
2809
+ if (!existsSync6(join7(root, rel)) || readFileSync5(join7(root, rel), "utf8") !== text) await writeFile3(join7(root, rel), text);
2687
2810
  await mkdir2(join7(root, ".ambasada"), { recursive: true });
2688
2811
  await writeFile3(join7(root, GENERATED_RECORD), `${JSON.stringify({ path: rel, content: text }, null, 2)}
2689
2812
  `);
@@ -2704,25 +2827,51 @@ function stateLine(state) {
2704
2827
  if (!state.on) return `mini app: off ${c.dim("(`ambasada miniapp enable` adds one)")}`;
2705
2828
  return `mini app: on at ${c.bold(state.entry ?? "/")}${state.source === "file" ? c.dim(` (manifest: ${state.file})`) : ""}`;
2706
2829
  }
2707
- var COMMENT = "// The Ambasada mini app, at a path of this app: `ambasada dev` and `deploy` serve its manifest from this.";
2830
+ function removeMiniappKey(source) {
2831
+ const key = /^([ \t]*)miniapp\s*:\s*\{[^{}]*\},?[ \t]*(\/\/[^\n]*)?\n/m.exec(source);
2832
+ if (!key) return /\bminiapp\s*:/.test(source) ? null : source;
2833
+ let start = key.index;
2834
+ const before = source.slice(0, start);
2835
+ const ours = COMMENT.map((l) => `${key[1]}${l}
2836
+ `).join("");
2837
+ if (before.endsWith(ours)) start -= ours.length;
2838
+ return source.slice(0, start) + source.slice(key.index + key[0].length);
2839
+ }
2840
+ var COMMENT = [
2841
+ "// The Ambasada mini app, at a path of this app: `ambasada dev` and `deploy` serve its manifest from this.",
2842
+ "// age_rating: 4+, 9+, 12+ or 17+. kind: tool, product, event or assistant. Also: support (an email or URL), tags, subtitle."
2843
+ ];
2844
+ var comment = (indent) => COMMENT.join(`
2845
+ ${indent}`);
2846
+ var WRITTEN_DEFAULTS = `age_rating: "${MINIAPP_DEFAULTS.age_rating}", kind: "${MINIAPP_DEFAULTS.kind}"`;
2708
2847
  function setMiniappEntry(source, entry2) {
2709
2848
  const existing = /(\bminiapp\s*:\s*\{[^}]*?\bentry\s*:\s*)(["'`])[^"'`]*\2/;
2710
- if (existing.test(source)) return source.replace(existing, `$1"${entry2}"`);
2849
+ if (existing.test(source)) {
2850
+ const next = source.replace(existing, `$1"${entry2}"`);
2851
+ return next.replace(/(\bminiapp\s*:\s*\{)([^}]*)\}/, (_all, open, body2) => {
2852
+ const missing = ["age_rating", "kind"].filter((k) => !new RegExp(`\\b${k}\\s*:`).test(body2)).map((k) => `${k}: "${MINIAPP_DEFAULTS[k]}"`);
2853
+ if (!missing.length) return `${open}${body2}}`;
2854
+ const trimmed = body2.replace(/\s*$/, "");
2855
+ const tail = body2.slice(trimmed.length);
2856
+ return `${open}${trimmed}${trimmed.endsWith(",") ? "" : ","} ${missing.join(", ")}${tail || " "}}`;
2857
+ });
2858
+ }
2859
+ const value = `miniapp: { entry: "${entry2}", ${WRITTEN_DEFAULTS} },`;
2711
2860
  const product = /^([ \t]*product\s*:\s*\{[^}]*\})(,?)([^\n]*\n)/m.exec(source);
2712
2861
  if (product) {
2713
2862
  const indent = /^[ \t]*/.exec(product[1])[0];
2714
2863
  const line = `${product[1]},${product[3]}`;
2715
- return `${source.slice(0, product.index)}${line}${indent}${COMMENT}
2716
- ${indent}miniapp: { entry: "${entry2}" },
2864
+ return `${source.slice(0, product.index)}${line}${indent}${comment(indent)}
2865
+ ${indent}${value}
2717
2866
  ${source.slice(product.index + product[0].length)}`;
2718
2867
  }
2719
2868
  const close = /\n([ \t]*)\}\)/.exec(source.slice(source.indexOf("defineApp(")));
2720
- if (!source.includes("defineApp(") || !close) throw new CliError(`could not find defineApp({ \u2026 }) in ${CONFIG_FILE}: add \`miniapp: { entry: "${entry2}" }\` to it by hand`);
2869
+ if (!source.includes("defineApp(") || !close) throw new CliError(`could not find defineApp({ \u2026 }) in ${CONFIG_FILE}: add \`miniapp: { entry: "${entry2}", ${WRITTEN_DEFAULTS} }\` to it by hand`);
2721
2870
  const at = source.indexOf("defineApp(") + close.index;
2722
2871
  const body = source.slice(0, at).trimEnd();
2723
2872
  return `${body}${body.endsWith(",") || body.endsWith("{") ? "" : ","}
2724
- ${COMMENT}
2725
- miniapp: { entry: "${entry2}" },${source.slice(at)}`;
2873
+ ${comment(" ")}
2874
+ ${value}${source.slice(at)}`;
2726
2875
  }
2727
2876
  async function patch(root, rel, done, apply, manual, placed) {
2728
2877
  const file = join7(root, rel);
@@ -2811,11 +2960,100 @@ void startInApp(() => location.reload());
2811
2960
  return;
2812
2961
  }
2813
2962
  if (existsSync6(join7(root, web, `${dir}.html`)) || existsSync6(join7(root, web, dir, "index.html"))) {
2814
- if (!readFileSync4(join7(root, web, existsSync6(join7(root, web, `${dir}.html`)) ? `${dir}.html` : `${dir}/index.html`), "utf8").includes("in-app.js")) throw new CliError(`${web}/${dir}.html is a page already: pick another --entry`);
2963
+ if (!readFileSync5(join7(root, web, existsSync6(join7(root, web, `${dir}.html`)) ? `${dir}.html` : `${dir}/index.html`), "utf8").includes("in-app.js")) throw new CliError(`${web}/${dir}.html is a page already: pick another --entry`);
2815
2964
  return;
2816
2965
  }
2817
2966
  await write("page.html", `${web}/${dir}.html`);
2818
2967
  }
2968
+ var without = (text, ...parts) => {
2969
+ let next = text;
2970
+ for (const part of parts) {
2971
+ if (!next.includes(part)) return null;
2972
+ next = next.replace(part, "");
2973
+ }
2974
+ return next;
2975
+ };
2976
+ function entryPieces(template, entry2) {
2977
+ const dir = entry2.slice(1);
2978
+ if (template === "next") {
2979
+ if (entry2 !== "/") return { files: ["layout.tsx", "page.tsx", "in-app.tsx"].map((f) => `app/${dir}/${f}`), hooks: [] };
2980
+ const body = "<body>\n {children}\n <InApp />\n </body>";
2981
+ return {
2982
+ files: ["app/in-app.tsx"],
2983
+ hooks: [{ file: "app/layout.tsx", what: "<InApp /> in <body>", present: (t) => /<InApp\b/.test(t), undo: (t) => without(t, 'import { InApp } from "./in-app.tsx";\n')?.replace(body, "<body>{children}</body>") ?? null }]
2984
+ };
2985
+ }
2986
+ if (template === "spa") {
2987
+ if (entry2 === "/") {
2988
+ return {
2989
+ files: ["src/in-app/shell.ts"],
2990
+ hooks: [
2991
+ {
2992
+ file: "src/main.tsx",
2993
+ what: "startInApp(\u2026) at the end",
2994
+ present: (t) => /startInApp/.test(t),
2995
+ undo: (t) => {
2996
+ const next = without(t, 'import { startInApp } from "./in-app/shell.ts";\n', "\n// Inside the Ambasada app: drop the splash and sign in silently (src/in-app/shell.ts). On the web: a no-op.\nvoid startInApp(() => location.reload());\n");
2997
+ return next === null ? null : `${next.trimEnd()}
2998
+ `;
2999
+ }
3000
+ }
3001
+ ]
3002
+ };
3003
+ }
3004
+ return {
3005
+ files: ["src/in-app/shell.ts", "src/in-app/InApp.tsx"],
3006
+ hooks: [
3007
+ {
3008
+ file: "src/main.tsx",
3009
+ what: "<InApp /> when isInApp(location.pathname)",
3010
+ present: (t) => /isInApp/.test(t),
3011
+ undo: (t) => without(t, 'import { InApp, isInApp } from "./in-app/InApp.tsx";\n')?.replace("{isInApp(location.pathname) ? <InApp /> : <App />}", "<App />") ?? null
3012
+ }
3013
+ ]
3014
+ };
3015
+ }
3016
+ const web = staticDir("worker");
3017
+ if (entry2 !== "/") return { files: [`${web}/in-app.js`, `${web}/${dir}.html`], hooks: [] };
3018
+ const tags = ' <!-- The mini app (`miniapp.entry` in ambasada.config.ts): @ambasada/miniapp as a <script>, then web/in-app.js. -->\n <script src="https://hub.ambasada.id/miniapp.js" defer></script>\n <script src="/in-app.js" defer></script>\n';
3019
+ return { files: [`${web}/in-app.js`], hooks: [{ file: `${web}/index.html`, what: "the miniapp.js and in-app.js <script> tags", present: (t) => /in-app\.js/.test(t), undo: (t) => without(t, tags) }] };
3020
+ }
3021
+ function leftoverOf(root, template, from, to) {
3022
+ const old = entryPieces(template, from);
3023
+ const now = to === null ? { files: [], hooks: [] } : entryPieces(template, to);
3024
+ const files2 = old.files.filter((f) => !now.files.includes(f) && existsSync6(join7(root, f)));
3025
+ const hooks = old.hooks.filter((h) => !now.hooks.some((n) => n.file === h.file && n.what === h.what)).filter((h) => existsSync6(join7(root, h.file)) && h.present(readFileSync5(join7(root, h.file), "utf8"))).map(({ file, what }) => ({ file, what }));
3026
+ const inApp = join7(root, "src/in-app/InApp.tsx");
3027
+ const constant = template === "spa" && to !== null && from !== "/" && to !== "/" && existsSync6(inApp) && readFileSync5(inApp, "utf8").includes(`export const IN_APP = "${from}";`) ? "src/in-app/InApp.tsx" : null;
3028
+ return { files: files2, hooks, inAppConstant: constant };
3029
+ }
3030
+ async function removeLeftover(root, template, from, to, left) {
3031
+ const removed = [];
3032
+ const changed = [];
3033
+ const manual = [];
3034
+ for (const f of left.files) {
3035
+ await rm2(join7(root, f), { force: true });
3036
+ removed.push(f);
3037
+ const parent = dirname4(join7(root, f));
3038
+ if (parent !== root && existsSync6(parent) && !(await readdir(parent)).length) await rm2(parent, { recursive: true, force: true });
3039
+ }
3040
+ for (const h of left.hooks) {
3041
+ const hook = entryPieces(template, from).hooks.find((x) => x.file === h.file && x.what === h.what);
3042
+ const text = await readFile3(join7(root, h.file), "utf8");
3043
+ const next = hook.undo(text);
3044
+ if (next === null || hook.present(next)) manual.push(`${h.file}: take out ${h.what}`);
3045
+ else {
3046
+ await writeFile3(join7(root, h.file), next);
3047
+ changed.push(h.file);
3048
+ }
3049
+ }
3050
+ if (left.inAppConstant) {
3051
+ const file = join7(root, left.inAppConstant);
3052
+ await writeFile3(file, (await readFile3(file, "utf8")).replace(`export const IN_APP = "${from}";`, `export const IN_APP = "${to}";`));
3053
+ changed.push(left.inAppConstant);
3054
+ }
3055
+ return { removed, changed, manual };
3056
+ }
2819
3057
  function miniappSpec(deps, flags, fallback) {
2820
3058
  const given = typeof flags["miniapp-spec"] === "string" ? flags["miniapp-spec"] : process.env.AMBASADA_MINIAPP_SPEC;
2821
3059
  if (given) return given;
@@ -2872,23 +3110,99 @@ async function miniappCommand(args, flags, releaseLine) {
2872
3110
  else out(stateLine(state));
2873
3111
  return;
2874
3112
  }
2875
- if (sub !== "enable") throw new CliError("usage: ambasada miniapp [enable [--entry /app]]");
3113
+ if (sub === "disable") return disableInFolder(root, flags);
3114
+ if (sub !== "enable") throw new CliError("usage: ambasada miniapp [enable [--entry /app] | disable [--force]]");
3115
+ return enableInFolder(root, flags, releaseLine);
3116
+ }
3117
+ async function disableInFolder(root, flags) {
3118
+ const { miniapp: miniapp2 } = miniappOfSource(root);
3119
+ const state = miniappState(root, miniapp2);
3120
+ if (state.source === "file") throw new CliError(`the mini app is on from ${state.file} (yours, it wins): delete that file to turn it off`);
3121
+ const entry2 = miniapp2?.entry ?? await movedFrom(root);
3122
+ if (!entry2) return void out(stateLine(state));
3123
+ const template = templateOf(root);
3124
+ const changed = [];
3125
+ if (miniapp2) {
3126
+ const configFile = join7(root, CONFIG_FILE);
3127
+ const next = removeMiniappKey(await readFile3(configFile, "utf8"));
3128
+ if (next === null) throw new CliError(`could not take \`miniapp: { \u2026 }\` out of ${CONFIG_FILE}: delete it by hand, then run \`ambasada miniapp disable\` again`);
3129
+ await writeFile3(configFile, next);
3130
+ changed.push(CONFIG_FILE);
3131
+ const manifest = join7(root, MANIFEST_FILES[template]);
3132
+ if (isOurs(root, manifest)) {
3133
+ await rm2(manifest, { force: true });
3134
+ await rm2(join7(root, GENERATED_RECORD), { force: true });
3135
+ }
3136
+ }
3137
+ const left = leftoverOf(root, template, entry2, null);
3138
+ const keep = entry2 !== "/" && !flags.force;
3139
+ const undone = keep ? { removed: [], changed: [], manual: [] } : await removeLeftover(root, template, entry2, null, left);
3140
+ await rememberMove(root, keep && left.files.length + left.hooks.length ? entry2 : null);
3141
+ out(`${c.green("\u2713")} mini app off ${c.dim(`(was at ${entry2}): from the next \`ambasada deploy\`, a website, not in the Ambasada app`)}`);
3142
+ for (const f of [...changed, ...undone.changed]) out(` ${c.dim("changed")} ${f}`);
3143
+ for (const f of undone.removed) out(` ${c.dim("removed")} ${f}`);
3144
+ for (const m of undone.manual) out(c.yellow(`! by hand: ${m}`));
3145
+ if (keep && left.files.length + left.hooks.length) {
3146
+ out(c.yellow(`! the pages at ${entry2} stay (move what you built, then \`ambasada miniapp disable --force\` removes them):`));
3147
+ for (const f of left.files) out(` ${f}`);
3148
+ for (const h of left.hooks) out(` ${h.file}: ${h.what}`);
3149
+ }
3150
+ if (template !== "worker") out(c.dim(" @ambasada/miniapp stays in package.json (`npm uninstall @ambasada/miniapp` once nothing imports it)"));
3151
+ }
3152
+ var MOVED_FROM = "miniapp_moved_from";
3153
+ var projectFile = (root) => join7(root, ".ambasada", "project.json");
3154
+ async function movedFrom(root) {
3155
+ try {
3156
+ const v = JSON.parse(await readFile3(projectFile(root), "utf8"))[MOVED_FROM];
3157
+ return typeof v === "string" ? v : null;
3158
+ } catch {
3159
+ return null;
3160
+ }
3161
+ }
3162
+ async function rememberMove(root, from) {
3163
+ if (!existsSync6(projectFile(root)) || await movedFrom(root) === from) return;
3164
+ const info = JSON.parse(await readFile3(projectFile(root), "utf8"));
3165
+ if (from) info[MOVED_FROM] = from;
3166
+ else delete info[MOVED_FROM];
3167
+ await writeFile3(projectFile(root), `${JSON.stringify(info, null, 2)}
3168
+ `);
3169
+ }
3170
+ async function enableInFolder(root, flags, releaseLine) {
3171
+ const { miniapp: miniapp2 } = miniappOfSource(root);
2876
3172
  const current2 = miniappState(root, miniapp2);
2877
3173
  if (current2.source === "file") throw new CliError(`the mini app is on already, from ${current2.file} (yours, it wins): change its "entry" there`);
2878
3174
  const entry2 = typeof flags.entry === "string" ? flags.entry : miniapp2?.entry ?? DEFAULT_ENTRY;
2879
- const moved = miniapp2 && miniapp2.entry !== entry2 ? miniapp2.entry : null;
3175
+ const recorded = await movedFrom(root);
3176
+ const moved = miniapp2 && miniapp2.entry !== entry2 ? miniapp2.entry : recorded && recorded !== entry2 ? recorded : null;
2880
3177
  const done = await enableMiniapp(root, entry2, flags, { releaseLine });
2881
3178
  if (done.installNeeded && existsSync6(join7(root, "node_modules")) && !flags["no-install"]) {
2882
3179
  out(c.dim("npm install \u2026"));
2883
3180
  try {
2884
- execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
3181
+ npmInstall(root);
2885
3182
  done.installNeeded = false;
2886
3183
  } catch {
2887
3184
  out(c.yellow("! npm install failed: run it yourself (@ambasada/miniapp)"));
2888
3185
  }
2889
3186
  }
2890
3187
  printEnabled(done, root);
2891
- if (moved) out(c.yellow(`! the entry moved from ${moved}: move what you built there, and delete the old in-app files if you don't need them`));
3188
+ if (!moved) return rememberMove(root, null);
3189
+ const template = templateOf(root);
3190
+ const left = leftoverOf(root, template, moved, entry2);
3191
+ const total = left.files.length + left.hooks.length + (left.inAppConstant ? 1 : 0);
3192
+ if (!total) return rememberMove(root, null);
3193
+ if (flags.force) {
3194
+ const undone = await removeLeftover(root, template, moved, entry2, left);
3195
+ out(`${c.green("\u2713")} the old entry ${moved} is gone`);
3196
+ for (const f of undone.removed) out(` ${c.dim("removed")} ${f}`);
3197
+ for (const f of undone.changed) out(` ${c.dim("changed")} ${f}`);
3198
+ for (const m of undone.manual) out(c.yellow(`! by hand: ${m}`));
3199
+ return rememberMove(root, null);
3200
+ }
3201
+ await rememberMove(root, moved);
3202
+ out(c.yellow(`! the entry moved from ${moved}; still there from it (move what you built, then \`ambasada miniapp enable --entry ${entry2} --force\` removes them):`));
3203
+ for (const f of left.files) out(` ${f}`);
3204
+ for (const h of left.hooks) out(` ${h.file}: ${h.what}`);
3205
+ if (left.inAppConstant) out(` ${left.inAppConstant}: IN_APP is still "${moved}"`);
2892
3206
  }
2893
3207
  function printEnabled(done, root) {
2894
3208
  out(`${c.green("\u2713")} mini app on at ${c.bold(done.entry)}`);
@@ -2921,7 +3235,7 @@ async function checkLocal(dir, opts) {
2921
3235
  if (state.file) {
2922
3236
  staticRoot = dirname5(dirname5(state.file));
2923
3237
  try {
2924
- manifest = JSON.parse(readFileSync5(state.file, "utf8"));
3238
+ manifest = JSON.parse(readFileSync6(state.file, "utf8"));
2925
3239
  merge2(report, validateManifest(manifest));
2926
3240
  } catch (error) {
2927
3241
  report.problems.push({ code: "manifest_json", message: `${state.file} is not valid JSON: ${error.message}` });
@@ -2938,21 +3252,38 @@ async function checkLocal(dir, opts) {
2938
3252
  const iconPath = typeof manifest?.icon === "string" && manifest.icon.startsWith("/") ? join8(staticRoot, manifest.icon) : null;
2939
3253
  if (iconPath) {
2940
3254
  if (!existsSync7(iconPath)) report.problems.push({ code: "icon_missing", message: `the manifest's icon ${manifest.icon} is not in ${staticRoot}` });
2941
- else merge2(report, checkIcon(readFileSync5(iconPath)));
3255
+ else merge2(report, checkIcon(readFileSync6(iconPath)));
2942
3256
  } else if (manifest && typeof manifest.icon === "string") report.notes.push(`icon ${manifest.icon}: not a local path, not checked`);
2943
3257
  const entry2 = state.entry ?? "/";
2944
3258
  const pages = entry2 === "/" ? [join8(staticRoot, "index.html")] : [join8(staticRoot, `${entry2.slice(1)}.html`), join8(staticRoot, entry2.slice(1), "index.html")];
2945
3259
  const page = pages.find((p) => existsSync7(p));
2946
- if (page) merge2(report, checkHtml(readFileSync5(page, "utf8")));
3260
+ if (page) merge2(report, checkHtml(readFileSync6(page, "utf8")));
2947
3261
  else report.notes.push(`no static ${pages[0]}: the entry page is rendered by the app, checked by \`ambasada check <url>\` once deployed`);
2948
3262
  report.ok = report.problems.length === 0;
2949
3263
  return report;
2950
3264
  }
3265
+ var out2 = (s = "") => process.stdout.write(`${s}
3266
+ `);
3267
+ function printListing(tier, check2, kind) {
3268
+ out2(` ${tier === "listed" || tier === "verified" ? c.green("\u2713") : c.yellow("!")} in the Ambasada app: ${listingLine(tier, check2, kind)}`);
3269
+ if (checkPending(check2)) return;
3270
+ for (const issue of check2?.issues.filter((i) => i.level === "problem") ?? []) out2(c.dim(` ${issue.message}`));
3271
+ }
3272
+ async function platformCheck(apiUrl, apiKey) {
3273
+ try {
3274
+ const res = await fetch(`${apiUrl}/v1/product`, { headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }, signal: AbortSignal.timeout(1e4) });
3275
+ if (!res.ok) return null;
3276
+ const body = await res.json();
3277
+ return body.deployed === false || !body.tier ? null : { tier: body.tier, check: body.check ?? null, kind: body.kind };
3278
+ } catch {
3279
+ return null;
3280
+ }
3281
+ }
2951
3282
 
2952
3283
  // src/storage.ts
2953
3284
  import { createHash } from "node:crypto";
2954
3285
  import { existsSync as existsSync10 } from "node:fs";
2955
- import { mkdtemp, readdir as readdir2, readFile as readFile6, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
3286
+ import { mkdtemp, readdir as readdir3, readFile as readFile6, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
2956
3287
  import { tmpdir } from "node:os";
2957
3288
  import { basename as basename2, extname as extname2, join as join11 } from "node:path";
2958
3289
  import { createInterface } from "node:readline";
@@ -2960,7 +3291,7 @@ import { createInterface } from "node:readline";
2960
3291
  // src/deploy.ts
2961
3292
  import { execFileSync as execFileSync3, spawn as spawn2 } from "node:child_process";
2962
3293
  import { existsSync as existsSync9 } from "node:fs";
2963
- import { mkdir as mkdir4, readdir, readFile as readFile5, rm as rm4, stat, writeFile as writeFile5 } from "node:fs/promises";
3294
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile5, rm as rm4, stat, writeFile as writeFile5 } from "node:fs/promises";
2964
3295
  import { createServer as createServer2 } from "node:net";
2965
3296
  import { basename, dirname as dirname6, extname, join as join10, relative as relative4, resolve as resolvePath4, sep } from "node:path";
2966
3297
 
@@ -2969,10 +3300,10 @@ import { execFileSync as execFileSync2 } from "node:child_process";
2969
3300
  import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync3 } from "node:fs";
2970
3301
  import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
2971
3302
  import { join as join9, relative as relative3 } from "node:path";
2972
- var VERSION2 = true ? "0.8.4" : "0.1.0-dev";
3303
+ var VERSION2 = true ? "0.8.6" : "0.1.0-dev";
2973
3304
  var RELEASE_SPEC = /^\d+\.\d+\.\d+$/.test(VERSION2) ? `^${VERSION2}` : "latest";
2974
3305
  var PROJECT_FILE = join9(".ambasada", "project.json");
2975
- var out2 = (s = "") => process.stdout.write(`${s}
3306
+ var out3 = (s = "") => process.stdout.write(`${s}
2976
3307
  `);
2977
3308
  async function readProjectInfo(root) {
2978
3309
  try {
@@ -3181,12 +3512,12 @@ async function upgrade(_args, flags) {
3181
3512
  const from = info?.devkit ?? "0.7";
3182
3513
  if (checkOnly) {
3183
3514
  if (!uniqueChanges.length && !stale.length) {
3184
- out2(`${c.green("\u2713")} up to date with the devkit ${VERSION2}`);
3515
+ out3(`${c.green("\u2713")} up to date with the devkit ${VERSION2}`);
3185
3516
  return;
3186
3517
  }
3187
- out2(`devkit ${from} \u2192 ${VERSION2}:`);
3188
- for (const line of uniqueChanges) out2(` ${line}`);
3189
- for (const s of stale) out2(` .claude/skills/${s}: removed`);
3518
+ out3(`devkit ${from} \u2192 ${VERSION2}:`);
3519
+ for (const line of uniqueChanges) out3(` ${line}`);
3520
+ for (const s of stale) out3(` .claude/skills/${s}: removed`);
3190
3521
  throw new CliError("`ambasada upgrade` would change the above", 1);
3191
3522
  }
3192
3523
  for (const [file, text] of edits) await writeFile4(file, text);
@@ -3200,7 +3531,7 @@ async function upgrade(_args, flags) {
3200
3531
  }
3201
3532
  for (const s of stale) await rm3(join9(skillsDir, s), { recursive: true, force: true });
3202
3533
  if (!flags["no-install"]) {
3203
- out2(c.dim("npm install \u2026"));
3534
+ out3(c.dim("npm install \u2026"));
3204
3535
  try {
3205
3536
  execFileSync2(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
3206
3537
  } catch {
@@ -3216,28 +3547,29 @@ async function upgrade(_args, flags) {
3216
3547
  await writeFile4(file, text);
3217
3548
  }
3218
3549
  }
3219
- if (village && !addons2.get("village")) out2(c.yellow("! @ambasada/village is in package.json but not installed yet: run `npm install`, then `ambasada upgrade` again for its skill"));
3550
+ 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"));
3220
3551
  await writeProjectInfo(root, nextInfo);
3221
- for (const line of uniqueChanges) out2(`${c.green("\u2713")} ${line}`);
3222
- for (const s of stale) out2(`${c.green("\u2713")} .claude/skills/${s}: removed (now ambasada-village)`);
3552
+ for (const line of uniqueChanges) out3(`${c.green("\u2713")} ${line}`);
3553
+ for (const s of stale) out3(`${c.green("\u2713")} .claude/skills/${s}: removed (now ambasada-village)`);
3223
3554
  const report = await checkLocal(root).catch(() => null);
3224
- for (const p of report?.problems ?? []) out2(`${c.yellow("!")} ${p.message} ${c.dim(`(${p.code}, \`ambasada check\`)`)}`);
3225
- out2(`${c.green("\u2713")} devkit ${from} \u2192 ${VERSION2}`);
3555
+ for (const p of report?.problems ?? []) out3(`${c.yellow("!")} ${p.message} ${c.dim(`(${p.code}, \`ambasada check\`)`)}`);
3556
+ out3(`${c.green("\u2713")} devkit ${from} \u2192 ${VERSION2}`);
3226
3557
  if (before08) {
3227
- out2("");
3228
- out2(`Then ask your coding agent: ${c.bold(`"run the Ambasada upgrade steps for 0.7 \u2192 0.8"`)} (.claude/skills/ambasada-product/reference/upgrading.md)`);
3558
+ out3("");
3559
+ out3(`Then ask your coding agent: ${c.bold(`"run the Ambasada upgrade steps for 0.7 \u2192 0.8"`)} (.claude/skills/ambasada-product/reference/upgrading.md)`);
3229
3560
  }
3230
3561
  }
3231
3562
 
3232
3563
  // src/deploy.ts
3233
- var out3 = (s = "") => process.stdout.write(`${s}
3564
+ var out4 = (s = "") => process.stdout.write(`${s}
3234
3565
  `);
3235
3566
  var LIMITS = { assets: 1e3, assetBytes: 25 * 1024 * 1024 };
3236
3567
  var WEBHOOK_FILES = ["app/api/webhook/route.ts", "app/api/webhook/route.js", "src/app/api/webhook/route.ts", "api/webhook.ts", "src/api/webhook.ts", "functions/api/webhook.ts", "product/api/webhook.ts"];
3237
3568
  var PLATFORM_STORAGE = { d1: "DB", kv: "KV", r2: "FILES" };
3238
3569
  async function deploy(_args, flags) {
3239
3570
  const project = await loadProject();
3240
- const hooks = (url = project.config.product?.url) => addons().afterDeploy({ event: "deploy", project, url, flags, out: out3 });
3571
+ await addons().beforeDeploy({ event: "deploy", project, url: project.config.product?.url, flags, out: out4 });
3572
+ const hooks = (url = project.config.product?.url) => addons().afterDeploy({ event: "deploy", project, url, flags, out: out4 });
3241
3573
  if (flags["agent-only"] || flags["offerings-only"]) {
3242
3574
  if (!addons().get("village")) throw new CliError("--agent-only and --offerings-only belong to the village add-on: `ambasada add village`");
3243
3575
  return hooks();
@@ -3245,56 +3577,70 @@ async function deploy(_args, flags) {
3245
3577
  const dir = findProductDir(project.root);
3246
3578
  if (!dir) {
3247
3579
  if (!project.config.product?.url || !addons().list.length) productDir(project.root);
3248
- out3(c.dim(`no wrangler.jsonc: not hosting ${project.config.product.url}`));
3580
+ out4(c.dim(`no wrangler.jsonc: not hosting ${project.config.product.url}`));
3249
3581
  return hooks();
3250
3582
  }
3251
3583
  const template = detectTemplate(dir);
3252
3584
  if (!flags.own && await isMockApi((await resolve(flags)).apiUrl)) {
3253
- out3(c.dim("ambasada dev does not host apps (deploy without --api-url to go live)"));
3585
+ out4(c.dim("ambasada dev does not host apps (deploy without --api-url to go live)"));
3254
3586
  return hooks();
3255
3587
  }
3256
- const r = flags.own || flags["dry-run"] ? await resolve(flags) : await resolveForApp(flags);
3588
+ const r = flags.own || flags["dry-run"] ? await resolve(flags) : await resolveForApp(flags, "deploy");
3257
3589
  if (!flags.own && !flags["dry-run"] && !r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
3258
3590
  const inApp = await syncManifest(project.root, project.config);
3259
- out3(stateLine(inApp));
3591
+ out4(stateLine(inApp));
3260
3592
  if (!flags["no-build"]) await build(dir, template);
3261
3593
  if (flags.own) {
3262
- out3(c.bold("wrangler deploy (your Cloudflare account)"));
3594
+ out4(c.bold("wrangler deploy (your Cloudflare account)"));
3263
3595
  const output = await run(bin(dir, "wrangler"), ["deploy"], dir, { capture: true });
3264
3596
  const url = /https:\/\/[^\s]+\.workers\.dev|https:\/\/[a-z0-9.-]+\.[a-z]{2,}(?=\s|$)/m.exec(output)?.[0];
3265
3597
  if (!url) throw new CliError("wrangler deployed, but I could not read the URL from its output: run `ambasada link https://\u2026` yourself");
3266
3598
  await link([url], flags);
3267
- return addons().afterDeploy({ event: "deploy", project: await loadProject(project.root), url, flags: { ...flags, "agent-only": true }, out: out3 });
3599
+ return addons().afterDeploy({ event: "deploy", project: await loadProject(project.root), url, flags: { ...flags, "agent-only": true }, out: out4 });
3268
3600
  }
3269
3601
  const built = await bundle(dir, template);
3270
3602
  durableObjects(built.config);
3271
- for (const warning of storageWarnings(built.config)) out3(c.yellow(`! ${warning}`));
3272
- out3(`${c.green("\u2713")} built ${c.bold(template)}: ${built.modules.length} module(s), ${built.assets.length} static file(s)`);
3273
- const smoked = flags["no-smoke"] ? { ok: false, skipped: true, checks: [], ms: null } : await smoke(built, project.config.id);
3603
+ for (const warning of storageWarnings(built.config)) out4(c.yellow(`! ${warning}`));
3604
+ out4(`${c.green("\u2713")} built ${c.bold(template)}: ${built.modules.length} module(s), ${built.assets.length} static file(s)`);
3605
+ const smoked = flags["no-smoke"] ? { ok: false, skipped: true, checks: [], ms: null } : await smoke(built, project.config.id, project.root);
3274
3606
  const metadata = { ...deployMetadata(built, project.root), report: { template, devkit: devkitVersion(), smoke: smoked, git: gitInfo(project.root) } };
3275
3607
  if (flags["dry-run"]) {
3276
- out3(`would POST ${r.apiUrl}/v1/product/deploy ${JSON.stringify({ ...metadata, modules: built.modules.map((m) => m.name), assets: built.assets.length })}`);
3608
+ out4(`would POST ${r.apiUrl}/v1/product/deploy ${JSON.stringify({ ...metadata, modules: built.modules.map((m) => m.name), assets: built.assets.length })}`);
3277
3609
  return hooks();
3278
3610
  }
3279
3611
  const deployment = await upload(r.apiUrl, r.apiKey, built, metadata);
3280
- out3(`${c.green("\u2713")} live at ${c.bold(c.cyan(deployment.url))} ${c.dim(`v${deployment.version}, ${kb(deployment.bundle_bytes)} gzipped`)}`);
3281
- for (const line of resourceLines(deployment.resources)) out3(line);
3282
- if (deployment.offerings_moved?.length) out3(` offerings moved to it: ${deployment.offerings_moved.join(", ")}`);
3283
- if (deployment.webhook) out3(` webhooks \u2192 ${deployment.webhook}`);
3612
+ out4(`${c.green("\u2713")} live at ${c.bold(c.cyan(deployment.url))} ${c.dim(`v${deployment.version}, ${kb(deployment.bundle_bytes)} gzipped`)}`);
3613
+ for (const line of resourceLines(deployment.resources)) out4(line);
3614
+ if (deployment.offerings_moved?.length) out4(` offerings moved to it: ${deployment.offerings_moved.join(", ")}`);
3615
+ if (deployment.webhook) out4(` webhooks \u2192 ${deployment.webhook}`);
3284
3616
  if (deployment.miniapp === false) {
3285
- out3(` ${c.dim("mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)")}`);
3286
- for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out3(c.yellow(`! ${issue.message}`));
3617
+ out4(` ${c.dim("mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)")}`);
3618
+ for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out4(c.yellow(`! ${issue.message}`));
3287
3619
  } else if (deployment.tier) {
3288
- const line = listingLine(deployment.tier, deployment.check);
3289
- out3(` ${deployment.tier === "listed" || deployment.tier === "verified" ? c.green("\u2713") : c.yellow("!")} in the Ambasada app: ${line}`);
3290
- for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out3(c.dim(` ${issue.message}`));
3620
+ printListing(deployment.tier, deployment.check, deployment.kind);
3621
+ if (checkPending(deployment.check)) await waitForCheck(r.apiUrl, r.apiKey);
3291
3622
  }
3292
3623
  if (!await setProductUrl(project.root, deployment.url)) {
3293
- out3(c.yellow(`! add \`product: { url: "${deployment.url}" }\` to ambasada.config.ts`));
3624
+ out4(c.yellow(`! add \`product: { url: "${deployment.url}" }\` to ambasada.config.ts`));
3294
3625
  }
3295
3626
  if (deployment.kind === "project") return;
3296
3627
  await hooks(deployment.url);
3297
3628
  }
3629
+ var CHECK_WAIT = { totalMs: 6e4, everyMs: 5e3 };
3630
+ async function waitForCheck(apiUrl, apiKey, wait = CHECK_WAIT) {
3631
+ out4(` checks: running (up to 5 min); ${c.bold("`ambasada check`")} shows the result`);
3632
+ const deadline = Date.now() + wait.totalMs;
3633
+ while (Date.now() < deadline) {
3634
+ await new Promise((r) => setTimeout(r, wait.everyMs));
3635
+ const state = await platformCheck(apiUrl, apiKey);
3636
+ if (state && !checkPending(state.check)) {
3637
+ printListing(state.tier, state.check, state.kind);
3638
+ return state.check;
3639
+ }
3640
+ }
3641
+ out4(c.dim(" still checking: run `ambasada check` in a few minutes"));
3642
+ return null;
3643
+ }
3298
3644
  async function isMockApi(apiUrl) {
3299
3645
  try {
3300
3646
  const res = await fetch(`${apiUrl}/health`, { signal: AbortSignal.timeout(2e3) });
@@ -3320,19 +3666,27 @@ function detectTemplate(dir) {
3320
3666
  if (has("vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts")) return "spa";
3321
3667
  return "worker";
3322
3668
  }
3669
+ var NEXT_BUILD_ENV = { __NEXT_PROCESSED_ENV: "true" };
3670
+ var EMPTY_NEXT_ENV = "export const production = {};\nexport const development = {};\nexport const test = {};\n";
3671
+ var VITE_BUILD = "const { createBuilder } = await import('vite'); const builder = await createBuilder({ envDir: false }, null); await builder.buildApp();";
3323
3672
  async function build(dir, template) {
3324
3673
  if (template === "next") {
3325
- out3(c.bold("opennextjs-cloudflare build"));
3326
- await run(bin(dir, "opennextjs-cloudflare", "@opennextjs/cloudflare"), ["build"], dir);
3674
+ out4(c.bold("opennextjs-cloudflare build"));
3675
+ await run(bin(dir, "opennextjs-cloudflare", "@opennextjs/cloudflare"), ["build"], dir, { env: NEXT_BUILD_ENV });
3327
3676
  } else if (template === "spa") {
3328
- out3(c.bold("vite build"));
3329
- await run(bin(dir, "vite"), ["build"], dir);
3677
+ out4(c.bold("vite build"));
3678
+ if (bin(dir, "vite").startsWith("npx ")) throw new CliError(`vite is not installed in ${dir}: run \`npm install\``);
3679
+ await run([process.execPath, "--input-type=module", "-e", VITE_BUILD], [], dir);
3680
+ const { file } = await readWranglerConfig(dir);
3681
+ if (dirname6(file) !== dir) for (const local of [".dev.vars", ".env"]) await rm4(join10(dirname6(file), local), { force: true });
3330
3682
  }
3331
3683
  }
3332
3684
  async function bundle(dir, template) {
3333
3685
  const { file: configFile, config } = await readWranglerConfig(dir);
3334
3686
  const outdir = join10(dir, ".ambasada", "deploy");
3335
3687
  await rm4(outdir, { recursive: true, force: true });
3688
+ const nextEnv = join10(dir, ".open-next", "cloudflare", "next-env.mjs");
3689
+ if (template === "next" && existsSync9(nextEnv)) await writeFile5(nextEnv, EMPTY_NEXT_ENV);
3336
3690
  await run(bin(dir, "wrangler"), ["deploy", "--dry-run", "--outdir", outdir], dir, { capture: true });
3337
3691
  const modules = (await walk(outdir)).map((path) => ({ name: relative4(outdir, path).split(sep).join("/"), path })).filter((m) => !m.name.endsWith(".map") && m.name !== "README.md");
3338
3692
  const wanted = config.main ? `${basename(config.main, extname(config.main))}.js` : "index.js";
@@ -3363,9 +3717,9 @@ function deployMetadata(built, root) {
3363
3717
  }
3364
3718
  function durableObjects(config) {
3365
3719
  const bindings = config.durable_objects?.bindings ?? [];
3366
- const out7 = {};
3720
+ const out8 = {};
3367
3721
  if (bindings.length) {
3368
- out7.durable_objects = bindings.map((b) => {
3722
+ out8.durable_objects = bindings.map((b) => {
3369
3723
  if (!b.name || !b.class_name) throw new CliError("every durable_objects.bindings entry in wrangler.jsonc needs a name and a class_name");
3370
3724
  if (b.script_name) throw new CliError(`durable_objects binding ${b.name} points at another Worker (script_name ${b.script_name}): on ambasada.dev a Durable Object lives in its own app's Worker; export ${b.class_name} from this one and drop script_name`);
3371
3725
  return { name: b.name, class_name: b.class_name };
@@ -3373,13 +3727,13 @@ function durableObjects(config) {
3373
3727
  }
3374
3728
  const migrations = config.migrations ?? [];
3375
3729
  if (migrations.length) {
3376
- out7.migrations = migrations.map((m) => {
3730
+ out8.migrations = migrations.map((m) => {
3377
3731
  if (!m || typeof m.tag !== "string" || !m.tag) throw new CliError("every entry of `migrations` in wrangler.jsonc needs a tag");
3378
3732
  const keep = ["tag", "new_sqlite_classes", "new_classes", "renamed_classes", "deleted_classes", "transferred_classes"];
3379
3733
  return Object.fromEntries(keep.filter((k) => m[k] !== void 0).map((k) => [k, m[k]]));
3380
3734
  });
3381
3735
  }
3382
- return out7;
3736
+ return out8;
3383
3737
  }
3384
3738
  function storageWarnings(config) {
3385
3739
  const warnings = [];
@@ -3394,17 +3748,17 @@ function storageWarnings(config) {
3394
3748
  return warnings;
3395
3749
  }
3396
3750
  function localBindings(config) {
3397
- const out7 = {};
3751
+ const out8 = {};
3398
3752
  const d1 = (config.d1_databases ?? []).filter((b) => b.binding);
3399
- if (d1.length) out7.d1_databases = d1.map((b) => ({ binding: b.binding, database_name: b.database_name ?? `local-${b.binding.toLowerCase()}`, database_id: b.database_id ?? `local-${b.binding.toLowerCase()}` }));
3753
+ if (d1.length) out8.d1_databases = d1.map((b) => ({ binding: b.binding, database_name: b.database_name ?? `local-${b.binding.toLowerCase()}`, database_id: b.database_id ?? `local-${b.binding.toLowerCase()}` }));
3400
3754
  const kv2 = (config.kv_namespaces ?? []).filter((b) => b.binding);
3401
- if (kv2.length) out7.kv_namespaces = kv2.map((b) => ({ binding: b.binding, id: b.id ?? `local-${b.binding.toLowerCase()}` }));
3755
+ if (kv2.length) out8.kv_namespaces = kv2.map((b) => ({ binding: b.binding, id: b.id ?? `local-${b.binding.toLowerCase()}` }));
3402
3756
  const r2 = (config.r2_buckets ?? []).filter((b) => b.binding);
3403
- if (r2.length) out7.r2_buckets = r2.map((b) => ({ binding: b.binding, bucket_name: b.bucket_name ?? `local-${b.binding.toLowerCase()}` }));
3757
+ if (r2.length) out8.r2_buckets = r2.map((b) => ({ binding: b.binding, bucket_name: b.bucket_name ?? `local-${b.binding.toLowerCase()}` }));
3404
3758
  const dos = (config.durable_objects?.bindings ?? []).filter((b) => b.name && b.class_name && !b.script_name);
3405
- if (dos.length) out7.durable_objects = { bindings: dos.map((b) => ({ name: b.name, class_name: b.class_name })) };
3406
- if (config.migrations?.length) out7.migrations = config.migrations;
3407
- return out7;
3759
+ if (dos.length) out8.durable_objects = { bindings: dos.map((b) => ({ name: b.name, class_name: b.class_name })) };
3760
+ if (config.migrations?.length) out8.migrations = config.migrations;
3761
+ return out8;
3408
3762
  }
3409
3763
  function resourceLines(r) {
3410
3764
  if (!r) return [];
@@ -3418,9 +3772,9 @@ function resourceLines(r) {
3418
3772
  if (created.has("FILES")) row("FILES", "R2", r.FILES?.name);
3419
3773
  lines.push(c.dim(" env.DB, env.KV, env.FILES in your Worker; `ambasada db migrate` creates your tables"));
3420
3774
  }
3421
- if (r.durable_objects?.length) {
3422
- lines.push(` Durable Objects: ${r.durable_objects.map((d) => `${d.name} (${d.class_name})`).join(", ")}${r.migration_tag ? c.dim(` \xB7 migration ${r.migration_tag}`) : ""}`);
3423
- }
3775
+ const bound = [r.DB && "DB", r.KV && "KV", r.FILES && "FILES"].filter((b) => !!b);
3776
+ const objects = r.durable_objects?.length ? `DO: ${r.durable_objects.map((d) => `${d.name} (${d.class_name})`).join(", ")}${r.migration_tag ? c.dim(` \xB7 migration ${r.migration_tag}`) : ""}` : null;
3777
+ if (bound.length || objects) lines.push(` storage: ${[...bound, ...objects ? [objects] : []].join(" \xB7 ")}`);
3424
3778
  if (r.migrations_applied?.length) lines.push(` Durable Object migrations applied: ${r.migrations_applied.join(", ")}`);
3425
3779
  if (r.missing_permissions?.length) {
3426
3780
  lines.push(c.yellow(`! storage not provisioned: the platform's Cloudflare token lacks ${r.missing_permissions.join(", ")}`));
@@ -3433,7 +3787,7 @@ async function upload(apiUrl, apiKey, built, metadata) {
3433
3787
  form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
3434
3788
  for (const m of built.modules) form.append(`module:${m.name}`, new Blob([await readFile5(m.path)], { type: /\.m?js$/.test(m.name) ? "application/javascript+module" : "" }), m.name);
3435
3789
  for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile5(a.file)]), basename(a.file));
3436
- out3(c.dim(`uploading to ${apiUrl} \u2026`));
3790
+ out4(c.dim(`uploading to ${apiUrl} \u2026`));
3437
3791
  let res;
3438
3792
  try {
3439
3793
  res = await fetch(`${apiUrl}/v1/product/deploy`, { method: "POST", headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }, body: form, signal: AbortSignal.timeout(3e5) });
@@ -3444,7 +3798,7 @@ async function upload(apiUrl, apiKey, built, metadata) {
3444
3798
  if (!res.ok) throw new CliError(`deploy failed: ${body.error?.code ?? res.status}: ${body.error?.message ?? res.statusText}`);
3445
3799
  return body;
3446
3800
  }
3447
- async function smoke(built, company) {
3801
+ async function smoke(built, company, root = built.dir) {
3448
3802
  const started = Date.now();
3449
3803
  const smokeDir = join10(built.dir, ".ambasada", "smoke");
3450
3804
  await rm4(smokeDir, { recursive: true, force: true });
@@ -3469,15 +3823,15 @@ async function smoke(built, company) {
3469
3823
  await writeFile5(configPath2, JSON.stringify(config, null, 2));
3470
3824
  const persistTo = join10(smokeDir, "state");
3471
3825
  if (config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) {
3472
- const root = findRoot(built.dir) ?? built.dir;
3826
+ const root2 = findRoot(built.dir) ?? built.dir;
3473
3827
  try {
3474
- await migrateLocal({ dir: built.dir, root, config: configPath2, persistTo, wrangler: bin(built.dir, "wrangler") });
3828
+ await migrateLocal({ dir: built.dir, root: root2, config: configPath2, persistTo, wrangler: bin(built.dir, "wrangler") });
3475
3829
  } catch (error) {
3476
- out3(c.yellow(`! smoke test: could not apply db/migrations locally: ${error.message.split("\n")[0]}`));
3830
+ out4(c.yellow(`! smoke test: could not apply db/migrations locally: ${error.message.split("\n")[0]}`));
3477
3831
  }
3478
3832
  }
3479
3833
  const port = await freePort();
3480
- out3(c.dim(`smoke test: wrangler dev on :${port} \u2026`));
3834
+ out4(c.dim(`smoke test: wrangler dev on :${port} \u2026`));
3481
3835
  const child = spawn2(bin(built.dir, "wrangler"), ["dev", "--config", join10(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"], {
3482
3836
  cwd: built.dir,
3483
3837
  stdio: ["ignore", "pipe", "pipe"],
@@ -3492,7 +3846,7 @@ async function smoke(built, company) {
3492
3846
  if (home.status >= 500) throw new CliError(`smoke test: GET / answered ${home.status}
3493
3847
  ${(await home.text()).slice(0, 500)}`);
3494
3848
  const checks = [{ method: "GET", path: "/", status: home.status }];
3495
- for (const check2 of [{ method: "GET", path: "/.well-known/ambasada.json" }, ...addons().smoke]) {
3849
+ for (const check2 of [{ method: "GET", path: "/.well-known/ambasada.json" }, ...addons().smoke.filter((s) => !s.when || s.when(root))]) {
3496
3850
  const body = "body" in check2 && check2.body !== void 0 ? JSON.stringify(check2.body) : void 0;
3497
3851
  const res = await fetch(`${base}${check2.path}`, { method: check2.method, headers: body ? { "content-type": "application/json" } : {}, body });
3498
3852
  if (res.status >= 500) throw new CliError(`smoke test: ${check2.method} ${check2.path} answered ${res.status}
@@ -3500,7 +3854,7 @@ ${(await res.text()).slice(0, 500)}`);
3500
3854
  await res.body?.cancel();
3501
3855
  checks.push({ method: check2.method, path: check2.path, status: res.status });
3502
3856
  }
3503
- out3(`${c.green("\u2713")} smoke test: ${checks.map((k) => `${k.method} ${k.path} ${k.status}`).join(", ")}`);
3857
+ out4(`${c.green("\u2713")} smoke test: ${checks.map((k) => `${k.method} ${k.path} ${k.status}`).join(", ")}`);
3504
3858
  return { ok: true, skipped: false, checks, ms: Date.now() - started };
3505
3859
  } finally {
3506
3860
  child.kill("SIGTERM");
@@ -3592,7 +3946,7 @@ async function collectAssets(dir) {
3592
3946
  }
3593
3947
  async function walk(dir) {
3594
3948
  const files2 = [];
3595
- for (const entry2 of await readdir(dir, { withFileTypes: true })) {
3949
+ for (const entry2 of await readdir2(dir, { withFileTypes: true })) {
3596
3950
  const path = join10(dir, entry2.name);
3597
3951
  if (entry2.isDirectory()) files2.push(...await walk(path));
3598
3952
  else if (entry2.isFile()) files2.push(path);
@@ -3607,7 +3961,7 @@ function bin(dir, name, pkg = name) {
3607
3961
  }
3608
3962
  }
3609
3963
  function run(command, args, cwd, opts = {}) {
3610
- const [file, ...pre] = command.split(" ");
3964
+ const [file, ...pre] = typeof command === "string" ? command.split(" ") : command;
3611
3965
  return new Promise((resolveRun, reject) => {
3612
3966
  const child = spawn2(file, [...pre, ...args], { cwd, stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit", env: { ...process.env, WRANGLER_SEND_METRICS: "false", ...opts.env } });
3613
3967
  let output = "";
@@ -3624,7 +3978,7 @@ ${output.slice(-3e3)}` : ""}`));
3624
3978
  var kb = (n) => n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`;
3625
3979
 
3626
3980
  // src/storage.ts
3627
- var out4 = (s = "") => void process.stdout.write(`${s}
3981
+ var out5 = (s = "") => void process.stdout.write(`${s}
3628
3982
  `);
3629
3983
  var str2 = (v) => typeof v === "string" && v ? v : void 0;
3630
3984
  var MIGRATIONS_DIR = join11("db", "migrations");
@@ -3635,11 +3989,11 @@ async function target(flags) {
3635
3989
  const r = await resolve(flags);
3636
3990
  let local = flags.local === true;
3637
3991
  if (!local && await isMockApi(r.apiUrl)) {
3638
- out4(c.dim(`${r.apiUrl} is ambasada dev: using wrangler's local state (--local)`));
3992
+ out5(c.dim(`${r.apiUrl} is ambasada dev: using wrangler's local state (--local)`));
3639
3993
  local = true;
3640
3994
  }
3641
3995
  if (!local && !r.apiKey && findProductDir(findRoot() ?? process.cwd())) {
3642
- out4(c.dim("not logged in: using wrangler's local state (--local); `ambasada login` for the hosted app"));
3996
+ out5(c.dim("not logged in: using wrangler's local state (--local); `ambasada login` for the hosted app"));
3643
3997
  local = true;
3644
3998
  }
3645
3999
  if (local) {
@@ -3649,7 +4003,7 @@ async function target(flags) {
3649
4003
  return { local: true, dir, root, wrangler: bin(dir, "wrangler") };
3650
4004
  }
3651
4005
  if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY), or add --local for wrangler's local state");
3652
- await resolveForApp(flags);
4006
+ await resolveForApp(flags, "use its storage");
3653
4007
  return { local: false, apiUrl: r.apiUrl, apiKey: r.apiKey };
3654
4008
  }
3655
4009
  async function api(t, method, path, body) {
@@ -3701,7 +4055,7 @@ ${output.slice(-1e3)}`);
3701
4055
  async function readMigrations(root) {
3702
4056
  const dir = join11(root, MIGRATIONS_DIR);
3703
4057
  if (!existsSync10(dir)) return [];
3704
- const names2 = (await readdir2(dir)).filter((f) => f.endsWith(".sql")).sort();
4058
+ const names2 = (await readdir3(dir)).filter((f) => f.endsWith(".sql")).sort();
3705
4059
  return Promise.all(names2.map(async (name) => ({ name, sql: await readFile6(join11(dir, name), "utf8") })));
3706
4060
  }
3707
4061
  var sha2562 = (s) => createHash("sha256").update(s).digest("hex");
@@ -3787,26 +4141,26 @@ async function migrateBeforeDev(root, print) {
3787
4141
  }
3788
4142
  function printMigrate(r, dryRun, where) {
3789
4143
  if (dryRun) {
3790
- out4(r.pending.length ? `would apply to ${where}: ${r.pending.join(", ")}` : `${where}: nothing to apply`);
4144
+ out5(r.pending.length ? `would apply to ${where}: ${r.pending.join(", ")}` : `${where}: nothing to apply`);
3791
4145
  } else {
3792
- for (const name of r.applied) out4(`${c.green("\u2713")} applied ${c.bold(name)}`);
3793
- if (!r.applied.length) out4(`${c.green("\u2713")} ${where}: up to date`);
4146
+ for (const name of r.applied) out5(`${c.green("\u2713")} applied ${c.bold(name)}`);
4147
+ if (!r.applied.length) out5(`${c.green("\u2713")} ${where}: up to date`);
3794
4148
  }
3795
- if (r.skipped.length) out4(c.dim(` already applied: ${r.skipped.join(", ")}`));
3796
- if (r.changed.length) out4(c.yellow(`! edited after they were applied (they don't run again; add a new migration instead): ${r.changed.join(", ")}`));
4149
+ if (r.skipped.length) out5(c.dim(` already applied: ${r.skipped.join(", ")}`));
4150
+ if (r.changed.length) out5(c.yellow(`! edited after they were applied (they don't run again; add a new migration instead): ${r.changed.join(", ")}`));
3797
4151
  }
3798
4152
  function printResults(results) {
3799
4153
  results.forEach((r, i) => {
3800
- if (results.length > 1) out4(c.dim(`-- statement ${i + 1}`));
4154
+ if (results.length > 1) out5(c.dim(`-- statement ${i + 1}`));
3801
4155
  if (r.columns.length) {
3802
4156
  const cols = r.columns.map((col, j) => r.columns.indexOf(col) === j ? col : `${col}:${j}`);
3803
- out4(table(r.rows.map((row) => Object.fromEntries(cols.map((col, j) => [col, row[j]]))), cols));
4157
+ out5(table(r.rows.map((row) => Object.fromEntries(cols.map((col, j) => [col, row[j]]))), cols));
3804
4158
  }
3805
4159
  const m = r.meta ?? {};
3806
4160
  const parts = [`${r.rows.length} row${r.rows.length === 1 ? "" : "s"}`];
3807
4161
  if (m.changes) parts.push(`${m.changes} changed`);
3808
4162
  if (typeof m.duration_ms === "number") parts.push(`${Math.round(m.duration_ms * 100) / 100} ms`);
3809
- out4(c.dim(parts.join(" \xB7 ")));
4163
+ out5(c.dim(parts.join(" \xB7 ")));
3810
4164
  });
3811
4165
  }
3812
4166
  async function query(t, sql) {
@@ -3823,7 +4177,7 @@ async function tables(t) {
3823
4177
  }
3824
4178
  async function shell(t, flags) {
3825
4179
  const where = t.local ? "local" : "ambasada.dev";
3826
- out4(`${c.bold("ambasada db shell")} ${c.dim(`(${where}) \xB7 end statements with ; \xB7 .tables \xB7 .schema [table] \xB7 .quit`)}`);
4180
+ out5(`${c.bold("ambasada db shell")} ${c.dim(`(${where}) \xB7 end statements with ; \xB7 .tables \xB7 .schema [table] \xB7 .quit`)}`);
3827
4181
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY, historySize: 200, prompt: "db> " });
3828
4182
  let buffer = "";
3829
4183
  const run2 = async (line) => {
@@ -3831,19 +4185,19 @@ async function shell(t, flags) {
3831
4185
  if (!buffer && trimmed.startsWith(".")) {
3832
4186
  const [cmd, arg] = trimmed.split(/\s+/);
3833
4187
  if (cmd === ".quit" || cmd === ".exit") return rl.close();
3834
- if (cmd === ".tables") return out4(table((await tables(t)).map((x) => ({ table: x.name, rows: x.rows }))));
4188
+ if (cmd === ".tables") return out5(table((await tables(t)).map((x) => ({ table: x.name, rows: x.rows }))));
3835
4189
  if (cmd === ".schema") {
3836
- for (const x of (await tables(t)).filter((x2) => !arg || x2.name === arg)) out4(`${x.sql ?? `-- ${x.name}`};`);
4190
+ for (const x of (await tables(t)).filter((x2) => !arg || x2.name === arg)) out5(`${x.sql ?? `-- ${x.name}`};`);
3837
4191
  return;
3838
4192
  }
3839
- return out4(c.yellow(`unknown command ${cmd}: .tables, .schema [table], .quit`));
4193
+ return out5(c.yellow(`unknown command ${cmd}: .tables, .schema [table], .quit`));
3840
4194
  }
3841
4195
  buffer += `${line}
3842
4196
  `;
3843
4197
  if (!/;\s*$/.test(trimmed)) return;
3844
4198
  const sql = buffer;
3845
4199
  buffer = "";
3846
- if (flags.json) out4(JSON.stringify(await query(t, sql), null, 2));
4200
+ if (flags.json) out5(JSON.stringify(await query(t, sql), null, 2));
3847
4201
  else printResults(await query(t, sql));
3848
4202
  };
3849
4203
  rl.prompt();
@@ -3851,7 +4205,7 @@ async function shell(t, flags) {
3851
4205
  try {
3852
4206
  await run2(line);
3853
4207
  } catch (error) {
3854
- out4(c.red(`\u2717 ${error.message}`));
4208
+ out5(c.red(`\u2717 ${error.message}`));
3855
4209
  }
3856
4210
  rl.setPrompt(buffer ? " > " : "db> ");
3857
4211
  rl.prompt();
@@ -3868,11 +4222,11 @@ async function db(args, flags) {
3868
4222
  const dryRun = flags["dry-run"] === true;
3869
4223
  if (t.local) {
3870
4224
  const r2 = await migrateLocal({ dir: t.dir, root, wrangler: t.wrangler, dryRun });
3871
- if (flags.json) return out4(JSON.stringify(r2, null, 2));
4225
+ if (flags.json) return out5(JSON.stringify(r2, null, 2));
3872
4226
  return printMigrate(r2, dryRun, "local DB");
3873
4227
  }
3874
4228
  const r = await api(t, "POST", "/v1/dev/db/migrate", { json: { migrations, dry_run: dryRun } });
3875
- if (flags.json) return out4(JSON.stringify(r, null, 2));
4229
+ if (flags.json) return out5(JSON.stringify(r, null, 2));
3876
4230
  return printMigrate({ applied: names(r.applied), skipped: names(r.skipped), changed: names(r.changed), pending: names(r.pending) }, dryRun, "the app's DB");
3877
4231
  }
3878
4232
  case "shell":
@@ -3881,19 +4235,19 @@ async function db(args, flags) {
3881
4235
  const sql = rest.join(" ").trim();
3882
4236
  if (!sql) return shell(t, flags);
3883
4237
  const results = await query(t, sql);
3884
- if (flags.json) return out4(JSON.stringify({ results }, null, 2));
4238
+ if (flags.json) return out5(JSON.stringify({ results }, null, 2));
3885
4239
  return printResults(results);
3886
4240
  }
3887
4241
  case "tables": {
3888
4242
  const list = await tables(t);
3889
- if (flags.json) return out4(JSON.stringify({ tables: list }, null, 2));
3890
- return out4(table(list.map((x) => ({ table: x.name, rows: x.rows, columns: x.columns.length ? x.columns.map((col) => col.name).join(", ") : "" }))));
4243
+ if (flags.json) return out5(JSON.stringify({ tables: list }, null, 2));
4244
+ return out5(table(list.map((x) => ({ table: x.name, rows: x.rows, columns: x.columns.length ? x.columns.map((col) => col.name).join(", ") : "" }))));
3891
4245
  }
3892
4246
  case "migrations": {
3893
4247
  const applied = t.local ? ((await localQuery(t, `${CREATE_MIGRATIONS}
3894
4248
  SELECT name, applied_at FROM ${MIGRATIONS_TABLE} ORDER BY id;`))[1]?.rows ?? []).map((r) => ({ name: r[0], applied_at: r[1] })) : (await api(t, "GET", "/v1/dev/db/migrations")).applied ?? [];
3895
- if (flags.json) return out4(JSON.stringify({ applied }, null, 2));
3896
- return out4(table(applied, ["name", "applied_at"]));
4249
+ if (flags.json) return out5(JSON.stringify({ applied }, null, 2));
4250
+ return out5(table(applied, ["name", "applied_at"]));
3897
4251
  }
3898
4252
  default:
3899
4253
  throw new CliError(`usage: ambasada db migrate [--dry-run] | shell ["<sql>"] | tables | migrations (--local: wrangler's local DB)`);
@@ -3921,18 +4275,18 @@ async function kv(args, flags) {
3921
4275
  cursor = r.cursor;
3922
4276
  }
3923
4277
  }
3924
- if (flags.json) return out4(JSON.stringify({ keys: keys3 }, null, 2));
3925
- return out4(table(keys3.map((k) => ({ key: k.name, expires: k.expiration ? new Date(k.expiration * 1e3).toISOString() : "" })), ["key", "expires"]));
4278
+ if (flags.json) return out5(JSON.stringify({ keys: keys3 }, null, 2));
4279
+ return out5(table(keys3.map((k) => ({ key: k.name, expires: k.expiration ? new Date(k.expiration * 1e3).toISOString() : "" })), ["key", "expires"]));
3926
4280
  }
3927
4281
  case "get": {
3928
4282
  if (!key) throw new CliError("usage: ambasada kv get <key>");
3929
4283
  if (t.local) return void process.stdout.write(await wrangler(t, ["kv", "key", "get", key, "--binding", PLATFORM_STORAGE.kv, "--text"]));
3930
4284
  const r = await api(t, "GET", kvPath(key));
3931
- if (flags.json) return out4(JSON.stringify(r, null, 2));
4285
+ if (flags.json) return out5(JSON.stringify(r, null, 2));
3932
4286
  if (r.binary) {
3933
- out4(c.dim(`(${r.size} bytes, binary; base64:)`));
4287
+ out5(c.dim(`(${r.size} bytes, binary; base64:)`));
3934
4288
  }
3935
- return out4(r.value ?? "");
4289
+ return out5(r.value ?? "");
3936
4290
  }
3937
4291
  case "put":
3938
4292
  case "set": {
@@ -3941,19 +4295,19 @@ async function kv(args, flags) {
3941
4295
  const fromFile = value.startsWith("@") ? value.slice(1) : null;
3942
4296
  if (t.local) {
3943
4297
  await wrangler(t, ["kv", "key", "put", key, ...fromFile ? ["--path", fromFile] : [value], "--binding", PLATFORM_STORAGE.kv, ...ttl ? ["--ttl", ttl] : []]);
3944
- return out4(`${c.green("\u2713")} ${key} (local)`);
4298
+ return out5(`${c.green("\u2713")} ${key} (local)`);
3945
4299
  }
3946
4300
  const raw = fromFile ? new Uint8Array(await readFile6(fromFile)) : value;
3947
4301
  const r = await api(t, "PUT", `${kvPath(key)}${ttl ? `?ttl=${ttl}` : ""}`, { raw, type: fromFile ? "application/octet-stream" : "text/plain; charset=utf-8" });
3948
- if (flags.json) return out4(JSON.stringify(r, null, 2));
3949
- return out4(`${c.green("\u2713")} ${r.key} ${c.dim(`${r.size} bytes${ttl ? `, expires in ${ttl} s` : ""}`)}`);
4302
+ if (flags.json) return out5(JSON.stringify(r, null, 2));
4303
+ return out5(`${c.green("\u2713")} ${r.key} ${c.dim(`${r.size} bytes${ttl ? `, expires in ${ttl} s` : ""}`)}`);
3950
4304
  }
3951
4305
  case "delete":
3952
4306
  case "rm": {
3953
4307
  if (!key) throw new CliError("usage: ambasada kv delete <key>");
3954
4308
  if (t.local) await wrangler(t, ["kv", "key", "delete", key, "--binding", PLATFORM_STORAGE.kv]);
3955
4309
  else await api(t, "DELETE", kvPath(key));
3956
- return out4(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
4310
+ return out5(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
3957
4311
  }
3958
4312
  default:
3959
4313
  throw new CliError("usage: ambasada kv list [prefix] | get <key> | put <key> <value | @file> [--ttl N] | delete <key> (--local: wrangler's local KV)");
@@ -4020,8 +4374,8 @@ async function files(args, flags) {
4020
4374
  if (!r.truncated || !r.cursor) break;
4021
4375
  cursor = r.cursor;
4022
4376
  }
4023
- if (flags.json) return out4(JSON.stringify({ objects }, null, 2));
4024
- return out4(table(objects.map((o) => ({ key: o.key, size: size(o.size), type: o.content_type ?? "", uploaded: o.uploaded ?? "" })), ["key", "size", "type", "uploaded"]));
4377
+ if (flags.json) return out5(JSON.stringify({ objects }, null, 2));
4378
+ return out5(table(objects.map((o) => ({ key: o.key, size: size(o.size), type: o.content_type ?? "", uploaded: o.uploaded ?? "" })), ["key", "size", "type", "uploaded"]));
4025
4379
  }
4026
4380
  case "put":
4027
4381
  case "upload": {
@@ -4031,11 +4385,11 @@ async function files(args, flags) {
4031
4385
  const type = str2(flags["content-type"]) ?? contentTypeOf(a);
4032
4386
  if (t.local) {
4033
4387
  await wrangler(t, ["r2", "object", "put", `${await localBucket(t)}/${key}`, "--file", a, "--content-type", type]);
4034
- return out4(`${c.green("\u2713")} ${key} (local) \u2192 ${await fileUrl(t, key, flags)}`);
4388
+ return out5(`${c.green("\u2713")} ${key} (local) \u2192 ${await fileUrl(t, key, flags)}`);
4035
4389
  }
4036
4390
  const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await readFile6(a)), type });
4037
- if (flags.json) return out4(JSON.stringify(r, null, 2));
4038
- return out4(`${c.green("\u2713")} ${r.key} ${c.dim(size(r.size))} \u2192 ${c.cyan(r.url ?? await fileUrl(t, key, flags))}`);
4391
+ if (flags.json) return out5(JSON.stringify(r, null, 2));
4392
+ return out5(`${c.green("\u2713")} ${r.key} ${c.dim(size(r.size))} \u2192 ${c.cyan(r.url ?? await fileUrl(t, key, flags))}`);
4039
4393
  }
4040
4394
  case "delete":
4041
4395
  case "rm": {
@@ -4043,11 +4397,11 @@ async function files(args, flags) {
4043
4397
  const key = checkKey(a);
4044
4398
  if (t.local) await wrangler(t, ["r2", "object", "delete", `${await localBucket(t)}/${key}`]);
4045
4399
  else await api(t, "DELETE", `/v1/dev/files/${keyPath(key)}`);
4046
- return out4(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
4400
+ return out5(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
4047
4401
  }
4048
4402
  case "url": {
4049
4403
  if (!a) throw new CliError("usage: ambasada files url <key>");
4050
- return out4(await fileUrl(t, checkKey(a), flags));
4404
+ return out5(await fileUrl(t, checkKey(a), flags));
4051
4405
  }
4052
4406
  default:
4053
4407
  throw new CliError("usage: ambasada files list [prefix] | put <path> [key] [--content-type T] | delete <key> | url <key> (--local: wrangler's local bucket)");
@@ -4061,7 +4415,7 @@ import { existsSync as existsSync11 } from "node:fs";
4061
4415
  import { chmod as chmod2, mkdir as mkdir5, mkdtemp as mkdtemp2, readFile as readFile7, rename, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
4062
4416
  import { tmpdir as tmpdir2 } from "node:os";
4063
4417
  import { join as join12 } from "node:path";
4064
- var out5 = (s = "") => process.stdout.write(`${s}
4418
+ var out6 = (s = "") => process.stdout.write(`${s}
4065
4419
  `);
4066
4420
  var CLOUDFLARED_VERSION = "2026.10.7";
4067
4421
  var CLOUDFLARED_ASSETS = {
@@ -4072,7 +4426,7 @@ var CLOUDFLARED_ASSETS = {
4072
4426
  "win32-x64": { file: "cloudflared-windows-amd64.exe", sha256: "2493dbc168923bb3e59e5e7716f71deced92992eea9acd6e9d74f84ac7751343" }
4073
4427
  };
4074
4428
  var RELEASES = "https://github.com/cloudflare/cloudflared/releases/download";
4075
- async function cloudflaredBin(print = out5) {
4429
+ async function cloudflaredBin(print = out6) {
4076
4430
  if (process.env.AMBASADA_CLOUDFLARED) return process.env.AMBASADA_CLOUDFLARED;
4077
4431
  if (spawnSync("cloudflared", ["--version"], { stdio: "ignore" }).status === 0) return "cloudflared";
4078
4432
  const dest = join12(configDir(), "bin", `cloudflared-${CLOUDFLARED_VERSION}${process.platform === "win32" ? ".exe" : ""}`);
@@ -4129,7 +4483,7 @@ async function call(opts, method, path, body) {
4129
4483
  return { status: res.status, json: await res.json().catch(() => ({})) };
4130
4484
  }
4131
4485
  async function startTunnel(opts) {
4132
- const print = opts.print ?? out5;
4486
+ const print = opts.print ?? out6;
4133
4487
  const bin2 = await cloudflaredBin(print);
4134
4488
  const opened = await call(opts, "POST", "/v1/product/tunnel", { port: opts.port, force: opts.force === true, register: opts.register !== false, ...opts.webhookPath ? { webhook_path: opts.webhookPath } : {} });
4135
4489
  if (opened.status !== 201) throw new CliError(`tunnel refused: ${opened.json.error?.code ?? opened.status}: ${opened.json.error?.message ?? "no answer"}`);
@@ -4210,7 +4564,7 @@ async function waitForPublic(url, timeoutMs = 18e4, streak = 1) {
4210
4564
  }
4211
4565
  }
4212
4566
  async function startTunnelAndWait(opts) {
4213
- const print = opts.print ?? out5;
4567
+ const print = opts.print ?? out6;
4214
4568
  const session = await startTunnel(opts);
4215
4569
  const { info } = session;
4216
4570
  print(c.dim(info.dns_propagating ? `publishing ${info.hostname} on Cloudflare's edge (first time only, up to 2 minutes) \u2026` : `connecting ${info.hostname} \u2026`));
@@ -4240,7 +4594,7 @@ function untilStopped(ended) {
4240
4594
  });
4241
4595
  });
4242
4596
  }
4243
- function printOpened(info, print = out5) {
4597
+ function printOpened(info, print = out6) {
4244
4598
  print(`${c.green("\u2713")} ${c.bold(c.cyan(info.url))} \u2192 ${info.local} ${c.dim("(press Ctrl+C to stop)")}`);
4245
4599
  if (info.registered) {
4246
4600
  print(` the village sends buyers, Checkout and webhooks here until you stop${info.previous_url ? c.dim(` (then back to ${info.previous_url})`) : ""}`);
@@ -4248,7 +4602,7 @@ function printOpened(info, print = out5) {
4248
4602
  if (info.webhook) print(` webhooks \u2192 ${info.webhook.url}`);
4249
4603
  } else print(c.dim(" only exposed: the product URL, offerings and webhook stay where they are (--no-register)"));
4250
4604
  }
4251
- function printClosed(closed, print = out5) {
4605
+ function printClosed(closed, print = out6) {
4252
4606
  if (!closed) print(c.yellow("! could not reach the village to close the tunnel: it closes itself within 5 minutes"));
4253
4607
  else print(`${c.green("\u2713")} tunnel closed${closed.url ? `; product URL back to ${closed.url}` : ""}`);
4254
4608
  }
@@ -4271,39 +4625,39 @@ function webhookPath(root) {
4271
4625
  return root && WEBHOOK_FILES.some((f) => existsSync11(join12(root, f))) ? "/api/webhook" : void 0;
4272
4626
  }
4273
4627
  async function tunnel(_args, flags) {
4274
- const r = await resolveForApp(flags);
4628
+ const r = await resolveForApp(flags, "open the tunnel");
4275
4629
  if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
4276
4630
  const port = await tunnelPort(flags);
4277
4631
  const local = `http://localhost:${port}`;
4278
4632
  try {
4279
4633
  await fetch(local, { signal: AbortSignal.timeout(2e3) });
4280
4634
  } catch {
4281
- out5(c.yellow(`! nothing answers on ${local} yet: start your dev server there (or pass --port)`));
4635
+ out6(c.yellow(`! nothing answers on ${local} yet: start your dev server there (or pass --port)`));
4282
4636
  }
4283
4637
  const root = findRoot();
4284
4638
  if (root && existsSync11(join12(root, ".env")) && /AMBASADA_API_URL=http:\/\/(127\.0\.0\.1|localhost)/.test(await readFile7(join12(root, ".env"), "utf8"))) {
4285
- out5(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"));
4639
+ 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"));
4286
4640
  }
4287
4641
  const register = flags["no-register"] !== true;
4288
4642
  const session = await startTunnelAndWait({ apiUrl: r.apiUrl, apiKey: r.apiKey, port, force: flags.force === true, register, webhookPath: register ? webhookPath(root) : void 0 });
4289
- if (flags.json) out5(JSON.stringify({ ...session.info, token: void 0 }, null, 2));
4643
+ if (flags.json) out6(JSON.stringify({ ...session.info, token: void 0 }, null, 2));
4290
4644
  else printOpened(session.info);
4291
4645
  const reason = await untilStopped(session.ended);
4292
- if (reason) out5(c.yellow(`! ${reason}`));
4293
- else out5("\nclosing the tunnel");
4646
+ if (reason) out6(c.yellow(`! ${reason}`));
4647
+ else out6("\nclosing the tunnel");
4294
4648
  printClosed(await session.close());
4295
4649
  if (reason) throw new CliError(reason);
4296
4650
  }
4297
4651
 
4298
4652
  // src/commands.ts
4299
- var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.4") ? `^${"0.8.4"}` : "latest";
4653
+ var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.6") ? `^${"0.8.6"}` : "latest";
4300
4654
  var DEV_PORT = 8788;
4301
- var out6 = (s = "") => process.stdout.write(`${s}
4655
+ var out7 = (s = "") => process.stdout.write(`${s}
4302
4656
  `);
4303
4657
  var str3 = (v) => typeof v === "string" && v ? v : void 0;
4304
4658
  var json = (flags, value) => {
4305
4659
  if (!flags.json) return false;
4306
- out6(JSON.stringify(value, null, 2));
4660
+ out7(JSON.stringify(value, null, 2));
4307
4661
  return true;
4308
4662
  };
4309
4663
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -4322,8 +4676,8 @@ async function deviceLogin(apiUrl) {
4322
4676
  if (res.status === 404 || res.status === 405 || !(res.headers.get("content-type") ?? "").includes("json")) return null;
4323
4677
  if (!res.ok) return null;
4324
4678
  const start = await res.json();
4325
- out6(`Open ${c.bold(start.verification_uri_complete ?? start.verification_uri)}`);
4326
- out6(`and confirm the code ${c.bold(c.cyan(start.user_code))} with your Ambasada ID.`);
4679
+ out7(`Open ${c.bold(start.verification_uri_complete ?? start.verification_uri)}`);
4680
+ out7(`and confirm the code ${c.bold(c.cyan(start.user_code))} with your Ambasada ID.`);
4327
4681
  let interval = Math.max(1, start.interval ?? 5) * 1e3;
4328
4682
  const deadline = Date.now() + (start.expires_in ?? 600) * 1e3;
4329
4683
  while (Date.now() < deadline) {
@@ -4361,15 +4715,15 @@ async function login(_args, flags) {
4361
4715
  if (device) key = device.token;
4362
4716
  }
4363
4717
  if (!key) {
4364
- out6(`Paste your app's key: ${c.bold("https://developer.ambasada.dev")} \u2192 New app (or Settings \u2192 New key).`);
4718
+ out7(`Paste your app's key: ${c.bold("https://developer.ambasada.dev")} \u2192 New app (or Settings \u2192 New key).`);
4365
4719
  key = await prompt("API key: ");
4366
4720
  }
4367
4721
  if (!key) throw new CliError("no key given");
4368
4722
  const who = await whoseKey(apiUrl, key);
4369
4723
  const { company: _old, apps, ...stored } = await readConfig();
4370
4724
  await writeConfig({ ...stored, apiUrl, apiKey: key, project: who.id, apps: { ...apps, [who.id]: { apiKey: key, apiUrl } } });
4371
- out6(`${c.green("\u2713")} logged in as ${c.bold(who.id)} on ${apiUrl} ${c.dim(`(saved to ${configPath()})`)}`);
4372
- if (here && here !== who.id) out6(c.yellow(`! this folder is the app ${here}, and the key is ${who.id}'s: deploy and storage commands here will refuse it`));
4725
+ out7(`${c.green("\u2713")} logged in as ${c.bold(who.id)} on ${apiUrl} ${c.dim(`(saved to ${configPath()})`)}`);
4726
+ if (here && here !== who.id) out7(c.yellow(`! this folder is the app ${here}, and the key is ${who.id}'s: deploy and storage commands here will refuse it`));
4373
4727
  }
4374
4728
  async function logout(_args = [], flags = {}) {
4375
4729
  const { here } = await resolve(flags);
@@ -4377,15 +4731,15 @@ async function logout(_args = [], flags = {}) {
4377
4731
  const { [here ?? ""]: _here, ...others } = apps;
4378
4732
  const kept = flags.all ? {} : here ? others : apps;
4379
4733
  await writeConfig({ ...rest, ...Object.keys(kept).length ? { apps: kept } : {} });
4380
- out6(flags.all ? "logged out of every app" : here && _here ? `logged out of ${here}` : "logged out");
4734
+ out7(flags.all ? "logged out of every app" : here && _here ? `logged out of ${here}` : "logged out");
4381
4735
  }
4382
4736
  async function whoami(_args, flags) {
4383
4737
  const r = await resolve(flags);
4384
4738
  if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
4385
4739
  const who = await whoseKey(r.apiUrl, r.apiKey);
4386
4740
  if (json(flags, { apiUrl: r.apiUrl, project: who.id, kind: who.kind, folder: r.here ?? null })) return;
4387
- out6(`${c.bold(who.id)} on ${r.apiUrl}`);
4388
- if (r.here && r.here !== who.id) out6(c.yellow(`! this folder is the app ${r.here}: \`ambasada login --key \u2026\` here with its key`));
4741
+ out7(`${c.bold(who.id)} on ${r.apiUrl}`);
4742
+ if (r.here && r.here !== who.id) out7(c.yellow(`! this folder is the app ${r.here}: \`ambasada login --key \u2026\` here with its key`));
4389
4743
  }
4390
4744
  function addonFlag(flags) {
4391
4745
  if (flags.village || str3(flags.for) === "hackaton" || str3(flags.for) === "village") return "village";
@@ -4408,7 +4762,7 @@ async function init(args, flags) {
4408
4762
  const target2 = resolvePath5(args[0] ?? ".");
4409
4763
  const name = str3(flags.name) ?? (args[0] ? basename3(target2) : "My App");
4410
4764
  const id = slug(str3(flags.id) ?? name);
4411
- if (existsSync12(target2) && (await readdir3(target2)).filter((f) => f !== ".git").length && !flags.force) {
4765
+ if (existsSync12(target2) && (await readdir4(target2)).filter((f) => f !== ".git").length && !flags.force) {
4412
4766
  throw new CliError(`${target2} is not empty (use --force to scaffold anyway)`);
4413
4767
  }
4414
4768
  const template = str3(flags.template) ?? TEMPLATES[0];
@@ -4453,18 +4807,29 @@ async function init(args, flags) {
4453
4807
  const entry2 = flags.miniapp ? str3(flags.entry) ?? DEFAULT_ENTRY : addon?.overlay?.miniapp?.entry;
4454
4808
  const miniapp2 = entry2 ? await enableMiniapp(target2, entry2, flags, { template, releaseLine: /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec }) : null;
4455
4809
  await writeProjectInfo(target2, { template, devkit: devkitVersion(), addons: addon ? [addon.short] : [] });
4810
+ let installed = existsSync12(join13(target2, "node_modules"));
4811
+ const missing = installed ? missingDependencies(target2) : [];
4812
+ if (missing.length && !flags["no-install"]) {
4813
+ out7(c.dim(`npm install (${missing.join(", ")}) \u2026`));
4814
+ try {
4815
+ npmInstall(target2);
4816
+ } catch {
4817
+ out7(c.yellow(`! npm install failed: run it yourself (${missing.join(", ")})`));
4818
+ installed = false;
4819
+ }
4820
+ } else if (missing.length) installed = false;
4456
4821
  const rel = args[0] ?? ".";
4457
- out6(`${c.green("\u2713")} ${c.bold(name)} (${id}) scaffolded in ${target2} ${c.dim(`(template ${template}${addon ? `, ${addon.short}` : ""})`)}`);
4458
- out6(miniapp2 ? ` mini app: on at ${c.bold(miniapp2.entry)} ${c.dim(`(${miniapp2.entry === "/" ? "the in-app glue" : "the in-app layout"}, icon.png, the manifest from ambasada.config.ts)`)}` : ` ${stateLine({ on: false, entry: null, source: null, file: null })}`);
4459
- for (const m of miniapp2?.manual ?? []) out6(c.yellow(`! by hand: ${m}`));
4460
- out6("");
4461
- out6("Next:");
4462
- if (rel !== ".") out6(` cd ${rel}`);
4463
- if (!addon) out6(" npm install");
4464
- out6(` npm run dev ${c.dim("# ambasada dev: the mock API on :8788 + your app on http://localhost:3000")}`);
4465
- for (const line of addon?.overlay?.next ?? []) out6(` ${line}`);
4466
- out6("");
4467
- out6(`Then ${c.bold("ambasada login")} (a key from https://developer.ambasada.dev \u2192 New app) and ${c.bold("ambasada deploy")}. See README.md.`);
4822
+ out7(`${c.green("\u2713")} ${c.bold(name)} (${id}) scaffolded in ${target2} ${c.dim(`(template ${template}${addon ? `, ${addon.short}` : ""})`)}`);
4823
+ out7(miniapp2 ? ` mini app: on at ${c.bold(miniapp2.entry)} ${c.dim(`(${miniapp2.entry === "/" ? "the in-app glue" : "the in-app layout"}, icon.png, the manifest from ambasada.config.ts)`)}` : ` ${stateLine({ on: false, entry: null, source: null, file: null })}`);
4824
+ for (const m of miniapp2?.manual ?? []) out7(c.yellow(`! by hand: ${m}`));
4825
+ out7("");
4826
+ out7("Next:");
4827
+ if (rel !== ".") out7(` cd ${rel}`);
4828
+ if (!installed) out7(" npm install");
4829
+ out7(` npm run dev ${c.dim("# ambasada dev: the mock API on :8788 + your app on http://localhost:3000")}`);
4830
+ for (const line of addon?.overlay?.next ?? []) out7(` ${line}`);
4831
+ out7("");
4832
+ out7(`Then ${c.bold("ambasada login")} (a key from https://developer.ambasada.dev \u2192 New app) and ${c.bold("ambasada deploy")}. See README.md.`);
4468
4833
  }
4469
4834
  async function applyAddon(root, template, short, flags, fresh) {
4470
4835
  const pkgName = KNOWN_ADDONS[short] ?? short;
@@ -4476,9 +4841,9 @@ async function applyAddon(root, template, short, flags, fresh) {
4476
4841
  const find = () => findAddons(root).find((dir2) => readJsonName(dir2) === pkgName) ?? besideCli(pkgName);
4477
4842
  let dir = find() ?? packageDir(pkgName, root);
4478
4843
  if (!dir && !flags["no-install"]) {
4479
- out6(c.dim(`npm install ${pkgName} \u2026`));
4844
+ out7(c.dim(`npm install ${pkgName} \u2026`));
4480
4845
  try {
4481
- execFileSync4(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
4846
+ npmInstall(root);
4482
4847
  } catch {
4483
4848
  throw new CliError(`npm install failed in ${root}: install ${pkgName} yourself, then \`ambasada add ${shortName(pkgName)}\``);
4484
4849
  }
@@ -4521,7 +4886,7 @@ ${text.trim()}
4521
4886
  }
4522
4887
  function readJsonName(dir) {
4523
4888
  try {
4524
- return JSON.parse(readFileSync6(join13(dir, "package.json"), "utf8")).name;
4889
+ return JSON.parse(readFileSync7(join13(dir, "package.json"), "utf8")).name;
4525
4890
  } catch {
4526
4891
  return void 0;
4527
4892
  }
@@ -4538,9 +4903,9 @@ async function add(args, flags) {
4538
4903
  if (entry2 && !miniappState(root, miniappOfSource(root).miniapp).on) printEnabled(await enableMiniapp(root, entry2, flags, { template, releaseLine: SDK_VERSION }), root);
4539
4904
  const info = await readProjectInfo(root) ?? { template, devkit: devkitVersion(), addons: [] };
4540
4905
  await writeProjectInfo(root, { ...info, addons: [.../* @__PURE__ */ new Set([...info.addons, addon.short])] });
4541
- out6(`${c.green("\u2713")} ${c.bold(addon.name)} added ${c.dim(`(${CONFIG_FILE}, package.json, .env, .claude/skills; existing files kept, --force overwrites)`)}`);
4542
- for (const line of addon.overlay?.next ?? []) out6(` ${line}`);
4543
- out6(` ambasada ${addon.short} --help`);
4906
+ out7(`${c.green("\u2713")} ${c.bold(addon.name)} added ${c.dim(`(${CONFIG_FILE}, package.json, .env, .claude/skills; existing files kept, --force overwrites)`)}`);
4907
+ for (const line of addon.overlay?.next ?? []) out7(` ${line}`);
4908
+ out7(` ambasada ${addon.short} --help`);
4544
4909
  }
4545
4910
  async function check(args, flags) {
4546
4911
  const target2 = args[0];
@@ -4553,20 +4918,30 @@ async function check(args, flags) {
4553
4918
  const dir = target2 ? resolvePath5(target2) : findRoot();
4554
4919
  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)");
4555
4920
  report = await checkLocal(dir);
4556
- const url = /\bproduct\s*:\s*\{\s*url\s*:\s*["'`](https:\/\/[^"'`]+)["'`]/.exec(existsSync12(join13(dir, CONFIG_FILE)) ? readFileSync6(join13(dir, CONFIG_FILE), "utf8") : "")?.[1];
4921
+ const url = /\bproduct\s*:\s*\{\s*url\s*:\s*["'`](https:\/\/[^"'`]+)["'`]/.exec(existsSync12(join13(dir, CONFIG_FILE)) ? readFileSync7(join13(dir, CONFIG_FILE), "utf8") : "")?.[1];
4557
4922
  if (!report.miniapp?.on && url) report = await checkRemote(url, void 0, { miniapp: false });
4558
4923
  }
4559
4924
  if (!json(flags, report)) {
4560
4925
  const on = report.miniapp?.on !== false;
4561
- out6(on ? `mini app: on at ${c.bold(report.miniapp?.entry ?? "/")}` : stateLine({ on: false, entry: null, source: null, file: null }));
4562
- for (const p of report.problems) out6(`${c.red("\u2717")} ${p.message} ${c.dim(`(${p.code})`)}`);
4563
- for (const w of report.warnings) out6(`${c.yellow("!")} ${w.message} ${c.dim(`(${w.code})`)}`);
4564
- for (const n of report.notes.filter((n2) => !n2.startsWith("mini app: off"))) out6(`${c.dim("\xB7")} ${c.dim(n)}`);
4926
+ out7(on ? `mini app: on at ${c.bold(report.miniapp?.entry ?? "/")}` : stateLine({ on: false, entry: null, source: null, file: null }));
4927
+ for (const p of report.problems) out7(`${c.red("\u2717")} ${p.message} ${c.dim(`(${p.code})`)}`);
4928
+ for (const w of report.warnings) out7(`${c.yellow("!")} ${w.message} ${c.dim(`(${w.code})`)}`);
4929
+ for (const n of report.notes.filter((n2) => !n2.startsWith("mini app: off"))) out7(`${c.dim("\xB7")} ${c.dim(n)}`);
4565
4930
  const warnings = report.warnings.length ? c.dim(` (${report.warnings.length} warning${report.warnings.length === 1 ? "" : "s"})`) : "";
4566
- if (report.ok) out6(`${c.green("\u2713")} ${report.target} passes the ${on ? "mini-app" : "product"} checks${warnings}`);
4931
+ if (report.ok) out7(`${c.green("\u2713")} ${report.target} passes the ${on ? "mini-app" : "product"} checks${warnings}`);
4932
+ if (!target2 && on) await printPlatformCheck(flags);
4567
4933
  }
4568
4934
  if (!report.ok) throw new CliError(`${report.problems.length} problem${report.problems.length === 1 ? "" : "s"} in ${report.target}: fix them and run \`ambasada check\` again`);
4569
4935
  }
4936
+ async function printPlatformCheck(flags) {
4937
+ const r = await resolve(flags).catch(() => null);
4938
+ if (!r?.apiKey || !r.here || r.project !== r.here) return;
4939
+ const state = await platformCheck(r.apiUrl, r.apiKey);
4940
+ if (!state?.check) return;
4941
+ out7(`on ambasada.dev ${c.dim(`(checked ${state.check.checked_at.slice(0, 16).replace("T", " ")} UTC)`)}:`);
4942
+ printListing(state.tier, state.check, state.kind);
4943
+ if (checkPending(state.check)) out7(c.dim(` the webhook ping is retried until ${state.check.pending_until?.slice(11, 16) ?? "5 min after the deploy"} UTC: run \`ambasada check\` again`));
4944
+ }
4570
4945
  var miniapp = (args, flags) => miniappCommand(args, flags, SDK_VERSION);
4571
4946
  async function dev(_args, flags) {
4572
4947
  const port = Number(str3(flags.port) ?? DEV_PORT);
@@ -4587,12 +4962,12 @@ async function dev(_args, flags) {
4587
4962
  company,
4588
4963
  appWebhook: appWebhook ?? void 0,
4589
4964
  extensions: addons().mocks,
4590
- log: (line) => out6(`${c.dim((/* @__PURE__ */ new Date()).toISOString().slice(11, 19))} ${line}`)
4965
+ log: (line) => out7(`${c.dim((/* @__PURE__ */ new Date()).toISOString().slice(11, 19))} ${line}`)
4591
4966
  });
4592
- out6(`${c.bold("ambasada dev")} \xB7 mock Ambasada API on ${c.cyan(mock.url)} ${c.dim("(dashboard: open it in a browser)")}`);
4593
- out6(` app ${c.bold(company)} \xB7 key ${c.bold(DEV_KEY)} \xB7 sign-in as a fake visitor \xB7 notifications to the mock hub`);
4594
- out6(` in your app: AMBASADA_API_URL=${mock.url} AMBASADA_API_KEY=${DEV_KEY}`);
4595
- if (inApp) out6(` ${stateLine(inApp)}`);
4967
+ out7(`${c.bold("ambasada dev")} \xB7 mock Ambasada API on ${c.cyan(mock.url)} ${c.dim("(dashboard: open it in a browser)")}`);
4968
+ out7(` app ${c.bold(company)} \xB7 key ${c.bold(DEV_KEY)} \xB7 sign-in as a fake visitor \xB7 notifications to the mock hub`);
4969
+ out7(` in your app: AMBASADA_API_URL=${mock.url} AMBASADA_API_KEY=${DEV_KEY}`);
4970
+ if (inApp) out7(` ${stateLine(inApp)}`);
4596
4971
  const env2 = {
4597
4972
  AMBASADA_API_URL: mock.url,
4598
4973
  AMBASADA_API_KEY: DEV_KEY,
@@ -4606,33 +4981,33 @@ async function dev(_args, flags) {
4606
4981
  AMBASADA_HUB_URL: mock.url,
4607
4982
  AMBASADA_APP_ORIGIN: new URL(productUrl).origin
4608
4983
  };
4609
- const ctx = { project, mockUrl: mock.url, productUrl, company, env: env2, hints: [], once: flags.once === true, out: out6 };
4984
+ const ctx = { project, mockUrl: mock.url, productUrl, company, env: env2, hints: [], once: flags.once === true, out: out7 };
4610
4985
  await addons().dev(ctx);
4611
- if (!project) out6(c.dim(` (no ${CONFIG_FILE} here: only the mock)`));
4612
- const hints = () => ctx.hints.forEach((h) => out6(h));
4986
+ if (!project) out7(c.dim(` (no ${CONFIG_FILE} here: only the mock)`));
4987
+ const hints = () => ctx.hints.forEach((h) => out7(h));
4613
4988
  if (flags.once) {
4614
4989
  hints();
4615
4990
  await mock.close();
4616
4991
  return;
4617
4992
  }
4618
- if (stack && project) await migrateBeforeDev(project.root, out6);
4619
- const app = stack && bin2 ? startApp(project.root, stack, bin2, new URL(productUrl), env2, out6, c.dim(`${stack.pkg} \u2502`)) : null;
4993
+ if (stack && project) await migrateBeforeDev(project.root, out7);
4994
+ const app = stack && bin2 ? startApp(project.root, stack, bin2, new URL(productUrl), env2, out7, c.dim(`${stack.pkg} \u2502`)) : null;
4620
4995
  if (app) {
4621
- out6(` starting ${c.bold(stack.label)} on ${productUrl} ${c.dim("(your app, with AMBASADA_* pointed at the mock)")}`);
4996
+ out7(` starting ${c.bold(stack.label)} on ${productUrl} ${c.dim("(your app, with AMBASADA_* pointed at the mock)")}`);
4622
4997
  void app.ready.then((ok) => {
4623
4998
  if (ok) {
4624
- out6(`${c.green("\u2713")} app on ${c.cyan(productUrl)}`);
4625
- if (inApp?.on) out6(` inside the Ambasada app: ${c.cyan(new URL(`${inApp.entry ?? "/"}?ambasada_shell=mock`, productUrl).toString())} ${c.dim("(fake shell: host bar insets, silent sign-in)")}`);
4999
+ out7(`${c.green("\u2713")} app on ${c.cyan(productUrl)}`);
5000
+ if (inApp?.on) out7(` inside the Ambasada app: ${c.cyan(new URL(`${inApp.entry ?? "/"}?ambasada_shell=mock`, productUrl).toString())} ${c.dim("(fake shell: host bar insets, silent sign-in)")}`);
4626
5001
  hints();
4627
5002
  if (appWebhook) void playHub(mock, appWebhook);
4628
- } else if (app.child.exitCode === null) out6(c.yellow(`! ${productUrl} does not answer yet`));
5003
+ } else if (app.child.exitCode === null) out7(c.yellow(`! ${productUrl} does not answer yet`));
4629
5004
  });
4630
5005
  } else hints();
4631
5006
  let closing;
4632
5007
  const close = () => closing ??= mock.close();
4633
5008
  const code = await new Promise((resolveStop) => {
4634
5009
  const stop2 = () => {
4635
- out6("\nstopping ambasada dev");
5010
+ out7("\nstopping ambasada dev");
4636
5011
  app?.child.kill("SIGTERM");
4637
5012
  void close().then(() => resolveStop(null));
4638
5013
  };
@@ -4647,7 +5022,7 @@ async function devTunnel(flags) {
4647
5022
  const project = await loadProject();
4648
5023
  const stack = detectStack(project.root);
4649
5024
  if (!stack) throw new CliError("no dev server to start here (no next, vite or wrangler config): run it yourself and `ambasada tunnel --port <port>`");
4650
- const r = await resolveForApp(flags);
5025
+ const r = await resolveForApp(flags, "open the tunnel");
4651
5026
  if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` first (the tunnel is registered with the real API)");
4652
5027
  const productUrl = new URL(str3(flags.product) ?? project.config.product?.url ?? "http://localhost:3000");
4653
5028
  if (!/^(localhost|127\.0\.0\.1|\[::1\])$/.test(productUrl.hostname)) throw new CliError(`product.url is ${productUrl.origin}: \`ambasada dev --tunnel --product http://localhost:3000\``);
@@ -4663,18 +5038,18 @@ async function devTunnel(flags) {
4663
5038
  ...info.webhook ? { AMBASADA_WEBHOOK_SECRET: info.webhook.secret } : {},
4664
5039
  ...info.sign_in ? { AMBASADA_AUTH_CLIENT_ID: info.sign_in.client_id, AMBASADA_AUTH_CLIENT_SECRET: info.sign_in.client_secret, AMBASADA_AUTH_ISSUER: info.sign_in.issuer } : {}
4665
5040
  };
4666
- out6(`${c.bold("ambasada dev --tunnel")} \xB7 ${c.bold(company)} on ${r.apiUrl} ${c.dim("(the real API, no mock)")}`);
5041
+ out7(`${c.bold("ambasada dev --tunnel")} \xB7 ${c.bold(company)} on ${r.apiUrl} ${c.dim("(the real API, no mock)")}`);
4667
5042
  printOpened(info);
4668
- if (!info.sign_in) out6(c.dim(" Sign in with Ambasada is not set up on this API: login keeps the .env values"));
4669
- const app = startApp(project.root, stack, bin2, productUrl, env2, out6, c.dim(`${stack.pkg} \u2502`));
4670
- out6(` starting ${c.bold(stack.label)} on ${productUrl.origin}`);
5043
+ if (!info.sign_in) out7(c.dim(" Sign in with Ambasada is not set up on this API: login keeps the .env values"));
5044
+ const app = startApp(project.root, stack, bin2, productUrl, env2, out7, c.dim(`${stack.pkg} \u2502`));
5045
+ out7(` starting ${c.bold(stack.label)} on ${productUrl.origin}`);
4671
5046
  void app.ready.then((ok) => {
4672
- if (ok) out6(`${c.green("\u2713")} product on ${productUrl.origin}, live at ${c.cyan(info.url)}`);
4673
- else if (app.child.exitCode === null) out6(c.yellow(`! ${productUrl.origin} does not answer yet`));
5047
+ if (ok) out7(`${c.green("\u2713")} product on ${productUrl.origin}, live at ${c.cyan(info.url)}`);
5048
+ else if (app.child.exitCode === null) out7(c.yellow(`! ${productUrl.origin} does not answer yet`));
4674
5049
  });
4675
5050
  const appExit = new Promise((resolveExit) => app.child.once("exit", (code) => resolveExit(`${stack.label} exited${code ? ` with code ${code}` : ""}`)));
4676
5051
  const reason = await untilStopped(Promise.race([session.ended, appExit]));
4677
- out6(reason ? c.yellow(`! ${reason}`) : "\nstopping ambasada dev --tunnel");
5052
+ out7(reason ? c.yellow(`! ${reason}`) : "\nstopping ambasada dev --tunnel");
4678
5053
  app.child.kill("SIGTERM");
4679
5054
  printClosed(await session.close());
4680
5055
  if (reason) throw new CliError(reason);
@@ -4683,7 +5058,7 @@ function manifestWebhook(root, productUrl) {
4683
5058
  const file = findLocalManifest(root);
4684
5059
  if (!file) return null;
4685
5060
  try {
4686
- const m = JSON.parse(readFileSync6(file, "utf8"));
5061
+ const m = JSON.parse(readFileSync7(file, "utf8"));
4687
5062
  const path = m.notifications?.webhook;
4688
5063
  return Array.isArray(m.scopes) && m.scopes.includes("notifications") && typeof path === "string" && path ? new URL(path, productUrl).toString() : null;
4689
5064
  } catch {
@@ -4693,12 +5068,12 @@ function manifestWebhook(root, productUrl) {
4693
5068
  async function playHub(mock, webhook) {
4694
5069
  const ping = await mock.appEvent("ping");
4695
5070
  if (ping.status !== 200) {
4696
- out6(c.yellow(`! ${webhook} answered the signed ping with ${ping.status || ping.error}: it must answer 200 (ambasada.apps.verifyWebhook)`));
5071
+ out7(c.yellow(`! ${webhook} answered the signed ping with ${ping.status || ping.error}: it must answer 200 (ambasada.apps.verifyWebhook)`));
4697
5072
  return;
4698
5073
  }
4699
5074
  const enabled = await mock.appEvent("notifications_enabled");
4700
- if (enabled.status === 200) out6(`${c.green("\u2713")} app webhook: ping and notifications_enabled delivered to ${webhook} ${c.dim(`(token ${enabled.token})`)}`);
4701
- else out6(c.yellow(`! ${webhook} answered notifications_enabled with ${enabled.status || enabled.error}`));
5075
+ if (enabled.status === 200) out7(`${c.green("\u2713")} app webhook: ping and notifications_enabled delivered to ${webhook} ${c.dim(`(token ${enabled.token})`)}`);
5076
+ else out7(c.yellow(`! ${webhook} answered notifications_enabled with ${enabled.status || enabled.error}`));
4702
5077
  }
4703
5078
  async function link(args, flags) {
4704
5079
  const url = args[0];
@@ -4706,29 +5081,29 @@ async function link(args, flags) {
4706
5081
  const root = findRoot();
4707
5082
  if (!root) throw new CliError(`run \`ambasada link\` inside your app (no ${CONFIG_FILE} found)`);
4708
5083
  if (!await setProductUrl(root, url.replace(/\/$/, ""))) throw new CliError(`could not find \`product: { url: "\u2026" }\` in ${CONFIG_FILE}: set it by hand`);
4709
- out6(`${c.green("\u2713")} product.url = ${url}`);
5084
+ out7(`${c.green("\u2713")} product.url = ${url}`);
4710
5085
  try {
4711
5086
  const res = await fetch(new URL("/health", url), { signal: AbortSignal.timeout(5e3) });
4712
- out6(res.ok ? `${c.green("\u2713")} ${url} answers` : c.yellow(`! ${url}/health answered ${res.status}`));
5087
+ out7(res.ok ? `${c.green("\u2713")} ${url} answers` : c.yellow(`! ${url}/health answered ${res.status}`));
4713
5088
  } catch {
4714
- out6(c.yellow(`! ${url} is not reachable from here yet`));
5089
+ out7(c.yellow(`! ${url} is not reachable from here yet`));
4715
5090
  }
4716
- await addons().afterDeploy({ event: "link", project: await loadProject(root), url, flags, out: out6 });
5091
+ await addons().afterDeploy({ event: "link", project: await loadProject(root), url, flags, out: out7 });
4717
5092
  }
4718
5093
  async function hosted(flags) {
4719
- const r = await resolveForApp(flags);
5094
+ const r = await resolveForApp(flags, "change it");
4720
5095
  if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
4721
5096
  return { local: false, apiUrl: r.apiUrl, apiKey: r.apiKey };
4722
5097
  }
4723
5098
  var ENV_NAME = /^[A-Z_][A-Z0-9_]{0,63}$/;
4724
- var PLATFORM_ENV = /^(AMBASADA_(API_KEY|API_URL|APP_|AUTH_|HUB_URL|COMPANY|WEBHOOK_SECRET|VILLAGE_API_URL)|VILLAGE_)/;
5099
+ var PLATFORM_ENV = /^(AMBASADA_(API$|API_KEY|API_URL|APP_|AUTH_|HUB_URL|COMPANY|WEBHOOK_SECRET|VILLAGE_API_URL)|VILLAGE_)/;
4725
5100
  async function env(args, flags) {
4726
5101
  const [sub = "list", name, ...value] = args;
4727
5102
  const t = await hosted(flags);
4728
5103
  if (sub === "list") {
4729
5104
  const res = await api(t, "GET", "/v1/product/env");
4730
5105
  if (json(flags, res)) return;
4731
- out6(table(res.vars.map((v) => ({ name: v.name, type: v.type, set_by: v.platform ? "ambasada" : "you", updated: v.updated_at?.slice(0, 19).replace("T", " ") ?? "" }))));
5106
+ out7(table(res.vars.map((v) => ({ name: v.name, value: v.type === "secret_text" ? "set (hidden)" : "set", set_by: v.platform ? "ambasada" : "you", updated: v.updated_at?.slice(0, 19).replace("T", " ") ?? "" }))));
4732
5107
  return;
4733
5108
  }
4734
5109
  if ((sub === "set" || sub === "unset") && name) {
@@ -4736,14 +5111,14 @@ async function env(args, flags) {
4736
5111
  if (PLATFORM_ENV.test(name)) throw new CliError(`${name} is set by the platform at deploy`);
4737
5112
  if (sub === "unset") {
4738
5113
  const res2 = await api(t, "DELETE", `/v1/product/env/${name}`);
4739
- out6(res2.deleted ? `${c.green("\u2713")} ${name} removed${res2.worker_updated ? " from the live Worker" : ""}` : c.dim(`${name} was not set`));
5114
+ out7(res2.deleted ? `${c.green("\u2713")} ${name} removed${res2.worker_updated ? " from the live Worker" : ""}` : c.dim(`${name} was not set`));
4740
5115
  return;
4741
5116
  }
4742
5117
  let text = value.join(" ");
4743
5118
  if (!text && !process.stdin.isTTY) text = (await new Response(process.stdin).text()).replace(/\n$/, "");
4744
5119
  if (!text) throw new CliError(`usage: ambasada env set ${name} <value> (or pipe the value on stdin)`);
4745
5120
  const res = await api(t, "PUT", `/v1/product/env/${name}`, { json: { value: text } });
4746
- out6(`${c.green("\u2713")} ${name} set ${c.dim(res.worker_updated ? "(live within about 30 s, and on every deploy)" : "(on the next deploy)")}`);
5121
+ out7(`${c.green("\u2713")} ${name} set ${c.dim(res.worker_updated ? "(live within about 30 s, and on every deploy)" : "(on the next deploy)")}`);
4747
5122
  return;
4748
5123
  }
4749
5124
  throw new CliError("usage: ambasada env list | set NAME <value> | unset NAME");
@@ -4755,14 +5130,14 @@ async function logs(_args, flags) {
4755
5130
  const res = await api(t, "GET", "/v1/product/logs");
4756
5131
  if (json(flags, res) && !flags.follow) return;
4757
5132
  if (first) {
4758
- out6(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`"));
4759
- out6(c.dim(`${res.errors.total_24h} error${res.errors.total_24h === 1 ? "" : "s"} in the last 24 h`));
5133
+ 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`"));
5134
+ out7(c.dim(`${res.errors.total_24h} error${res.errors.total_24h === 1 ? "" : "s"} in the last 24 h`));
4760
5135
  }
4761
5136
  for (const e of [...res.errors.recent].reverse()) {
4762
5137
  const key = `${e.at} ${e.message}`;
4763
5138
  if (seen.has(key)) continue;
4764
5139
  seen.add(key);
4765
- out6(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${c.red(String(e.status ?? "error"))} ${e.method ?? ""} ${e.path ?? ""} ${e.message ?? ""}`.trimEnd());
5140
+ out7(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${c.red(String(e.status ?? "error"))} ${e.method ?? ""} ${e.path ?? ""} ${e.message ?? ""}`.trimEnd());
4766
5141
  }
4767
5142
  if (!flags.follow) return;
4768
5143
  await sleep(5e3);
@@ -4770,7 +5145,7 @@ async function logs(_args, flags) {
4770
5145
  }
4771
5146
 
4772
5147
  // src/main.ts
4773
- var VERSION3 = true ? "0.8.4" : "0.1.0-dev";
5148
+ var VERSION3 = true ? "0.8.6" : "0.1.0-dev";
4774
5149
  var MAJOR_MINOR = VERSION3.split(".").slice(0, 2).join(".");
4775
5150
  var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasada
4776
5151
 
@@ -4782,12 +5157,25 @@ var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasad
4782
5157
  deploy [--own] [--no-smoke] [--dry-run] host it at https://<slug>.ambasada.dev
4783
5158
  tunnel [--port] | link <url> | check [url|dir] your laptop at https://<slug>-dev.ambasada.dev; hosted
4784
5159
  elsewhere; the product (and mini-app) checks
4785
- miniapp [enable [--entry /app]] the Ambasada mini app at a path of your app: off by default
5160
+ miniapp [enable [--entry /app] | disable [--force]] the Ambasada mini app at a path of your app: off by default
4786
5161
  env list|set|unset | logs [--follow] the hosted Worker
4787
5162
  db migrate|shell|tables | kv \u2026 | files \u2026 storage (env.DB, env.KV, env.FILES); --local: wrangler's
4788
5163
  add <addon> install an add-on (today: village)
4789
5164
  upgrade [--check] bring an app made with an older CLI up to this one
4790
5165
  `;
5166
+ var COMMAND_HELP = {
5167
+ init: `${c.bold("ambasada init")} [dir]: scaffold an app (one Cloudflare Worker) in dir (default: here)
5168
+
5169
+ --template next|spa|worker next (default): Next.js via OpenNext; spa: Vite + React; worker: a plain Worker
5170
+ --village the Hack A Ton add-on: TON payments, offerings, an agent (a mini app at /)
5171
+ --miniapp [--entry /app] an Ambasada mini app at that path of the app (--entry / for the whole site)
5172
+ --name "My App" display name (default: the folder's name)
5173
+ --id my-app the slug: https://<id>.ambasada.dev (default: from the name)
5174
+ --no-install don't npm install (the add-on, and what it adds)
5175
+ --force scaffold into a folder that isn't empty
5176
+
5177
+ npm create ambasada@latest my-app -- --village is the same as npx ambasada init my-app --village`
5178
+ };
4791
5179
  var VILLAGE_HINT = `
4792
5180
  village \u2026 Hack A Ton add-on: \`ambasada add village\``;
4793
5181
  var FOOTER = `
@@ -4865,6 +5253,11 @@ async function main(argv) {
4865
5253
  const root = findRoot();
4866
5254
  setAddons(await Addons.load(name === "init" ? null : root));
4867
5255
  const addons2 = addons();
5256
+ if (flags.help && name && COMMAND_HELP[name] && !addons2.namespace(name)) {
5257
+ process.stdout.write(`${COMMAND_HELP[name]}
5258
+ `);
5259
+ return 0;
5260
+ }
4868
5261
  if (!name || name === "help" || flags.help && !addons2.namespace(name)) {
4869
5262
  process.stdout.write(`${help(addons2)}
4870
5263
  `);