@ambasada/cli 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ambasada.mjs +862 -470
- package/package.json +4 -4
package/dist/ambasada.mjs
CHANGED
|
@@ -1,7 +1,41 @@
|
|
|
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, and it can live inside the Ambasada app as a mini app with push notifications. 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, 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 # you need 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/`) and pass `npx ambasada check https://YOUR-SITE` ([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; `http://localhost:3000/?ambasada_shell=mock` pretends to be inside\nthe 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 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 at once, 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 # the Ambasada app's checks on the live app\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 product inside the Ambasada app (mini app)\n\nEvery product on `https://<slug>.ambasada.dev` is also a **mini app**: it can open inside the Ambasada\nmobile app, sign people in without a consent screen and send them push notifications. The template does\nthe wiring; this page says what is there and how to use it. Platform spec: docs/MINI-APPS.md in the repo.\n\n## Is it listed?\n\n`ambasada deploy` registers the product with the Ambasada hub and checks it. It prints one line:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\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 page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, the\nfirst page answers in under 3 s, `/api/auth/callback` exists, the notifications webhook answers a signed ping.\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.5/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:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | `public/.well-known/ambasada.json` (`web/` in the worker template) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in 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: when your product 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/?ambasada_shell=mock` to pretend to be inside the\napp: 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/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/miniapp.tsx inside the Ambasada app: ready() and silent sign-in\n app/privacy/page.tsx what you keep about people (the manifest\'s privacy_url)\n app/room/ the live room demo\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\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, notifications in the Ambasada app, a database,\nkey-value, files and live rooms. Everything here is a default; change anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\npublic/.well-known/ambasada.json the app manifest: the Ambasada app lists your app from it (written by `ambasada init` from ambasada.config.ts; icon public/icon.png)\napp/page.tsx, app/notes.tsx the landing page and 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; http://localhost:3000/?ambasada_shell=mock pretends to be inside the Ambasada app.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description (the manifest is written from them).\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## 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 Ambasada app's checks (manifest, icon, viewport); `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 Ambasada app shows it under the name (`ambasada init` writes the manifest from it).\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/account.tsx": `import { ambasada, currentUser } from "../lib/ambasada.ts";
|
|
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 at once, 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.5/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";
|
|
5
|
+
import { Account } from "../account.tsx";
|
|
6
|
+
|
|
7
|
+
/** The site's pages (the route group adds nothing to the URL): the header with sign-in, then the page. */
|
|
8
|
+
export default function SiteLayout({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<Account />
|
|
12
|
+
<main className="page">{children}</main>
|
|
13
|
+
</>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
`, "app/(site)/page.tsx": 'import { currentUser } from "../../lib/ambasada.ts";\nimport { Notes } from "../notes.tsx";\n\n// Who is signed in comes from the request: render per request, not at build time.\nexport const dynamic = "force-dynamic";\n\nexport default async function Home() {\n const user = await currentUser();\n return (\n <>\n <p className="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p className="lead">\n {user ? `Hello, ${user.name ?? "you"}. ` : ""}Notes in a database, a live room, and Sign in with Ambasada. Change anything.\n </p>\n {/* app/api/notes: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. */}\n <Notes />\n <p>\n <a href="/room">Open the live room</a> in two tabs and type.\n </p>\n </>\n );\n}\n', "app/(site)/privacy/page.tsx": `// /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
|
|
17
|
+
// (docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes.
|
|
18
|
+
import Link from "next/link";
|
|
19
|
+
|
|
20
|
+
export const metadata = { title: "Privacy \xB7 My App" };
|
|
21
|
+
|
|
22
|
+
export default function Privacy() {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<h1>Privacy</h1>
|
|
26
|
+
<p className="lead">What My App keeps about you, and why.</p>
|
|
27
|
+
<ul>
|
|
28
|
+
<li>When you sign in with Ambasada: your Ambasada ID, name and avatar, in a cookie on this site only. Signing out deletes it.</li>
|
|
29
|
+
<li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when your order is ready. Turn them off in the app and we forget it.</li>
|
|
30
|
+
<li>What you send to translate goes to a language model to be translated and is not kept after the answer.</li>
|
|
31
|
+
</ul>
|
|
32
|
+
<p>
|
|
33
|
+
<Link href="/">\u2190 back</Link>
|
|
34
|
+
</p>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
`, "app/(site)/room/live-room.tsx": '"use client";\nimport { useState } from "react";\nimport { useRoom } from "../../../src/realtime/use-room.ts";\n\nexport function LiveRoom({ id }: { id: string }) {\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <>\n <h1>Live room</h1>\n <p className="ui-label">{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n {status === "closed" && <p className="hint">Under `next dev` rooms don't connect: try `npm run preview`.</p>}\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button className="ui-button">Send</button>\n </form>\n </>\n );\n}\n', "app/(site)/room/page.tsx": '// /room: a live room (src/realtime/room.ts). Open it in two tabs and type; ?room=<id> picks the room.\n// The WebSocket is answered by worker.ts: deployed and under `npm run preview`, not under `next dev`.\nimport { LiveRoom } from "./live-room.tsx";\n\nexport default async function RoomPage({ searchParams }: { searchParams: Promise<{ room?: string }> }) {\n return <LiveRoom id={(await searchParams).room ?? "lobby"} />;\n}\n', "app/account.tsx": `import { ambasada, currentUser } from "../lib/ambasada.ts";
|
|
5
39
|
|
|
6
40
|
/** The header: "Sign in with Ambasada", or the person's name and avatar once signed in (app/api/auth/*). */
|
|
7
41
|
export async function Account() {
|
|
@@ -68,9 +102,6 @@ a { color: var(--color-accent-text); }
|
|
|
68
102
|
[hidden] { display: none !important; }
|
|
69
103
|
`, "app/health/route.ts": "// GET /health: `ambasada link` and the deploy smoke test check that the product answers.\nexport function GET(): Response {\n return Response.json({ ok: true });\n}\n", "app/layout.tsx": `import type { Metadata } from "next";
|
|
70
104
|
import type { ReactNode } from "react";
|
|
71
|
-
import { currentUser } from "../lib/ambasada.ts";
|
|
72
|
-
import { Account } from "./account.tsx";
|
|
73
|
-
import { MiniApp } from "./miniapp.tsx";
|
|
74
105
|
// The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this app's own styles.
|
|
75
106
|
import "@ambasada/ui";
|
|
76
107
|
import "./globals.css";
|
|
@@ -80,41 +111,14 @@ export const metadata: Metadata = {
|
|
|
80
111
|
description: "Notes, a live room and sign-in",
|
|
81
112
|
};
|
|
82
113
|
|
|
83
|
-
export default
|
|
84
|
-
const user = await currentUser();
|
|
114
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
85
115
|
return (
|
|
86
116
|
<html lang="en">
|
|
87
|
-
<body>
|
|
88
|
-
<Account />
|
|
89
|
-
<main className="page">{children}</main>
|
|
90
|
-
<MiniApp signedIn={!!user} />
|
|
91
|
-
</body>
|
|
117
|
+
<body>{children}</body>
|
|
92
118
|
</html>
|
|
93
119
|
);
|
|
94
120
|
}
|
|
95
|
-
`, "app/
|
|
96
|
-
// (docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes.
|
|
97
|
-
import Link from "next/link";
|
|
98
|
-
|
|
99
|
-
export const metadata = { title: "Privacy \xB7 My App" };
|
|
100
|
-
|
|
101
|
-
export default function Privacy() {
|
|
102
|
-
return (
|
|
103
|
-
<>
|
|
104
|
-
<h1>Privacy</h1>
|
|
105
|
-
<p className="lead">What My App keeps about you, and why.</p>
|
|
106
|
-
<ul>
|
|
107
|
-
<li>When you sign in with Ambasada: your Ambasada ID, name and avatar, in a cookie on this site only. Signing out deletes it.</li>
|
|
108
|
-
<li>When you turn on notifications in the Ambasada app: a notification token, so we can tell you when your order is ready. Turn them off in the app and we forget it.</li>
|
|
109
|
-
<li>What you send to translate goes to a language model to be translated and is not kept after the answer.</li>
|
|
110
|
-
</ul>
|
|
111
|
-
<p>
|
|
112
|
-
<Link href="/">\u2190 back</Link>
|
|
113
|
-
</p>
|
|
114
|
-
</>
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
`, "app/room/live-room.tsx": '"use client";\nimport { useState } from "react";\nimport { useRoom } from "../../src/realtime/use-room.ts";\n\nexport function LiveRoom({ id }: { id: string }) {\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <>\n <h1>Live room</h1>\n <p className="ui-label">{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n {status === "closed" && <p className="hint">Under `next dev` rooms don't connect: try `npm run preview`.</p>}\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button className="ui-button">Send</button>\n </form>\n </>\n );\n}\n', "app/room/page.tsx": '// /room: a live room (src/realtime/room.ts). Open it in two tabs and type; ?room=<id> picks the room.\n// The WebSocket is answered by worker.ts: deployed and under `npm run preview`, not under `next dev`.\nimport { LiveRoom } from "./live-room.tsx";\n\nexport default async function RoomPage({ searchParams }: { searchParams: Promise<{ room?: string }> }) {\n return <LiveRoom id={(await searchParams).room ?? "lobby"} />;\n}\n', "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";
|
|
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
122
|
import type { Database } from "./db.ts";
|
|
119
123
|
import type { Bucket } from "./files.ts";
|
|
120
124
|
import type { KeyValue } from "./kv.ts";
|
|
@@ -131,7 +135,7 @@ export interface Bindings {
|
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
export const bindings = (): Bindings => getCloudflareContext().env as unknown as Bindings;
|
|
134
|
-
`, "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/miniapp": "workspace:*",\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', "public/.well-known/ambasada.json": '{\n "schema": 2,\n "id": "my-app",\n "name": "My App",\n "subtitle": "Notes, a live room and sign-in",\n "description": "Notes, a live room and sign-in",\n "icon": "/icon.png",\n "splash": {\n "image": "/icon.png",\n "color": "#FFFFFF"\n },\n "accent": "#FF002E",\n "entry": "/",\n "kind": "product",\n "category": "other",\n "scopes": [\n "identity",\n "notifications"\n ],\n "notifications": {\n "webhook": "/api/ambasada/webhook"\n },\n "privacy_url": "/privacy",\n "visibility": "public"\n}\n', "public/icon.png": "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AACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgArwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAOGzzA06D16OwLgAAAABJRU5ErkJggg==", "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, and it can live inside the Ambasada app as a mini app with push notifications. 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, 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 # you need 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/`) and pass `npx ambasada check https://YOUR-SITE` ([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; `http://localhost:3000/?ambasada_shell=mock` pretends to be inside\nthe 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 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 at once, 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 # the Ambasada app's checks on the live app\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 product inside the Ambasada app (mini app)\n\nEvery product on `https://<slug>.ambasada.dev` is also a **mini app**: it can open inside the Ambasada\nmobile app, sign people in without a consent screen and send them push notifications. The template does\nthe wiring; this page says what is there and how to use it. Platform spec: docs/MINI-APPS.md in the repo.\n\n## Is it listed?\n\n`ambasada deploy` registers the product with the Ambasada hub and checks it. It prints one line:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\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 page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, the\nfirst page answers in under 3 s, `/api/auth/callback` exists, the notifications webhook answers a signed ping.\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.5/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:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | `public/.well-known/ambasada.json` (`web/` in the worker template) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in 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: when your product 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/?ambasada_shell=mock` to pretend to be inside the\napp: 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/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/miniapp.tsx inside the Ambasada app: ready() and silent sign-in\n app/privacy/page.tsx what you keep about people (the manifest\'s privacy_url)\n app/room/ the live room demo\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\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, notifications in the Ambasada app, a database,\nkey-value, files and live rooms. Everything here is a default; change anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\npublic/.well-known/ambasada.json the app manifest: the Ambasada app lists your app from it (written by `ambasada init` from ambasada.config.ts; icon public/icon.png)\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; http://localhost:3000/?ambasada_shell=mock pretends to be inside the Ambasada app.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description (the manifest is written from them).\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## 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 Ambasada app's checks (manifest, icon, viewport); `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 Ambasada app shows it under the name (`ambasada init` writes the manifest from it).\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/miniapp": "workspace:*",\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/.well-known/ambasada.json": '{\n "schema": 2,\n "id": "my-app",\n "name": "My App",\n "subtitle": "Notes, a live room and sign-in",\n "description": "Notes, a live room and sign-in",\n "icon": "/icon.png",\n "splash": {\n "image": "/icon.png",\n "color": "#FFFFFF"\n },\n "accent": "#FF002E",\n "entry": "/",\n "kind": "product",\n "category": "other",\n "scopes": [\n "identity",\n "notifications"\n ],\n "notifications": {\n "webhook": "/api/ambasada/webhook"\n },\n "privacy_url": "/privacy",\n "visibility": "public"\n}\n', "public/icon.png": "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AACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgArwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAOGzzA06D16OwLgAAAABJRU5ErkJggg==", "public/privacy.html": `<!doctype html>
|
|
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 at once, 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.5/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
139
|
<!-- /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
|
|
136
140
|
(docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes. -->
|
|
137
141
|
<html lang="en">
|
|
@@ -154,8 +158,7 @@ export const bindings = (): Bindings => getCloudflareContext().env as unknown as
|
|
|
154
158
|
</main>
|
|
155
159
|
</body>
|
|
156
160
|
</html>
|
|
157
|
-
`, "src/App.tsx": 'import {
|
|
158
|
-
import { StrictMode } from "react";
|
|
161
|
+
`, "src/App.tsx": 'import { useEffect, useState } from "react";\nimport { RoomDemo } from "./realtime/room-demo.tsx";\n\n/** Two pages: the landing page and /room (a live room). */\nexport function App() {\n return (\n <>\n <Header />\n <main className="page">{location.pathname === "/room" ? <RoomDemo /> : <Home />}</main>\n </>\n );\n}\n\n/** What GET /api/auth/me returns (api/auth.ts): the person signed in with Ambasada, or null. */\ntype User = { id: string; name: string | null; avatar_url: string | null; builder: boolean; pass: string | null };\n\ntype Me = { user: User | null; configured: boolean; dev_issuer?: string };\n\n/** "Sign in with Ambasada", or the person\'s name and avatar once signed in. */\nfunction Header() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n }, []);\n return (\n <header className="topbar">\n <a className="brand" href="/">\n My App\n </a>\n {me?.user ? (\n <span className="account">\n {me.user.avatar_url && <img src={me.user.avatar_url} alt="" />}\n <span>{me.user.name ?? "Signed in"}</span>\n <a href="/api/auth/logout">Sign out</a>\n </span>\n ) : me?.configured ? (\n <a className="ui-button secondary signin" href={`/api/auth/login?returnTo=${encodeURIComponent(location.pathname + location.search)}`}>\n Sign in with Ambasada\n </a>\n ) : null}\n </header>\n );\n}\n\nfunction Home() {\n return (\n <>\n <p className="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p className="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>\n <Notes />\n <p>\n <a href="/room">Open the live room</a> in two tabs and type.\n </p>\n </>\n );\n}\n\ntype Note = { id: number; text: string; created_at: number };\n\n/** api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. */\nfunction Notes() {\n const [notes, setNotes] = useState<Note[]>([]);\n const [text, setText] = useState("");\n const load = () =>\n fetch("/api/notes")\n .then((res) => res.json() as Promise<{ notes: Note[] }>)\n .then((r) => setNotes(r.notes))\n .catch(() => setNotes([]));\n useEffect(() => void load(), []);\n return (\n <form\n className="ui-card"\n onSubmit={async (e) => {\n e.preventDefault();\n if (!text.trim()) return;\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });\n setText("");\n await load();\n }}\n >\n <div className="row">\n <input value={text} onChange={(e) => setText(e.target.value)} maxLength={500} placeholder="A note" aria-label="A note" />\n <button type="submit" className="ui-button">\n Add\n </button>\n </div>\n <ul>\n {notes.map((n) => (\n <li key={n.id}>{n.text}</li>\n ))}\n </ul>\n </form>\n );\n}\n', "src/env.d.ts": '/// <reference types="vite/client" />\n', "src/main.tsx": `import { StrictMode } from "react";
|
|
159
162
|
import { createRoot } from "react-dom/client";
|
|
160
163
|
import { App } from "./App.tsx";
|
|
161
164
|
// The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this product's own styles.
|
|
@@ -167,8 +170,6 @@ createRoot(document.getElementById("root")!).render(
|
|
|
167
170
|
<App />
|
|
168
171
|
</StrictMode>,
|
|
169
172
|
);
|
|
170
|
-
// Inside the Ambasada app: the first screen is drawn, drop the splash. On the web: a no-op.
|
|
171
|
-
ready();
|
|
172
173
|
`, "src/realtime/room-demo.tsx": 'import { useState } from "react";\nimport { useRoom } from "./use-room.ts";\n\n/** /room: a live room (src/realtime/room.ts). Open it in two tabs and type; ?room=<id> picks the room. */\nexport function RoomDemo() {\n const id = new URLSearchParams(location.search).get("room") ?? "lobby";\n const { messages, online, status, send } = useRoom(id, { name: "guest" });\n const [text, setText] = useState("");\n return (\n <>\n <h1>Live room</h1>\n <p className="ui-label">{status === "open" ? `${online} online` : `${status}\u2026`}</p>\n <ul>\n {messages.map((m) => (\n <li key={m.id}>\n {m.name}: {m.text}\n </li>\n ))}\n </ul>\n <form onSubmit={(e) => (e.preventDefault(), text.trim() && send(text) && setText(""))}>\n <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" /> <button className="ui-button">Send</button>\n </form>\n </>\n );\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/realtime/use-room.ts": '/**\n * `useRoom(id)`: a live room in a React component (src/realtime/room.ts on the server). Reconnects on its own.\n *\n * const { messages, online, send, status } = useRoom("lobby", { name: "Ana" });\n */\n"use client";\nimport { useCallback, useEffect, useRef, useState } from "react";\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\ntype RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport function useRoom(id: string, options: { name?: string } = {}) {\n const [messages, setMessages] = useState<RoomMessage[]>([]);\n const [online, setOnline] = useState(0);\n const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");\n const socket = useRef<WebSocket | null>(null);\n\n useEffect(() => {\n let stopped = false;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let tries = 0;\n const connect = () => {\n const url = new URL(`/api/rooms/${encodeURIComponent(id)}/ws`, location.href);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n if (options.name) url.searchParams.set("name", options.name);\n const ws = new WebSocket(url);\n socket.current = ws;\n setStatus("connecting");\n const timeout = setTimeout(() => ws.close(), 10_000); // no answer (e.g. `next dev`, which can\'t serve rooms): retry later\n ws.onopen = () => {\n clearTimeout(timeout);\n tries = 0;\n setStatus("open");\n };\n ws.onmessage = (e) => {\n const event = JSON.parse(String(e.data)) as RoomEvent;\n if (event.type === "history") setMessages(event.messages);\n else if (event.type === "message") setMessages((m) => [...m.slice(-49), event.message]);\n else setOnline(event.online);\n };\n ws.onclose = () => {\n clearTimeout(timeout);\n setStatus("closed");\n if (!stopped) retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); // back off; give up never\n };\n };\n connect();\n return () => {\n stopped = true;\n clearTimeout(retry);\n socket.current?.close();\n };\n }, [id, options.name]);\n\n const send = useCallback((text: string) => {\n if (socket.current?.readyState !== WebSocket.OPEN) return false;\n socket.current.send(JSON.stringify({ text }));\n return true;\n }, []);\n\n return { messages, online, status, send };\n}\n', "src/style.css": `/*
|
|
173
174
|
* This product's own styles, on top of @ambasada/ui: the Ambasada tokens (var(--color-\u2026), var(--space-\u2026))
|
|
174
175
|
* and components (ui-card, ui-button, ui-label, \u2026), loaded first by app/layout.tsx (next), src/main.tsx (spa)
|
|
@@ -209,7 +210,56 @@ a { color: var(--color-accent-text); }
|
|
|
209
210
|
.account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
|
|
210
211
|
.account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
|
|
211
212
|
[hidden] { display: none !important; }
|
|
212
|
-
`, "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, and it can live inside the Ambasada app as a mini app with push notifications. 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, 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 # you need 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/`) and pass `npx ambasada check https://YOUR-SITE` ([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; `http://localhost:3000/?ambasada_shell=mock` pretends to be inside\nthe 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 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 at once, 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 # the Ambasada app's checks on the live app\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 product inside the Ambasada app (mini app)\n\nEvery product on `https://<slug>.ambasada.dev` is also a **mini app**: it can open inside the Ambasada\nmobile app, sign people in without a consent screen and send them push notifications. The template does\nthe wiring; this page says what is there and how to use it. Platform spec: docs/MINI-APPS.md in the repo.\n\n## Is it listed?\n\n`ambasada deploy` registers the product with the Ambasada hub and checks it. It prints one line:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\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 page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, the\nfirst page answers in under 3 s, `/api/auth/callback` exists, the notifications webhook answers a signed ping.\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.5/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:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | `public/.well-known/ambasada.json` (`web/` in the worker template) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in 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: when your product 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/?ambasada_shell=mock` to pretend to be inside the\napp: 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/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/miniapp.tsx inside the Ambasada app: ready() and silent sign-in\n app/privacy/page.tsx what you keep about people (the manifest\'s privacy_url)\n app/room/ the live room demo\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\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, notifications in the Ambasada app, a database,\nkey-value, files and live rooms. Everything here is a default; change anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nweb/.well-known/ambasada.json the app manifest: the Ambasada app lists your app from it (written by `ambasada init` from ambasada.config.ts; icon web/icon.png)\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; http://localhost:3000/?ambasada_shell=mock pretends to be inside the Ambasada app.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description (the manifest is written from them).\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## 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 Ambasada app's checks (manifest, icon, viewport); `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 Ambasada app shows it under the name (`ambasada init` writes the manifest from it).\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/.well-known/ambasada.json": '{\n "schema": 2,\n "id": "my-app",\n "name": "My App",\n "subtitle": "Notes, a live room and sign-in",\n "description": "Notes, a live room and sign-in",\n "icon": "/icon.png",\n "splash": {\n "image": "/icon.png",\n "color": "#FFFFFF"\n },\n "accent": "#FF002E",\n "entry": "/",\n "kind": "product",\n "category": "other",\n "scopes": [\n "identity",\n "notifications"\n ],\n "notifications": {\n "webhook": "/api/ambasada/webhook"\n },\n "privacy_url": "/privacy",\n "visibility": "public"\n}\n', "web/icon.png": "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AACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgArwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAOGzzA06D16OwLgAAAABJRU5ErkJggg==", "web/index.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n <!-- The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this product\'s own styles. -->\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.5/dist/ambasada-ui.css" />\n <link rel="stylesheet" href="/style.css" />\n <!-- Mini-app shell (@ambasada/miniapp as a <script>): ready() drops the Ambasada app\'s splash; a no-op on the web. -->\n <script src="https://hub.ambasada.id/miniapp.js" defer></script>\n <script>\n // Inside the Ambasada app: drop the splash, then sign in without a consent screen (docs/MINI-APPS.md \xA75):\n // a shell token, POSTed to /api/auth/shell, gives the same session as "Sign in with Ambasada".\n // Under `ambasada dev`, /?ambasada_shell=mock pretends to be inside the app (the mock signs the token).\n addEventListener("DOMContentLoaded", async () => {\n const a = window.ambasada;\n if (!a) return;\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock";\n if (!mock) a.ready();\n const me = await fetch("/api/auth/me").then((r) => r.json());\n if (mock && me.dev_issuer) {\n const token = await fetch(`${me.dev_issuer}/dev/shell-token?aud=${encodeURIComponent(location.origin)}`).then((r) => r.json());\n a.mockEnv({ answers: { "auth.getToken": token } });\n a.ready();\n }\n if (me.user || !a.capabilities().includes("auth.getToken")) return;\n const shell = await a.auth.getToken().catch(() => null);\n if (!shell) return;\n const res = await fetch("/api/auth/shell", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: shell.token }) });\n if (res.ok) location.replace(location.pathname);\n });\n </script>\n </head>\n <body>\n <!-- Sign in with Ambasada: src/worker.ts keeps the button, or shows who is signed in (api/auth.ts). -->\n <header class="topbar">\n <a class="brand" href="/">My App</a>\n <a class="ui-button secondary signin" href="/api/auth/login" data-auth-signin>Sign in with Ambasada</a>\n <span class="account" data-auth-user hidden><img data-auth-avatar alt="" /><span data-auth-name></span><a href="/api/auth/logout">Sign out</a></span>\n </header>\n <main class="page">\n <p class="ui-label eyebrow">Ambasada app</p>\n <h1>My App</h1>\n <p class="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>\n\n <!-- api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. -->\n <form class="ui-card" id="notes">\n <div class="row">\n <input name="text" maxlength="500" required placeholder="A note" aria-label="A note" />\n <button type="submit" class="ui-button">Add</button>\n </div>\n <ul id="list"></ul>\n </form>\n <p><a href="/room">Open the live room</a> in two tabs and type.</p>\n </main>\n <script>\n const form = document.getElementById("notes");\n const list = document.getElementById("list");\n const load = async () => {\n const { notes } = await fetch("/api/notes").then((r) => r.json());\n list.replaceChildren(...notes.map((n) => Object.assign(document.createElement("li"), { textContent: n.text })));\n };\n form.addEventListener("submit", async (e) => {\n e.preventDefault();\n await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: form.text.value }) });\n form.reset();\n await load();\n });\n load();\n </script>\n </body>\n</html>\n', "web/privacy.html": `<!doctype html>
|
|
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 at once, 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.5/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>
|
|
214
|
+
<html lang="en">
|
|
215
|
+
<head>
|
|
216
|
+
<meta charset="utf-8" />
|
|
217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
218
|
+
<title>My App</title>
|
|
219
|
+
<!-- The Ambasada look (@ambasada/ui: tokens, fonts, ui-card / ui-button / \u2026), then this product's own styles. -->
|
|
220
|
+
<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.5/dist/ambasada-ui.css" />
|
|
221
|
+
<link rel="stylesheet" href="/style.css" />
|
|
222
|
+
</head>
|
|
223
|
+
<body>
|
|
224
|
+
<!-- Sign in with Ambasada: src/worker.ts keeps the button, or shows who is signed in (api/auth.ts). -->
|
|
225
|
+
<header class="topbar">
|
|
226
|
+
<a class="brand" href="/">My App</a>
|
|
227
|
+
<a class="ui-button secondary signin" href="/api/auth/login" data-auth-signin>Sign in with Ambasada</a>
|
|
228
|
+
<span class="account" data-auth-user hidden><img data-auth-avatar alt="" /><span data-auth-name></span><a href="/api/auth/logout">Sign out</a></span>
|
|
229
|
+
</header>
|
|
230
|
+
<main class="page">
|
|
231
|
+
<p class="ui-label eyebrow">Ambasada app</p>
|
|
232
|
+
<h1>My App</h1>
|
|
233
|
+
<p class="lead">Notes in a database, a live room, and Sign in with Ambasada. Change anything.</p>
|
|
234
|
+
|
|
235
|
+
<!-- api/notes.ts: rows in env.DB (db/migrations/0001_init.sql), the list cached in env.KV. -->
|
|
236
|
+
<form class="ui-card" id="notes">
|
|
237
|
+
<div class="row">
|
|
238
|
+
<input name="text" maxlength="500" required placeholder="A note" aria-label="A note" />
|
|
239
|
+
<button type="submit" class="ui-button">Add</button>
|
|
240
|
+
</div>
|
|
241
|
+
<ul id="list"></ul>
|
|
242
|
+
</form>
|
|
243
|
+
<p><a href="/room">Open the live room</a> in two tabs and type.</p>
|
|
244
|
+
</main>
|
|
245
|
+
<script>
|
|
246
|
+
const form = document.getElementById("notes");
|
|
247
|
+
const list = document.getElementById("list");
|
|
248
|
+
const load = async () => {
|
|
249
|
+
const { notes } = await fetch("/api/notes").then((r) => r.json());
|
|
250
|
+
list.replaceChildren(...notes.map((n) => Object.assign(document.createElement("li"), { textContent: n.text })));
|
|
251
|
+
};
|
|
252
|
+
form.addEventListener("submit", async (e) => {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: form.text.value }) });
|
|
255
|
+
form.reset();
|
|
256
|
+
await load();
|
|
257
|
+
});
|
|
258
|
+
load();
|
|
259
|
+
</script>
|
|
260
|
+
</body>
|
|
261
|
+
</html>
|
|
262
|
+
`, "web/privacy.html": `<!doctype html>
|
|
213
263
|
<!-- /privacy: the manifest's privacy_url. Apps that ask for scopes (identity, notifications) need one to be Listed
|
|
214
264
|
(docs/MINI-APPS.md \xA72). Say plainly what you keep; edit it when that changes. -->
|
|
215
265
|
<html lang="en">
|
|
@@ -272,11 +322,11 @@ a { color: var(--color-accent-text); }
|
|
|
272
322
|
.account img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: var(--color-line); }
|
|
273
323
|
.account a { font-weight: var(--weight-regular); font-size: var(--text-sm); color: var(--color-ink-2); }
|
|
274
324
|
[hidden] { display: none !important; }
|
|
275
|
-
`, "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: src/worker.ts answers /, /api/*, /files/*, /thanks and /health; web/ is served as static assets.\n "name": "my-app",\n "main": "src/worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": "./web",\n "binding": "ASSETS",\n "run_worker_first": ["/", "/api/*", "/files/*", "/thanks", "/health"]\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }] },\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' } };
|
|
325
|
+
`, "wrangler.jsonc": '{\n "$schema": "node_modules/wrangler/config-schema.json",\n // One Worker: src/worker.ts answers /, /api/*, /files/*, /thanks and /health; web/ is served as static assets.\n "name": "my-app",\n "main": "src/worker.ts",\n "compatibility_date": "2026-09-01",\n "compatibility_flags": ["nodejs_compat"],\n "assets": {\n "directory": "./web",\n "binding": "ASSETS",\n "run_worker_first": ["/", "/api/*", "/files/*", "/thanks", "/health"]\n },\n // Storage and realtime: env.DB (SQLite), env.KV, env.FILES and the live rooms (src/realtime/room.ts).\n // No ids: wrangler keeps local copies in .wrangler/state; `ambasada deploy` attaches the app\'s real ones.\n "d1_databases": [{ "binding": "DB", "database_name": "my-app", "migrations_dir": "db/migrations", "migrations_table": "_ambasada_migrations" }],\n "kv_namespaces": [{ "binding": "KV" }],\n "r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }] },\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' }, miniapp: { "README.md": '# The mini-app pieces\n\nWhat `ambasada miniapp enable` and `ambasada init --miniapp` add to an app made from templates/{next,spa,worker}\n(docs/MINI-APPS.md \xA715, decision 15.6). Not a template on its own: packages/cli/src/miniapp.ts writes these files.\n\n- `__ENTRY__` is `miniapp.entry` ("/app"), `__ENTRY_DIR__` the same without the leading slash ("app"), `__ROOT__`\n the way back from the file to the app\'s root ("../..").\n- Entry `/`: only the glue (`in-app.tsx`, `shell.ts`, `in-app.js`), hooked into the root (app/layout.tsx,\n src/main.tsx, web/index.html). Any other entry: the in-app layout and page under that path too.\n- `icon.png`: the 1024\xD71024 placeholder the manifest points at.\n', "icon.png": "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AACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAAAgArwAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAEAAAAIAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAgAAAAAAEAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAQAAAAAACAAAAEAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAIAAAAAABAAAACAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAIAAAAAABAAAACAAAAAAAQAAAAgAAABAAAAAAAIAAAAQAAAAgAAAAAAEAAAAIAAAAEAAAAAAAgAAABAAAACAAAAAAAQAAAAgAAAAAAEAAAAIAAAAQAAAAAACAAAAOGzzA06D16OwLgAAAABJRU5ErkJggg==", "next/in-app.tsx": '"use client";\n\nimport { auth, mockEnv, ready } from "@ambasada/miniapp";\nimport { useRouter } from "next/navigation";\nimport { useEffect } from "react";\n\n/**\n * Inside the Ambasada app: drops the splash (`ready()`), then signs in without a consent screen\n * (docs/MINI-APPS.md \xA75): `auth.signIn()` gets a shell token and POSTs it to /api/auth/shell, which sets the same\n * session as "Sign in with Ambasada". On the web: a no-op. Under `ambasada dev`, `?ambasada_shell=mock` pretends to\n * be inside the app (the mock signs the token).\n */\nexport function InApp() {\n const router = useRouter();\n useEffect(() => {\n void (async () => {\n const me = (await fetch("/api/auth/me")\n .then((r) => r.json())\n .catch(() => null)) as { user?: unknown; dev_issuer?: string } | null;\n if (me?.dev_issuer && (new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock")) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n ready();\n if (!me?.user && (await auth.signIn().catch(() => null))) router.refresh();\n })();\n }, [router]);\n return null;\n}\n', "next/layout.tsx": 'import { Page } from "@ambasada/ui/react";\nimport type { ReactNode } from "react";\nimport { InApp } from "./in-app.tsx";\n\n/**\n * The mini app: what the Ambasada app opens (`miniapp.entry` in ambasada.config.ts). Outside the (site) group, so\n * no site header: the Ambasada app draws its own bar, and `Page` (ui-page) keeps clear of it and the safe areas.\n */\nexport default function InAppLayout({ children }: { children: ReactNode }) {\n return (\n <Page>\n {children}\n <InApp />\n </Page>\n );\n}\n', "next/page.tsx": 'import { Card } from "@ambasada/ui/react";\nimport { currentUser } from "__ROOT__/lib/ambasada.ts";\n\n// Who is signed in comes from the request: render per request, not at build time.\nexport const dynamic = "force-dynamic";\n\n/** The mini app\'s first screen. Build the in-app experience under this folder; the site stays as it is. */\nexport default async function InAppHome() {\n const user = await currentUser();\n return (\n <>\n <p className="ui-label">My App</p>\n <h1>{user ? `Hello, ${user.name ?? "you"}` : "Hello"}</h1>\n <Card title="In the Ambasada app">\n <p>\n This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>app/__ENTRY_DIR__/page.tsx</code>.\n </p>\n </Card>\n </>\n );\n}\n', "spa/InApp.tsx": 'import { Card, Page } from "@ambasada/ui/react";\nimport { useEffect, useState } from "react";\nimport { startInApp } from "./shell.ts";\n\n/** Where the mini app lives (`miniapp.entry` in ambasada.config.ts): src/main.tsx renders InApp there. */\nexport const IN_APP = "__ENTRY__";\nexport const isInApp = (path: string) => path === IN_APP || path.startsWith(`${IN_APP}/`);\n\ntype Me = { user: { name: string | null } | null };\n\n/**\n * The mini app: what the Ambasada app opens. No site header: the Ambasada app draws its own bar, and `Page`\n * (ui-page) keeps clear of it and the safe areas. Build the in-app experience here; the site stays as it is.\n */\nexport function InApp() {\n const [me, setMe] = useState<Me | null>(null);\n useEffect(() => {\n const load = () =>\n fetch("/api/auth/me")\n .then((res) => (res.ok ? (res.json() as Promise<Me>) : null))\n .then(setMe)\n .catch(() => setMe(null));\n void load();\n void startInApp(() => void load());\n }, []);\n return (\n <Page>\n <p className="ui-label">My App</p>\n <h1>{me?.user ? `Hello, ${me.user.name ?? "you"}` : "Hello"}</h1>\n <Card title="In the Ambasada app">\n <p>\n This is <code>{IN_APP}</code>, the page the Ambasada app opens. Edit <code>src/in-app/InApp.tsx</code>.\n </p>\n </Card>\n </Page>\n );\n}\n', "spa/shell.ts": 'import { auth, mockEnv, ready } from "@ambasada/miniapp";\n\n/**\n * Inside the Ambasada app: drops the splash (`ready()`), then signs in without a consent screen\n * (docs/MINI-APPS.md \xA75): `auth.signIn()` gets a shell token and POSTs it to /api/auth/shell, which sets the same\n * session as "Sign in with Ambasada"; `onSignedIn` runs after that. On the web: a no-op. Under `ambasada dev`,\n * `?ambasada_shell=mock` pretends to be inside the app (the mock signs the token).\n */\nexport async function startInApp(onSignedIn: () => void): Promise<void> {\n const me = (await fetch("/api/auth/me")\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)) as { user?: unknown; dev_issuer?: string } | null;\n if (me?.dev_issuer && (new URLSearchParams(location.search).get("ambasada_shell") === "mock" || sessionStorage.getItem("ambasada_shell") === "mock")) {\n sessionStorage.setItem("ambasada_shell", "mock");\n mockEnv({ issuer: me.dev_issuer });\n }\n ready();\n if (!me?.user && (await auth.signIn().catch(() => null))) onSignedIn();\n}\n', "worker/in-app.js": '// Inside the Ambasada app (the mini app, `miniapp.entry` in ambasada.config.ts): drop the splash, then sign in without\n// a consent screen (docs/MINI-APPS.md \xA75): a shell token, POSTed to /api/auth/shell, gives the same session as\n// "Sign in with Ambasada". Needs https://hub.ambasada.id/miniapp.js before it; on the web it does nothing.\n// Under `ambasada dev`, ?ambasada_shell=mock pretends to be inside the app (the mock signs the token).\naddEventListener("DOMContentLoaded", async () => {\n const a = window.ambasada;\n const me = await fetch("/api/auth/me").then((r) => r.json()).catch(() => ({}));\n for (const el of document.querySelectorAll("[data-in-app-name]")) if (me.user) el.textContent = `, ${me.user.name ?? "you"}`;\n if (!a) return;\n const mock = new URLSearchParams(location.search).get("ambasada_shell") === "mock";\n if (mock && me.dev_issuer) {\n const token = await fetch(`${me.dev_issuer}/dev/shell-token?aud=${encodeURIComponent(location.origin)}`).then((r) => r.json());\n a.mockEnv({ answers: { "auth.getToken": token } });\n }\n a.ready();\n if (me.user || !a.capabilities().includes("auth.getToken")) return;\n const shell = await a.auth.getToken().catch(() => null);\n if (!shell) return;\n const res = await fetch("/api/auth/shell", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: shell.token }) });\n if (res.ok) location.replace(location.pathname);\n});\n', "worker/page.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n <!-- The mini app: what the Ambasada app opens (`miniapp.entry` in ambasada.config.ts). No site header: the\n Ambasada app draws its own bar, and ui-page keeps clear of it and the safe areas. -->\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.5/dist/ambasada-ui.css" />\n <link rel="stylesheet" href="/style.css" />\n <script src="https://hub.ambasada.id/miniapp.js" defer></script>\n <script src="/in-app.js" defer></script>\n </head>\n <body>\n <main class="ui-page">\n <p class="ui-label">My App</p>\n <h1>Hello<span data-in-app-name></span></h1>\n <section class="ui-card">\n <h2 class="ui-card-title">In the Ambasada app</h2>\n <p>This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>web/__ENTRY_DIR__.html</code>.</p>\n </section>\n </main>\n </body>\n</html>\n' } };
|
|
276
326
|
|
|
277
327
|
// src/main.ts
|
|
278
|
-
import { existsSync as
|
|
279
|
-
import { join as
|
|
328
|
+
import { existsSync as existsSync13 } from "node:fs";
|
|
329
|
+
import { join as join14 } from "node:path";
|
|
280
330
|
|
|
281
331
|
// ../protocol/src/money.ts
|
|
282
332
|
var NANO_PER_TON = 1e9;
|
|
@@ -345,10 +395,10 @@ function aexists(instance, checkFinished = true) {
|
|
|
345
395
|
if (checkFinished && instance.finished)
|
|
346
396
|
throw new Error("digest() was already called");
|
|
347
397
|
}
|
|
348
|
-
function aoutput(
|
|
349
|
-
abytes(
|
|
398
|
+
function aoutput(out7, instance) {
|
|
399
|
+
abytes(out7, void 0, "output");
|
|
350
400
|
const min = instance.outputLen;
|
|
351
|
-
if (!(
|
|
401
|
+
if (!(out7.length >= min)) {
|
|
352
402
|
throw new RangeError('"output" expected length >= ' + min);
|
|
353
403
|
}
|
|
354
404
|
}
|
|
@@ -446,9 +496,9 @@ var HashMD = class {
|
|
|
446
496
|
this.roundClean();
|
|
447
497
|
return this;
|
|
448
498
|
}
|
|
449
|
-
digestInto(
|
|
499
|
+
digestInto(out7) {
|
|
450
500
|
aexists(this);
|
|
451
|
-
aoutput(
|
|
501
|
+
aoutput(out7, this);
|
|
452
502
|
this.finished = true;
|
|
453
503
|
const { buffer, view, blockLen, isLE } = this;
|
|
454
504
|
let { pos } = this;
|
|
@@ -461,7 +511,7 @@ var HashMD = class {
|
|
|
461
511
|
setU64FromNum(view, blockLen - 8, this.length * 8, isLE);
|
|
462
512
|
this.process(view, 0);
|
|
463
513
|
this.roundClean();
|
|
464
|
-
const oview =
|
|
514
|
+
const oview = out7 === buffer ? view : createView(out7);
|
|
465
515
|
const len = this.outputLen;
|
|
466
516
|
const outLen = len / 4;
|
|
467
517
|
const state = this.get();
|
|
@@ -915,9 +965,9 @@ function checkIcon(bytes) {
|
|
|
915
965
|
function metaTags(html) {
|
|
916
966
|
const tags = [];
|
|
917
967
|
for (const [, attrs] of html.matchAll(/<meta\b([^>]*)>/gi)) {
|
|
918
|
-
const
|
|
919
|
-
for (const [, key, dq, sq, bare] of attrs.matchAll(/([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g))
|
|
920
|
-
tags.push(
|
|
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);
|
|
921
971
|
}
|
|
922
972
|
return tags;
|
|
923
973
|
}
|
|
@@ -941,6 +991,20 @@ var USER_AGENT = "ambasada-check/1 (+https://ambasada.id)";
|
|
|
941
991
|
async function get(fetchImpl, url) {
|
|
942
992
|
return fetchImpl(url, { redirect: "manual", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { "user-agent": USER_AGENT, accept: "*/*" } });
|
|
943
993
|
}
|
|
994
|
+
async function checkPage(fetchImpl, url, report, html) {
|
|
995
|
+
try {
|
|
996
|
+
const started = Date.now();
|
|
997
|
+
const res = await get(fetchImpl, url);
|
|
998
|
+
const text = res.status === 200 && html ? await res.text() : "";
|
|
999
|
+
if (!html) await res.body?.cancel();
|
|
1000
|
+
const ms = Date.now() - started;
|
|
1001
|
+
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));
|
|
1003
|
+
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
|
+
} catch (error) {
|
|
1005
|
+
report.problems.push({ code: "entry_fetch", message: `could not fetch ${url}: ${error.message}` });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
944
1008
|
async function checkRemote(url, fetchImpl = fetch, options = {}) {
|
|
945
1009
|
const report = { ok: true, target: url, problems: [], warnings: [], notes: [] };
|
|
946
1010
|
let origin;
|
|
@@ -954,8 +1018,23 @@ async function checkRemote(url, fetchImpl = fetch, options = {}) {
|
|
|
954
1018
|
if (origin.protocol !== "https:") report.problems.push({ code: "not_https", message: "the app must be served over https" });
|
|
955
1019
|
let manifest = null;
|
|
956
1020
|
const manifestUrl = new URL(MANIFEST_URL_PATH, origin).toString();
|
|
1021
|
+
if (options.miniapp === false) {
|
|
1022
|
+
report.miniapp = { on: false, entry: null };
|
|
1023
|
+
report.notes.push("mini app: off");
|
|
1024
|
+
await checkPage(fetchImpl, new URL("/", origin).toString(), report, false);
|
|
1025
|
+
report.ok = report.problems.length === 0;
|
|
1026
|
+
return report;
|
|
1027
|
+
}
|
|
957
1028
|
try {
|
|
958
1029
|
let res = await get(fetchImpl, manifestUrl);
|
|
1030
|
+
if (res.status === 404 && options.miniapp === void 0) {
|
|
1031
|
+
await res.body?.cancel();
|
|
1032
|
+
report.miniapp = { on: false, entry: null };
|
|
1033
|
+
report.notes.push(`mini app: off (no ${MANIFEST_URL_PATH})`);
|
|
1034
|
+
await checkPage(fetchImpl, new URL("/", origin).toString(), report, false);
|
|
1035
|
+
report.ok = report.problems.length === 0;
|
|
1036
|
+
return report;
|
|
1037
|
+
}
|
|
959
1038
|
if (res.status >= 300 && res.status < 400) {
|
|
960
1039
|
const location = res.headers.get("location") ?? "";
|
|
961
1040
|
if (res.status === 307 && HOSTED_MANIFEST.test(location)) {
|
|
@@ -977,18 +1056,8 @@ async function checkRemote(url, fetchImpl = fetch, options = {}) {
|
|
|
977
1056
|
report.problems.push({ code: "manifest_fetch", message: `could not fetch ${manifestUrl}: ${error.message}` });
|
|
978
1057
|
}
|
|
979
1058
|
const entryPath = typeof manifest?.entry === "string" && manifest.entry.startsWith("/") ? manifest.entry : "/";
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
const started = Date.now();
|
|
983
|
-
const res = await get(fetchImpl, entryUrl);
|
|
984
|
-
const html = res.status === 200 ? await res.text() : "";
|
|
985
|
-
const ms = Date.now() - started;
|
|
986
|
-
if (res.status !== 200) report.problems.push({ code: "entry_status", message: `${entryUrl} answered ${res.status}${res.headers.get("location") ? ` \u2192 ${res.headers.get("location")}` : ""}; the entry must answer 200 without redirects` });
|
|
987
|
-
else merge(report, checkHtml(html));
|
|
988
|
-
if (ms > SLOW_ENTRY_MS) report.warnings.push({ code: "slow_entry", message: `${entryUrl} took ${ms} ms; the splash stays until ready(), aim for under ${SLOW_ENTRY_MS} ms` });
|
|
989
|
-
} catch (error) {
|
|
990
|
-
report.problems.push({ code: "entry_fetch", message: `could not fetch ${entryUrl}: ${error.message}` });
|
|
991
|
-
}
|
|
1059
|
+
report.miniapp = { on: true, entry: entryPath };
|
|
1060
|
+
await checkPage(fetchImpl, new URL(entryPath, origin).toString(), report, true);
|
|
992
1061
|
if (typeof manifest?.icon === "string" && manifest.icon) {
|
|
993
1062
|
const iconUrl = new URL(manifest.icon, origin).toString();
|
|
994
1063
|
try {
|
|
@@ -1036,6 +1105,7 @@ async function checkRemote(url, fetchImpl = fetch, options = {}) {
|
|
|
1036
1105
|
return report;
|
|
1037
1106
|
}
|
|
1038
1107
|
function listingLine(tier, check2) {
|
|
1108
|
+
if (check2?.miniapp === "off") return "Mini app: off";
|
|
1039
1109
|
if (tier === "verified") return "Verified";
|
|
1040
1110
|
if (tier === "listed") return "Listed for the event";
|
|
1041
1111
|
if (tier === "unlisted") return check2?.needs.length ? `Unlisted \xB7 needs: ${check2.needs.join(", ")}` : "Unlisted";
|
|
@@ -1242,9 +1312,9 @@ function b64url(bytes) {
|
|
|
1242
1312
|
}
|
|
1243
1313
|
function fromB64url(text) {
|
|
1244
1314
|
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
|
|
1245
|
-
const
|
|
1246
|
-
for (let i = 0; i < s.length; i++)
|
|
1247
|
-
return
|
|
1315
|
+
const out7 = new Uint8Array(s.length);
|
|
1316
|
+
for (let i = 0; i < s.length; i++) out7[i] = s.charCodeAt(i);
|
|
1317
|
+
return out7;
|
|
1248
1318
|
}
|
|
1249
1319
|
var random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
|
|
1250
1320
|
async function pkce() {
|
|
@@ -1685,14 +1755,17 @@ function cachedKeys(load, ttlMs = 10 * 6e4) {
|
|
|
1685
1755
|
};
|
|
1686
1756
|
}
|
|
1687
1757
|
|
|
1758
|
+
// ../sdk/src/define.ts
|
|
1759
|
+
var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
|
|
1760
|
+
|
|
1688
1761
|
// ../sdk/src/platform.ts
|
|
1689
1762
|
var ambasada = new Ambasada();
|
|
1690
1763
|
|
|
1691
1764
|
// src/commands.ts
|
|
1692
1765
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
1693
|
-
import { existsSync as
|
|
1694
|
-
import { appendFile, mkdir as
|
|
1695
|
-
import { basename as basename3, dirname as
|
|
1766
|
+
import { existsSync as existsSync12, readFileSync as readFileSync6 } from "node:fs";
|
|
1767
|
+
import { appendFile as appendFile2, mkdir as mkdir6, readdir as readdir3, readFile as readFile8, writeFile as writeFile8 } from "node:fs/promises";
|
|
1768
|
+
import { basename as basename3, dirname as dirname7, join as join13, resolve as resolvePath5 } from "node:path";
|
|
1696
1769
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1697
1770
|
|
|
1698
1771
|
// src/config.ts
|
|
@@ -2009,8 +2082,8 @@ async function startMock(options = {}) {
|
|
|
2009
2082
|
}
|
|
2010
2083
|
if (path === "/dev/app/event" && method === "POST") {
|
|
2011
2084
|
const b = await body();
|
|
2012
|
-
const
|
|
2013
|
-
return json2(
|
|
2085
|
+
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 });
|
|
2086
|
+
return json2(out7, out7.error ? 502 : 200);
|
|
2014
2087
|
}
|
|
2015
2088
|
if (path === "/dev/state") return json2(Object.assign({ company, notifications, events }, ...extensions.map((e) => e.state?.() ?? {})));
|
|
2016
2089
|
if (!path.startsWith("/v1/")) throw fail("NOT_FOUND", "no such route");
|
|
@@ -2114,43 +2187,59 @@ async function startMock(options = {}) {
|
|
|
2114
2187
|
}
|
|
2115
2188
|
|
|
2116
2189
|
// src/plugin.ts
|
|
2117
|
-
import { existsSync as
|
|
2190
|
+
import { existsSync as existsSync4, readFileSync } from "node:fs";
|
|
2118
2191
|
import { createRequire } from "node:module";
|
|
2119
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
2192
|
+
import { dirname as dirname2, isAbsolute, join as join4, resolve as resolvePath2 } from "node:path";
|
|
2120
2193
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
2121
2194
|
|
|
2122
2195
|
// src/manifest.ts
|
|
2196
|
+
import { existsSync as existsSync2, readdirSync, statSync } from "node:fs";
|
|
2197
|
+
import { join as join2 } from "node:path";
|
|
2123
2198
|
var MANIFEST_FILES = {
|
|
2124
2199
|
next: "public/.well-known/ambasada.json",
|
|
2125
2200
|
spa: "public/.well-known/ambasada.json",
|
|
2126
2201
|
worker: "web/.well-known/ambasada.json"
|
|
2127
2202
|
};
|
|
2203
|
+
var STATIC_DIRS = ["public", "web", "static"];
|
|
2204
|
+
function findLocalManifest(dir) {
|
|
2205
|
+
for (const rel of Object.values(MANIFEST_FILES)) if (existsSync2(join2(dir, rel))) return join2(dir, rel);
|
|
2206
|
+
const candidates = [join2(dir, ".well-known/ambasada.json"), ...STATIC_DIRS.map((s) => join2(dir, s, ".well-known/ambasada.json"))];
|
|
2207
|
+
for (const s of STATIC_DIRS) {
|
|
2208
|
+
const base = join2(dir, s);
|
|
2209
|
+
if (existsSync2(base) && statSync(base).isDirectory()) {
|
|
2210
|
+
for (const sub of readdirSync(base)) candidates.push(join2(base, sub, ".well-known/ambasada.json"));
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
return candidates.find((c2) => existsSync2(c2)) ?? null;
|
|
2214
|
+
}
|
|
2128
2215
|
var APP_WEBHOOK_PATH = "/api/ambasada/webhook";
|
|
2129
2216
|
var oneLine = (s, max) => {
|
|
2130
2217
|
const line = s.replace(/\s+/g, " ").trim();
|
|
2131
2218
|
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}\u2026` : line;
|
|
2132
2219
|
};
|
|
2133
|
-
function appManifest(
|
|
2220
|
+
function appManifest(app, miniapp2 = { entry: "/" }) {
|
|
2221
|
+
const description = app.description || app.name;
|
|
2222
|
+
const icon = miniapp2.icon ?? "/icon.png";
|
|
2223
|
+
const scopes = miniapp2.scopes ?? ["identity", "notifications"];
|
|
2134
2224
|
return {
|
|
2135
2225
|
schema: 2,
|
|
2136
|
-
id:
|
|
2137
|
-
name: oneLine(
|
|
2138
|
-
subtitle: oneLine(
|
|
2139
|
-
description:
|
|
2140
|
-
icon
|
|
2141
|
-
splash: { image:
|
|
2142
|
-
accent: "#FF002E",
|
|
2143
|
-
entry:
|
|
2226
|
+
id: app.id,
|
|
2227
|
+
name: oneLine(app.name, MANIFEST_LIMITS.name),
|
|
2228
|
+
subtitle: oneLine(description, MANIFEST_LIMITS.subtitle),
|
|
2229
|
+
description: description.length > MANIFEST_LIMITS.description ? oneLine(description, MANIFEST_LIMITS.description) : description,
|
|
2230
|
+
icon,
|
|
2231
|
+
splash: { image: icon, color: "#FFFFFF" },
|
|
2232
|
+
accent: miniapp2.accent ?? "#FF002E",
|
|
2233
|
+
entry: miniapp2.entry,
|
|
2144
2234
|
kind: "product",
|
|
2145
|
-
category: "other",
|
|
2146
|
-
|
|
2147
|
-
scopes
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
visibility: "public"
|
|
2235
|
+
category: miniapp2.category ?? "other",
|
|
2236
|
+
scopes,
|
|
2237
|
+
...scopes.includes("notifications") ? { notifications: { webhook: APP_WEBHOOK_PATH } } : {},
|
|
2238
|
+
...scopes.length ? { privacy_url: "/privacy" } : {},
|
|
2239
|
+
visibility: miniapp2.visibility ?? "public"
|
|
2151
2240
|
};
|
|
2152
2241
|
}
|
|
2153
|
-
var manifestJson = (
|
|
2242
|
+
var manifestJson = (app, miniapp2) => `${JSON.stringify(appManifest(app, miniapp2), null, 2)}
|
|
2154
2243
|
`;
|
|
2155
2244
|
function readCompany(configSource) {
|
|
2156
2245
|
const str4 = (re) => {
|
|
@@ -2160,20 +2249,27 @@ function readCompany(configSource) {
|
|
|
2160
2249
|
const id = str4(/\bid\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
2161
2250
|
const name = str4(/\bname\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
2162
2251
|
const description = str4(/\bdescription\s*:\s*"((?:[^"\\]|\\.)*)"/) ?? str4(/\bidea\s*:\s*\{\s*title\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
2163
|
-
return id && name
|
|
2252
|
+
return id && name ? { id, name, description: description ?? "" } : null;
|
|
2253
|
+
}
|
|
2254
|
+
function readMiniapp(configSource) {
|
|
2255
|
+
const block = /\bminiapp\s*:\s*\{([^}]*)\}/.exec(configSource.replace(/(?<!:)\/\/[^\n]*|\/\*[\s\S]*?\*\//g, ""));
|
|
2256
|
+
if (!block) return null;
|
|
2257
|
+
const entry2 = /\bentry\s*:\s*["'`]([^"'`]*)["'`]/.exec(block[1])?.[1];
|
|
2258
|
+
const icon = /\bicon\s*:\s*["'`]([^"'`]*)["'`]/.exec(block[1])?.[1];
|
|
2259
|
+
return entry2 === void 0 ? null : { entry: entry2, ...icon ? { icon } : {} };
|
|
2164
2260
|
}
|
|
2165
2261
|
|
|
2166
2262
|
// src/project.ts
|
|
2167
|
-
import { existsSync as
|
|
2263
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
2168
2264
|
import { readFile as readFile2, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
2169
2265
|
import * as nodeModule from "node:module";
|
|
2170
|
-
import { dirname, join as
|
|
2266
|
+
import { dirname, join as join3, resolve as resolvePath } from "node:path";
|
|
2171
2267
|
import { pathToFileURL } from "node:url";
|
|
2172
2268
|
var CONFIG_FILE = "ambasada.config.ts";
|
|
2173
2269
|
function findRoot(from = process.cwd()) {
|
|
2174
2270
|
let dir = resolvePath(from);
|
|
2175
2271
|
for (; ; ) {
|
|
2176
|
-
if (
|
|
2272
|
+
if (existsSync3(join3(dir, CONFIG_FILE))) return dir;
|
|
2177
2273
|
const parent = dirname(dir);
|
|
2178
2274
|
if (parent === dir) return null;
|
|
2179
2275
|
dir = parent;
|
|
@@ -2204,14 +2300,14 @@ async function importTs(file) {
|
|
|
2204
2300
|
async function loadProject(from) {
|
|
2205
2301
|
const root = findRoot(from);
|
|
2206
2302
|
if (!root) throw new CliError(`no ${CONFIG_FILE} here or in a parent folder: run \`ambasada init\` first`);
|
|
2207
|
-
const config = await importTs(
|
|
2303
|
+
const config = await importTs(join3(root, CONFIG_FILE));
|
|
2208
2304
|
if (!config || typeof config !== "object" || typeof config.id !== "string") {
|
|
2209
2305
|
throw new CliError(`${CONFIG_FILE} must \`export default defineApp({ id, name, \u2026 })\``);
|
|
2210
2306
|
}
|
|
2211
2307
|
return { root, config };
|
|
2212
2308
|
}
|
|
2213
2309
|
async function setProductUrl(root, url) {
|
|
2214
|
-
const file =
|
|
2310
|
+
const file = join3(root, CONFIG_FILE);
|
|
2215
2311
|
const source = await readFile2(file, "utf8");
|
|
2216
2312
|
const next = source.replace(/(product\s*:\s*\{\s*url\s*:\s*)(["'`])[^"'`]*\2/, `$1"${url}"`);
|
|
2217
2313
|
if (next === source) return source.includes(`url: "${url}"`) || source.includes(`url: '${url}'`);
|
|
@@ -2219,13 +2315,13 @@ async function setProductUrl(root, url) {
|
|
|
2219
2315
|
return true;
|
|
2220
2316
|
}
|
|
2221
2317
|
async function loadIdName(root) {
|
|
2222
|
-
const app = readCompany(await readFile2(
|
|
2318
|
+
const app = readCompany(await readFile2(join3(root, CONFIG_FILE), "utf8"));
|
|
2223
2319
|
return app ? { id: app.id, name: app.name } : null;
|
|
2224
2320
|
}
|
|
2225
2321
|
|
|
2226
2322
|
// src/plugin.ts
|
|
2227
2323
|
var PLATFORM_CONFIG_KEYS = ["id", "name", "product", "addons"];
|
|
2228
|
-
var VERSION = true ? "0.8.
|
|
2324
|
+
var VERSION = true ? "0.8.3" : "0.1.0-dev";
|
|
2229
2325
|
var shortName = (pkg) => pkg.replace(/^@ambasada\//, "").replace(/^ambasada-(addon-)?/, "");
|
|
2230
2326
|
var KNOWN_ADDONS = { village: "@ambasada/village" };
|
|
2231
2327
|
function readJson(file) {
|
|
@@ -2238,8 +2334,8 @@ function readJson(file) {
|
|
|
2238
2334
|
function packageDir(name, root) {
|
|
2239
2335
|
let dir = resolvePath2(root);
|
|
2240
2336
|
for (; ; ) {
|
|
2241
|
-
const candidate =
|
|
2242
|
-
if (
|
|
2337
|
+
const candidate = join4(dir, "node_modules", name);
|
|
2338
|
+
if (existsSync4(join4(candidate, "package.json"))) return candidate;
|
|
2243
2339
|
const parent = dirname2(dir);
|
|
2244
2340
|
if (parent === dir) return null;
|
|
2245
2341
|
dir = parent;
|
|
@@ -2249,11 +2345,11 @@ function findAddons(root, configAddons = []) {
|
|
|
2249
2345
|
const dirs = /* @__PURE__ */ new Set();
|
|
2250
2346
|
for (const dir of (process.env.AMBASADA_ADDONS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) dirs.add(resolvePath2(dir));
|
|
2251
2347
|
if (root) {
|
|
2252
|
-
const pkg = readJson(
|
|
2348
|
+
const pkg = readJson(join4(root, "package.json"));
|
|
2253
2349
|
const names2 = /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {}), ...configAddons]);
|
|
2254
2350
|
for (const name of names2) {
|
|
2255
2351
|
const dir = isAbsolute(name) || name.startsWith(".") ? resolvePath2(root, name) : packageDir(name, root);
|
|
2256
|
-
if (dir && readJson(
|
|
2352
|
+
if (dir && readJson(join4(dir, "package.json"))?.ambasada?.plugin) dirs.add(dir);
|
|
2257
2353
|
}
|
|
2258
2354
|
}
|
|
2259
2355
|
return [...dirs];
|
|
@@ -2266,11 +2362,11 @@ function besideCli(name) {
|
|
|
2266
2362
|
}
|
|
2267
2363
|
}
|
|
2268
2364
|
async function loadAddon(dir) {
|
|
2269
|
-
const pkg = readJson(
|
|
2365
|
+
const pkg = readJson(join4(dir, "package.json"));
|
|
2270
2366
|
const entry2 = pkg?.ambasada?.plugin;
|
|
2271
2367
|
if (!pkg?.name || !entry2) throw new CliError(`${dir} is not an ambasada add-on (no "ambasada": { "plugin" } in package.json)`);
|
|
2272
|
-
const file =
|
|
2273
|
-
if (!
|
|
2368
|
+
const file = join4(dir, entry2);
|
|
2369
|
+
if (!existsSync4(file)) throw new CliError(`${pkg.name}: ${entry2} is missing (build the add-on, or reinstall it)`);
|
|
2274
2370
|
const mod = await import(pathToFileURL2(file).href);
|
|
2275
2371
|
const register = mod.register ?? mod.default?.register;
|
|
2276
2372
|
if (typeof register !== "function") throw new CliError(`${pkg.name}: ${entry2} exports no register(api)`);
|
|
@@ -2358,8 +2454,8 @@ function setAddons(a) {
|
|
|
2358
2454
|
|
|
2359
2455
|
// src/stack.ts
|
|
2360
2456
|
import { spawn } from "node:child_process";
|
|
2361
|
-
import { existsSync as
|
|
2362
|
-
import { dirname as dirname3, join as
|
|
2457
|
+
import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
|
|
2458
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
2363
2459
|
var STACKS = [
|
|
2364
2460
|
{
|
|
2365
2461
|
files: ["next.config.ts", "next.config.mjs", "next.config.js", "open-next.config.ts"],
|
|
@@ -2396,15 +2492,15 @@ var STACKS = [
|
|
|
2396
2492
|
}
|
|
2397
2493
|
];
|
|
2398
2494
|
function detectStack(root) {
|
|
2399
|
-
return STACKS.find((s) => s.files.some((f) =>
|
|
2495
|
+
return STACKS.find((s) => s.files.some((f) => existsSync5(join5(root, f))))?.stack ?? null;
|
|
2400
2496
|
}
|
|
2401
2497
|
function resolveBin(root, pkg, bin2) {
|
|
2402
2498
|
for (let dir = root; ; dir = dirname3(dir)) {
|
|
2403
|
-
const manifest =
|
|
2404
|
-
if (
|
|
2499
|
+
const manifest = join5(dir, "node_modules", pkg, "package.json");
|
|
2500
|
+
if (existsSync5(manifest)) {
|
|
2405
2501
|
const json2 = JSON.parse(readFileSync2(manifest, "utf8"));
|
|
2406
2502
|
const rel = typeof json2.bin === "string" ? json2.bin : json2.bin?.[bin2];
|
|
2407
|
-
return rel ?
|
|
2503
|
+
return rel ? join5(dirname3(manifest), rel) : null;
|
|
2408
2504
|
}
|
|
2409
2505
|
if (dirname3(dir) === dir) return null;
|
|
2410
2506
|
}
|
|
@@ -2445,55 +2541,16 @@ function startApp(root, stack, bin2, url, vars, print, prefix) {
|
|
|
2445
2541
|
}
|
|
2446
2542
|
|
|
2447
2543
|
// src/check.ts
|
|
2448
|
-
import { existsSync as
|
|
2449
|
-
import { dirname as
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
};
|
|
2454
|
-
|
|
2455
|
-
function findLocalManifest(dir) {
|
|
2456
|
-
for (const rel of Object.values(MANIFEST_FILES)) if (existsSync5(join5(dir, rel))) return join5(dir, rel);
|
|
2457
|
-
const candidates = [join5(dir, ".well-known/ambasada.json"), ...STATIC_DIRS.map((s) => join5(dir, s, ".well-known/ambasada.json"))];
|
|
2458
|
-
for (const s of STATIC_DIRS) {
|
|
2459
|
-
const base = join5(dir, s);
|
|
2460
|
-
if (existsSync5(base) && statSync(base).isDirectory()) {
|
|
2461
|
-
for (const sub of readdirSync(base)) candidates.push(join5(base, sub, ".well-known/ambasada.json"));
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
return candidates.find((c2) => existsSync5(c2)) ?? null;
|
|
2465
|
-
}
|
|
2466
|
-
async function checkLocal(dir) {
|
|
2467
|
-
const target2 = resolvePath3(dir);
|
|
2468
|
-
const report = { ok: true, target: target2, problems: [], warnings: [], notes: [] };
|
|
2469
|
-
const manifestFile = findLocalManifest(target2);
|
|
2470
|
-
if (!manifestFile) {
|
|
2471
|
-
report.problems.push({ code: "manifest_missing", message: `no .well-known/ambasada.json under ${target2} (${Object.values(MANIFEST_FILES).join(", ")}): \`ambasada init\` writes one` });
|
|
2472
|
-
report.ok = false;
|
|
2473
|
-
return report;
|
|
2474
|
-
}
|
|
2475
|
-
const staticRoot = dirname4(dirname4(manifestFile));
|
|
2476
|
-
let manifest = null;
|
|
2477
|
-
try {
|
|
2478
|
-
manifest = JSON.parse(readFileSync3(manifestFile, "utf8"));
|
|
2479
|
-
merge2(report, validateManifest(manifest));
|
|
2480
|
-
} catch (error) {
|
|
2481
|
-
report.problems.push({ code: "manifest_json", message: `${manifestFile} is not valid JSON: ${error.message}` });
|
|
2482
|
-
}
|
|
2483
|
-
const iconPath = typeof manifest?.icon === "string" && manifest.icon.startsWith("/") ? join5(staticRoot, manifest.icon) : null;
|
|
2484
|
-
if (iconPath) {
|
|
2485
|
-
if (!existsSync5(iconPath)) report.problems.push({ code: "icon_missing", message: `the manifest's icon ${manifest.icon} is not in ${staticRoot}` });
|
|
2486
|
-
else merge2(report, checkIcon(readFileSync3(iconPath)));
|
|
2487
|
-
} else if (manifest && typeof manifest.icon === "string") report.notes.push(`icon ${manifest.icon}: not a local path, not checked`);
|
|
2488
|
-
const index = join5(staticRoot, "index.html");
|
|
2489
|
-
if (existsSync5(index)) merge2(report, checkHtml(readFileSync3(index, "utf8")));
|
|
2490
|
-
else report.notes.push(`no static ${index}: the entry page is rendered by the app, checked by \`ambasada check <url>\` once deployed`);
|
|
2491
|
-
report.ok = report.problems.length === 0;
|
|
2492
|
-
return report;
|
|
2493
|
-
}
|
|
2544
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
|
|
2545
|
+
import { dirname as dirname5, join as join8, resolve as resolvePath3 } from "node:path";
|
|
2546
|
+
|
|
2547
|
+
// src/miniapp.ts
|
|
2548
|
+
import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
|
|
2549
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2550
|
+
import { dirname as dirname4, join as join7, relative as relative2 } from "node:path";
|
|
2494
2551
|
|
|
2495
2552
|
// src/template.ts
|
|
2496
|
-
import { readdirSync as readdirSync2, readFileSync as
|
|
2553
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
|
|
2497
2554
|
import { join as join6, relative } from "node:path";
|
|
2498
2555
|
import { fileURLToPath } from "node:url";
|
|
2499
2556
|
var TEMPLATES = ["next", "spa", "worker"];
|
|
@@ -2501,58 +2558,379 @@ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".next", ".open-next
|
|
|
2501
2558
|
var BINARY = /\.(png|ico|jpe?g|gif|webp|woff2?)$/i;
|
|
2502
2559
|
var isBinary = (path) => BINARY.test(path);
|
|
2503
2560
|
function readTemplateDir(dir) {
|
|
2504
|
-
const
|
|
2561
|
+
const out7 = {};
|
|
2505
2562
|
const walk2 = (d) => {
|
|
2506
2563
|
for (const name of readdirSync2(d)) {
|
|
2507
2564
|
if (SKIP.has(name)) continue;
|
|
2508
2565
|
const path = join6(d, name);
|
|
2509
2566
|
if (statSync2(path).isDirectory()) walk2(path);
|
|
2510
|
-
else
|
|
2567
|
+
else out7[relative(dir, path).split("\\").join("/")] = readFileSync3(path, isBinary(name) ? "base64" : "utf8");
|
|
2511
2568
|
}
|
|
2512
2569
|
};
|
|
2513
2570
|
walk2(dir);
|
|
2514
|
-
return
|
|
2571
|
+
return out7;
|
|
2515
2572
|
}
|
|
2516
2573
|
function templateFiles(name) {
|
|
2517
2574
|
if (typeof define_AMBASADA_TEMPLATES_default !== "undefined") return define_AMBASADA_TEMPLATES_default[name] ?? {};
|
|
2518
2575
|
return readTemplateDir(fileURLToPath(new URL(`../../../templates/${name}/`, import.meta.url)));
|
|
2519
2576
|
}
|
|
2577
|
+
var miniappFiles = () => templateFiles("miniapp");
|
|
2578
|
+
|
|
2579
|
+
// src/miniapp.ts
|
|
2580
|
+
var out = (s = "") => process.stdout.write(`${s}
|
|
2581
|
+
`);
|
|
2582
|
+
var GENERATED_RECORD = ".ambasada/manifest.json";
|
|
2583
|
+
var DEFAULT_ENTRY = "/app";
|
|
2584
|
+
var OFF = { on: false, entry: null, source: null, file: null };
|
|
2585
|
+
function templateOf(root) {
|
|
2586
|
+
const stack = detectStack(root);
|
|
2587
|
+
if (stack) return stack.name;
|
|
2588
|
+
return existsSync6(join7(root, "src/worker.ts")) ? "worker" : "next";
|
|
2589
|
+
}
|
|
2590
|
+
var staticDir = (template) => dirname4(dirname4(MANIFEST_FILES[template]));
|
|
2591
|
+
function appInfo(config) {
|
|
2592
|
+
const idea = config.idea;
|
|
2593
|
+
return { id: config.id, name: config.name, description: config.description ?? (typeof idea?.title === "string" ? idea.title : "") };
|
|
2594
|
+
}
|
|
2595
|
+
function readRecord(root) {
|
|
2596
|
+
try {
|
|
2597
|
+
return JSON.parse(readFileSync4(join7(root, GENERATED_RECORD), "utf8"));
|
|
2598
|
+
} catch {
|
|
2599
|
+
return null;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
function isOurs(root, file) {
|
|
2603
|
+
const record = readRecord(root);
|
|
2604
|
+
if (!record || join7(root, record.path) !== file || !existsSync6(file)) return false;
|
|
2605
|
+
return readFileSync4(file, "utf8") === record.content;
|
|
2606
|
+
}
|
|
2607
|
+
function handWritten(root) {
|
|
2608
|
+
const file = findLocalManifest(root);
|
|
2609
|
+
return file && !isOurs(root, file) ? file : null;
|
|
2610
|
+
}
|
|
2611
|
+
function fileEntry(file) {
|
|
2612
|
+
try {
|
|
2613
|
+
const entry2 = JSON.parse(readFileSync4(file, "utf8")).entry;
|
|
2614
|
+
return typeof entry2 === "string" && entry2.startsWith("/") ? entry2 : "/";
|
|
2615
|
+
} catch {
|
|
2616
|
+
return "/";
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
function miniappState(root, miniapp2) {
|
|
2620
|
+
const file = handWritten(root);
|
|
2621
|
+
if (file) return { on: true, entry: fileEntry(file), source: "file", file };
|
|
2622
|
+
return miniapp2 ? { on: true, entry: miniapp2.entry, source: "config", file: null } : OFF;
|
|
2623
|
+
}
|
|
2624
|
+
function miniappOfSource(root) {
|
|
2625
|
+
const file = join7(root, CONFIG_FILE);
|
|
2626
|
+
const source = existsSync6(file) ? readFileSync4(file, "utf8") : "";
|
|
2627
|
+
return { app: readCompany(source), miniapp: readMiniapp(source) };
|
|
2628
|
+
}
|
|
2629
|
+
async function ensureGitignored(root, rel) {
|
|
2630
|
+
const file = join7(root, ".gitignore");
|
|
2631
|
+
if (!existsSync6(file)) return;
|
|
2632
|
+
const text = await readFile3(file, "utf8");
|
|
2633
|
+
if (text.split("\n").some((l) => l.trim() === `/${rel}`)) return;
|
|
2634
|
+
await appendFile(file, `${text.endsWith("\n") ? "" : "\n"}# the mini-app manifest: \`ambasada dev\` and \`deploy\` write it from ambasada.config.ts (drop this line to write it by hand)
|
|
2635
|
+
/${rel}
|
|
2636
|
+
`);
|
|
2637
|
+
}
|
|
2638
|
+
async function writeGenerated(root, template, app, miniapp2) {
|
|
2639
|
+
const rel = MANIFEST_FILES[template];
|
|
2640
|
+
const text = manifestJson(app, miniapp2);
|
|
2641
|
+
await mkdir2(dirname4(join7(root, rel)), { recursive: true });
|
|
2642
|
+
if (!existsSync6(join7(root, rel)) || readFileSync4(join7(root, rel), "utf8") !== text) await writeFile3(join7(root, rel), text);
|
|
2643
|
+
await mkdir2(join7(root, ".ambasada"), { recursive: true });
|
|
2644
|
+
await writeFile3(join7(root, GENERATED_RECORD), `${JSON.stringify({ path: rel, content: text }, null, 2)}
|
|
2645
|
+
`);
|
|
2646
|
+
await ensureGitignored(root, rel);
|
|
2647
|
+
}
|
|
2648
|
+
async function syncManifest(root, config) {
|
|
2649
|
+
const template = templateOf(root);
|
|
2650
|
+
const target2 = join7(root, MANIFEST_FILES[template]);
|
|
2651
|
+
const state = miniappState(root, config.miniapp);
|
|
2652
|
+
if (state.source === "config") await writeGenerated(root, template, appInfo(config), config.miniapp);
|
|
2653
|
+
else if (!config.miniapp && isOurs(root, target2)) {
|
|
2654
|
+
await rm2(target2, { force: true });
|
|
2655
|
+
await rm2(join7(root, GENERATED_RECORD), { force: true });
|
|
2656
|
+
}
|
|
2657
|
+
return state;
|
|
2658
|
+
}
|
|
2659
|
+
function stateLine(state) {
|
|
2660
|
+
if (!state.on) return `mini app: off ${c.dim("(`ambasada miniapp enable` adds one)")}`;
|
|
2661
|
+
return `mini app: on at ${c.bold(state.entry ?? "/")}${state.source === "file" ? c.dim(` (manifest: ${state.file})`) : ""}`;
|
|
2662
|
+
}
|
|
2663
|
+
var COMMENT = "// The Ambasada mini app, at a path of this app: `ambasada dev` and `deploy` serve its manifest from this.";
|
|
2664
|
+
function setMiniappEntry(source, entry2) {
|
|
2665
|
+
const existing = /(\bminiapp\s*:\s*\{[^}]*?\bentry\s*:\s*)(["'`])[^"'`]*\2/;
|
|
2666
|
+
if (existing.test(source)) return source.replace(existing, `$1"${entry2}"`);
|
|
2667
|
+
const product = /^([ \t]*product\s*:\s*\{[^}]*\})(,?)([^\n]*\n)/m.exec(source);
|
|
2668
|
+
if (product) {
|
|
2669
|
+
const indent = /^[ \t]*/.exec(product[1])[0];
|
|
2670
|
+
const line = `${product[1]},${product[3]}`;
|
|
2671
|
+
return `${source.slice(0, product.index)}${line}${indent}${COMMENT}
|
|
2672
|
+
${indent}miniapp: { entry: "${entry2}" },
|
|
2673
|
+
${source.slice(product.index + product[0].length)}`;
|
|
2674
|
+
}
|
|
2675
|
+
const close = /\n([ \t]*)\}\)/.exec(source.slice(source.indexOf("defineApp(")));
|
|
2676
|
+
if (!source.includes("defineApp(") || !close) throw new CliError(`could not find defineApp({ \u2026 }) in ${CONFIG_FILE}: add \`miniapp: { entry: "${entry2}" }\` to it by hand`);
|
|
2677
|
+
const at = source.indexOf("defineApp(") + close.index;
|
|
2678
|
+
const body = source.slice(0, at).trimEnd();
|
|
2679
|
+
return `${body}${body.endsWith(",") || body.endsWith("{") ? "" : ","}
|
|
2680
|
+
${COMMENT}
|
|
2681
|
+
miniapp: { entry: "${entry2}" },${source.slice(at)}`;
|
|
2682
|
+
}
|
|
2683
|
+
async function patch(root, rel, done, apply, manual, placed) {
|
|
2684
|
+
const file = join7(root, rel);
|
|
2685
|
+
const text = existsSync6(file) ? await readFile3(file, "utf8") : null;
|
|
2686
|
+
if (text !== null && done.test(text)) return;
|
|
2687
|
+
const next = text === null ? null : apply(text);
|
|
2688
|
+
if (next === null || next === text) placed.manual.push(manual);
|
|
2689
|
+
else {
|
|
2690
|
+
await writeFile3(file, next);
|
|
2691
|
+
placed.patched.push(rel);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
async function placeFiles(root, template, entry2, name, placed) {
|
|
2695
|
+
const pieces = miniappFiles();
|
|
2696
|
+
const dir = entry2.slice(1);
|
|
2697
|
+
const write = async (piece, rel) => {
|
|
2698
|
+
const dest = join7(root, rel);
|
|
2699
|
+
if (existsSync6(dest)) return;
|
|
2700
|
+
const back = relative2(dirname4(dest), root).split("\\").join("/") || ".";
|
|
2701
|
+
const text = pieces[`${template}/${piece}`];
|
|
2702
|
+
if (text === void 0) throw new CliError(`this CLI has no ${template}/${piece} mini-app piece`);
|
|
2703
|
+
await mkdir2(dirname4(dest), { recursive: true });
|
|
2704
|
+
await writeFile3(dest, text.replaceAll("__ENTRY_DIR__", dir).replaceAll("__ENTRY__", entry2).replaceAll("__ROOT__", back).replaceAll("My App", name));
|
|
2705
|
+
placed.written.push(rel);
|
|
2706
|
+
};
|
|
2707
|
+
if (template === "next") {
|
|
2708
|
+
if (entry2 === "/") {
|
|
2709
|
+
await write("in-app.tsx", "app/in-app.tsx");
|
|
2710
|
+
await patch(
|
|
2711
|
+
root,
|
|
2712
|
+
"app/layout.tsx",
|
|
2713
|
+
/<InApp\b/,
|
|
2714
|
+
(t) => t.includes("<body>{children}</body>") ? t.replace('import type { ReactNode } from "react";\n', 'import type { ReactNode } from "react";\nimport { InApp } from "./in-app.tsx";\n').replace("<body>{children}</body>", "<body>\n {children}\n <InApp />\n </body>") : null,
|
|
2715
|
+
"app/layout.tsx: render <InApp /> (app/in-app.tsx) inside <body>",
|
|
2716
|
+
placed
|
|
2717
|
+
);
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
for (const taken of [`app/${dir}/page.tsx`, `app/(site)/${dir}/page.tsx`]) {
|
|
2721
|
+
if (existsSync6(join7(root, taken)) && !existsSync6(join7(root, `app/${dir}/in-app.tsx`))) throw new CliError(`${taken} is a page already: pick another --entry`);
|
|
2722
|
+
}
|
|
2723
|
+
for (const piece of ["layout.tsx", "page.tsx", "in-app.tsx"]) await write(piece, `app/${dir}/${piece}`);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (template === "spa") {
|
|
2727
|
+
await write("shell.ts", "src/in-app/shell.ts");
|
|
2728
|
+
if (entry2 === "/") {
|
|
2729
|
+
await patch(
|
|
2730
|
+
root,
|
|
2731
|
+
"src/main.tsx",
|
|
2732
|
+
/startInApp/,
|
|
2733
|
+
(t) => t.includes('import { App } from "./App.tsx";\n') ? `${t.replace('import { App } from "./App.tsx";\n', 'import { App } from "./App.tsx";\nimport { startInApp } from "./in-app/shell.ts";\n').trimEnd()}
|
|
2734
|
+
// Inside the Ambasada app: drop the splash and sign in silently (src/in-app/shell.ts). On the web: a no-op.
|
|
2735
|
+
void startInApp(() => location.reload());
|
|
2736
|
+
` : null,
|
|
2737
|
+
"src/main.tsx: call startInApp(() => location.reload()) from src/in-app/shell.ts",
|
|
2738
|
+
placed
|
|
2739
|
+
);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
await write("InApp.tsx", "src/in-app/InApp.tsx");
|
|
2743
|
+
await patch(
|
|
2744
|
+
root,
|
|
2745
|
+
"src/main.tsx",
|
|
2746
|
+
/isInApp/,
|
|
2747
|
+
(t) => t.includes('import { App } from "./App.tsx";\n') && t.includes("<App />") ? t.replace('import { App } from "./App.tsx";\n', 'import { App } from "./App.tsx";\nimport { InApp, isInApp } from "./in-app/InApp.tsx";\n').replace("<App />", "{isInApp(location.pathname) ? <InApp /> : <App />}") : null,
|
|
2748
|
+
`src/main.tsx: render <InApp /> (src/in-app/InApp.tsx) when isInApp(location.pathname)`,
|
|
2749
|
+
placed
|
|
2750
|
+
);
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
const web = staticDir("worker");
|
|
2754
|
+
await write("in-app.js", `${web}/in-app.js`);
|
|
2755
|
+
if (entry2 === "/") {
|
|
2756
|
+
await patch(
|
|
2757
|
+
root,
|
|
2758
|
+
`${web}/index.html`,
|
|
2759
|
+
/in-app\.js/,
|
|
2760
|
+
(t) => t.includes(" </head>") ? t.replace(
|
|
2761
|
+
" </head>",
|
|
2762
|
+
' <!-- 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 </head>'
|
|
2763
|
+
) : null,
|
|
2764
|
+
`${web}/index.html: <script src="https://hub.ambasada.id/miniapp.js" defer></script> and <script src="/in-app.js" defer></script> in <head>`,
|
|
2765
|
+
placed
|
|
2766
|
+
);
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
if (existsSync6(join7(root, web, `${dir}.html`)) || existsSync6(join7(root, web, dir, "index.html"))) {
|
|
2770
|
+
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`);
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
await write("page.html", `${web}/${dir}.html`);
|
|
2774
|
+
}
|
|
2775
|
+
function miniappSpec(deps, flags, fallback) {
|
|
2776
|
+
const given = typeof flags["miniapp-spec"] === "string" ? flags["miniapp-spec"] : process.env.AMBASADA_MINIAPP_SPEC;
|
|
2777
|
+
if (given) return given;
|
|
2778
|
+
const like = [deps["@ambasada/ui"], deps["@ambasada/sdk"]].find((v) => v && /^[\^~]?\d/.test(v));
|
|
2779
|
+
return like ?? fallback;
|
|
2780
|
+
}
|
|
2781
|
+
async function enableMiniapp(root, entry2, flags, opts) {
|
|
2782
|
+
if (!MINIAPP_ENTRY.test(entry2) || entry2.split("/").includes("..")) throw new CliError(`--entry must be a path like "/app" or "/" (got ${JSON.stringify(entry2)})`);
|
|
2783
|
+
const template = opts.template ?? templateOf(root);
|
|
2784
|
+
const placed = { written: [], patched: [], manual: [] };
|
|
2785
|
+
const configFile = join7(root, CONFIG_FILE);
|
|
2786
|
+
const source = await readFile3(configFile, "utf8");
|
|
2787
|
+
const next = setMiniappEntry(source, entry2);
|
|
2788
|
+
if (next !== source) {
|
|
2789
|
+
await writeFile3(configFile, next);
|
|
2790
|
+
placed.patched.push(CONFIG_FILE);
|
|
2791
|
+
}
|
|
2792
|
+
const app = readCompany(next) ?? { id: "my-app", name: "My App", description: "" };
|
|
2793
|
+
const miniapp2 = readMiniapp(next) ?? { entry: entry2 };
|
|
2794
|
+
await placeFiles(root, template, entry2, app.name, placed);
|
|
2795
|
+
const icon = join7(root, staticDir(template), (miniapp2.icon ?? "/icon.png").slice(1));
|
|
2796
|
+
if (!existsSync6(icon)) {
|
|
2797
|
+
const png = miniappFiles()["icon.png"];
|
|
2798
|
+
if (png && isBinary("icon.png")) {
|
|
2799
|
+
await mkdir2(dirname4(icon), { recursive: true });
|
|
2800
|
+
await writeFile3(icon, Buffer.from(png, "base64"));
|
|
2801
|
+
placed.written.push(relative2(root, icon));
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
let installNeeded = false;
|
|
2805
|
+
if (template !== "worker") {
|
|
2806
|
+
const pkgFile = join7(root, "package.json");
|
|
2807
|
+
const pkg = JSON.parse(await readFile3(pkgFile, "utf8"));
|
|
2808
|
+
const deps = pkg.dependencies ?? {};
|
|
2809
|
+
if (!deps["@ambasada/miniapp"]) {
|
|
2810
|
+
pkg.dependencies = Object.fromEntries(Object.entries({ ...deps, "@ambasada/miniapp": miniappSpec(deps, flags, opts.releaseLine) }).sort(([a], [b]) => a.localeCompare(b)));
|
|
2811
|
+
await writeFile3(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
2812
|
+
`);
|
|
2813
|
+
placed.patched.push("package.json");
|
|
2814
|
+
installNeeded = true;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
if (!handWritten(root)) await writeGenerated(root, template, app, miniapp2);
|
|
2818
|
+
return { ...placed, entry: entry2, installNeeded };
|
|
2819
|
+
}
|
|
2820
|
+
async function miniappCommand(args, flags, releaseLine) {
|
|
2821
|
+
const root = findRoot();
|
|
2822
|
+
if (!root) throw new CliError(`run \`ambasada miniapp\` inside your app (no ${CONFIG_FILE} found)`);
|
|
2823
|
+
const [sub] = args;
|
|
2824
|
+
const { miniapp: miniapp2 } = miniappOfSource(root);
|
|
2825
|
+
if (!sub || sub === "status") {
|
|
2826
|
+
const state = miniappState(root, miniapp2);
|
|
2827
|
+
if (flags.json) out(JSON.stringify(state, null, 2));
|
|
2828
|
+
else out(stateLine(state));
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
if (sub !== "enable") throw new CliError("usage: ambasada miniapp [enable [--entry /app]]");
|
|
2832
|
+
const current2 = miniappState(root, miniapp2);
|
|
2833
|
+
if (current2.source === "file") throw new CliError(`the mini app is on already, from ${current2.file} (yours, it wins): change its "entry" there`);
|
|
2834
|
+
const entry2 = typeof flags.entry === "string" ? flags.entry : miniapp2?.entry ?? DEFAULT_ENTRY;
|
|
2835
|
+
const moved = miniapp2 && miniapp2.entry !== entry2 ? miniapp2.entry : null;
|
|
2836
|
+
const done = await enableMiniapp(root, entry2, flags, { releaseLine });
|
|
2837
|
+
printEnabled(done, root);
|
|
2838
|
+
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`));
|
|
2839
|
+
}
|
|
2840
|
+
function printEnabled(done, root) {
|
|
2841
|
+
out(`${c.green("\u2713")} mini app on at ${c.bold(done.entry)}`);
|
|
2842
|
+
for (const f of done.patched) out(` ${c.dim("changed")} ${f}`);
|
|
2843
|
+
for (const f of done.written) out(` ${c.dim("added")} ${f}`);
|
|
2844
|
+
out(` ${c.dim("/.well-known/ambasada.json: written from ambasada.config.ts by `ambasada dev` and `deploy`")}`);
|
|
2845
|
+
if (done.written.some((f) => f.endsWith("icon.png"))) out(` ${c.dim("icon.png is a placeholder: replace it with your 1024\xD71024 PNG, no transparency")}`);
|
|
2846
|
+
for (const m of done.manual) out(c.yellow(`! by hand: ${m}`));
|
|
2847
|
+
if (done.installNeeded && existsSync6(join7(root, "node_modules"))) out(` then: npm install ${c.dim("(@ambasada/miniapp)")}`);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// src/check.ts
|
|
2851
|
+
var merge2 = (into, from) => {
|
|
2852
|
+
into.problems.push(...from.problems);
|
|
2853
|
+
into.warnings.push(...from.warnings);
|
|
2854
|
+
};
|
|
2855
|
+
async function checkLocal(dir, opts) {
|
|
2856
|
+
const target2 = resolvePath3(dir);
|
|
2857
|
+
const report = { ok: true, target: target2, problems: [], warnings: [], notes: [] };
|
|
2858
|
+
const { app, miniapp: miniapp2 } = opts ?? miniappOfSource(target2);
|
|
2859
|
+
const state = miniappState(target2, miniapp2);
|
|
2860
|
+
report.miniapp = { on: state.on, entry: state.entry };
|
|
2861
|
+
if (!state.on) {
|
|
2862
|
+
report.notes.push("mini app: off (`ambasada miniapp enable` adds one)");
|
|
2863
|
+
report.notes.push("the product rules (https, no redirects, response time) need a URL: `ambasada check https://\u2026`");
|
|
2864
|
+
return report;
|
|
2865
|
+
}
|
|
2866
|
+
let manifest = null;
|
|
2867
|
+
let staticRoot;
|
|
2868
|
+
if (state.file) {
|
|
2869
|
+
staticRoot = dirname5(dirname5(state.file));
|
|
2870
|
+
try {
|
|
2871
|
+
manifest = JSON.parse(readFileSync5(state.file, "utf8"));
|
|
2872
|
+
merge2(report, validateManifest(manifest));
|
|
2873
|
+
} catch (error) {
|
|
2874
|
+
report.problems.push({ code: "manifest_json", message: `${state.file} is not valid JSON: ${error.message}` });
|
|
2875
|
+
}
|
|
2876
|
+
if (miniapp2 && manifest && manifest.entry !== miniapp2.entry) {
|
|
2877
|
+
report.warnings.push({ code: "entry_mismatch", message: `ambasada.config.ts says miniapp.entry ${miniapp2.entry}, your ${state.file} says ${JSON.stringify(manifest.entry)}: yours wins` });
|
|
2878
|
+
}
|
|
2879
|
+
} else {
|
|
2880
|
+
staticRoot = join8(target2, staticDir(templateOf(target2)));
|
|
2881
|
+
manifest = appManifest(app ?? { id: "my-app", name: "My App", description: "" }, miniapp2);
|
|
2882
|
+
merge2(report, validateManifest(manifest));
|
|
2883
|
+
report.notes.push("manifest: generated from ambasada.config.ts (`ambasada dev` and `deploy` serve it)");
|
|
2884
|
+
}
|
|
2885
|
+
const iconPath = typeof manifest?.icon === "string" && manifest.icon.startsWith("/") ? join8(staticRoot, manifest.icon) : null;
|
|
2886
|
+
if (iconPath) {
|
|
2887
|
+
if (!existsSync7(iconPath)) report.problems.push({ code: "icon_missing", message: `the manifest's icon ${manifest.icon} is not in ${staticRoot}` });
|
|
2888
|
+
else merge2(report, checkIcon(readFileSync5(iconPath)));
|
|
2889
|
+
} else if (manifest && typeof manifest.icon === "string") report.notes.push(`icon ${manifest.icon}: not a local path, not checked`);
|
|
2890
|
+
const entry2 = state.entry ?? "/";
|
|
2891
|
+
const pages = entry2 === "/" ? [join8(staticRoot, "index.html")] : [join8(staticRoot, `${entry2.slice(1)}.html`), join8(staticRoot, entry2.slice(1), "index.html")];
|
|
2892
|
+
const page = pages.find((p) => existsSync7(p));
|
|
2893
|
+
if (page) merge2(report, checkHtml(readFileSync5(page, "utf8")));
|
|
2894
|
+
else report.notes.push(`no static ${pages[0]}: the entry page is rendered by the app, checked by \`ambasada check <url>\` once deployed`);
|
|
2895
|
+
report.ok = report.problems.length === 0;
|
|
2896
|
+
return report;
|
|
2897
|
+
}
|
|
2520
2898
|
|
|
2521
2899
|
// src/storage.ts
|
|
2522
2900
|
import { createHash } from "node:crypto";
|
|
2523
|
-
import { existsSync as
|
|
2524
|
-
import { mkdtemp, readdir as readdir2, readFile as
|
|
2901
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
2902
|
+
import { mkdtemp, readdir as readdir2, readFile as readFile6, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2525
2903
|
import { tmpdir } from "node:os";
|
|
2526
|
-
import { basename as basename2, extname as extname2, join as
|
|
2904
|
+
import { basename as basename2, extname as extname2, join as join11 } from "node:path";
|
|
2527
2905
|
import { createInterface } from "node:readline";
|
|
2528
2906
|
|
|
2529
2907
|
// src/deploy.ts
|
|
2530
2908
|
import { execFileSync as execFileSync2, spawn as spawn2 } from "node:child_process";
|
|
2531
|
-
import { existsSync as
|
|
2532
|
-
import { mkdir as
|
|
2909
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
2910
|
+
import { mkdir as mkdir4, readdir, readFile as readFile5, rm as rm4, stat, writeFile as writeFile5 } from "node:fs/promises";
|
|
2533
2911
|
import { createServer as createServer2 } from "node:net";
|
|
2534
|
-
import { basename, dirname as
|
|
2912
|
+
import { basename, dirname as dirname6, extname, join as join10, relative as relative4, resolve as resolvePath4, sep } from "node:path";
|
|
2535
2913
|
|
|
2536
2914
|
// src/upgrade.ts
|
|
2537
2915
|
import { execFileSync } from "node:child_process";
|
|
2538
|
-
import { existsSync as
|
|
2539
|
-
import { mkdir as
|
|
2540
|
-
import { join as
|
|
2541
|
-
var VERSION2 = true ? "0.8.
|
|
2916
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync3 } from "node:fs";
|
|
2917
|
+
import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
2918
|
+
import { join as join9, relative as relative3 } from "node:path";
|
|
2919
|
+
var VERSION2 = true ? "0.8.3" : "0.1.0-dev";
|
|
2542
2920
|
var RELEASE_SPEC = /^\d+\.\d+\.\d+$/.test(VERSION2) ? `^${VERSION2}` : "latest";
|
|
2543
|
-
var PROJECT_FILE =
|
|
2544
|
-
var
|
|
2921
|
+
var PROJECT_FILE = join9(".ambasada", "project.json");
|
|
2922
|
+
var out2 = (s = "") => process.stdout.write(`${s}
|
|
2545
2923
|
`);
|
|
2546
2924
|
async function readProjectInfo(root) {
|
|
2547
2925
|
try {
|
|
2548
|
-
return JSON.parse(await
|
|
2926
|
+
return JSON.parse(await readFile4(join9(root, PROJECT_FILE), "utf8"));
|
|
2549
2927
|
} catch {
|
|
2550
2928
|
return null;
|
|
2551
2929
|
}
|
|
2552
2930
|
}
|
|
2553
2931
|
async function writeProjectInfo(root, info) {
|
|
2554
|
-
await
|
|
2555
|
-
await
|
|
2932
|
+
await mkdir3(join9(root, ".ambasada"), { recursive: true });
|
|
2933
|
+
await writeFile4(join9(root, PROJECT_FILE), `${JSON.stringify(info, null, 2)}
|
|
2556
2934
|
`);
|
|
2557
2935
|
}
|
|
2558
2936
|
var devkitVersion = () => VERSION2;
|
|
@@ -2650,7 +3028,7 @@ function sourceFiles(root) {
|
|
|
2650
3028
|
const walk2 = (dir) => {
|
|
2651
3029
|
for (const name of readdirSync3(dir)) {
|
|
2652
3030
|
if (SKIP_DIRS.has(name)) continue;
|
|
2653
|
-
const path =
|
|
3031
|
+
const path = join9(dir, name);
|
|
2654
3032
|
if (statSync3(path).isDirectory()) walk2(path);
|
|
2655
3033
|
else if (SOURCE.test(name) && !name.endsWith(".d.ts")) found.push(path);
|
|
2656
3034
|
}
|
|
@@ -2660,12 +3038,12 @@ function sourceFiles(root) {
|
|
|
2660
3038
|
}
|
|
2661
3039
|
async function usesVillage(root, pkg) {
|
|
2662
3040
|
if (pkg.dependencies?.["@ambasada/village"] || pkg.devDependencies?.["@ambasada/village"]) return true;
|
|
2663
|
-
if (
|
|
2664
|
-
const config = await
|
|
3041
|
+
if (existsSync8(join9(root, "offerings.ts")) || existsSync8(join9(root, "product/offerings.ts"))) return true;
|
|
3042
|
+
const config = await readFile4(join9(root, CONFIG_FILE), "utf8").catch(() => "");
|
|
2665
3043
|
return /\bdefineCompany\b|\bidea\s*:/.test(config);
|
|
2666
3044
|
}
|
|
2667
|
-
function
|
|
2668
|
-
return detectStack(root)?.name ?? (
|
|
3045
|
+
function templateOf2(root) {
|
|
3046
|
+
return detectStack(root)?.name ?? (existsSync8(join9(root, "src/worker.ts")) ? "worker" : "next");
|
|
2669
3047
|
}
|
|
2670
3048
|
var specFor = (name) => process.env[`AMBASADA_${name.replace(/^@ambasada\//, "").toUpperCase()}_SPEC`] ?? RELEASE_SPEC;
|
|
2671
3049
|
async function upgrade(_args, flags) {
|
|
@@ -2673,16 +3051,16 @@ async function upgrade(_args, flags) {
|
|
|
2673
3051
|
if (!root) throw new CliError(`run \`ambasada upgrade\` inside your app (no ${CONFIG_FILE} found)`);
|
|
2674
3052
|
const checkOnly = flags.check === true;
|
|
2675
3053
|
const info = await readProjectInfo(root);
|
|
2676
|
-
const pkgFile =
|
|
2677
|
-
const pkg = JSON.parse(await
|
|
3054
|
+
const pkgFile = join9(root, "package.json");
|
|
3055
|
+
const pkg = JSON.parse(await readFile4(pkgFile, "utf8"));
|
|
2678
3056
|
const village = await usesVillage(root, pkg);
|
|
2679
|
-
const template = info?.template && TEMPLATES.includes(info.template) ? info.template :
|
|
3057
|
+
const template = info?.template && TEMPLATES.includes(info.template) ? info.template : templateOf2(root);
|
|
2680
3058
|
const changes = [];
|
|
2681
|
-
const rel = (p) =>
|
|
3059
|
+
const rel = (p) => relative3(root, p);
|
|
2682
3060
|
const before08 = !info || /^0\.[0-7]\./.test(info.devkit) && !info.devkit.includes("-dev");
|
|
2683
3061
|
const edits = /* @__PURE__ */ new Map();
|
|
2684
3062
|
for (const file of before08 ? sourceFiles(root) : []) {
|
|
2685
|
-
const text = await
|
|
3063
|
+
const text = await readFile4(file, "utf8");
|
|
2686
3064
|
if (!text.includes("@ambasada/sdk")) continue;
|
|
2687
3065
|
const next = rewriteImports(text, village);
|
|
2688
3066
|
if (next !== text) {
|
|
@@ -2691,9 +3069,9 @@ async function upgrade(_args, flags) {
|
|
|
2691
3069
|
}
|
|
2692
3070
|
}
|
|
2693
3071
|
for (const name of before08 ? [".env", ".dev.vars"] : []) {
|
|
2694
|
-
const file =
|
|
2695
|
-
if (!
|
|
2696
|
-
const text = await
|
|
3072
|
+
const file = join9(root, name);
|
|
3073
|
+
if (!existsSync8(file)) continue;
|
|
3074
|
+
const text = await readFile4(file, "utf8");
|
|
2697
3075
|
const next = rewriteEnv(text, village);
|
|
2698
3076
|
if (next !== text) {
|
|
2699
3077
|
edits.set(file, next);
|
|
@@ -2720,10 +3098,10 @@ async function upgrade(_args, flags) {
|
|
|
2720
3098
|
const skills = {};
|
|
2721
3099
|
for (const [path, text] of Object.entries(templateFiles(template))) if (path.startsWith(".claude/skills/ambasada-")) skills[path] = text.replaceAll("my-app", idName.id).replaceAll("My App", idName.name);
|
|
2722
3100
|
const stale = [];
|
|
2723
|
-
const skillsDir =
|
|
2724
|
-
if (
|
|
3101
|
+
const skillsDir = join9(root, ".claude/skills");
|
|
3102
|
+
if (existsSync8(join9(skillsDir, "ambasada-payments"))) stale.push("ambasada-payments");
|
|
2725
3103
|
for (const [path, text] of Object.entries(skills)) {
|
|
2726
|
-
const current2 = await
|
|
3104
|
+
const current2 = await readFile4(join9(root, path), "utf8").catch(() => null);
|
|
2727
3105
|
if (current2 !== text) changes.push(`${path.split("/").slice(0, 3).join("/")}: re-synced`);
|
|
2728
3106
|
}
|
|
2729
3107
|
const nextInfo = { template, devkit: VERSION2, addons: village ? ["village"] : info?.addons ?? [] };
|
|
@@ -2734,26 +3112,26 @@ async function upgrade(_args, flags) {
|
|
|
2734
3112
|
const from = info?.devkit ?? "0.7";
|
|
2735
3113
|
if (checkOnly) {
|
|
2736
3114
|
if (!uniqueChanges.length && !stale.length) {
|
|
2737
|
-
|
|
3115
|
+
out2(`${c.green("\u2713")} up to date with the devkit ${VERSION2}`);
|
|
2738
3116
|
return;
|
|
2739
3117
|
}
|
|
2740
|
-
|
|
2741
|
-
for (const line of uniqueChanges)
|
|
2742
|
-
for (const s of stale)
|
|
3118
|
+
out2(`devkit ${from} \u2192 ${VERSION2}:`);
|
|
3119
|
+
for (const line of uniqueChanges) out2(` ${line}`);
|
|
3120
|
+
for (const s of stale) out2(` .claude/skills/${s}: removed`);
|
|
2743
3121
|
throw new CliError("`ambasada upgrade` would change the above", 1);
|
|
2744
3122
|
}
|
|
2745
|
-
for (const [file, text] of edits) await
|
|
3123
|
+
for (const [file, text] of edits) await writeFile4(file, text);
|
|
2746
3124
|
pkg.dependencies = deps;
|
|
2747
3125
|
if (pkg.devDependencies) pkg.devDependencies = devDeps;
|
|
2748
|
-
await
|
|
3126
|
+
await writeFile4(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
2749
3127
|
`);
|
|
2750
3128
|
for (const [path, text] of Object.entries(skills)) {
|
|
2751
|
-
await
|
|
2752
|
-
await
|
|
3129
|
+
await mkdir3(join9(root, path, ".."), { recursive: true });
|
|
3130
|
+
await writeFile4(join9(root, path), text);
|
|
2753
3131
|
}
|
|
2754
|
-
for (const s of stale) await
|
|
3132
|
+
for (const s of stale) await rm3(join9(skillsDir, s), { recursive: true, force: true });
|
|
2755
3133
|
if (!flags["no-install"]) {
|
|
2756
|
-
|
|
3134
|
+
out2(c.dim("npm install \u2026"));
|
|
2757
3135
|
try {
|
|
2758
3136
|
execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
|
|
2759
3137
|
} catch {
|
|
@@ -2764,33 +3142,33 @@ async function upgrade(_args, flags) {
|
|
|
2764
3142
|
setAddons(addons2);
|
|
2765
3143
|
for (const addon of addons2.list) {
|
|
2766
3144
|
for (const [path, text] of Object.entries(addon.skills)) {
|
|
2767
|
-
const file =
|
|
2768
|
-
await
|
|
2769
|
-
await
|
|
3145
|
+
const file = join9(skillsDir, path);
|
|
3146
|
+
await mkdir3(join9(file, ".."), { recursive: true });
|
|
3147
|
+
await writeFile4(file, text);
|
|
2770
3148
|
}
|
|
2771
3149
|
}
|
|
2772
|
-
if (village && !addons2.get("village"))
|
|
3150
|
+
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"));
|
|
2773
3151
|
await writeProjectInfo(root, nextInfo);
|
|
2774
|
-
for (const line of uniqueChanges)
|
|
2775
|
-
for (const s of stale)
|
|
3152
|
+
for (const line of uniqueChanges) out2(`${c.green("\u2713")} ${line}`);
|
|
3153
|
+
for (const s of stale) out2(`${c.green("\u2713")} .claude/skills/${s}: removed (now ambasada-village)`);
|
|
2776
3154
|
const report = await checkLocal(root).catch(() => null);
|
|
2777
|
-
for (const p of report?.problems ?? [])
|
|
2778
|
-
|
|
3155
|
+
for (const p of report?.problems ?? []) out2(`${c.yellow("!")} ${p.message} ${c.dim(`(${p.code}, \`ambasada check\`)`)}`);
|
|
3156
|
+
out2(`${c.green("\u2713")} devkit ${from} \u2192 ${VERSION2}`);
|
|
2779
3157
|
if (before08) {
|
|
2780
|
-
|
|
2781
|
-
|
|
3158
|
+
out2("");
|
|
3159
|
+
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)`);
|
|
2782
3160
|
}
|
|
2783
3161
|
}
|
|
2784
3162
|
|
|
2785
3163
|
// src/deploy.ts
|
|
2786
|
-
var
|
|
3164
|
+
var out3 = (s = "") => process.stdout.write(`${s}
|
|
2787
3165
|
`);
|
|
2788
3166
|
var LIMITS = { assets: 1e3, assetBytes: 25 * 1024 * 1024 };
|
|
2789
3167
|
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"];
|
|
2790
3168
|
var PLATFORM_STORAGE = { d1: "DB", kv: "KV", r2: "FILES" };
|
|
2791
3169
|
async function deploy(_args, flags) {
|
|
2792
3170
|
const project = await loadProject();
|
|
2793
|
-
const hooks = (url = project.config.product?.url) => addons().afterDeploy({ event: "deploy", project, url, flags, out:
|
|
3171
|
+
const hooks = (url = project.config.product?.url) => addons().afterDeploy({ event: "deploy", project, url, flags, out: out3 });
|
|
2794
3172
|
if (flags["agent-only"] || flags["offerings-only"]) {
|
|
2795
3173
|
if (!addons().get("village")) throw new CliError("--agent-only and --offerings-only belong to the village add-on: `ambasada add village`");
|
|
2796
3174
|
return hooks();
|
|
@@ -2798,47 +3176,52 @@ async function deploy(_args, flags) {
|
|
|
2798
3176
|
const dir = findProductDir(project.root);
|
|
2799
3177
|
if (!dir) {
|
|
2800
3178
|
if (!project.config.product?.url || !addons().list.length) productDir(project.root);
|
|
2801
|
-
|
|
3179
|
+
out3(c.dim(`no wrangler.jsonc: not hosting ${project.config.product.url}`));
|
|
2802
3180
|
return hooks();
|
|
2803
3181
|
}
|
|
2804
3182
|
const template = detectTemplate(dir);
|
|
2805
3183
|
if (!flags.own && await isMockApi((await resolve(flags)).apiUrl)) {
|
|
2806
|
-
|
|
3184
|
+
out3(c.dim("ambasada dev does not host apps (deploy without --api-url to go live)"));
|
|
2807
3185
|
return hooks();
|
|
2808
3186
|
}
|
|
3187
|
+
const inApp = await syncManifest(project.root, project.config);
|
|
3188
|
+
out3(stateLine(inApp));
|
|
2809
3189
|
if (!flags["no-build"]) await build(dir, template);
|
|
2810
3190
|
if (flags.own) {
|
|
2811
|
-
|
|
3191
|
+
out3(c.bold("wrangler deploy (your Cloudflare account)"));
|
|
2812
3192
|
const output = await run(bin(dir, "wrangler"), ["deploy"], dir, { capture: true });
|
|
2813
3193
|
const url = /https:\/\/[^\s]+\.workers\.dev|https:\/\/[a-z0-9.-]+\.[a-z]{2,}(?=\s|$)/m.exec(output)?.[0];
|
|
2814
3194
|
if (!url) throw new CliError("wrangler deployed, but I could not read the URL from its output: run `ambasada link https://\u2026` yourself");
|
|
2815
3195
|
await link([url], flags);
|
|
2816
|
-
return addons().afterDeploy({ event: "deploy", project: await loadProject(project.root), url, flags: { ...flags, "agent-only": true }, out:
|
|
3196
|
+
return addons().afterDeploy({ event: "deploy", project: await loadProject(project.root), url, flags: { ...flags, "agent-only": true }, out: out3 });
|
|
2817
3197
|
}
|
|
2818
3198
|
const built = await bundle(dir, template);
|
|
2819
3199
|
durableObjects(built.config);
|
|
2820
|
-
for (const warning of storageWarnings(built.config))
|
|
2821
|
-
|
|
3200
|
+
for (const warning of storageWarnings(built.config)) out3(c.yellow(`! ${warning}`));
|
|
3201
|
+
out3(`${c.green("\u2713")} built ${c.bold(template)}: ${built.modules.length} module(s), ${built.assets.length} static file(s)`);
|
|
2822
3202
|
const smoked = flags["no-smoke"] ? { ok: false, skipped: true, checks: [], ms: null } : await smoke(built, project.config.id);
|
|
2823
3203
|
const r = await resolve(flags);
|
|
2824
3204
|
const metadata = { ...deployMetadata(built, project.root), report: { template, devkit: devkitVersion(), smoke: smoked, git: gitInfo(project.root) } };
|
|
2825
3205
|
if (flags["dry-run"]) {
|
|
2826
|
-
|
|
3206
|
+
out3(`would POST ${r.apiUrl}/v1/product/deploy ${JSON.stringify({ ...metadata, modules: built.modules.map((m) => m.name), assets: built.assets.length })}`);
|
|
2827
3207
|
return hooks();
|
|
2828
3208
|
}
|
|
2829
3209
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
2830
3210
|
const deployment = await upload(r.apiUrl, r.apiKey, built, metadata);
|
|
2831
|
-
|
|
2832
|
-
for (const line of resourceLines(deployment.resources))
|
|
2833
|
-
if (deployment.offerings_moved?.length)
|
|
2834
|
-
if (deployment.webhook)
|
|
2835
|
-
if (deployment.
|
|
3211
|
+
out3(`${c.green("\u2713")} live at ${c.bold(c.cyan(deployment.url))} ${c.dim(`v${deployment.version}, ${kb(deployment.bundle_bytes)} gzipped`)}`);
|
|
3212
|
+
for (const line of resourceLines(deployment.resources)) out3(line);
|
|
3213
|
+
if (deployment.offerings_moved?.length) out3(` offerings moved to it: ${deployment.offerings_moved.join(", ")}`);
|
|
3214
|
+
if (deployment.webhook) out3(` webhooks \u2192 ${deployment.webhook}`);
|
|
3215
|
+
if (deployment.miniapp === false) {
|
|
3216
|
+
out3(` ${c.dim("mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)")}`);
|
|
3217
|
+
for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out3(c.yellow(`! ${issue.message}`));
|
|
3218
|
+
} else if (deployment.tier) {
|
|
2836
3219
|
const line = listingLine(deployment.tier, deployment.check);
|
|
2837
|
-
|
|
2838
|
-
for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? [])
|
|
3220
|
+
out3(` ${deployment.tier === "listed" || deployment.tier === "verified" ? c.green("\u2713") : c.yellow("!")} in the Ambasada app: ${line}`);
|
|
3221
|
+
for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out3(c.dim(` ${issue.message}`));
|
|
2839
3222
|
}
|
|
2840
3223
|
if (!await setProductUrl(project.root, deployment.url)) {
|
|
2841
|
-
|
|
3224
|
+
out3(c.yellow(`! add \`product: { url: "${deployment.url}" }\` to ambasada.config.ts`));
|
|
2842
3225
|
}
|
|
2843
3226
|
if (deployment.kind === "project") return;
|
|
2844
3227
|
await hooks(deployment.url);
|
|
@@ -2853,41 +3236,41 @@ async function isMockApi(apiUrl) {
|
|
|
2853
3236
|
}
|
|
2854
3237
|
}
|
|
2855
3238
|
function findProductDir(root) {
|
|
2856
|
-
for (const d of [root,
|
|
3239
|
+
for (const d of [root, join10(root, "product")]) if (["wrangler.jsonc", "wrangler.json"].some((f) => existsSync9(join10(d, f)))) return d;
|
|
2857
3240
|
return null;
|
|
2858
3241
|
}
|
|
2859
3242
|
function productDir(root) {
|
|
2860
3243
|
const dir = findProductDir(root);
|
|
2861
3244
|
if (dir) return dir;
|
|
2862
|
-
if (
|
|
3245
|
+
if (existsSync9(join10(root, "wrangler.toml"))) throw new CliError("wrangler.toml is not supported by `ambasada deploy`: use wrangler.jsonc");
|
|
2863
3246
|
throw new CliError("no wrangler.jsonc here, so `ambasada deploy` can't host this app. Host it anywhere and `ambasada link <url>`");
|
|
2864
3247
|
}
|
|
2865
3248
|
function detectTemplate(dir) {
|
|
2866
|
-
const has = (...files2) => files2.some((f) =>
|
|
3249
|
+
const has = (...files2) => files2.some((f) => existsSync9(join10(dir, f)));
|
|
2867
3250
|
if (has("open-next.config.ts", "open-next.config.js", "next.config.ts", "next.config.js", "next.config.mjs")) return "next";
|
|
2868
3251
|
if (has("vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts")) return "spa";
|
|
2869
3252
|
return "worker";
|
|
2870
3253
|
}
|
|
2871
3254
|
async function build(dir, template) {
|
|
2872
3255
|
if (template === "next") {
|
|
2873
|
-
|
|
3256
|
+
out3(c.bold("opennextjs-cloudflare build"));
|
|
2874
3257
|
await run(bin(dir, "opennextjs-cloudflare", "@opennextjs/cloudflare"), ["build"], dir);
|
|
2875
3258
|
} else if (template === "spa") {
|
|
2876
|
-
|
|
3259
|
+
out3(c.bold("vite build"));
|
|
2877
3260
|
await run(bin(dir, "vite"), ["build"], dir);
|
|
2878
3261
|
}
|
|
2879
3262
|
}
|
|
2880
3263
|
async function bundle(dir, template) {
|
|
2881
3264
|
const { file: configFile, config } = await readWranglerConfig(dir);
|
|
2882
|
-
const outdir =
|
|
2883
|
-
await
|
|
3265
|
+
const outdir = join10(dir, ".ambasada", "deploy");
|
|
3266
|
+
await rm4(outdir, { recursive: true, force: true });
|
|
2884
3267
|
await run(bin(dir, "wrangler"), ["deploy", "--dry-run", "--outdir", outdir], dir, { capture: true });
|
|
2885
|
-
const modules = (await walk(outdir)).map((path) => ({ name:
|
|
3268
|
+
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");
|
|
2886
3269
|
const wanted = config.main ? `${basename(config.main, extname(config.main))}.js` : "index.js";
|
|
2887
3270
|
const main2 = modules.find((m) => m.name === wanted) ?? modules.find((m) => m.name === "index.js") ?? (modules.filter((m) => /\.m?js$/.test(m.name)).length === 1 ? modules.find((m) => /\.m?js$/.test(m.name)) : void 0);
|
|
2888
3271
|
if (!main2) throw new CliError(`wrangler wrote ${modules.map((m) => m.name).join(", ") || "nothing"} to ${outdir}: can't tell which is the main module`);
|
|
2889
|
-
const assetsDir = config.assets?.directory ? resolvePath4(
|
|
2890
|
-
const assets = assetsDir &&
|
|
3272
|
+
const assetsDir = config.assets?.directory ? resolvePath4(dirname6(configFile), config.assets.directory) : null;
|
|
3273
|
+
const assets = assetsDir && existsSync9(assetsDir) ? await collectAssets(assetsDir) : [];
|
|
2891
3274
|
if (assets.length > LIMITS.assets) throw new CliError(`${assets.length} static files in ${assetsDir}; ambasada.dev hosts up to ${LIMITS.assets}`);
|
|
2892
3275
|
const big = assets.find((a) => a.size > LIMITS.assetBytes);
|
|
2893
3276
|
if (big) throw new CliError(`${big.file} is ${kb(big.size)}; static files are at most 25 MB`);
|
|
@@ -2897,7 +3280,7 @@ function deployMetadata(built, root) {
|
|
|
2897
3280
|
const a = built.config.assets ?? {};
|
|
2898
3281
|
const assetsConfig = Object.fromEntries(Object.entries({ html_handling: a.html_handling, not_found_handling: a.not_found_handling, run_worker_first: a.run_worker_first }).filter(([, v]) => v !== void 0));
|
|
2899
3282
|
const vars = Object.fromEntries(Object.entries(built.config.vars ?? {}).filter(([k, v]) => typeof v === "string" && !/^(AMBASADA|VILLAGE)_/.test(k)));
|
|
2900
|
-
const webhook = WEBHOOK_FILES.some((f) =>
|
|
3283
|
+
const webhook = WEBHOOK_FILES.some((f) => existsSync9(join10(root, f)) || existsSync9(join10(built.dir, f)));
|
|
2901
3284
|
return {
|
|
2902
3285
|
main_module: built.mainModule,
|
|
2903
3286
|
compatibility_date: built.config.compatibility_date,
|
|
@@ -2911,9 +3294,9 @@ function deployMetadata(built, root) {
|
|
|
2911
3294
|
}
|
|
2912
3295
|
function durableObjects(config) {
|
|
2913
3296
|
const bindings = config.durable_objects?.bindings ?? [];
|
|
2914
|
-
const
|
|
3297
|
+
const out7 = {};
|
|
2915
3298
|
if (bindings.length) {
|
|
2916
|
-
|
|
3299
|
+
out7.durable_objects = bindings.map((b) => {
|
|
2917
3300
|
if (!b.name || !b.class_name) throw new CliError("every durable_objects.bindings entry in wrangler.jsonc needs a name and a class_name");
|
|
2918
3301
|
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`);
|
|
2919
3302
|
return { name: b.name, class_name: b.class_name };
|
|
@@ -2921,13 +3304,13 @@ function durableObjects(config) {
|
|
|
2921
3304
|
}
|
|
2922
3305
|
const migrations = config.migrations ?? [];
|
|
2923
3306
|
if (migrations.length) {
|
|
2924
|
-
|
|
3307
|
+
out7.migrations = migrations.map((m) => {
|
|
2925
3308
|
if (!m || typeof m.tag !== "string" || !m.tag) throw new CliError("every entry of `migrations` in wrangler.jsonc needs a tag");
|
|
2926
3309
|
const keep = ["tag", "new_sqlite_classes", "new_classes", "renamed_classes", "deleted_classes", "transferred_classes"];
|
|
2927
3310
|
return Object.fromEntries(keep.filter((k) => m[k] !== void 0).map((k) => [k, m[k]]));
|
|
2928
3311
|
});
|
|
2929
3312
|
}
|
|
2930
|
-
return
|
|
3313
|
+
return out7;
|
|
2931
3314
|
}
|
|
2932
3315
|
function storageWarnings(config) {
|
|
2933
3316
|
const warnings = [];
|
|
@@ -2942,17 +3325,17 @@ function storageWarnings(config) {
|
|
|
2942
3325
|
return warnings;
|
|
2943
3326
|
}
|
|
2944
3327
|
function localBindings(config) {
|
|
2945
|
-
const
|
|
3328
|
+
const out7 = {};
|
|
2946
3329
|
const d1 = (config.d1_databases ?? []).filter((b) => b.binding);
|
|
2947
|
-
if (d1.length)
|
|
3330
|
+
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()}` }));
|
|
2948
3331
|
const kv2 = (config.kv_namespaces ?? []).filter((b) => b.binding);
|
|
2949
|
-
if (kv2.length)
|
|
3332
|
+
if (kv2.length) out7.kv_namespaces = kv2.map((b) => ({ binding: b.binding, id: b.id ?? `local-${b.binding.toLowerCase()}` }));
|
|
2950
3333
|
const r2 = (config.r2_buckets ?? []).filter((b) => b.binding);
|
|
2951
|
-
if (r2.length)
|
|
3334
|
+
if (r2.length) out7.r2_buckets = r2.map((b) => ({ binding: b.binding, bucket_name: b.bucket_name ?? `local-${b.binding.toLowerCase()}` }));
|
|
2952
3335
|
const dos = (config.durable_objects?.bindings ?? []).filter((b) => b.name && b.class_name && !b.script_name);
|
|
2953
|
-
if (dos.length)
|
|
2954
|
-
if (config.migrations?.length)
|
|
2955
|
-
return
|
|
3336
|
+
if (dos.length) out7.durable_objects = { bindings: dos.map((b) => ({ name: b.name, class_name: b.class_name })) };
|
|
3337
|
+
if (config.migrations?.length) out7.migrations = config.migrations;
|
|
3338
|
+
return out7;
|
|
2956
3339
|
}
|
|
2957
3340
|
function resourceLines(r) {
|
|
2958
3341
|
if (!r) return [];
|
|
@@ -2979,9 +3362,9 @@ function resourceLines(r) {
|
|
|
2979
3362
|
async function upload(apiUrl, apiKey, built, metadata) {
|
|
2980
3363
|
const form = new FormData();
|
|
2981
3364
|
form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
|
|
2982
|
-
for (const m of built.modules) form.append(`module:${m.name}`, new Blob([await
|
|
2983
|
-
for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await
|
|
2984
|
-
|
|
3365
|
+
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);
|
|
3366
|
+
for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile5(a.file)]), basename(a.file));
|
|
3367
|
+
out3(c.dim(`uploading to ${apiUrl} \u2026`));
|
|
2985
3368
|
let res;
|
|
2986
3369
|
try {
|
|
2987
3370
|
res = await fetch(`${apiUrl}/v1/product/deploy`, { method: "POST", headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }, body: form, signal: AbortSignal.timeout(3e5) });
|
|
@@ -2994,14 +3377,14 @@ async function upload(apiUrl, apiKey, built, metadata) {
|
|
|
2994
3377
|
}
|
|
2995
3378
|
async function smoke(built, company) {
|
|
2996
3379
|
const started = Date.now();
|
|
2997
|
-
const smokeDir =
|
|
2998
|
-
await
|
|
2999
|
-
await
|
|
3000
|
-
const outdir =
|
|
3380
|
+
const smokeDir = join10(built.dir, ".ambasada", "smoke");
|
|
3381
|
+
await rm4(smokeDir, { recursive: true, force: true });
|
|
3382
|
+
await mkdir4(smokeDir, { recursive: true });
|
|
3383
|
+
const outdir = dirname6(built.modules.find((m) => m.name === built.mainModule).path);
|
|
3001
3384
|
const a = built.config.assets;
|
|
3002
3385
|
const config = {
|
|
3003
3386
|
name: built.config.name ?? company,
|
|
3004
|
-
main:
|
|
3387
|
+
main: join10(outdir, built.mainModule),
|
|
3005
3388
|
compatibility_date: built.config.compatibility_date,
|
|
3006
3389
|
compatibility_flags: built.config.compatibility_flags ?? [],
|
|
3007
3390
|
no_bundle: true,
|
|
@@ -3013,20 +3396,20 @@ async function smoke(built, company) {
|
|
|
3013
3396
|
// DB, KV, FILES and the Durable Objects as wrangler simulates them locally, in a fresh state under .ambasada/smoke.
|
|
3014
3397
|
...localBindings(built.config)
|
|
3015
3398
|
};
|
|
3016
|
-
const configPath2 =
|
|
3017
|
-
await
|
|
3018
|
-
const persistTo =
|
|
3399
|
+
const configPath2 = join10(smokeDir, "wrangler.json");
|
|
3400
|
+
await writeFile5(configPath2, JSON.stringify(config, null, 2));
|
|
3401
|
+
const persistTo = join10(smokeDir, "state");
|
|
3019
3402
|
if (config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) {
|
|
3020
3403
|
const root = findRoot(built.dir) ?? built.dir;
|
|
3021
3404
|
try {
|
|
3022
3405
|
await migrateLocal({ dir: built.dir, root, config: configPath2, persistTo, wrangler: bin(built.dir, "wrangler") });
|
|
3023
3406
|
} catch (error) {
|
|
3024
|
-
|
|
3407
|
+
out3(c.yellow(`! smoke test: could not apply db/migrations locally: ${error.message.split("\n")[0]}`));
|
|
3025
3408
|
}
|
|
3026
3409
|
}
|
|
3027
3410
|
const port = await freePort();
|
|
3028
|
-
|
|
3029
|
-
const child = spawn2(bin(built.dir, "wrangler"), ["dev", "--config",
|
|
3411
|
+
out3(c.dim(`smoke test: wrangler dev on :${port} \u2026`));
|
|
3412
|
+
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"], {
|
|
3030
3413
|
cwd: built.dir,
|
|
3031
3414
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3032
3415
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false", CI: "1" }
|
|
@@ -3048,11 +3431,11 @@ ${(await res.text()).slice(0, 500)}`);
|
|
|
3048
3431
|
await res.body?.cancel();
|
|
3049
3432
|
checks.push({ method: check2.method, path: check2.path, status: res.status });
|
|
3050
3433
|
}
|
|
3051
|
-
|
|
3434
|
+
out3(`${c.green("\u2713")} smoke test: ${checks.map((k) => `${k.method} ${k.path} ${k.status}`).join(", ")}`);
|
|
3052
3435
|
return { ok: true, skipped: false, checks, ms: Date.now() - started };
|
|
3053
3436
|
} finally {
|
|
3054
3437
|
child.kill("SIGTERM");
|
|
3055
|
-
await
|
|
3438
|
+
await rm4(smokeDir, { recursive: true, force: true });
|
|
3056
3439
|
}
|
|
3057
3440
|
}
|
|
3058
3441
|
function gitInfo(dir) {
|
|
@@ -3092,16 +3475,16 @@ function freePort() {
|
|
|
3092
3475
|
});
|
|
3093
3476
|
}
|
|
3094
3477
|
async function readWranglerConfig(dir) {
|
|
3095
|
-
const redirect2 =
|
|
3478
|
+
const redirect2 = join10(dir, ".wrangler", "deploy", "config.json");
|
|
3096
3479
|
let file;
|
|
3097
|
-
if (
|
|
3098
|
-
const target2 = JSON.parse(await
|
|
3099
|
-
if (target2) file = resolvePath4(
|
|
3480
|
+
if (existsSync9(redirect2)) {
|
|
3481
|
+
const target2 = JSON.parse(await readFile5(redirect2, "utf8")).configPath;
|
|
3482
|
+
if (target2) file = resolvePath4(dirname6(redirect2), target2);
|
|
3100
3483
|
}
|
|
3101
|
-
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) =>
|
|
3484
|
+
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) => join10(dir, f)).find((f) => existsSync9(f));
|
|
3102
3485
|
if (!file) throw new CliError(`no wrangler.jsonc in ${dir}`);
|
|
3103
3486
|
try {
|
|
3104
|
-
return { file, config: JSON.parse(stripJsonc(await
|
|
3487
|
+
return { file, config: JSON.parse(stripJsonc(await readFile5(file, "utf8"))) };
|
|
3105
3488
|
} catch (error) {
|
|
3106
3489
|
throw new CliError(`${file} is not valid JSON(C): ${error.message}`);
|
|
3107
3490
|
}
|
|
@@ -3126,11 +3509,11 @@ function stripJsonc(text) {
|
|
|
3126
3509
|
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
3127
3510
|
}
|
|
3128
3511
|
async function collectAssets(dir) {
|
|
3129
|
-
const ignore =
|
|
3512
|
+
const ignore = existsSync9(join10(dir, ".assetsignore")) ? (await readFile5(join10(dir, ".assetsignore"), "utf8")).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) : [];
|
|
3130
3513
|
const patterns = ignore.map((p) => new RegExp(`^${p.replace(/^\//, "").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*")}(/.*)?$`));
|
|
3131
3514
|
const files2 = [];
|
|
3132
3515
|
for (const file of await walk(dir)) {
|
|
3133
|
-
const rel =
|
|
3516
|
+
const rel = relative4(dir, file).split(sep).join("/");
|
|
3134
3517
|
const name = basename(rel);
|
|
3135
3518
|
if (name === ".assetsignore" || name === ".DS_Store" || name === "_headers" || name === "_redirects" || rel.startsWith("node_modules/")) continue;
|
|
3136
3519
|
if (patterns.some((p) => p.test(rel) || p.test(name))) continue;
|
|
@@ -3141,17 +3524,17 @@ async function collectAssets(dir) {
|
|
|
3141
3524
|
async function walk(dir) {
|
|
3142
3525
|
const files2 = [];
|
|
3143
3526
|
for (const entry2 of await readdir(dir, { withFileTypes: true })) {
|
|
3144
|
-
const path =
|
|
3527
|
+
const path = join10(dir, entry2.name);
|
|
3145
3528
|
if (entry2.isDirectory()) files2.push(...await walk(path));
|
|
3146
3529
|
else if (entry2.isFile()) files2.push(path);
|
|
3147
3530
|
}
|
|
3148
3531
|
return files2.sort();
|
|
3149
3532
|
}
|
|
3150
3533
|
function bin(dir, name, pkg = name) {
|
|
3151
|
-
for (let d = dir; ; d =
|
|
3152
|
-
const local =
|
|
3153
|
-
if (
|
|
3154
|
-
if (
|
|
3534
|
+
for (let d = dir; ; d = dirname6(d)) {
|
|
3535
|
+
const local = join10(d, "node_modules", ".bin", name);
|
|
3536
|
+
if (existsSync9(local)) return local;
|
|
3537
|
+
if (dirname6(d) === d) return `npx --yes ${pkg}`;
|
|
3155
3538
|
}
|
|
3156
3539
|
}
|
|
3157
3540
|
function run(command, args, cwd, opts = {}) {
|
|
@@ -3172,10 +3555,10 @@ ${output.slice(-3e3)}` : ""}`));
|
|
|
3172
3555
|
var kb = (n) => n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`;
|
|
3173
3556
|
|
|
3174
3557
|
// src/storage.ts
|
|
3175
|
-
var
|
|
3558
|
+
var out4 = (s = "") => void process.stdout.write(`${s}
|
|
3176
3559
|
`);
|
|
3177
3560
|
var str2 = (v) => typeof v === "string" && v ? v : void 0;
|
|
3178
|
-
var MIGRATIONS_DIR =
|
|
3561
|
+
var MIGRATIONS_DIR = join11("db", "migrations");
|
|
3179
3562
|
var MIGRATIONS_TABLE = "_ambasada_migrations";
|
|
3180
3563
|
var CREATE_MIGRATIONS = `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, sha256 TEXT);`;
|
|
3181
3564
|
var PRODUCTS_DOMAIN = "ambasada.dev";
|
|
@@ -3183,11 +3566,11 @@ async function target(flags) {
|
|
|
3183
3566
|
const r = await resolve(flags);
|
|
3184
3567
|
let local = flags.local === true;
|
|
3185
3568
|
if (!local && await isMockApi(r.apiUrl)) {
|
|
3186
|
-
|
|
3569
|
+
out4(c.dim(`${r.apiUrl} is ambasada dev: using wrangler's local state (--local)`));
|
|
3187
3570
|
local = true;
|
|
3188
3571
|
}
|
|
3189
3572
|
if (!local && !r.apiKey && findProductDir(findRoot() ?? process.cwd())) {
|
|
3190
|
-
|
|
3573
|
+
out4(c.dim("not logged in: using wrangler's local state (--local); `ambasada login` for the hosted app"));
|
|
3191
3574
|
local = true;
|
|
3192
3575
|
}
|
|
3193
3576
|
if (local) {
|
|
@@ -3246,23 +3629,23 @@ ${output.slice(-1e3)}`);
|
|
|
3246
3629
|
}
|
|
3247
3630
|
}
|
|
3248
3631
|
async function readMigrations(root) {
|
|
3249
|
-
const dir =
|
|
3250
|
-
if (!
|
|
3632
|
+
const dir = join11(root, MIGRATIONS_DIR);
|
|
3633
|
+
if (!existsSync10(dir)) return [];
|
|
3251
3634
|
const names2 = (await readdir2(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
3252
|
-
return Promise.all(names2.map(async (name) => ({ name, sql: await
|
|
3635
|
+
return Promise.all(names2.map(async (name) => ({ name, sql: await readFile6(join11(dir, name), "utf8") })));
|
|
3253
3636
|
}
|
|
3254
3637
|
var sha2562 = (s) => createHash("sha256").update(s).digest("hex");
|
|
3255
3638
|
var names = (list) => Array.isArray(list) ? list.map((x) => typeof x === "string" ? x : String(x?.name ?? "")).filter(Boolean) : [];
|
|
3256
3639
|
var sqlString = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
3257
3640
|
async function localQuery(t, sql, extra = {}) {
|
|
3258
|
-
const dir = await mkdtemp(
|
|
3641
|
+
const dir = await mkdtemp(join11(tmpdir(), "ambasada-sql-"));
|
|
3259
3642
|
let rows;
|
|
3260
3643
|
try {
|
|
3261
|
-
const file =
|
|
3262
|
-
await
|
|
3644
|
+
const file = join11(dir, "query.sql");
|
|
3645
|
+
await writeFile6(file, sql);
|
|
3263
3646
|
rows = wranglerJson(await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--json", "--yes"], extra));
|
|
3264
3647
|
} finally {
|
|
3265
|
-
await
|
|
3648
|
+
await rm5(dir, { recursive: true, force: true });
|
|
3266
3649
|
}
|
|
3267
3650
|
return rows.map((r) => {
|
|
3268
3651
|
const results = r.results ?? [];
|
|
@@ -3275,13 +3658,13 @@ async function localQuery(t, sql, extra = {}) {
|
|
|
3275
3658
|
});
|
|
3276
3659
|
}
|
|
3277
3660
|
async function localExec(t, sql, extra = {}) {
|
|
3278
|
-
const dir = await mkdtemp(
|
|
3661
|
+
const dir = await mkdtemp(join11(tmpdir(), "ambasada-sql-"));
|
|
3279
3662
|
try {
|
|
3280
|
-
const file =
|
|
3281
|
-
await
|
|
3663
|
+
const file = join11(dir, "migration.sql");
|
|
3664
|
+
await writeFile6(file, sql);
|
|
3282
3665
|
await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--yes"], extra);
|
|
3283
3666
|
} finally {
|
|
3284
|
-
await
|
|
3667
|
+
await rm5(dir, { recursive: true, force: true });
|
|
3285
3668
|
}
|
|
3286
3669
|
}
|
|
3287
3670
|
async function migrateLocal(opts) {
|
|
@@ -3321,7 +3704,7 @@ INSERT INTO ${MIGRATIONS_TABLE} ${hasSha ? `(name, sha256) VALUES (${sqlString(m
|
|
|
3321
3704
|
}
|
|
3322
3705
|
async function migrateBeforeDev(root, print) {
|
|
3323
3706
|
const dir = findProductDir(root);
|
|
3324
|
-
if (!dir || !
|
|
3707
|
+
if (!dir || !existsSync10(join11(root, MIGRATIONS_DIR))) return;
|
|
3325
3708
|
try {
|
|
3326
3709
|
const { config } = await readWranglerConfig(dir);
|
|
3327
3710
|
if (!config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) return;
|
|
@@ -3334,26 +3717,26 @@ async function migrateBeforeDev(root, print) {
|
|
|
3334
3717
|
}
|
|
3335
3718
|
function printMigrate(r, dryRun, where) {
|
|
3336
3719
|
if (dryRun) {
|
|
3337
|
-
|
|
3720
|
+
out4(r.pending.length ? `would apply to ${where}: ${r.pending.join(", ")}` : `${where}: nothing to apply`);
|
|
3338
3721
|
} else {
|
|
3339
|
-
for (const name of r.applied)
|
|
3340
|
-
if (!r.applied.length)
|
|
3722
|
+
for (const name of r.applied) out4(`${c.green("\u2713")} applied ${c.bold(name)}`);
|
|
3723
|
+
if (!r.applied.length) out4(`${c.green("\u2713")} ${where}: up to date`);
|
|
3341
3724
|
}
|
|
3342
|
-
if (r.skipped.length)
|
|
3343
|
-
if (r.changed.length)
|
|
3725
|
+
if (r.skipped.length) out4(c.dim(` already applied: ${r.skipped.join(", ")}`));
|
|
3726
|
+
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(", ")}`));
|
|
3344
3727
|
}
|
|
3345
3728
|
function printResults(results) {
|
|
3346
3729
|
results.forEach((r, i) => {
|
|
3347
|
-
if (results.length > 1)
|
|
3730
|
+
if (results.length > 1) out4(c.dim(`-- statement ${i + 1}`));
|
|
3348
3731
|
if (r.columns.length) {
|
|
3349
3732
|
const cols = r.columns.map((col, j) => r.columns.indexOf(col) === j ? col : `${col}:${j}`);
|
|
3350
|
-
|
|
3733
|
+
out4(table(r.rows.map((row) => Object.fromEntries(cols.map((col, j) => [col, row[j]]))), cols));
|
|
3351
3734
|
}
|
|
3352
3735
|
const m = r.meta ?? {};
|
|
3353
3736
|
const parts = [`${r.rows.length} row${r.rows.length === 1 ? "" : "s"}`];
|
|
3354
3737
|
if (m.changes) parts.push(`${m.changes} changed`);
|
|
3355
3738
|
if (typeof m.duration_ms === "number") parts.push(`${Math.round(m.duration_ms * 100) / 100} ms`);
|
|
3356
|
-
|
|
3739
|
+
out4(c.dim(parts.join(" \xB7 ")));
|
|
3357
3740
|
});
|
|
3358
3741
|
}
|
|
3359
3742
|
async function query(t, sql) {
|
|
@@ -3370,7 +3753,7 @@ async function tables(t) {
|
|
|
3370
3753
|
}
|
|
3371
3754
|
async function shell(t, flags) {
|
|
3372
3755
|
const where = t.local ? "local" : "ambasada.dev";
|
|
3373
|
-
|
|
3756
|
+
out4(`${c.bold("ambasada db shell")} ${c.dim(`(${where}) \xB7 end statements with ; \xB7 .tables \xB7 .schema [table] \xB7 .quit`)}`);
|
|
3374
3757
|
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY, historySize: 200, prompt: "db> " });
|
|
3375
3758
|
let buffer = "";
|
|
3376
3759
|
const run2 = async (line) => {
|
|
@@ -3378,19 +3761,19 @@ async function shell(t, flags) {
|
|
|
3378
3761
|
if (!buffer && trimmed.startsWith(".")) {
|
|
3379
3762
|
const [cmd, arg] = trimmed.split(/\s+/);
|
|
3380
3763
|
if (cmd === ".quit" || cmd === ".exit") return rl.close();
|
|
3381
|
-
if (cmd === ".tables") return
|
|
3764
|
+
if (cmd === ".tables") return out4(table((await tables(t)).map((x) => ({ table: x.name, rows: x.rows }))));
|
|
3382
3765
|
if (cmd === ".schema") {
|
|
3383
|
-
for (const x of (await tables(t)).filter((x2) => !arg || x2.name === arg))
|
|
3766
|
+
for (const x of (await tables(t)).filter((x2) => !arg || x2.name === arg)) out4(`${x.sql ?? `-- ${x.name}`};`);
|
|
3384
3767
|
return;
|
|
3385
3768
|
}
|
|
3386
|
-
return
|
|
3769
|
+
return out4(c.yellow(`unknown command ${cmd}: .tables, .schema [table], .quit`));
|
|
3387
3770
|
}
|
|
3388
3771
|
buffer += `${line}
|
|
3389
3772
|
`;
|
|
3390
3773
|
if (!/;\s*$/.test(trimmed)) return;
|
|
3391
3774
|
const sql = buffer;
|
|
3392
3775
|
buffer = "";
|
|
3393
|
-
if (flags.json)
|
|
3776
|
+
if (flags.json) out4(JSON.stringify(await query(t, sql), null, 2));
|
|
3394
3777
|
else printResults(await query(t, sql));
|
|
3395
3778
|
};
|
|
3396
3779
|
rl.prompt();
|
|
@@ -3398,7 +3781,7 @@ async function shell(t, flags) {
|
|
|
3398
3781
|
try {
|
|
3399
3782
|
await run2(line);
|
|
3400
3783
|
} catch (error) {
|
|
3401
|
-
|
|
3784
|
+
out4(c.red(`\u2717 ${error.message}`));
|
|
3402
3785
|
}
|
|
3403
3786
|
rl.setPrompt(buffer ? " > " : "db> ");
|
|
3404
3787
|
rl.prompt();
|
|
@@ -3415,11 +3798,11 @@ async function db(args, flags) {
|
|
|
3415
3798
|
const dryRun = flags["dry-run"] === true;
|
|
3416
3799
|
if (t.local) {
|
|
3417
3800
|
const r2 = await migrateLocal({ dir: t.dir, root, wrangler: t.wrangler, dryRun });
|
|
3418
|
-
if (flags.json) return
|
|
3801
|
+
if (flags.json) return out4(JSON.stringify(r2, null, 2));
|
|
3419
3802
|
return printMigrate(r2, dryRun, "local DB");
|
|
3420
3803
|
}
|
|
3421
3804
|
const r = await api(t, "POST", "/v1/dev/db/migrate", { json: { migrations, dry_run: dryRun } });
|
|
3422
|
-
if (flags.json) return
|
|
3805
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3423
3806
|
return printMigrate({ applied: names(r.applied), skipped: names(r.skipped), changed: names(r.changed), pending: names(r.pending) }, dryRun, "the app's DB");
|
|
3424
3807
|
}
|
|
3425
3808
|
case "shell":
|
|
@@ -3428,19 +3811,19 @@ async function db(args, flags) {
|
|
|
3428
3811
|
const sql = rest.join(" ").trim();
|
|
3429
3812
|
if (!sql) return shell(t, flags);
|
|
3430
3813
|
const results = await query(t, sql);
|
|
3431
|
-
if (flags.json) return
|
|
3814
|
+
if (flags.json) return out4(JSON.stringify({ results }, null, 2));
|
|
3432
3815
|
return printResults(results);
|
|
3433
3816
|
}
|
|
3434
3817
|
case "tables": {
|
|
3435
3818
|
const list = await tables(t);
|
|
3436
|
-
if (flags.json) return
|
|
3437
|
-
return
|
|
3819
|
+
if (flags.json) return out4(JSON.stringify({ tables: list }, null, 2));
|
|
3820
|
+
return out4(table(list.map((x) => ({ table: x.name, rows: x.rows, columns: x.columns.length ? x.columns.map((col) => col.name).join(", ") : "" }))));
|
|
3438
3821
|
}
|
|
3439
3822
|
case "migrations": {
|
|
3440
3823
|
const applied = t.local ? ((await localQuery(t, `${CREATE_MIGRATIONS}
|
|
3441
3824
|
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 ?? [];
|
|
3442
|
-
if (flags.json) return
|
|
3443
|
-
return
|
|
3825
|
+
if (flags.json) return out4(JSON.stringify({ applied }, null, 2));
|
|
3826
|
+
return out4(table(applied, ["name", "applied_at"]));
|
|
3444
3827
|
}
|
|
3445
3828
|
default:
|
|
3446
3829
|
throw new CliError(`usage: ambasada db migrate [--dry-run] | shell ["<sql>"] | tables | migrations (--local: wrangler's local DB)`);
|
|
@@ -3468,18 +3851,18 @@ async function kv(args, flags) {
|
|
|
3468
3851
|
cursor = r.cursor;
|
|
3469
3852
|
}
|
|
3470
3853
|
}
|
|
3471
|
-
if (flags.json) return
|
|
3472
|
-
return
|
|
3854
|
+
if (flags.json) return out4(JSON.stringify({ keys: keys3 }, null, 2));
|
|
3855
|
+
return out4(table(keys3.map((k) => ({ key: k.name, expires: k.expiration ? new Date(k.expiration * 1e3).toISOString() : "" })), ["key", "expires"]));
|
|
3473
3856
|
}
|
|
3474
3857
|
case "get": {
|
|
3475
3858
|
if (!key) throw new CliError("usage: ambasada kv get <key>");
|
|
3476
3859
|
if (t.local) return void process.stdout.write(await wrangler(t, ["kv", "key", "get", key, "--binding", PLATFORM_STORAGE.kv, "--text"]));
|
|
3477
3860
|
const r = await api(t, "GET", kvPath(key));
|
|
3478
|
-
if (flags.json) return
|
|
3861
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3479
3862
|
if (r.binary) {
|
|
3480
|
-
|
|
3863
|
+
out4(c.dim(`(${r.size} bytes, binary; base64:)`));
|
|
3481
3864
|
}
|
|
3482
|
-
return
|
|
3865
|
+
return out4(r.value ?? "");
|
|
3483
3866
|
}
|
|
3484
3867
|
case "put":
|
|
3485
3868
|
case "set": {
|
|
@@ -3488,19 +3871,19 @@ async function kv(args, flags) {
|
|
|
3488
3871
|
const fromFile = value.startsWith("@") ? value.slice(1) : null;
|
|
3489
3872
|
if (t.local) {
|
|
3490
3873
|
await wrangler(t, ["kv", "key", "put", key, ...fromFile ? ["--path", fromFile] : [value], "--binding", PLATFORM_STORAGE.kv, ...ttl ? ["--ttl", ttl] : []]);
|
|
3491
|
-
return
|
|
3874
|
+
return out4(`${c.green("\u2713")} ${key} (local)`);
|
|
3492
3875
|
}
|
|
3493
|
-
const raw = fromFile ? new Uint8Array(await
|
|
3876
|
+
const raw = fromFile ? new Uint8Array(await readFile6(fromFile)) : value;
|
|
3494
3877
|
const r = await api(t, "PUT", `${kvPath(key)}${ttl ? `?ttl=${ttl}` : ""}`, { raw, type: fromFile ? "application/octet-stream" : "text/plain; charset=utf-8" });
|
|
3495
|
-
if (flags.json) return
|
|
3496
|
-
return
|
|
3878
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3879
|
+
return out4(`${c.green("\u2713")} ${r.key} ${c.dim(`${r.size} bytes${ttl ? `, expires in ${ttl} s` : ""}`)}`);
|
|
3497
3880
|
}
|
|
3498
3881
|
case "delete":
|
|
3499
3882
|
case "rm": {
|
|
3500
3883
|
if (!key) throw new CliError("usage: ambasada kv delete <key>");
|
|
3501
3884
|
if (t.local) await wrangler(t, ["kv", "key", "delete", key, "--binding", PLATFORM_STORAGE.kv]);
|
|
3502
3885
|
else await api(t, "DELETE", kvPath(key));
|
|
3503
|
-
return
|
|
3886
|
+
return out4(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
|
|
3504
3887
|
}
|
|
3505
3888
|
default:
|
|
3506
3889
|
throw new CliError("usage: ambasada kv list [prefix] | get <key> | put <key> <value | @file> [--ttl N] | delete <key> (--local: wrangler's local KV)");
|
|
@@ -3567,22 +3950,22 @@ async function files(args, flags) {
|
|
|
3567
3950
|
if (!r.truncated || !r.cursor) break;
|
|
3568
3951
|
cursor = r.cursor;
|
|
3569
3952
|
}
|
|
3570
|
-
if (flags.json) return
|
|
3571
|
-
return
|
|
3953
|
+
if (flags.json) return out4(JSON.stringify({ objects }, null, 2));
|
|
3954
|
+
return out4(table(objects.map((o) => ({ key: o.key, size: size(o.size), type: o.content_type ?? "", uploaded: o.uploaded ?? "" })), ["key", "size", "type", "uploaded"]));
|
|
3572
3955
|
}
|
|
3573
3956
|
case "put":
|
|
3574
3957
|
case "upload": {
|
|
3575
3958
|
if (!a) throw new CliError("usage: ambasada files put <path> [key] [--content-type type]");
|
|
3576
|
-
if (!
|
|
3959
|
+
if (!existsSync10(a)) throw new CliError(`no file ${a}`);
|
|
3577
3960
|
const key = checkKey(b ?? basename2(a));
|
|
3578
3961
|
const type = str2(flags["content-type"]) ?? contentTypeOf(a);
|
|
3579
3962
|
if (t.local) {
|
|
3580
3963
|
await wrangler(t, ["r2", "object", "put", `${await localBucket(t)}/${key}`, "--file", a, "--content-type", type]);
|
|
3581
|
-
return
|
|
3964
|
+
return out4(`${c.green("\u2713")} ${key} (local) \u2192 ${await fileUrl(t, key, flags)}`);
|
|
3582
3965
|
}
|
|
3583
|
-
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await
|
|
3584
|
-
if (flags.json) return
|
|
3585
|
-
return
|
|
3966
|
+
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await readFile6(a)), type });
|
|
3967
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3968
|
+
return out4(`${c.green("\u2713")} ${r.key} ${c.dim(size(r.size))} \u2192 ${c.cyan(r.url ?? await fileUrl(t, key, flags))}`);
|
|
3586
3969
|
}
|
|
3587
3970
|
case "delete":
|
|
3588
3971
|
case "rm": {
|
|
@@ -3590,11 +3973,11 @@ async function files(args, flags) {
|
|
|
3590
3973
|
const key = checkKey(a);
|
|
3591
3974
|
if (t.local) await wrangler(t, ["r2", "object", "delete", `${await localBucket(t)}/${key}`]);
|
|
3592
3975
|
else await api(t, "DELETE", `/v1/dev/files/${keyPath(key)}`);
|
|
3593
|
-
return
|
|
3976
|
+
return out4(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
|
|
3594
3977
|
}
|
|
3595
3978
|
case "url": {
|
|
3596
3979
|
if (!a) throw new CliError("usage: ambasada files url <key>");
|
|
3597
|
-
return
|
|
3980
|
+
return out4(await fileUrl(t, checkKey(a), flags));
|
|
3598
3981
|
}
|
|
3599
3982
|
default:
|
|
3600
3983
|
throw new CliError("usage: ambasada files list [prefix] | put <path> [key] [--content-type T] | delete <key> | url <key> (--local: wrangler's local bucket)");
|
|
@@ -3604,11 +3987,11 @@ async function files(args, flags) {
|
|
|
3604
3987
|
// src/tunnel.ts
|
|
3605
3988
|
import { spawn as spawn3, spawnSync } from "node:child_process";
|
|
3606
3989
|
import { createHash as createHash2 } from "node:crypto";
|
|
3607
|
-
import { existsSync as
|
|
3608
|
-
import { chmod as chmod2, mkdir as
|
|
3990
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
3991
|
+
import { chmod as chmod2, mkdir as mkdir5, mkdtemp as mkdtemp2, readFile as readFile7, rename, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
|
|
3609
3992
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3610
|
-
import { join as
|
|
3611
|
-
var
|
|
3993
|
+
import { join as join12 } from "node:path";
|
|
3994
|
+
var out5 = (s = "") => process.stdout.write(`${s}
|
|
3612
3995
|
`);
|
|
3613
3996
|
var CLOUDFLARED_VERSION = "2026.10.7";
|
|
3614
3997
|
var CLOUDFLARED_ASSETS = {
|
|
@@ -3619,11 +4002,11 @@ var CLOUDFLARED_ASSETS = {
|
|
|
3619
4002
|
"win32-x64": { file: "cloudflared-windows-amd64.exe", sha256: "2493dbc168923bb3e59e5e7716f71deced92992eea9acd6e9d74f84ac7751343" }
|
|
3620
4003
|
};
|
|
3621
4004
|
var RELEASES = "https://github.com/cloudflare/cloudflared/releases/download";
|
|
3622
|
-
async function cloudflaredBin(print =
|
|
4005
|
+
async function cloudflaredBin(print = out5) {
|
|
3623
4006
|
if (process.env.AMBASADA_CLOUDFLARED) return process.env.AMBASADA_CLOUDFLARED;
|
|
3624
4007
|
if (spawnSync("cloudflared", ["--version"], { stdio: "ignore" }).status === 0) return "cloudflared";
|
|
3625
|
-
const dest =
|
|
3626
|
-
if (
|
|
4008
|
+
const dest = join12(configDir(), "bin", `cloudflared-${CLOUDFLARED_VERSION}${process.platform === "win32" ? ".exe" : ""}`);
|
|
4009
|
+
if (existsSync11(dest)) return dest;
|
|
3627
4010
|
print(c.dim(`downloading cloudflared ${CLOUDFLARED_VERSION} (once, ~20\u201340 MB) \u2026`));
|
|
3628
4011
|
await downloadCloudflared(dest);
|
|
3629
4012
|
return dest;
|
|
@@ -3643,22 +4026,22 @@ async function downloadCloudflared(dest, opts = {}) {
|
|
|
3643
4026
|
}
|
|
3644
4027
|
const digest = createHash2("sha256").update(bytes).digest("hex");
|
|
3645
4028
|
if (digest !== asset.sha256) throw new CliError(`cloudflared download from ${url} has SHA-256 ${digest}, expected ${asset.sha256}: not running it`);
|
|
3646
|
-
const dir =
|
|
3647
|
-
await
|
|
3648
|
-
const work = await mkdtemp2(
|
|
4029
|
+
const dir = join12(dest, "..");
|
|
4030
|
+
await mkdir5(dir, { recursive: true });
|
|
4031
|
+
const work = await mkdtemp2(join12(tmpdir2(), "ambasada-cloudflared-"));
|
|
3649
4032
|
try {
|
|
3650
|
-
const binary =
|
|
4033
|
+
const binary = join12(work, "cloudflared");
|
|
3651
4034
|
if (asset.file.endsWith(".tgz")) {
|
|
3652
|
-
await
|
|
3653
|
-
const tar = spawnSync("tar", ["-xzf",
|
|
3654
|
-
if (tar.status !== 0 || !
|
|
3655
|
-
} else await
|
|
4035
|
+
await writeFile7(join12(work, asset.file), bytes);
|
|
4036
|
+
const tar = spawnSync("tar", ["-xzf", join12(work, asset.file), "-C", work], { encoding: "utf8" });
|
|
4037
|
+
if (tar.status !== 0 || !existsSync11(binary)) throw new CliError(`could not unpack ${asset.file}: ${tar.stderr || "no cloudflared inside"}`);
|
|
4038
|
+
} else await writeFile7(binary, bytes);
|
|
3656
4039
|
await chmod2(binary, 493);
|
|
3657
4040
|
const tmp = `${dest}.${process.pid}`;
|
|
3658
|
-
await
|
|
4041
|
+
await writeFile7(tmp, await readFile7(binary), { mode: 493 });
|
|
3659
4042
|
await rename(tmp, dest);
|
|
3660
4043
|
} finally {
|
|
3661
|
-
await
|
|
4044
|
+
await rm6(work, { recursive: true, force: true });
|
|
3662
4045
|
}
|
|
3663
4046
|
}
|
|
3664
4047
|
async function call(opts, method, path, body) {
|
|
@@ -3676,7 +4059,7 @@ async function call(opts, method, path, body) {
|
|
|
3676
4059
|
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
3677
4060
|
}
|
|
3678
4061
|
async function startTunnel(opts) {
|
|
3679
|
-
const print = opts.print ??
|
|
4062
|
+
const print = opts.print ?? out5;
|
|
3680
4063
|
const bin2 = await cloudflaredBin(print);
|
|
3681
4064
|
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 } : {} });
|
|
3682
4065
|
if (opened.status !== 201) throw new CliError(`tunnel refused: ${opened.json.error?.code ?? opened.status}: ${opened.json.error?.message ?? "no answer"}`);
|
|
@@ -3757,7 +4140,7 @@ async function waitForPublic(url, timeoutMs = 18e4, streak = 1) {
|
|
|
3757
4140
|
}
|
|
3758
4141
|
}
|
|
3759
4142
|
async function startTunnelAndWait(opts) {
|
|
3760
|
-
const print = opts.print ??
|
|
4143
|
+
const print = opts.print ?? out5;
|
|
3761
4144
|
const session = await startTunnel(opts);
|
|
3762
4145
|
const { info } = session;
|
|
3763
4146
|
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`));
|
|
@@ -3787,7 +4170,7 @@ function untilStopped(ended) {
|
|
|
3787
4170
|
});
|
|
3788
4171
|
});
|
|
3789
4172
|
}
|
|
3790
|
-
function printOpened(info, print =
|
|
4173
|
+
function printOpened(info, print = out5) {
|
|
3791
4174
|
print(`${c.green("\u2713")} ${c.bold(c.cyan(info.url))} \u2192 ${info.local} ${c.dim("(press Ctrl+C to stop)")}`);
|
|
3792
4175
|
if (info.registered) {
|
|
3793
4176
|
print(` the village sends buyers, Checkout and webhooks here until you stop${info.previous_url ? c.dim(` (then back to ${info.previous_url})`) : ""}`);
|
|
@@ -3795,7 +4178,7 @@ function printOpened(info, print = out4) {
|
|
|
3795
4178
|
if (info.webhook) print(` webhooks \u2192 ${info.webhook.url}`);
|
|
3796
4179
|
} else print(c.dim(" only exposed: the product URL, offerings and webhook stay where they are (--no-register)"));
|
|
3797
4180
|
}
|
|
3798
|
-
function printClosed(closed, print =
|
|
4181
|
+
function printClosed(closed, print = out5) {
|
|
3799
4182
|
if (!closed) print(c.yellow("! could not reach the village to close the tunnel: it closes itself within 5 minutes"));
|
|
3800
4183
|
else print(`${c.green("\u2713")} tunnel closed${closed.url ? `; product URL back to ${closed.url}` : ""}`);
|
|
3801
4184
|
}
|
|
@@ -3815,7 +4198,7 @@ async function tunnelPort(flags) {
|
|
|
3815
4198
|
return 3e3;
|
|
3816
4199
|
}
|
|
3817
4200
|
function webhookPath(root) {
|
|
3818
|
-
return root && WEBHOOK_FILES.some((f) =>
|
|
4201
|
+
return root && WEBHOOK_FILES.some((f) => existsSync11(join12(root, f))) ? "/api/webhook" : void 0;
|
|
3819
4202
|
}
|
|
3820
4203
|
async function tunnel(_args, flags) {
|
|
3821
4204
|
const r = await resolve(flags);
|
|
@@ -3825,32 +4208,32 @@ async function tunnel(_args, flags) {
|
|
|
3825
4208
|
try {
|
|
3826
4209
|
await fetch(local, { signal: AbortSignal.timeout(2e3) });
|
|
3827
4210
|
} catch {
|
|
3828
|
-
|
|
4211
|
+
out5(c.yellow(`! nothing answers on ${local} yet: start your dev server there (or pass --port)`));
|
|
3829
4212
|
}
|
|
3830
4213
|
const root = findRoot();
|
|
3831
|
-
if (root &&
|
|
3832
|
-
|
|
4214
|
+
if (root && existsSync11(join12(root, ".env")) && /AMBASADA_API_URL=http:\/\/(127\.0\.0\.1|localhost)/.test(await readFile7(join12(root, ".env"), "utf8"))) {
|
|
4215
|
+
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"));
|
|
3833
4216
|
}
|
|
3834
4217
|
const register = flags["no-register"] !== true;
|
|
3835
4218
|
const session = await startTunnelAndWait({ apiUrl: r.apiUrl, apiKey: r.apiKey, port, force: flags.force === true, register, webhookPath: register ? webhookPath(root) : void 0 });
|
|
3836
|
-
if (flags.json)
|
|
4219
|
+
if (flags.json) out5(JSON.stringify({ ...session.info, token: void 0 }, null, 2));
|
|
3837
4220
|
else printOpened(session.info);
|
|
3838
4221
|
const reason = await untilStopped(session.ended);
|
|
3839
|
-
if (reason)
|
|
3840
|
-
else
|
|
4222
|
+
if (reason) out5(c.yellow(`! ${reason}`));
|
|
4223
|
+
else out5("\nclosing the tunnel");
|
|
3841
4224
|
printClosed(await session.close());
|
|
3842
4225
|
if (reason) throw new CliError(reason);
|
|
3843
4226
|
}
|
|
3844
4227
|
|
|
3845
4228
|
// src/commands.ts
|
|
3846
|
-
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.
|
|
4229
|
+
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.3") ? `^${"0.8.3"}` : "latest";
|
|
3847
4230
|
var DEV_PORT = 8788;
|
|
3848
|
-
var
|
|
4231
|
+
var out6 = (s = "") => process.stdout.write(`${s}
|
|
3849
4232
|
`);
|
|
3850
4233
|
var str3 = (v) => typeof v === "string" && v ? v : void 0;
|
|
3851
4234
|
var json = (flags, value) => {
|
|
3852
4235
|
if (!flags.json) return false;
|
|
3853
|
-
|
|
4236
|
+
out6(JSON.stringify(value, null, 2));
|
|
3854
4237
|
return true;
|
|
3855
4238
|
};
|
|
3856
4239
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -3869,8 +4252,8 @@ async function deviceLogin(apiUrl) {
|
|
|
3869
4252
|
if (res.status === 404 || res.status === 405 || !(res.headers.get("content-type") ?? "").includes("json")) return null;
|
|
3870
4253
|
if (!res.ok) return null;
|
|
3871
4254
|
const start = await res.json();
|
|
3872
|
-
|
|
3873
|
-
|
|
4255
|
+
out6(`Open ${c.bold(start.verification_uri_complete ?? start.verification_uri)}`);
|
|
4256
|
+
out6(`and confirm the code ${c.bold(c.cyan(start.user_code))} with your Ambasada ID.`);
|
|
3874
4257
|
let interval = Math.max(1, start.interval ?? 5) * 1e3;
|
|
3875
4258
|
const deadline = Date.now() + (start.expires_in ?? 600) * 1e3;
|
|
3876
4259
|
while (Date.now() < deadline) {
|
|
@@ -3908,14 +4291,14 @@ async function login(_args, flags) {
|
|
|
3908
4291
|
if (device) key = device.token;
|
|
3909
4292
|
}
|
|
3910
4293
|
if (!key) {
|
|
3911
|
-
|
|
4294
|
+
out6(`Paste your app's key: ${c.bold("https://developer.ambasada.dev")} \u2192 New app (or Settings \u2192 New key).`);
|
|
3912
4295
|
key = await prompt("API key: ");
|
|
3913
4296
|
}
|
|
3914
4297
|
if (!key) throw new CliError("no key given");
|
|
3915
4298
|
const who = await whoseKey(apiUrl, key);
|
|
3916
4299
|
const { company: _old, ...stored } = await readConfig();
|
|
3917
4300
|
await writeConfig({ ...stored, apiUrl, apiKey: key, project: who.id });
|
|
3918
|
-
|
|
4301
|
+
out6(`${c.green("\u2713")} logged in as ${c.bold(who.id)} on ${apiUrl} ${c.dim(`(saved to ${configPath()})`)}`);
|
|
3919
4302
|
}
|
|
3920
4303
|
async function whoseKey(apiUrl, key) {
|
|
3921
4304
|
let res;
|
|
@@ -3934,14 +4317,14 @@ async function whoseKey(apiUrl, key) {
|
|
|
3934
4317
|
async function logout() {
|
|
3935
4318
|
const { apiKey: _k, company: _c, project: _p, ...rest } = await readConfig();
|
|
3936
4319
|
await writeConfig(rest);
|
|
3937
|
-
|
|
4320
|
+
out6("logged out");
|
|
3938
4321
|
}
|
|
3939
4322
|
async function whoami(_args, flags) {
|
|
3940
4323
|
const r = await resolve(flags);
|
|
3941
4324
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
3942
4325
|
const who = await whoseKey(r.apiUrl, r.apiKey);
|
|
3943
4326
|
if (json(flags, { apiUrl: r.apiUrl, project: who.id, kind: who.kind })) return;
|
|
3944
|
-
|
|
4327
|
+
out6(`${c.bold(who.id)} on ${r.apiUrl}`);
|
|
3945
4328
|
}
|
|
3946
4329
|
function addonFlag(flags) {
|
|
3947
4330
|
if (flags.village || str3(flags.for) === "hackaton" || str3(flags.for) === "village") return "village";
|
|
@@ -3951,11 +4334,11 @@ var addonSpec = (short, flags) => str3(flags[`${short}-spec`]) ?? process.env[`A
|
|
|
3951
4334
|
async function writeFiles(root, files2, map, force = true) {
|
|
3952
4335
|
const written = [];
|
|
3953
4336
|
for (const [rel, content] of Object.entries(files2)) {
|
|
3954
|
-
const dest =
|
|
3955
|
-
if (!force &&
|
|
3956
|
-
await
|
|
3957
|
-
if (isBinary(rel)) await
|
|
3958
|
-
else await
|
|
4337
|
+
const dest = join13(root, rel === "gitignore" ? ".gitignore" : rel);
|
|
4338
|
+
if (!force && existsSync12(dest)) continue;
|
|
4339
|
+
await mkdir6(dirname7(dest), { recursive: true });
|
|
4340
|
+
if (isBinary(rel)) await writeFile8(dest, Buffer.from(content, "base64"));
|
|
4341
|
+
else await writeFile8(dest, map(rel, content));
|
|
3959
4342
|
written.push(rel);
|
|
3960
4343
|
}
|
|
3961
4344
|
return written;
|
|
@@ -3964,7 +4347,7 @@ async function init(args, flags) {
|
|
|
3964
4347
|
const target2 = resolvePath5(args[0] ?? ".");
|
|
3965
4348
|
const name = str3(flags.name) ?? (args[0] ? basename3(target2) : "My App");
|
|
3966
4349
|
const id = slug(str3(flags.id) ?? name);
|
|
3967
|
-
if (
|
|
4350
|
+
if (existsSync12(target2) && (await readdir3(target2)).filter((f) => f !== ".git").length && !flags.force) {
|
|
3968
4351
|
throw new CliError(`${target2} is not empty (use --force to scaffold anyway)`);
|
|
3969
4352
|
}
|
|
3970
4353
|
const template = str3(flags.template) ?? TEMPLATES[0];
|
|
@@ -3977,15 +4360,15 @@ async function init(args, flags) {
|
|
|
3977
4360
|
pkg.name = id;
|
|
3978
4361
|
pkg.dependencies["@ambasada/sdk"] = sdkSpec;
|
|
3979
4362
|
const releaseLine = /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec;
|
|
3980
|
-
if (pkg.dependencies["@ambasada/miniapp"]) pkg.dependencies["@ambasada/miniapp"] = str3(flags
|
|
4363
|
+
if (pkg.dependencies["@ambasada/miniapp"]) pkg.dependencies["@ambasada/miniapp"] = str3(flags["miniapp-spec"]) ?? process.env.AMBASADA_MINIAPP_SPEC ?? releaseLine;
|
|
3981
4364
|
if (pkg.dependencies["@ambasada/ui"]) pkg.dependencies["@ambasada/ui"] = str3(flags.ui) ?? process.env.AMBASADA_UI_SPEC ?? releaseLine;
|
|
3982
4365
|
if (pkg.devDependencies?.["@ambasada/cli"]) pkg.devDependencies["@ambasada/cli"] = str3(flags.cli) ?? process.env.AMBASADA_CLI_SPEC ?? SDK_VERSION;
|
|
3983
4366
|
return `${JSON.stringify(pkg, null, 2)}
|
|
3984
4367
|
`;
|
|
3985
4368
|
});
|
|
3986
|
-
const env2 =
|
|
3987
|
-
if (!
|
|
3988
|
-
await
|
|
4369
|
+
const env2 = join13(target2, ".env");
|
|
4370
|
+
if (!existsSync12(env2)) {
|
|
4371
|
+
await writeFile8(
|
|
3989
4372
|
env2,
|
|
3990
4373
|
[
|
|
3991
4374
|
"# Local development against `ambasada dev`. On ambasada.dev the deploy sets the real values.",
|
|
@@ -4006,36 +4389,33 @@ async function init(args, flags) {
|
|
|
4006
4389
|
const addonName = addonFlag(flags);
|
|
4007
4390
|
let addon = null;
|
|
4008
4391
|
if (addonName) addon = await applyAddon(target2, template, addonName, flags, true);
|
|
4009
|
-
const
|
|
4010
|
-
|
|
4392
|
+
const entry2 = flags.miniapp ? str3(flags.entry) ?? DEFAULT_ENTRY : addon?.overlay?.miniapp?.entry;
|
|
4393
|
+
const miniapp2 = entry2 ? await enableMiniapp(target2, entry2, flags, { template, releaseLine: /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec }) : null;
|
|
4011
4394
|
await writeProjectInfo(target2, { template, devkit: devkitVersion(), addons: addon ? [addon.short] : [] });
|
|
4012
4395
|
const rel = args[0] ?? ".";
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
const stack = detectStack(root);
|
|
4025
|
-
if (stack) return stack.name;
|
|
4026
|
-
return existsSync10(join11(root, "src/worker.ts")) ? "worker" : "next";
|
|
4396
|
+
out6(`${c.green("\u2713")} ${c.bold(name)} (${id}) scaffolded in ${target2} ${c.dim(`(template ${template}${addon ? `, ${addon.short}` : ""})`)}`);
|
|
4397
|
+
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 })}`);
|
|
4398
|
+
for (const m of miniapp2?.manual ?? []) out6(c.yellow(`! by hand: ${m}`));
|
|
4399
|
+
out6("");
|
|
4400
|
+
out6("Next:");
|
|
4401
|
+
if (rel !== ".") out6(` cd ${rel}`);
|
|
4402
|
+
if (!addon) out6(" npm install");
|
|
4403
|
+
out6(` npm run dev ${c.dim("# ambasada dev: the mock API on :8788 + your app on http://localhost:3000")}`);
|
|
4404
|
+
for (const line of addon?.overlay?.next ?? []) out6(` ${line}`);
|
|
4405
|
+
out6("");
|
|
4406
|
+
out6(`Then ${c.bold("ambasada login")} (a key from https://developer.ambasada.dev \u2192 New app) and ${c.bold("ambasada deploy")}. See README.md.`);
|
|
4027
4407
|
}
|
|
4028
4408
|
async function applyAddon(root, template, short, flags, fresh) {
|
|
4029
4409
|
const pkgName = KNOWN_ADDONS[short] ?? short;
|
|
4030
|
-
const pkgFile =
|
|
4031
|
-
const pkg = JSON.parse(await
|
|
4410
|
+
const pkgFile = join13(root, "package.json");
|
|
4411
|
+
const pkg = JSON.parse(await readFile8(pkgFile, "utf8"));
|
|
4032
4412
|
pkg.dependencies = { ...pkg.dependencies, [pkgName]: pkg.dependencies?.[pkgName] ?? addonSpec(shortName(pkgName), flags) };
|
|
4033
|
-
await
|
|
4413
|
+
await writeFile8(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
4034
4414
|
`);
|
|
4035
4415
|
const find = () => findAddons(root).find((dir2) => readJsonName(dir2) === pkgName) ?? besideCli(pkgName);
|
|
4036
4416
|
let dir = find() ?? packageDir(pkgName, root);
|
|
4037
4417
|
if (!dir && !flags["no-install"]) {
|
|
4038
|
-
|
|
4418
|
+
out6(c.dim(`npm install ${pkgName} \u2026`));
|
|
4039
4419
|
try {
|
|
4040
4420
|
execFileSync3(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
|
|
4041
4421
|
} catch {
|
|
@@ -4050,37 +4430,37 @@ async function applyAddon(root, template, short, flags, fresh) {
|
|
|
4050
4430
|
const id = await loadIdName(root) ?? { id: "my-app", name: "My App" };
|
|
4051
4431
|
await writeFiles(root, overlay.files(template), (_rel, text) => text.replaceAll("my-app", id.id).replaceAll("My App", id.name), fresh || flags.force === true);
|
|
4052
4432
|
if (overlay.config) {
|
|
4053
|
-
const file =
|
|
4054
|
-
await
|
|
4433
|
+
const file = join13(root, CONFIG_FILE);
|
|
4434
|
+
await writeFile8(file, overlay.config(await readFile8(file, "utf8"), template));
|
|
4055
4435
|
}
|
|
4056
4436
|
if (overlay.env?.length) {
|
|
4057
|
-
const env2 =
|
|
4058
|
-
const current2 =
|
|
4437
|
+
const env2 = join13(root, ".env");
|
|
4438
|
+
const current2 = existsSync12(env2) ? await readFile8(env2, "utf8") : "";
|
|
4059
4439
|
const missing = overlay.env.filter((line) => line.startsWith("#") || !current2.includes(`${line.split("=")[0]}=`));
|
|
4060
|
-
if (missing.some((l) => !l.startsWith("#"))) await
|
|
4440
|
+
if (missing.some((l) => !l.startsWith("#"))) await appendFile2(env2, `${current2 && !current2.endsWith("\n") ? "\n" : ""}${missing.join("\n")}
|
|
4061
4441
|
`);
|
|
4062
4442
|
}
|
|
4063
4443
|
for (const [rel, text] of Object.entries(overlay.append ?? {})) {
|
|
4064
|
-
const file =
|
|
4065
|
-
const current2 =
|
|
4066
|
-
if (!current2.includes(text.trim().split("\n")[0])) await
|
|
4444
|
+
const file = join13(root, rel);
|
|
4445
|
+
const current2 = existsSync12(file) ? await readFile8(file, "utf8") : "";
|
|
4446
|
+
if (!current2.includes(text.trim().split("\n")[0])) await writeFile8(file, `${current2.trimEnd()}
|
|
4067
4447
|
|
|
4068
4448
|
${text.trim()}
|
|
4069
4449
|
`);
|
|
4070
4450
|
}
|
|
4071
4451
|
if (overlay.dependencies) {
|
|
4072
|
-
const next = JSON.parse(await
|
|
4452
|
+
const next = JSON.parse(await readFile8(pkgFile, "utf8"));
|
|
4073
4453
|
next.dependencies = { ...overlay.dependencies, ...next.dependencies };
|
|
4074
|
-
await
|
|
4454
|
+
await writeFile8(pkgFile, `${JSON.stringify(next, null, 2)}
|
|
4075
4455
|
`);
|
|
4076
4456
|
}
|
|
4077
4457
|
}
|
|
4078
|
-
await writeFiles(
|
|
4458
|
+
await writeFiles(join13(root, ".claude/skills"), addon.skills, (_rel, text) => text, true);
|
|
4079
4459
|
return addon;
|
|
4080
4460
|
}
|
|
4081
4461
|
function readJsonName(dir) {
|
|
4082
4462
|
try {
|
|
4083
|
-
return JSON.parse(
|
|
4463
|
+
return JSON.parse(readFileSync6(join13(dir, "package.json"), "utf8")).name;
|
|
4084
4464
|
} catch {
|
|
4085
4465
|
return void 0;
|
|
4086
4466
|
}
|
|
@@ -4091,34 +4471,42 @@ async function add(args, flags) {
|
|
|
4091
4471
|
if (!KNOWN_ADDONS[short] && !short.includes("/")) throw new CliError(`unknown add-on ${short} (known: ${Object.keys(KNOWN_ADDONS).join(", ")})`);
|
|
4092
4472
|
const root = findRoot();
|
|
4093
4473
|
if (!root) throw new CliError(`run \`ambasada add ${short}\` inside your app (no ${CONFIG_FILE} found)`);
|
|
4094
|
-
const template =
|
|
4474
|
+
const template = templateOf(root);
|
|
4095
4475
|
const addon = await applyAddon(root, template, short, flags, false);
|
|
4096
|
-
const
|
|
4097
|
-
|
|
4098
|
-
if (app && !existsSync10(manifest)) await writeFile7(manifest, manifestJson(app));
|
|
4476
|
+
const entry2 = addon.overlay?.miniapp?.entry;
|
|
4477
|
+
if (entry2 && !miniappState(root, miniappOfSource(root).miniapp).on) printEnabled(await enableMiniapp(root, entry2, flags, { template, releaseLine: SDK_VERSION }), root);
|
|
4099
4478
|
const info = await readProjectInfo(root) ?? { template, devkit: devkitVersion(), addons: [] };
|
|
4100
4479
|
await writeProjectInfo(root, { ...info, addons: [.../* @__PURE__ */ new Set([...info.addons, addon.short])] });
|
|
4101
|
-
|
|
4102
|
-
for (const line of addon.overlay?.next ?? [])
|
|
4103
|
-
|
|
4480
|
+
out6(`${c.green("\u2713")} ${c.bold(addon.name)} added ${c.dim(`(${CONFIG_FILE}, package.json, .env, .claude/skills; existing files kept, --force overwrites)`)}`);
|
|
4481
|
+
for (const line of addon.overlay?.next ?? []) out6(` ${line}`);
|
|
4482
|
+
out6(` ambasada ${addon.short} --help`);
|
|
4104
4483
|
}
|
|
4105
4484
|
async function check(args, flags) {
|
|
4106
4485
|
const target2 = args[0];
|
|
4107
4486
|
let report;
|
|
4108
|
-
if (target2 && /^https?:\/\//i.test(target2))
|
|
4109
|
-
|
|
4487
|
+
if (target2 && /^https?:\/\//i.test(target2)) {
|
|
4488
|
+
const root = findRoot();
|
|
4489
|
+
const on = root ? miniappState(root, miniappOfSource(root).miniapp).on : false;
|
|
4490
|
+
report = await checkRemote(target2, void 0, on ? { miniapp: true } : {});
|
|
4491
|
+
} else {
|
|
4110
4492
|
const dir = target2 ? resolvePath5(target2) : findRoot();
|
|
4111
|
-
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.
|
|
4493
|
+
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)");
|
|
4112
4494
|
report = await checkLocal(dir);
|
|
4495
|
+
const url = /\bproduct\s*:\s*\{\s*url\s*:\s*["'`](https:\/\/[^"'`]+)["'`]/.exec(existsSync12(join13(dir, CONFIG_FILE)) ? readFileSync6(join13(dir, CONFIG_FILE), "utf8") : "")?.[1];
|
|
4496
|
+
if (!report.miniapp?.on && url) report = await checkRemote(url, void 0, { miniapp: false });
|
|
4113
4497
|
}
|
|
4114
4498
|
if (!json(flags, report)) {
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
for (const
|
|
4118
|
-
|
|
4499
|
+
const on = report.miniapp?.on !== false;
|
|
4500
|
+
out6(on ? `mini app: on at ${c.bold(report.miniapp?.entry ?? "/")}` : stateLine({ on: false, entry: null, source: null, file: null }));
|
|
4501
|
+
for (const p of report.problems) out6(`${c.red("\u2717")} ${p.message} ${c.dim(`(${p.code})`)}`);
|
|
4502
|
+
for (const w of report.warnings) out6(`${c.yellow("!")} ${w.message} ${c.dim(`(${w.code})`)}`);
|
|
4503
|
+
for (const n of report.notes.filter((n2) => !n2.startsWith("mini app: off"))) out6(`${c.dim("\xB7")} ${c.dim(n)}`);
|
|
4504
|
+
const warnings = report.warnings.length ? c.dim(` (${report.warnings.length} warning${report.warnings.length === 1 ? "" : "s"})`) : "";
|
|
4505
|
+
if (report.ok) out6(`${c.green("\u2713")} ${report.target} passes the ${on ? "mini-app" : "product"} checks${warnings}`);
|
|
4119
4506
|
}
|
|
4120
4507
|
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`);
|
|
4121
4508
|
}
|
|
4509
|
+
var miniapp = (args, flags) => miniappCommand(args, flags, SDK_VERSION);
|
|
4122
4510
|
async function dev(_args, flags) {
|
|
4123
4511
|
const port = Number(str3(flags.port) ?? DEV_PORT);
|
|
4124
4512
|
if (flags.tunnel) return devTunnel(flags);
|
|
@@ -4130,18 +4518,20 @@ async function dev(_args, flags) {
|
|
|
4130
4518
|
const local = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(new URL(productUrl).hostname);
|
|
4131
4519
|
if (stack && !local) throw new CliError(`--product ${productUrl} is not local: \`ambasada dev --mock-only\` to point the mock at it`);
|
|
4132
4520
|
const bin2 = stack ? stackBin(project.root, stack) : null;
|
|
4133
|
-
const
|
|
4521
|
+
const inApp = project ? await syncManifest(project.root, project.config) : null;
|
|
4522
|
+
const appWebhook = project && inApp?.on ? manifestWebhook(project.root, productUrl) : null;
|
|
4134
4523
|
const mock = await startMock({
|
|
4135
4524
|
port,
|
|
4136
4525
|
host: str3(flags.host) ?? "127.0.0.1",
|
|
4137
4526
|
company,
|
|
4138
4527
|
appWebhook: appWebhook ?? void 0,
|
|
4139
4528
|
extensions: addons().mocks,
|
|
4140
|
-
log: (line) =>
|
|
4529
|
+
log: (line) => out6(`${c.dim((/* @__PURE__ */ new Date()).toISOString().slice(11, 19))} ${line}`)
|
|
4141
4530
|
});
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4531
|
+
out6(`${c.bold("ambasada dev")} \xB7 mock Ambasada API on ${c.cyan(mock.url)} ${c.dim("(dashboard: open it in a browser)")}`);
|
|
4532
|
+
out6(` app ${c.bold(company)} \xB7 key ${c.bold(DEV_KEY)} \xB7 sign-in as a fake visitor \xB7 notifications to the mock hub`);
|
|
4533
|
+
out6(` in your app: AMBASADA_API_URL=${mock.url} AMBASADA_API_KEY=${DEV_KEY}`);
|
|
4534
|
+
if (inApp) out6(` ${stateLine(inApp)}`);
|
|
4145
4535
|
const env2 = {
|
|
4146
4536
|
AMBASADA_API_URL: mock.url,
|
|
4147
4537
|
AMBASADA_API_KEY: DEV_KEY,
|
|
@@ -4155,33 +4545,33 @@ async function dev(_args, flags) {
|
|
|
4155
4545
|
AMBASADA_HUB_URL: mock.url,
|
|
4156
4546
|
AMBASADA_APP_ORIGIN: new URL(productUrl).origin
|
|
4157
4547
|
};
|
|
4158
|
-
const ctx = { project, mockUrl: mock.url, productUrl, company, env: env2, hints: [], once: flags.once === true, out:
|
|
4548
|
+
const ctx = { project, mockUrl: mock.url, productUrl, company, env: env2, hints: [], once: flags.once === true, out: out6 };
|
|
4159
4549
|
await addons().dev(ctx);
|
|
4160
|
-
if (!project)
|
|
4161
|
-
const hints = () => ctx.hints.forEach((h) =>
|
|
4550
|
+
if (!project) out6(c.dim(` (no ${CONFIG_FILE} here: only the mock)`));
|
|
4551
|
+
const hints = () => ctx.hints.forEach((h) => out6(h));
|
|
4162
4552
|
if (flags.once) {
|
|
4163
4553
|
hints();
|
|
4164
4554
|
await mock.close();
|
|
4165
4555
|
return;
|
|
4166
4556
|
}
|
|
4167
|
-
if (stack && project) await migrateBeforeDev(project.root,
|
|
4168
|
-
const app = stack && bin2 ? startApp(project.root, stack, bin2, new URL(productUrl), env2,
|
|
4557
|
+
if (stack && project) await migrateBeforeDev(project.root, out6);
|
|
4558
|
+
const app = stack && bin2 ? startApp(project.root, stack, bin2, new URL(productUrl), env2, out6, c.dim(`${stack.pkg} \u2502`)) : null;
|
|
4169
4559
|
if (app) {
|
|
4170
|
-
|
|
4560
|
+
out6(` starting ${c.bold(stack.label)} on ${productUrl} ${c.dim("(your app, with AMBASADA_* pointed at the mock)")}`);
|
|
4171
4561
|
void app.ready.then((ok) => {
|
|
4172
4562
|
if (ok) {
|
|
4173
|
-
|
|
4174
|
-
|
|
4563
|
+
out6(`${c.green("\u2713")} app on ${c.cyan(productUrl)}`);
|
|
4564
|
+
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)")}`);
|
|
4175
4565
|
hints();
|
|
4176
4566
|
if (appWebhook) void playHub(mock, appWebhook);
|
|
4177
|
-
} else if (app.child.exitCode === null)
|
|
4567
|
+
} else if (app.child.exitCode === null) out6(c.yellow(`! ${productUrl} does not answer yet`));
|
|
4178
4568
|
});
|
|
4179
4569
|
} else hints();
|
|
4180
4570
|
let closing;
|
|
4181
4571
|
const close = () => closing ??= mock.close();
|
|
4182
4572
|
const code = await new Promise((resolveStop) => {
|
|
4183
4573
|
const stop2 = () => {
|
|
4184
|
-
|
|
4574
|
+
out6("\nstopping ambasada dev");
|
|
4185
4575
|
app?.child.kill("SIGTERM");
|
|
4186
4576
|
void close().then(() => resolveStop(null));
|
|
4187
4577
|
};
|
|
@@ -4212,18 +4602,18 @@ async function devTunnel(flags) {
|
|
|
4212
4602
|
...info.webhook ? { AMBASADA_WEBHOOK_SECRET: info.webhook.secret } : {},
|
|
4213
4603
|
...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 } : {}
|
|
4214
4604
|
};
|
|
4215
|
-
|
|
4605
|
+
out6(`${c.bold("ambasada dev --tunnel")} \xB7 ${c.bold(company)} on ${r.apiUrl} ${c.dim("(the real API, no mock)")}`);
|
|
4216
4606
|
printOpened(info);
|
|
4217
|
-
if (!info.sign_in)
|
|
4218
|
-
const app = startApp(project.root, stack, bin2, productUrl, env2,
|
|
4219
|
-
|
|
4607
|
+
if (!info.sign_in) out6(c.dim(" Sign in with Ambasada is not set up on this API: login keeps the .env values"));
|
|
4608
|
+
const app = startApp(project.root, stack, bin2, productUrl, env2, out6, c.dim(`${stack.pkg} \u2502`));
|
|
4609
|
+
out6(` starting ${c.bold(stack.label)} on ${productUrl.origin}`);
|
|
4220
4610
|
void app.ready.then((ok) => {
|
|
4221
|
-
if (ok)
|
|
4222
|
-
else if (app.child.exitCode === null)
|
|
4611
|
+
if (ok) out6(`${c.green("\u2713")} product on ${productUrl.origin}, live at ${c.cyan(info.url)}`);
|
|
4612
|
+
else if (app.child.exitCode === null) out6(c.yellow(`! ${productUrl.origin} does not answer yet`));
|
|
4223
4613
|
});
|
|
4224
4614
|
const appExit = new Promise((resolveExit) => app.child.once("exit", (code) => resolveExit(`${stack.label} exited${code ? ` with code ${code}` : ""}`)));
|
|
4225
4615
|
const reason = await untilStopped(Promise.race([session.ended, appExit]));
|
|
4226
|
-
|
|
4616
|
+
out6(reason ? c.yellow(`! ${reason}`) : "\nstopping ambasada dev --tunnel");
|
|
4227
4617
|
app.child.kill("SIGTERM");
|
|
4228
4618
|
printClosed(await session.close());
|
|
4229
4619
|
if (reason) throw new CliError(reason);
|
|
@@ -4232,7 +4622,7 @@ function manifestWebhook(root, productUrl) {
|
|
|
4232
4622
|
const file = findLocalManifest(root);
|
|
4233
4623
|
if (!file) return null;
|
|
4234
4624
|
try {
|
|
4235
|
-
const m = JSON.parse(
|
|
4625
|
+
const m = JSON.parse(readFileSync6(file, "utf8"));
|
|
4236
4626
|
const path = m.notifications?.webhook;
|
|
4237
4627
|
return Array.isArray(m.scopes) && m.scopes.includes("notifications") && typeof path === "string" && path ? new URL(path, productUrl).toString() : null;
|
|
4238
4628
|
} catch {
|
|
@@ -4242,12 +4632,12 @@ function manifestWebhook(root, productUrl) {
|
|
|
4242
4632
|
async function playHub(mock, webhook) {
|
|
4243
4633
|
const ping = await mock.appEvent("ping");
|
|
4244
4634
|
if (ping.status !== 200) {
|
|
4245
|
-
|
|
4635
|
+
out6(c.yellow(`! ${webhook} answered the signed ping with ${ping.status || ping.error}: it must answer 200 (ambasada.apps.verifyWebhook)`));
|
|
4246
4636
|
return;
|
|
4247
4637
|
}
|
|
4248
4638
|
const enabled = await mock.appEvent("notifications_enabled");
|
|
4249
|
-
if (enabled.status === 200)
|
|
4250
|
-
else
|
|
4639
|
+
if (enabled.status === 200) out6(`${c.green("\u2713")} app webhook: ping and notifications_enabled delivered to ${webhook} ${c.dim(`(token ${enabled.token})`)}`);
|
|
4640
|
+
else out6(c.yellow(`! ${webhook} answered notifications_enabled with ${enabled.status || enabled.error}`));
|
|
4251
4641
|
}
|
|
4252
4642
|
async function link(args, flags) {
|
|
4253
4643
|
const url = args[0];
|
|
@@ -4255,14 +4645,14 @@ async function link(args, flags) {
|
|
|
4255
4645
|
const root = findRoot();
|
|
4256
4646
|
if (!root) throw new CliError(`run \`ambasada link\` inside your app (no ${CONFIG_FILE} found)`);
|
|
4257
4647
|
if (!await setProductUrl(root, url.replace(/\/$/, ""))) throw new CliError(`could not find \`product: { url: "\u2026" }\` in ${CONFIG_FILE}: set it by hand`);
|
|
4258
|
-
|
|
4648
|
+
out6(`${c.green("\u2713")} product.url = ${url}`);
|
|
4259
4649
|
try {
|
|
4260
4650
|
const res = await fetch(new URL("/health", url), { signal: AbortSignal.timeout(5e3) });
|
|
4261
|
-
|
|
4651
|
+
out6(res.ok ? `${c.green("\u2713")} ${url} answers` : c.yellow(`! ${url}/health answered ${res.status}`));
|
|
4262
4652
|
} catch {
|
|
4263
|
-
|
|
4653
|
+
out6(c.yellow(`! ${url} is not reachable from here yet`));
|
|
4264
4654
|
}
|
|
4265
|
-
await addons().afterDeploy({ event: "link", project: await loadProject(root), url, flags, out:
|
|
4655
|
+
await addons().afterDeploy({ event: "link", project: await loadProject(root), url, flags, out: out6 });
|
|
4266
4656
|
}
|
|
4267
4657
|
async function hosted(flags) {
|
|
4268
4658
|
const r = await resolve(flags);
|
|
@@ -4277,7 +4667,7 @@ async function env(args, flags) {
|
|
|
4277
4667
|
if (sub === "list") {
|
|
4278
4668
|
const res = await api(t, "GET", "/v1/product/env");
|
|
4279
4669
|
if (json(flags, res)) return;
|
|
4280
|
-
|
|
4670
|
+
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", " ") ?? "" }))));
|
|
4281
4671
|
return;
|
|
4282
4672
|
}
|
|
4283
4673
|
if ((sub === "set" || sub === "unset") && name) {
|
|
@@ -4285,14 +4675,14 @@ async function env(args, flags) {
|
|
|
4285
4675
|
if (PLATFORM_ENV.test(name)) throw new CliError(`${name} is set by the platform at deploy`);
|
|
4286
4676
|
if (sub === "unset") {
|
|
4287
4677
|
const res2 = await api(t, "DELETE", `/v1/product/env/${name}`);
|
|
4288
|
-
|
|
4678
|
+
out6(res2.deleted ? `${c.green("\u2713")} ${name} removed${res2.worker_updated ? " from the live Worker" : ""}` : c.dim(`${name} was not set`));
|
|
4289
4679
|
return;
|
|
4290
4680
|
}
|
|
4291
4681
|
let text = value.join(" ");
|
|
4292
4682
|
if (!text && !process.stdin.isTTY) text = (await new Response(process.stdin).text()).replace(/\n$/, "");
|
|
4293
4683
|
if (!text) throw new CliError(`usage: ambasada env set ${name} <value> (or pipe the value on stdin)`);
|
|
4294
4684
|
const res = await api(t, "PUT", `/v1/product/env/${name}`, { json: { value: text } });
|
|
4295
|
-
|
|
4685
|
+
out6(`${c.green("\u2713")} ${name} set ${c.dim(res.worker_updated ? "(live now, and on every deploy)" : "(on the next deploy)")}`);
|
|
4296
4686
|
return;
|
|
4297
4687
|
}
|
|
4298
4688
|
throw new CliError("usage: ambasada env list | set NAME <value> | unset NAME");
|
|
@@ -4304,14 +4694,14 @@ async function logs(_args, flags) {
|
|
|
4304
4694
|
const res = await api(t, "GET", "/v1/product/logs");
|
|
4305
4695
|
if (json(flags, res) && !flags.follow) return;
|
|
4306
4696
|
if (first) {
|
|
4307
|
-
|
|
4308
|
-
|
|
4697
|
+
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`"));
|
|
4698
|
+
out6(c.dim(`${res.errors.total_24h} error${res.errors.total_24h === 1 ? "" : "s"} in the last 24 h`));
|
|
4309
4699
|
}
|
|
4310
4700
|
for (const e of [...res.errors.recent].reverse()) {
|
|
4311
4701
|
const key = `${e.at} ${e.message}`;
|
|
4312
4702
|
if (seen.has(key)) continue;
|
|
4313
4703
|
seen.add(key);
|
|
4314
|
-
|
|
4704
|
+
out6(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${c.red(String(e.status ?? "error"))} ${e.method ?? ""} ${e.path ?? ""} ${e.message ?? ""}`.trimEnd());
|
|
4315
4705
|
}
|
|
4316
4706
|
if (!flags.follow) return;
|
|
4317
4707
|
await sleep(5e3);
|
|
@@ -4319,18 +4709,19 @@ async function logs(_args, flags) {
|
|
|
4319
4709
|
}
|
|
4320
4710
|
|
|
4321
4711
|
// src/main.ts
|
|
4322
|
-
var VERSION3 = true ? "0.8.
|
|
4712
|
+
var VERSION3 = true ? "0.8.3" : "0.1.0-dev";
|
|
4323
4713
|
var MAJOR_MINOR = VERSION3.split(".").slice(0, 2).join(".");
|
|
4324
4714
|
var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasada
|
|
4325
4715
|
|
|
4326
4716
|
init [dir] [--template next|spa|worker] [--village] scaffold an app (one Cloudflare Worker): next (default,
|
|
4327
|
-
|
|
4717
|
+
[--miniapp [--entry /app]] Next.js via OpenNext), spa (Vite + React) or worker
|
|
4328
4718
|
login [--key vk_\u2026] | logout | whoami Ambasada ID, or a project key from developer.ambasada.dev
|
|
4329
4719
|
dev [--port] [--tunnel] [--mock-only] your dev server + the platform mock (:8788): sign-in as a
|
|
4330
4720
|
fake visitor, notifications, store, local D1/KV/R2/DO
|
|
4331
4721
|
deploy [--own] [--no-smoke] [--dry-run] host it at https://<slug>.ambasada.dev
|
|
4332
4722
|
tunnel [--port] | link <url> | check [url|dir] your laptop at https://<slug>-dev.ambasada.dev; hosted
|
|
4333
|
-
elsewhere; the mini-app checks
|
|
4723
|
+
elsewhere; the product (and mini-app) checks
|
|
4724
|
+
miniapp [enable [--entry /app]] the Ambasada mini app at a path of your app: off by default
|
|
4334
4725
|
env list|set|unset | logs [--follow] the hosted Worker
|
|
4335
4726
|
db migrate|shell|tables | kv \u2026 | files \u2026 storage (env.DB, env.KV, env.FILES); --local: wrangler's
|
|
4336
4727
|
add <addon> install an add-on (today: village)
|
|
@@ -4348,7 +4739,7 @@ ${ns.help}`)).join("\n");
|
|
|
4348
4739
|
return `${HELP}${extra || VILLAGE_HINT}
|
|
4349
4740
|
${FOOTER}`;
|
|
4350
4741
|
}
|
|
4351
|
-
var BOOLEAN = /* @__PURE__ */ new Set(["village", "no-install", "check", "json", "force", "dry-run", "agent-only", "offerings-only", "follow", "agent", "gateway", "no-sync", "no-device", "rotate", "once", "mock-only", "help", "version", "own", "no-smoke", "no-build", "tunnel", "no-register", "local"]);
|
|
4742
|
+
var BOOLEAN = /* @__PURE__ */ new Set(["village", "miniapp", "no-install", "check", "json", "force", "dry-run", "agent-only", "offerings-only", "follow", "agent", "gateway", "no-sync", "no-device", "rotate", "once", "mock-only", "help", "version", "own", "no-smoke", "no-build", "tunnel", "no-register", "local"]);
|
|
4352
4743
|
function parseArgs(argv) {
|
|
4353
4744
|
const args = [];
|
|
4354
4745
|
const flags = {};
|
|
@@ -4379,6 +4770,7 @@ var COMMANDS = {
|
|
|
4379
4770
|
tunnel,
|
|
4380
4771
|
link,
|
|
4381
4772
|
check,
|
|
4773
|
+
miniapp,
|
|
4382
4774
|
env,
|
|
4383
4775
|
logs,
|
|
4384
4776
|
db,
|
|
@@ -4390,7 +4782,7 @@ var COMMANDS = {
|
|
|
4390
4782
|
var VILLAGE_COMMANDS = /* @__PURE__ */ new Set(["wallet", "ledger", "offerings", "market", "steer", "webhook", "links", "village"]);
|
|
4391
4783
|
async function villageHint(root, addons2) {
|
|
4392
4784
|
if (!root || addons2.get("village")) return;
|
|
4393
|
-
let uses =
|
|
4785
|
+
let uses = existsSync13(join14(root, "offerings.ts"));
|
|
4394
4786
|
if (!uses) {
|
|
4395
4787
|
try {
|
|
4396
4788
|
uses = (await loadProject(root)).config.idea !== void 0;
|