@ambasada/cli 0.8.2 → 0.8.4
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 +1059 -606
- package/package.json +3 -3
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 within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over. More: docs/TUNNEL.md.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nSets `product.url` in `ambasada.config.ts` and checks that `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\n```\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app" }` | `ambasada.config.ts`; optional `icon`, `category`, `accent`, `scopes`, `visibility` next to `entry` |\n| the in-app layout and page, no site header | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp` | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe templates import `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`, `ui-row`,\n`ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`). Use those classes before writing CSS.\nReact: `import { Card, Button, Sheet } from "@ambasada/ui/react"`. Plain HTML: one\n`<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nLeave space for the app\'s bar: the page uses `--ambasada-safe-area-*` and `--ambasada-host-bar`.\nLight theme only; one red (primary) button per screen.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/(site)/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor".\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html>, the Ambasada look (@ambasada/ui) and globals.css\n app/(site)/layout.tsx the site\'s header (app/account.tsx) around its pages; (site) is not in the URL\n app/(site)/page.tsx the landing page\n app/notes.tsx the notes example (a client component calling app/api/notes)\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/account.tsx the header: "Sign in with Ambasada", or the person\'s name and avatar\n app/(site)/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/(site)/room/ the live room demo\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe template\'s header already has the button. In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only.\n- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": "# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI's version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project's own skills are left alone), the code\nchanges listed below as \"done by `upgrade`\", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project's `typecheck` script.\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest's subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client's `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform's from 0.8.\n5. **Login.** The CLI's login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n", "AGENTS.md": "# Agents\n\nThis is an Ambasada app: Next.js that runs as one Cloudflare Worker (OpenNext), with `@ambasada/sdk` for\nsign-in, notifications and the store. Read `README.md` first, and `.claude/skills/ambasada-product/`.\nRun it with `npm run dev` (`ambasada dev`: the mock API + `next dev`). Data and realtime (`env.DB`, `env.KV`,\n`env.FILES`, live rooms): `.claude/skills/ambasada-product/reference/storage.md`.\n\n<!-- BEGIN:nextjs-agent-rules -->\n\n## This is NOT the Next.js you know\n\nThis version has breaking changes \u2014 APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.\n\nThis block is written and re-added by `next dev` \u2014 verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.\n\n<!-- END:nextjs-agent-rules -->\n", "README.md": "# My App\n\nAn app on Ambasada, Next.js on one Cloudflare Worker (template `next`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\napp/(site)/, app/notes.tsx the site: header, landing page, room, privacy; the notes example\napp/api/auth/* Sign in with Ambasada (login, callback, logout, me, shell); app/account.tsx is the header\napp/api/ambasada/webhook the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napp/api/notes, app/api/files env.DB + env.KV, uploads to env.FILES; app/files/[...key] serves them\nlib/ambasada.ts the SDK client from the Worker's env (getCloudflareContext), process.env under `next dev`\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables; lib/bindings.ts reaches them\nsrc/realtime/ the live room (a Durable Object) and its React hook; worker.ts routes its WebSockets\nnext.config.ts, open-next.config.ts, wrangler.jsonc, worker.ts Next.js \u2192 one Worker (OpenNext, nodejs_compat)\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `next dev` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `app/api/<name>/route.ts`. Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nStay inside what runs on a Worker: pages, server components, route handlers, server actions and `fetch`.\nNot: the `next/image` default loader, ISR / `revalidate`, `fs`, `child_process` or native packages, a custom\nserver. `npm run preview` builds the Worker (`opennextjs-cloudflare build`) and runs it in workerd, as in production.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # Ambasada ID, or a project key from https://developer.ambasada.dev\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx opennextjs-cloudflare build && npx opennextjs-cloudflare deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the header's button signs people in with their Ambasada ID; on the server,\n`ambasada(\u2026).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `app/api/notes/route.ts` |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `app/api/files/route.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts` (wired in `worker.ts`), hook `src/realtime/use-room.ts`, demo at `/room` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nUnder `next dev`, `env.DB`, `env.KV` and `env.FILES` work (`lib/bindings.ts`); live rooms need workerd: `npm run preview`.\n\nOptional: an agent with the Agents SDK in `examples/agent/` (its README: `npm i agents` and four lines to wire it).\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, app/app/ (layout, page) and app/app/in-app.tsx, a placeholder\n`public/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. The ambasada-product skill:\nreference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "Notes, a live room and sign-in",\n // Where the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\n product: { url: "http://localhost:3000" },\n});\n', "app/(site)/layout.tsx": `import type { ReactNode } from "react";
|
|
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 within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over. More: docs/TUNNEL.md.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nSets `product.url` in `ambasada.config.ts` and checks that `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\n```\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app" }` | `ambasada.config.ts`; optional `icon`, `category`, `accent`, `scopes`, `visibility` next to `entry` |\n| the in-app layout and page, no site header | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp` | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe templates import `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`, `ui-row`,\n`ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`). Use those classes before writing CSS.\nReact: `import { Card, Button, Sheet } from "@ambasada/ui/react"`. Plain HTML: one\n`<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nLeave space for the app\'s bar: the page uses `--ambasada-safe-area-*` and `--ambasada-host-bar`.\nLight theme only; one red (primary) button per screen.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/(site)/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor".\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html>, the Ambasada look (@ambasada/ui) and globals.css\n app/(site)/layout.tsx the site\'s header (app/account.tsx) around its pages; (site) is not in the URL\n app/(site)/page.tsx the landing page\n app/notes.tsx the notes example (a client component calling app/api/notes)\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/account.tsx the header: "Sign in with Ambasada", or the person\'s name and avatar\n app/(site)/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/(site)/room/ the live room demo\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe template\'s header already has the button. In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only.\n- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": "# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI's version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project's own skills are left alone), the code\nchanges listed below as \"done by `upgrade`\", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project's `typecheck` script.\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest's subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client's `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform's from 0.8.\n5. **Login.** The CLI's login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n", "README.md": "# My App\n\nAn app on Ambasada, Vite + React on one Cloudflare Worker (template `spa`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/ the React app (drop a Lovable/Bolt export here); src/App.tsx has the header and the notes example\nworker/index.ts the Worker: /api/<name> \u2192 api/<name>.ts, everything else is the app\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker's env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/ the live room (a Durable Object) and its React hook\nvite.config.ts, wrangler.jsonc @cloudflare/vite-plugin: `vite` runs the app and the Worker together\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `vite` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `api/<name>.ts` (and a line in `worker/index.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # Ambasada ID, or a project key from https://developer.ambasada.dev\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx vite build && npx wrangler deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the header's button signs people in with their Ambasada ID; on the server,\n`ambasada(\u2026).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `api/notes.ts` |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `api/files.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts`, hook `src/realtime/use-room.ts`, demo at `/room` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nOptional: an agent with the Agents SDK in `examples/agent/` (its README: `npm i agents` and four lines to wire it).\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, src/in-app/ (the in-app page, rendered by src/main.tsx under the entry), a placeholder\n`public/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. The ambasada-product skill:\nreference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "Notes, a live room and sign-in",\n // Where the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\n product: { url: "http://localhost:3000" },\n});\n', "api/ambasada-webhook.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../lib/notifications.ts";\n\nexport default async function appWebhook(req: Request, env: Env): Promise<Response> {\n const app = ambasada(env);\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/auth.ts": '// Sign in with Ambasada (Ambasada ID, OAuth + PKCE): zero setup. On https://<slug>.ambasada.dev the deploy\n// injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\n// GET /api/auth/login?returnTo=/ \u2192 the Ambasada ID consent screen\n// GET /api/auth/callback \u2192 back from it: sets an HttpOnly session cookie, redirects to returnTo\n// GET /api/auth/logout \u2192 clears it\n// GET /api/auth/me \u2192 { user } (null when signed out), for the browser\n// POST /api/auth/shell { token } \u2192 silent sign-in inside the Ambasada app: the same session, no consent screen\n// (the page calls `auth.signIn()` from @ambasada/miniapp)\n// On the server: `await ambasada(env).auth.user(req)` \u2192 { id, name, avatar_url, builder, pass, \u2026 } or null.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\n\nexport default async function auth(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n try {\n switch (new URL(req.url).pathname.replace(/\\/+$/, "")) {\n case "/api/auth/login":\n return await auth.login(req);\n case "/api/auth/callback":\n return await auth.callback(req);\n case "/api/auth/logout":\n return auth.logout(req);\n case "/api/auth/shell":\n return await auth.shell(req);\n case "/api/auth/me": {\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env.AMBASADA_AUTH_ISSUER?.startsWith("http://") ? env.AMBASADA_AUTH_ISSUER : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n }\n }\n return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/files.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by the /files/* route.\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { upload } from "../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport default async function files(req: Request, env: Env): Promise<Response> {\n if (req.method !== "POST") return Response.json({ error: { code: "INVALID_REQUEST", message: "POST the file" } }, { status: 405 });\n const user = await ambasada(env).auth.user(req);\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "api/notes.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport type { Env } from "../lib/ambasada.ts";\nimport { all, run } from "../lib/db.ts";\nimport { cached, remove } from "../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport default async function notes(req: Request, env: Env): Promise<Response> {\n if (req.method === "POST") {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { lastRowId } = await run(env.DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(env.KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n }\n const latest = await cached(env.KV, "notes:latest", 60, () => all<Note>(env.DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\ndist/\n.wrangler/\n*.tsbuildinfo\n.env*\n.dev.vars*\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "index.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>My App</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="/src/main.tsx"></script>\n </body>\n</html>\n', "lib/ambasada.ts": 'import { createAmbasada, type Ambasada } from "@ambasada/sdk";\nimport type { Room } from "../src/realtime/room.ts";\n\n/** The Worker\'s env: `.env` locally (`ambasada dev` points it at the mock), secrets injected at deploy. */\nexport interface Env {\n ASSETS: Fetcher;\n /** The app\'s SQLite database (lib/db.ts), key-value (lib/kv.ts), files (lib/files.ts) and live rooms (src/realtime/room.ts). */\n DB: D1Database;\n KV: KVNamespace;\n FILES: R2Bucket;\n ROOMS: DurableObjectNamespace<Room>;\n AMBASADA_API_KEY?: string;\n /** Set by `ambasada deploy` and `ambasada dev`; the SDK defaults to https://api.ambasada.dev. */\n AMBASADA_API_URL?: string;\n AMBASADA_APP_ID?: string;\n /** Sign in with Ambasada (api/auth.ts): set by `ambasada deploy` on ambasada.dev, and by `ambasada dev`. */\n AMBASADA_AUTH_CLIENT_ID?: string;\n AMBASADA_AUTH_CLIENT_SECRET?: string;\n AMBASADA_AUTH_ISSUER?: string;\n /** Mini-app notifications (api/ambasada-webhook.ts, lib/notifications.ts): set on ambasada.dev and by `ambasada dev`. */\n AMBASADA_APP_SECRET?: string;\n AMBASADA_HUB_URL?: string;\n AMBASADA_APP_ORIGIN?: string;\n /** Your public URL, if the Worker sees another host (behind a proxy). */\n PUBLIC_URL?: string;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API\'s signing keys. */\nexport function ambasada(env: Env): Ambasada {\n return (client ??= createAmbasada({ env: env as unknown as Record<string, unknown> }));\n}\n', "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-spa",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: Vite + React (Lovable/Bolt exports) and api/*.ts handlers as one Cloudflare Worker.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "build": "vite build",\n "preview": "vite build && vite preview --port 3000",\n "typecheck": "tsc -b",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*",\n "@ambasada/ui": "workspace:*",\n "react": "~19.3.1",\n "react-dom": "~19.3.1"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/vite-plugin": "^1.67.0",\n "@cloudflare/workers-types": "^5.20261102.1",\n "@types/node": "^22.20.5",\n "@types/react": "^19.3.0",\n "@types/react-dom": "^19.3.0",\n "@vitejs/plugin-react": "^6.1.2",\n "typescript": "~5.9.3",\n "vite": "^8.3.5",\n "wrangler": "^4.156.1"\n }\n}\n', "public/privacy.html": `<!doctype html>
|
|
135
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">
|
|
@@ -139,7 +143,7 @@ export const bindings = (): Bindings => getCloudflareContext().env as unknown as
|
|
|
139
143
|
<meta charset="utf-8" />
|
|
140
144
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
141
145
|
<title>Privacy \xB7 My App</title>
|
|
142
|
-
<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.
|
|
146
|
+
<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />
|
|
143
147
|
</head>
|
|
144
148
|
<body>
|
|
145
149
|
<main class="ui-page">
|
|
@@ -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 within about 30 s, and on\nevery deploy); `npx ambasada env list` shows the names.\n\nLimits: 10 MB compressed bundle, 1000 static files, short CPU time per request (waiting on `fetch` does not\ncount). Heavy work belongs in an API you call, or a Durable Object, not in one request.\n\n## `ambasada dev --tunnel`: your laptop, live\n\nWhen the Ambasada app, sign-in or webhooks need to reach the version on your laptop (before deploying, or to\ndebug), log in once, then:\n\n```sh\nnpx ambasada dev --tunnel # your dev server, against the real API, at https://<slug>-dev.ambasada.dev\nnpx ambasada tunnel --port 5173 # or: only the tunnel, for a server you started yourself\n```\n\nUntil Ctrl+C, `https://<slug>-dev.ambasada.dev` is your app's URL. There is no mock in this mode. The first\ntime takes up to 2 minutes while the address is published; after that it is instant. Only one terminal per\napp at a time: `--force` takes it over. More: docs/TUNNEL.md.\n\n## `ambasada deploy --own`: your own Cloudflare account\n\nRuns `wrangler deploy` into your account (you need `npx wrangler login` first), then\n`ambasada link <your workers.dev URL>`. Set the secrets yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n\n## `ambasada link`: the app is hosted somewhere else\n\n```sh\nnpx ambasada link https://my-thing.lovable.app\n```\n\nSets `product.url` in `ambasada.config.ts` and checks that `<url>/health` answers. Use it for Lovable, Bolt,\nv0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).\n\n## Watch it run\n\n```sh\nnpx ambasada logs --follow # the last deploy and new errors\nnpx ambasada check https://<slug>.ambasada.dev # https, no redirects, speed; the mini-app rules when it is on\n```\n\nThe Developer app (https://developer.ambasada.dev) has deploys, errors, users and the storage tabs.\n\n## Upgrading\n\nA new devkit: `npx ambasada upgrade` (dependencies, skills, mechanical code changes), then\n[upgrading.md](upgrading.md) for the steps a coding agent does.\n\n## When it goes wrong\n\n| Symptom | Fix |\n|---|---|\n| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |\n| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |\n| `no such table: \u2026` online | `npx ambasada db migrate` |\n| a Durable Object migration is refused on deploy | never edit or remove a deployed tag in `wrangler.jsonc`'s `migrations`; add the next one ([storage.md](storage.md)) |\n| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |\n| sign-in loops back to the login page | `PUBLIC_URL` differs from the URL in the browser; remove it or fix it |\n| `this app uses the village \u2026 not installed` | a Hack A Ton project: `npx ambasada upgrade` (adds `@ambasada/village`) |\n", ".claude/skills/ambasada-product/reference/mini-app.md": '# The app inside the Ambasada app (mini app)\n\n**Optional, opt in with `npx ambasada miniapp enable`** (or `npm create ambasada@latest my-app -- --miniapp`). An app\nis a website first; most stay that way. The mini app is one more surface of it, at a path: what the Ambasada mobile\napp opens, with silent sign-in and push notifications. Platform spec: docs/MINI-APPS.md (\xA715, decision 15.6).\n\n## Turn it on\n\n```sh\nnpx ambasada miniapp enable # at /app; --entry /in-app for another path, --entry / for the whole site\nnpx ambasada miniapp # on or off, and the entry\n```\n\nIt adds:\n\n| | |\n|---|---|\n| `miniapp: { entry: "/app" }` | `ambasada.config.ts`; optional `icon`, `category`, `accent`, `scopes`, `visibility` next to `entry` |\n| the in-app layout and page, no site header | `app/app/` (Next), `src/in-app/InApp.tsx` rendered by `src/main.tsx` (spa), `web/app.html` (worker) |\n| `ready()` and silent sign-in | `app/app/in-app.tsx`, `src/in-app/shell.ts`, `web/in-app.js`; with `--entry /` hooked into the root layout instead |\n| a placeholder icon, 1024\xD71024 | `public/icon.png` (`web/icon.png`): replace it, keep it square and opaque |\n| `@ambasada/miniapp` | `package.json` (Next, spa): run `npm install` |\n\nThe manifest, `/.well-known/ambasada.json`, is not a file you keep: `ambasada dev` and `ambasada deploy` write it\nfrom `ambasada.config.ts` (id, name, description, `miniapp`) into `public/` (`web/`), and `.gitignore` leaves it out.\nTo write it by hand, put your own there and drop that `.gitignore` line: yours wins. Without `miniapp`, nothing is\nserved, `ambasada check` says "mini app: off" and the deploy does not register the app with the Ambasada app.\n\n## Is it listed?\n\n`ambasada deploy` registers the app with the Ambasada hub only when it serves the manifest, then checks it. It prints:\n\n```\n\u2713 in the Ambasada app: Listed for the event\n! in the Ambasada app: Unlisted \xB7 needs: icon, viewport\n mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)\n```\n\nUnlisted apps work, but only you see them in the Ambasada app. Fix what `needs` says and deploy again,\nor run `npx ambasada check https://<slug>.ambasada.dev` (or `npx ambasada check` for the local folder).\nThe checks: `/.well-known/ambasada.json` valid, `icon.png` a 1024\xD71024 PNG with no transparency, the entry page\nhas `<meta name="viewport" content="width=device-width, initial-scale=1">`, no dark-only theme, it answers in under\n3 s without a redirect, `/api/auth/callback` exists, the notifications webhook answers a signed ping. With the mini\napp off, only https, no redirects and the response time of `/`.\nConnected tools can do the same through MCP: `app_status`, `app_check`.\n\n## Look like the app: @ambasada/ui\n\nThe templates import `@ambasada/ui` (tokens, Inter + JetBrains Mono, `ui-card`, `ui-button`, `ui-row`,\n`ui-pill`, `ui-sheet`, `ui-stepper`, `ui-empty`, `ui-skeleton`). Use those classes before writing CSS.\nReact: `import { Card, Button, Sheet } from "@ambasada/ui/react"`. Plain HTML: one\n`<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css">`.\nLeave space for the app\'s bar: the page uses `--ambasada-safe-area-*` and `--ambasada-host-bar`.\nLight theme only; one red (primary) button per screen.\n\n## Notifications\n\nAlready wired in every template; they work once the mini app is on:\n\n| Piece | Where |\n|---|---|\n| manifest: `"scopes": ["identity", "notifications"]`, `"notifications": { "webhook": "/api/ambasada/webhook" }`, `"privacy_url": "/privacy"` | generated from `ambasada.config.ts` (the defaults; `miniapp.scopes` to change them) |\n| the webhook: verifies the hub\'s signature, saves `token \u2194 Ambasada user id` in `ambasada.store` | `app/api/ambasada/webhook/route.ts` (Next), `api/ambasada-webhook.ts` (spa, worker) |\n| `notifyUser(app, userId, { \u2026 })`: push to one person if they turned notifications on | `lib/notifications.ts` |\n| the privacy page the scopes need | `app/(site)/privacy/page.tsx`, `public/privacy.html`, `web/privacy.html` |\n\nSend one when something the person waits for happens (their order is ready), never as marketing:\n\n```ts\nimport { notifyUser } from "../lib/notifications.ts";\n\nconst user = await ambasada().auth.user(req); // signed in with Ambasada\nif (user) await notifyUser(ambasada(), user.id, {\n notification_id: `order-${id}-ready`, // unique per message; repeats are dropped for 24 h\n title: "Your translation is ready", // \u2264 40 characters\n body: "Tap to read it.", // \u2264 140\n url: `/orders/${id}`, // a path on your site: where the tap opens\n});\n```\n\nPeople turn notifications on in the app (the app asks; the page can ask too with\n`notifications.request()` from `@ambasada/miniapp`, from a button). The secret (`AMBASADA_APP_SECRET`) is\ninjected by `ambasada deploy`; never put it in the browser.\n\n## Signed in without a consent screen\n\nInside the Ambasada app the page signs in silently: `auth.signIn()` from `@ambasada/miniapp` gets a short\ntoken from the app and POSTs it to `/api/auth/shell`, which sets the same session as "Sign in with\nAmbasada". Server code doesn\'t change: `currentUser()` / `ambasada().auth.user(req)` work either way.\nOn the web, the button and consent screen stay. Silent sign-in needs the app to be Verified; until then\n`signIn()` resolves `null` and the button shows.\n\n## Try it locally\n\n`npx ambasada dev` plays the hub (with the mini app on): when your app starts it sends a signed `ping` and\n`notifications_enabled` to your webhook (the token lands in the store as\n`ambasada/notifications/user_fake_visitor`), and its `/api/apps/notify` accepts pushes for that token\n(they show in the terminal). Open `http://localhost:3000/app?ambasada_shell=mock` (your entry) to pretend to be\ninside the app: the fake shell signs you in silently as "Fake Visitor".\n', ".claude/skills/ambasada-product/reference/stack.md": '# The stack: Next.js on Cloudflare\n\nEvery Ambasada app is one Cloudflare Worker. The default template builds it from **Next.js 16.4**\nwith **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a\nnormal Next.js App Router app; `ambasada deploy` turns it into a Worker.\n\n## Folder layout (template `next`)\n\n```\nmy-app/\n app/layout.tsx <html>, the Ambasada look (@ambasada/ui) and globals.css\n app/(site)/layout.tsx the site\'s header (app/account.tsx) around its pages; (site) is not in the URL\n app/(site)/page.tsx the landing page\n app/notes.tsx the notes example (a client component calling app/api/notes)\n app/api/notes/route.ts rows in env.DB, the list cached in env.KV\n app/api/files/route.ts uploads to env.FILES; app/files/[...key]/route.ts serves them\n app/api/auth/*/route.ts Sign in with Ambasada: login, callback, logout, me, shell (silent, in the app)\n app/api/ambasada/webhook/ the Ambasada app\'s events: who turned notifications on (reference/mini-app.md)\n app/account.tsx the header: "Sign in with Ambasada", or the person\'s name and avatar\n app/(site)/privacy/page.tsx what you keep about people (the mini app\'s privacy_url)\n app/(site)/room/ the live room demo\n app/app/ the mini app, once on (`ambasada miniapp enable`): its layout, page and in-app.tsx\n lib/ambasada.ts the Ambasada client for server code (reads the Worker env)\n lib/notifications.ts notifyUser(): a push to one person who turned notifications on\n lib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES (reference/storage.md); lib/bindings.ts reaches them\n db/migrations/ the database\'s tables, one .sql file per change\n src/realtime/ the live room (a Durable Object) and its React hook\n ambasada.config.ts app id, name, one-line description, product URL, `miniapp` (optional)\n wrangler.jsonc Worker settings (nodejs_compat, assets) and the bindings DB, KV, FILES, ROOMS\n worker.ts the Worker entry: live rooms\' WebSockets, then the Next.js app\n open-next.config.ts OpenNext settings; leave as is\n .env secrets for local runs (never commit, never send to the browser)\n .ambasada/project.json the template and devkit version (`ambasada upgrade` reads it); commit it\n .claude/skills/ this skill\n```\n\nOne more endpoint = one more `app/api/<name>/route.ts`. Data it keeps goes in `env.DB` (a migration in\n`db/migrations/`), `env.KV` or `env.FILES` ([storage.md](storage.md)).\n\n## Using the client\n\nServer code only (route handlers, server components, server actions):\n\n```ts\n// app/api/hello/route.ts\nimport { ambasada } from "../../../lib/ambasada.ts";\n\nexport async function GET(req: Request) {\n const user = await ambasada().auth.user(req);\n return Response.json({ hello: user?.name ?? "stranger" });\n}\n```\n\n`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,\nwhere `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.\nIt reads `AMBASADA_API_KEY` and `AMBASADA_API_URL` (default https://api.ambasada.dev). The client has `auth`\n(below), `apps` (notifications, [mini-app.md](mini-app.md)), `store` and `files`.\n\n## Who is signed in\n\nEvery hosted app gets **Sign in with Ambasada** (Ambasada ID) for free: no keys, no settings.\nThe template\'s header already has the button. In server code:\n\n```ts\nimport { currentUser } from "../lib/ambasada.ts";\nimport { bindings } from "../lib/bindings.ts";\nimport { run } from "../lib/db.ts";\n\nconst user = await currentUser(); // null, or { id, name, avatar_url, builder, pass, member_since, \u2026 }\nif (!user) redirect("/api/auth/login?returnTo=/orders");\nawait run(bindings().DB, "INSERT INTO orders (user_id, item) VALUES (?, ?)", user.id, item); // key per-person data by user.id\n```\n\nIn a route handler, `ambasada().auth.user(req)` does the same. Client components fetch `/api/auth/me`.\nAsk for the email only if you need it: `/api/auth/login` calls `auth.login(req, { email: true })`.\nDon\'t build your own login or accounts table, and don\'t add Clerk, Auth.js or Supabase Auth.\n\n## Next.js: allowed and banned\n\nAllowed, and enough for any product here:\n\n- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)\n- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)\n- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind\n\nBanned (they fail on Workers or break the deploy):\n\n| Don\'t | Do instead |\n|---|---|\n| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |\n| ISR, `export const revalidate = \u2026`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or cache results in `env.KV` |\n| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API (an LLM provider with your own key in a secret, for example) |\n| a custom server (`server.js`, Express inside Next) | route handlers |\n| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next\'s default runtime is right |\n| Prisma, Drizzle with a database, Supabase, Firebase, Mongo, Redis | `env.DB` (SQLite), `env.KV`, `env.FILES` ([storage.md](storage.md)) |\n| writing files at runtime, global variables as storage | `env.DB`, `env.KV`, `env.FILES` or a Durable Object (each request can run on a different machine) |\n\nKeep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.\n\n## Where data lives\n\nThe app has its own SQLite database (`env.DB`), key-value (`env.KV`), file storage (`env.FILES`, public at\n`/files/<key>`) and Durable Objects for live state, created at the first deploy. Which one fits, the migrations\nworkflow and copy-paste patterns: [storage.md](storage.md).\n\nAlso there, for small things: `ambasada.store` (JSON, 1 MB for the whole app: settings, not user data) and\n`ambasada.files.put(\u2026)` \u2192 `{ url }` (platform-hosted files that expire).\n\n## Secrets\n\n- Local: `.env` in the project (Cloudflare\'s `.dev.vars` works too). The template writes it with test\n values for `ambasada dev`. Both are in `.gitignore`; keep them there.\n- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL`, the Sign in with Ambasada\n credentials (`AMBASADA_AUTH_*`) and the mini-app secret (`AMBASADA_APP_SECRET`, with `AMBASADA_HUB_URL`\n and `AMBASADA_APP_ORIGIN`) for you. With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.\n- Other services\' keys (an OpenAI key, say): `.env` locally, `npx ambasada env set OPENAI_API_KEY \u2026` online;\n read on the server only.\n- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.\n- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.\n\n## Variants\n\n- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. The\n `api/` endpoints are small fetch handlers; the React app calls them with `fetch("/api/\u2026")`.\n- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the app is a page or two.\n\nThe rules on this page (state, secrets, no native packages) apply to all three.\n', ".claude/skills/ambasada-product/reference/storage.md": '# Data, files and realtime\n\nEvery app on ambasada.dev gets a real database, key-value, file storage and Durable Objects. They are created\nat the first `ambasada deploy` and bound to the Worker on every deploy; nothing to sign up for or configure.\nLocally (`ambasada dev`, `wrangler dev`, `vite`) wrangler runs the same things from `.wrangler/state`.\n\n| Need | Use | Not |\n|---|---|---|\n| anything with rows: users\' data, orders, posts, scores, anything you query, sort or join | `env.DB` (SQLite, D1) | KV, files, `ambasada.store` |\n| sessions, caches of slow results, feature flags, "seen this already" | `env.KV` | a table you scan |\n| uploads, images, audio, PDFs, generated files | `env.FILES` (R2), public at `/files/<key>` | base64 in the database |\n| state several people see change live: chat, presence, a shared board, a game table, live counters, a queue, rate limits, one thing per entity | a Durable Object (`env.ROOMS` in the templates) | polling the database |\n| an AI agent with memory, schedules and live state per user or task | the Agents SDK (`examples/agent/`) | a cron that rereads everything |\n| a few small settings for a tiny app | `ambasada.store` (1 MB per app) | user data of any size |\n\nQuotas per app: database 500 MB, KV 1 GB, files 5 GB. A database over its quota stops the next deploy.\n\n## `env.DB`: SQLite\n\nSchema lives in `db/migrations/`, one file per change, applied in name order, never edited once applied:\n\n```sql\n-- db/migrations/0002_orders.sql\nCREATE TABLE orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n item TEXT NOT NULL,\n paid INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX orders_user ON orders (user_id, created_at);\n```\n\n```sh\nnpx wrangler d1 migrations apply DB --local # the local database, for ambasada dev\nnpx ambasada db migrate # the app\'s database online (run before or right after deploying)\nnpx ambasada db shell "SELECT * FROM orders LIMIT 5"\n```\n\nQuery with `lib/db.ts` (SQL with `?` placeholders, typed rows, no ORM):\n\n```ts\nimport { all, batch, first, run } from "../lib/db.ts";\n\ntype Order = { id: number; user_id: string; item: string; paid: number; created_at: number };\nconst mine = await all<Order>(env.DB, "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", user.id);\nconst one = await first<Order>(env.DB, "SELECT * FROM orders WHERE id = ?", id);\nconst { lastRowId } = await run(env.DB, "INSERT INTO orders (user_id, item, created_at) VALUES (?, ?, ?)", user.id, item, Date.now());\nawait batch(env.DB, [["UPDATE stock SET n = n - 1 WHERE item = ?", item], ["UPDATE orders SET paid = 1 WHERE id = ?", id]]); // all or nothing\n```\n\nIn Next.js route handlers and server components, the bindings come from `bindings()` (`lib/bindings.ts`):\n`const { DB } = bindings();`. In the spa and worker templates, handlers get `env`.\n\nRules: always placeholders, never string-built SQL. Key per-person rows by the Ambasada user id\n(`auth.user(req).id`). Add an index for every `WHERE` you run often. Times as `Date.now()` integers.\n\n## `env.KV`: sessions, caches, flags\n\nReads are fast everywhere; a write shows up everywhere within about a minute; one write per second per key.\n\n```ts\nimport { cached, getJson, putJson } from "../lib/kv.ts";\n\nawait putJson(env.KV, `session:${id}`, { userId }, { ttl: 60 * 60 * 24 });\nconst session = await getJson<{ userId: string }>(env.KV, `session:${id}`);\nconst stats = await cached(env.KV, "home:stats", 300, () => computeStats(env)); // recompute at most every 5 min\n```\n\nNot for counters, balances or anything two people write at once: use `env.DB` (a transaction) or a Durable Object.\n\n## `env.FILES`: uploads and generated files\n\n```ts\nimport { serveFile, upload } from "../lib/files.ts";\n\n// POST /api/files: a signed-in person uploads (the templates\' api/files.ts)\nconst { key, url } = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\nawait run(env.DB, "INSERT INTO photos (user_id, file_key) VALUES (?, ?)", user.id, key); // keep the key, not the bytes\n\n// GET /files/*: already wired in every template, with ETag, ranges and cache headers\nreturn serveFile(env.FILES, req);\n```\n\n`url` is `/files/<key>` on the app (`https://<slug>.ambasada.dev/files/<key>`): public to anyone with the link.\nPrivate files: store them under a key nobody can guess and serve them from your own route that checks\n`auth.user(req)` first, instead of `/files/*`. `npx ambasada files list | put | url | delete` from the terminal.\n\n## Durable Objects: realtime and per-entity state\n\nA Durable Object is one tiny server per id (a room, a document, a match, a user) with its own SQLite, that\nevery request for that id reaches. Use one whenever people must see each other\'s changes live, or one thing\nmust never be updated by two requests at once. The templates ship `src/realtime/room.ts` (`env.ROOMS`):\n\n```ts\n// the Worker: /api/rooms/<id>/ws \u2192 the room\nconst room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\nif (room) return env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n```\n\n```ts\n// the room (shortened): WebSocket hibernation, broadcast, its own SQLite\nexport class Room extends DurableObject<unknown> {\n async fetch(req: Request) {\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server); // hibernation: no cost while nobody talks\n server.serializeAttachment({ name: "guest" }); // per-connection data that survives hibernation\n return new Response(null, { status: 101, webSocket: client });\n }\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {\n this.ctx.storage.sql.exec("INSERT INTO messages (text, at) VALUES (?, ?)", String(data), Date.now());\n for (const peer of this.ctx.getWebSockets()) peer.send(String(data)); // everyone in the room\n }\n}\n```\n\n```tsx\n// the browser (React): src/realtime/use-room.ts\nconst { messages, online, send, status } = useRoom(roomId, { name: user.name });\n```\n\nA new class needs a binding and a migration in `wrangler.jsonc` (new tags go at the end; never edit or remove one\nthat was deployed; to drop a class add `{ "tag": "v3", "deleted_classes": ["Old"] }`):\n\n```jsonc\n"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "BOARDS", "class_name": "Board" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["Board"] }]\n```\n\nExport the class from the Worker\'s entry (`src/worker.ts`; spa `worker/index.ts`; next `worker.ts`).\nOther shapes, same pattern: presence (who is online), a live counter (`UPDATE \u2026 RETURNING`, then broadcast), a\nper-user rate limit (`getByName(user.id)`), a job queue (`this.ctx.storage.setAlarm`). A Durable Object belongs\nto this app; other apps can\'t reach it. Under `next dev` they don\'t run: use `npm run preview` (workerd).\n\n## Agents (Agents SDK)\n\nFor agentic products (an assistant per user, a research task that runs for minutes, a bot that follows up),\nuse the Cloudflare Agents SDK: `examples/agent/` in the template (`npm i agents`, four lines to wire). An agent\nis a Durable Object with live state synced to clients (`setState`, `useAgent` in React), `this.sql`,\n`this.schedule(\u2026)` and WebSockets. One agent per user or task: `/agents/agent-example/<user id>`.\n\n## Don\'t\n\n- Poll (`setInterval(fetch\u2026)`) for what others changed: a Durable Object + WebSocket pushes it.\n- Put files or base64 in the database or KV: `env.FILES`, and keep the key in a row.\n- Keep user data in `ambasada.store`: it is 1 MB for the whole app.\n- Keep state in module variables or global objects: every request may run on another machine.\n- Count, rank or move money in KV: use a SQL transaction (`batch`) or a Durable Object.\n- Build SQL by joining strings with user input: `?` placeholders.\n- Edit an applied migration: add the next file.\n- Add Prisma, Drizzle with Postgres, Supabase, Firebase, Mongo or Redis: everything above is already here.\n', ".claude/skills/ambasada-product/reference/upgrading.md": "# Upgrading a project\n\n`npx ambasada upgrade` does the mechanical part: every `@ambasada/*` dependency to the CLI's version, the\npackage manager, `.claude/skills/ambasada-*` re-synced (the project's own skills are left alone), the code\nchanges listed below as \"done by `upgrade`\", the `ambasada check` rules, and `.ambasada/project.json`\n(the template and devkit version). `npx ambasada upgrade --check` only reports.\n\nWhat is left is below, per version, for a coding agent. Do the steps for every version between the one in\n`.ambasada/project.json` (none: 0.7) and the current one, in order. After each, run the app with\n`npx ambasada dev` and the project's `typecheck` script.\n\n## 0.7 \u2192 0.8: the platform and the village split\n\n0.8 splits the devkit: `@ambasada/sdk` and `ambasada` are the platform (sign-in, notifications, storage,\nhosting); the Hack A Ton economy (TON, checkout, offerings, the agent) is the add-on `@ambasada/village`.\n\nDone by `upgrade`:\n\n- the village names (`village`, `createVillage`, `offering`, `defineCompany`, `isVillageError`, the receipt and\n webhook helpers, their types) import from `@ambasada/village`; in a village app `createAmbasada` became\n `createVillage` and `DEFAULT_API_URL` became `DEFAULT_VILLAGE_API_URL` (their 0.7 meaning);\n- `@ambasada/village` added to `package.json` when the app has `offerings.ts` or an `idea`;\n- `.env` / `.dev.vars`: `AMBASADA_API_URL=https://hackaton.ambasada.id` became `https://api.ambasada.dev` plus\n `AMBASADA_VILLAGE_API_URL=https://hackaton.ambasada.id`;\n- the `ambasada-payments` skill replaced by `ambasada-village`.\n\nFor the agent:\n\n1. **Platform code uses the platform client.** Files that only sign people in, send notifications or use\n `store`/`files` can import `createAmbasada` / `ambasada` from `@ambasada/sdk` instead of the village client.\n Leave files that sell, check receipts or call `llm` on the village client.\n2. **Config.** `ambasada.config.ts` may stay `defineCompany({ id, name, idea, \u2026 })`; the 0.8 form is\n `defineCompany(defineApp({ id, name, description, product }), { idea, offerings, agent })` with\n `defineApp` from `@ambasada/sdk`. Add a one-line `description` (the manifest's subtitle).\n3. **CLI calls.** In scripts, READMEs and `package.json`: `ambasada wallet|ledger|offerings|market|steer|webhook|links`\n \u2192 `ambasada village \u2026`, `ambasada logs --agent` \u2192 `ambasada village logs --agent`, `ambasada dev buy` \u2192\n `ambasada village dev buy`. The old spellings still work through 1.x with a warning.\n4. **Pages that load pay.js** from `AMBASADA_API_URL` should use the village client's `apiUrl` (or\n `AMBASADA_VILLAGE_API_URL`): `AMBASADA_API_URL` is the platform's from 0.8.\n5. **Login.** The CLI's login moved from `~/.config/village` to `~/.config/ambasada` (moved on first use);\n nothing to do unless a script reads that file.\n6. Run `npx ambasada check`, `npx ambasada dev` and the typecheck; deploy when they pass.\n", "README.md": "# My App\n\nAn app on Ambasada, one bare Cloudflare Worker (template `worker`): sign-in with Ambasada ID, a database,\nkey-value, files and live rooms; a mini app inside the Ambasada app if you want one. Everything here is a default;\nchange anything.\n\n```\nambasada.config.ts id, name, one-line description, where the app runs\nsrc/worker.ts the Worker: /api/<name> \u2192 api/<name>.ts, / (landing page), the rest from web/\napi/auth.ts Sign in with Ambasada (login, callback, logout, me, shell)\napi/ambasada-webhook.ts the Ambasada app's events (notifications on/off); lib/notifications.ts sends pushes\napi/notes.ts, api/files.ts env.DB + env.KV, uploads to env.FILES\nlib/ambasada.ts the SDK client from the Worker's env\nlib/db.ts, kv.ts, files.ts env.DB, env.KV, env.FILES; db/migrations/ the tables\nsrc/realtime/room.ts the live room (a Durable Object); web/room.html is the demo\nweb/ the landing page, static assets\nwrangler.jsonc one Worker + assets, nodejs_compat, the storage bindings\n.claude/skills/ docs your coding agent reads (ambasada-product)\n```\n\n## 1. Run it (2 min)\n\nNeeds Node \u2265 22.\n\n```sh\nnpm install\nnpm run dev # `ambasada dev`: the mock Ambasada API on :8788 + `wrangler dev` on http://localhost:3000\n```\n\nOpen http://localhost:3000: add a note, sign in (the mock signs you in as \"Fake Visitor\"), open /room in two tabs.\nhttp://127.0.0.1:8788 is the mock's page (sign-ins, notifications, the store). `ambasada dev` points your app at the\nmock whatever `.env` says.\n\n## 2. Make it yours\n\n- `ambasada.config.ts`: name and description.\n- A new endpoint: `api/<name>.ts` (and a line in `src/worker.ts`). Data goes in `env.DB` (a migration in `db/migrations/`), `env.KV` or `env.FILES`.\n- Ask your coding agent: it has the ambasada-product skill (stack, storage, sign-in, mini app, deploy).\n\nNo framework, no build step: `wrangler` bundles `src/worker.ts`, and `web/` is served as is.\n\n## 3. Go live (1 min)\n\n```sh\nnpx ambasada login # Ambasada ID, or a project key from https://developer.ambasada.dev\nnpx ambasada deploy # build, smoke test, host at https://my-app.ambasada.dev\nnpx ambasada db migrate # db/migrations \u2192 the app's database\nnpx ambasada logs # the last deploy and recent errors\n```\n\nSign-in, notifications and the storage are set up by the deploy; nothing to configure. Your own secrets:\n`npx ambasada env set NAME value`. Hosting elsewhere: `npx wrangler deploy`, then `npx ambasada link https://\u2026`.\n\n**Sign in with Ambasada**: the header's button signs people in with their Ambasada ID; on the server,\n`ambasada(\u2026).auth.user(req)` gives `{ id, name, avatar_url, builder, pass }` or null.\n\n## Storage and realtime\n\nEvery app gets these, provisioned at the first `ambasada deploy`; locally wrangler keeps them in `.wrangler/state`.\n\n| Binding | For | Here |\n|---|---|---|\n| `env.DB` | SQLite: anything relational | `lib/db.ts`, tables in `db/migrations/`, example `api/notes.ts` |\n| `env.KV` | sessions, caches, flags | `lib/kv.ts` (the notes cache) |\n| `env.FILES` | uploads, generated files; public at `/files/<key>` | `lib/files.ts`, upload `api/files.ts` |\n| `env.ROOMS` | live state shared between people (Durable Object) | `src/realtime/room.ts`, demo at `/room` |\n\n```sh\nnpx wrangler d1 migrations apply DB --local # db/migrations \u2192 the local database (ambasada dev)\nnpx ambasada db migrate # \u2026 \u2192 the app's database online\nnpx ambasada db shell \"SELECT * FROM notes\" # or `ambasada db shell` for a prompt\nnpx ambasada kv list | get <key> | put <key> <value> | delete <key>\nnpx ambasada files put <file> | list | url <key> | delete <key>\n```\n\nOptional: an agent with the Agents SDK in `examples/agent/` (its README: `npm i agents` and four lines to wire it).\n\n## In the Ambasada app (optional)\n\nOff by default: this is a website. `npx ambasada miniapp enable` adds a mini app at `/app` (`--entry /` for the whole\nsite): `miniapp: { entry: \"/app\" }` in `ambasada.config.ts`, web/app.html and web/in-app.js, a placeholder\n`web/icon.png`, and the manifest `ambasada dev` and `deploy` serve at `/.well-known/ambasada.json`. Then\n`http://localhost:3000/app?ambasada_shell=mock` pretends to be inside the Ambasada app. The ambasada-product skill:\nreference/mini-app.md.\n\n## Rules worth knowing\n\n- State lives in `env.DB`, `env.KV`, `env.FILES` or a Durable Object, never in module variables: a Worker has many instances.\n- Secrets only in `.env` locally and `ambasada env set` online; never in the code or `wrangler.jsonc` vars.\n- `npx ambasada check` runs the checks (https, no redirects, speed; the mini-app rules when it is on); `npx ambasada upgrade` after a new devkit.\n\nFor the Hack A Ton: `npx ambasada add village` (the add-on).\n\nBuilt by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id).\n", "ambasada.config.ts": 'import { defineApp } from "@ambasada/sdk";\n\nexport default defineApp({\n id: "my-app",\n name: "My App",\n // One line about the app (the subtitle of its Ambasada mini app, if you add one: `ambasada miniapp enable`).\n description: "Notes, a live room and sign-in",\n // Where the app runs. `ambasada dev` serves it on http://localhost:3000; hosted, it is https://my-app.ambasada.dev\n // (`ambasada deploy` sets it; `ambasada link https://\u2026` when you host elsewhere).\n product: { url: "http://localhost:3000" },\n});\n', "api/ambasada-webhook.ts": '// POST /api/ambasada/webhook: the Ambasada app\'s events for this mini app (docs/MINI-APPS.md \xA77), signed with\n// AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` plays the hub and sends a ping and\n// notifications_enabled here when it starts). The manifest points here: notifications.webhook.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { forgetToken, saveToken } from "../lib/notifications.ts";\n\nexport default async function appWebhook(req: Request, env: Env): Promise<Response> {\n const app = ambasada(env);\n try {\n const event = await app.apps.verifyWebhook(req); // throws if forged or stale\n switch (event.event) {\n case "notifications_enabled":\n case "app_added":\n if (event.token) await saveToken(app, event.user, event.token);\n break;\n case "notifications_disabled":\n case "app_removed":\n await forgetToken(app, event.user);\n break;\n case "ping": // `ambasada check` and the registry: answer 200\n break;\n }\n return new Response("ok");\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/auth.ts": '// Sign in with Ambasada (Ambasada ID, OAuth + PKCE): zero setup. On https://<slug>.ambasada.dev the deploy\n// injects AMBASADA_AUTH_*; under `ambasada dev` the mock signs you in as a fake visitor.\n// GET /api/auth/login?returnTo=/ \u2192 the Ambasada ID consent screen\n// GET /api/auth/callback \u2192 back from it: sets an HttpOnly session cookie, redirects to returnTo\n// GET /api/auth/logout \u2192 clears it\n// GET /api/auth/me \u2192 { user } (null when signed out), for the browser\n// POST /api/auth/shell { token } \u2192 silent sign-in inside the Ambasada app: the same session, no consent screen\n// (the page calls `auth.signIn()` from @ambasada/miniapp)\n// On the server: `await ambasada(env).auth.user(req)` \u2192 { id, name, avatar_url, builder, pass, \u2026 } or null.\nimport { isAmbasadaError } from "@ambasada/sdk";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\n\nexport default async function auth(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n try {\n switch (new URL(req.url).pathname.replace(/\\/+$/, "")) {\n case "/api/auth/login":\n return await auth.login(req);\n case "/api/auth/callback":\n return await auth.callback(req);\n case "/api/auth/logout":\n return auth.logout(req);\n case "/api/auth/shell":\n return await auth.shell(req);\n case "/api/auth/me": {\n // Under `ambasada dev` the page needs the mock issuer for its fake shell (?ambasada_shell=mock).\n const devIssuer = env.AMBASADA_AUTH_ISSUER?.startsWith("http://") ? env.AMBASADA_AUTH_ISSUER : undefined;\n return Response.json({ user: await auth.user(req), configured: auth.configured, ...(devIssuer ? { dev_issuer: devIssuer } : {}) }, { headers: { "cache-control": "no-store" } });\n }\n }\n return Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n } catch (error) {\n if (isAmbasadaError(error)) return error.toResponse();\n throw error;\n }\n}\n', "api/files.ts": '// POST /api/files (raw body, content-type): a signed-in person uploads a file to env.FILES (lib/files.ts)\n// \u2192 { key, url }; the url (/files/<key>) is public and served by the /files/* route.\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { upload } from "../lib/files.ts";\n\nconst MAX_BYTES = 10 * 1024 * 1024;\n\nexport default async function files(req: Request, env: Env): Promise<Response> {\n if (req.method !== "POST") return Response.json({ error: { code: "INVALID_REQUEST", message: "POST the file" } }, { status: 405 });\n const user = await ambasada(env).auth.user(req);\n if (!user) return Response.json({ error: { code: "UNAUTHORIZED", message: "sign in to upload" } }, { status: 401 });\n const size = Number(req.headers.get("content-length") ?? 0);\n if (!size || size > MAX_BYTES) return Response.json({ error: { code: "INVALID_REQUEST", message: "send a file up to 10 MB with a content-length" } }, { status: 413 });\n const stored = await upload(env.FILES, req.body, { contentType: req.headers.get("content-type") ?? undefined, folder: `u/${user.id}` });\n return Response.json(stored, { status: 201 });\n}\n', "api/notes.ts": '// GET /api/notes: the latest notes from env.DB (lib/db.ts), cached a minute in env.KV (lib/kv.ts).\n// POST /api/notes { text }: adds one. The table is db/migrations/0001_init.sql.\nimport type { Env } from "../lib/ambasada.ts";\nimport { all, run } from "../lib/db.ts";\nimport { cached, remove } from "../lib/kv.ts";\n\ntype Note = { id: number; text: string; created_at: number };\n\nexport default async function notes(req: Request, env: Env): Promise<Response> {\n if (req.method === "POST") {\n const { text } = (await req.json().catch(() => ({}))) as { text?: unknown };\n if (typeof text !== "string" || !text.trim() || text.length > 500) {\n return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { text }, 1 to 500 characters" } }, { status: 400 });\n }\n const { lastRowId } = await run(env.DB, "INSERT INTO notes (text, created_at) VALUES (?, ?)", text.trim(), Date.now());\n await remove(env.KV, "notes:latest");\n return Response.json({ id: lastRowId }, { status: 201 });\n }\n const latest = await cached(env.KV, "notes:latest", 60, () => all<Note>(env.DB, "SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20"));\n return Response.json({ notes: latest });\n}\n', "db/migrations/0001_init.sql": "-- The app's first table. Add the next change as db/migrations/0002_<what>.sql (never edit an applied file), then:\n-- npx wrangler d1 migrations apply DB --local (ambasada dev)\n-- npx ambasada db migrate (online)\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n created_at INTEGER NOT NULL\n);\nCREATE INDEX notes_created ON notes (created_at);\n", "examples/agent/README.md": '# Agent (optional): the Cloudflare Agents SDK\n\nFor agentic products: an agent per user, document or task, with live state, its own SQL, schedules and\nWebSockets (`agent.ts`). It is a Durable Object, so it runs on ambasada.dev like the live rooms. To turn it on:\n\n1. `npm i agents`\n2. `wrangler.jsonc`: add the binding and a migration after the existing ones:\n ```jsonc\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }, { "name": "AgentExample", "class_name": "AgentExample" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }, { "tag": "v2", "new_sqlite_classes": ["AgentExample"] }]\n ```\n3. In the Worker entry (`src/worker.ts`; spa: `worker/index.ts`; next: `worker.ts`):\n ```ts\n export { AgentExample } from "../examples/agent/agent.ts"; // next: "./examples/agent/agent.ts"\n import { routeAgents } from "../examples/agent/agent.ts";\n // first thing in fetch():\n const agent = await routeAgents(req, env as unknown as Record<string, unknown>);\n if (agent) return agent;\n ```\n4. In the browser (React): `useAgent({ agent: "agent-example", name: user.id, onStateUpdate: setState })` from\n `agents/react`; `agent.send("hello")`. Plain fetch: `GET /agents/agent-example/<name>`.\n\n`ambasada deploy` sends the new class and its migration; nothing else to set up. Docs: https://developers.cloudflare.com/agents/\n', "examples/agent/agent.ts": '/**\n * Optional: an agent with the Cloudflare Agents SDK (`agents`), for agentic products. An agent is a Durable\n * Object with superpowers: state synced live to every connected client (`setState`), its own SQL (`this.sql`),\n * scheduled work (`this.schedule`), WebSockets and HTTP. One instance per name: per user, per document, per task.\n * Not wired in by default: examples/agent/README.md has the four steps.\n */\nimport { Agent, routeAgentRequest, type Connection, type WSMessage } from "agents";\n\ntype State = { notes: number; lastNote: string | null };\n\nexport class AgentExample extends Agent<Cloudflare.Env, State> {\n initialState: State = { notes: 0, lastNote: null };\n\n async onStart() {\n this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, at INTEGER NOT NULL)`;\n }\n\n // GET /agents/agent-example/<name>: what this agent knows.\n async onRequest(_req: Request): Promise<Response> {\n const notes = this.sql<{ id: number; text: string; at: number }>`SELECT id, text, at FROM notes ORDER BY id DESC LIMIT 20`;\n return Response.json({ state: this.state, notes });\n }\n\n // A client (useAgent in the browser) sent a message: remember it, sync the new state to every client,\n // and come back to it in a minute (call an LLM here, send a notification, \u2026).\n async onMessage(connection: Connection, message: WSMessage) {\n const text = String(message).slice(0, 2000);\n this.sql`INSERT INTO notes (text, at) VALUES (${text}, ${Date.now()})`;\n this.setState({ notes: this.state.notes + 1, lastNote: text });\n await this.schedule(60, "followUp", { text });\n connection.send(JSON.stringify({ ok: true }));\n }\n\n async followUp(payload: { text: string }) {\n console.log(`a minute ago someone wrote: ${payload.text}`);\n }\n}\n\n/** In your Worker\'s fetch, before your own routes: answers /agents/<agent>/<name> (HTTP and WebSocket). */\nexport const routeAgents = (req: Request, env: Record<string, unknown>) => routeAgentRequest(req, env);\n', gitignore: "node_modules/\n.env*\n.dev.vars*\n.wrangler/\ndist/\n.DS_Store\n# build output of `ambasada deploy`; project.json (template, devkit version) is kept\n.ambasada/*\n!.ambasada/project.json\n", "lib/ambasada.ts": 'import { createAmbasada, type Ambasada } from "@ambasada/sdk";\nimport type { Room } from "../src/realtime/room.ts";\n\n/** The Worker\'s env: `.env` locally (`ambasada dev` points it at the mock), secrets injected at deploy. */\nexport interface Env {\n ASSETS: Fetcher;\n /** The app\'s SQLite database (lib/db.ts), key-value (lib/kv.ts), files (lib/files.ts) and live rooms (src/realtime/room.ts). */\n DB: D1Database;\n KV: KVNamespace;\n FILES: R2Bucket;\n ROOMS: DurableObjectNamespace<Room>;\n AMBASADA_API_KEY?: string;\n /** Set by `ambasada deploy` and `ambasada dev`; the SDK defaults to https://api.ambasada.dev. */\n AMBASADA_API_URL?: string;\n AMBASADA_APP_ID?: string;\n /** Sign in with Ambasada (api/auth.ts): set by `ambasada deploy` on ambasada.dev, and by `ambasada dev`. */\n AMBASADA_AUTH_CLIENT_ID?: string;\n AMBASADA_AUTH_CLIENT_SECRET?: string;\n AMBASADA_AUTH_ISSUER?: string;\n /** Mini-app notifications (api/ambasada-webhook.ts, lib/notifications.ts): set on ambasada.dev and by `ambasada dev`. */\n AMBASADA_APP_SECRET?: string;\n AMBASADA_HUB_URL?: string;\n AMBASADA_APP_ORIGIN?: string;\n /** Your public URL, if the Worker sees another host (behind a proxy). */\n PUBLIC_URL?: string;\n}\n\nlet client: Ambasada | undefined;\n\n/** One client per isolate: it caches the API\'s signing keys. */\nexport function ambasada(env: Env): Ambasada {\n return (client ??= createAmbasada({ env: env as unknown as Record<string, unknown> }));\n}\n', "lib/db.ts": '/**\n * env.DB: this app\'s SQLite database (Cloudflare D1). The tables come from db/migrations/*.sql:\n * `npx ambasada db migrate` online, `npx wrangler d1 migrations apply DB --local` for `ambasada dev`.\n * A tiny typed layer, no ORM: SQL with ? placeholders in, plain objects out.\n *\n * const notes = await all<Note>(env.DB, "SELECT * FROM notes ORDER BY id DESC LIMIT ?", 20);\n * const note = await first<Note>(env.DB, "SELECT * FROM notes WHERE id = ?", id);\n * const { lastRowId } = await run(env.DB, "INSERT INTO notes (text) VALUES (?)", text);\n * await batch(env.DB, [["UPDATE a SET n = n - 1 WHERE id = ?", 1], ["UPDATE b SET n = n + 1 WHERE id = ?", 2]]); // one transaction\n */\n\nexport type Param = string | number | boolean | null | ArrayBuffer;\n\n/** What this file needs from a D1 binding (env.DB satisfies it). */\nexport interface Database {\n prepare(query: string): Statement;\n batch(statements: Statement[]): Promise<unknown[]>;\n}\n\nexport interface Statement {\n bind(...values: unknown[]): Statement;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n run(): Promise<{ meta: { changes: number; last_row_id: number } }>;\n}\n\nconst statement = (db: Database, query: string, params: Param[]) => db.prepare(query).bind(...params);\n\n/** Every row. */\nexport async function all<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T[]> {\n return (await statement(db, query, params).all<T>()).results;\n}\n\n/** The first row, or null. */\nexport function first<T = Record<string, unknown>>(db: Database, query: string, ...params: Param[]): Promise<T | null> {\n return statement(db, query, params).first<T>();\n}\n\n/** INSERT, UPDATE, DELETE: how many rows changed and the last inserted id. */\nexport async function run(db: Database, query: string, ...params: Param[]): Promise<{ changes: number; lastRowId: number }> {\n const { meta } = await statement(db, query, params).run();\n return { changes: meta.changes, lastRowId: meta.last_row_id };\n}\n\n/** Several statements in one transaction: all of them apply, or none. */\nexport async function batch(db: Database, statements: [query: string, ...params: Param[]][]): Promise<void> {\n await db.batch(statements.map(([query, ...params]) => statement(db, query, params)));\n}\n', "lib/files.ts": '/**\n * env.FILES: this app\'s file storage (Cloudflare R2). Uploads and generated files; each one is public at\n * /files/<key> on the app\'s own address (the /files/* route calls serveFile). Don\'t keep files in the database.\n *\n * const { key, url } = await upload(env.FILES, req.body, { contentType: "image/png" }); // url: /files/u/\u2026\n * return serveFile(env.FILES, req); // GET /files/*\n *\n * Files are public to anyone with the URL: keep private documents behind your own route that checks who asks.\n * `npx ambasada files put|list|delete|url` manages them from the terminal.\n */\n\n/** What this file needs from an R2 binding (env.FILES satisfies it). */\nexport interface Bucket {\n put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;\n get(key: string, options?: { onlyIf?: Headers; range?: Headers }): Promise<StoredFile | null>;\n delete(key: string): Promise<void>;\n}\n\nexport interface StoredFile {\n httpEtag: string;\n size: number;\n httpMetadata?: { contentType?: string };\n range?: { offset?: number; length?: number; suffix?: number };\n body?: ReadableStream;\n}\n\nexport const FILES_PATH = "/files/";\n\n/** Stores `body` under a new random key (in `folder`) \u2192 `{ key, url }`, url relative to the app (/files/<key>). */\nexport async function upload(bucket: Bucket, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, options: { contentType?: string; folder?: string } = {}): Promise<{ key: string; url: string }> {\n const key = `${options.folder ?? "u"}/${crypto.randomUUID()}`;\n await bucket.put(key, body, { httpMetadata: { contentType: options.contentType ?? "application/octet-stream" } });\n return { key, url: fileUrl(key) };\n}\n\nexport const fileUrl = (key: string) => `${FILES_PATH}${key.split("/").map(encodeURIComponent).join("/")}`;\n\n/** GET/HEAD /files/<key>: the file with its type, an ETag (304 when unchanged), byte ranges, and a cache header. */\nexport async function serveFile(bucket: Bucket, req: Request): Promise<Response> {\n if (req.method !== "GET" && req.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });\n const path = new URL(req.url).pathname;\n const key = decodeURIComponent(path.slice(path.indexOf(FILES_PATH) + FILES_PATH.length));\n if (!key) return new Response("Not found", { status: 404 });\n const file = await bucket.get(key, { onlyIf: req.headers, range: req.headers });\n if (!file) return new Response("Not found", { status: 404 });\n const headers = new Headers({ etag: file.httpEtag, "cache-control": "public, max-age=3600", "accept-ranges": "bytes" });\n if (file.httpMetadata?.contentType) headers.set("content-type", file.httpMetadata.contentType);\n if (!file.body) return new Response(null, { status: 304, headers }); // If-None-Match matched: nothing changed\n const r = file.range;\n if (r && req.headers.has("range")) {\n const offset = r.suffix !== undefined ? file.size - r.suffix : (r.offset ?? 0);\n const length = r.suffix ?? r.length ?? file.size - offset;\n headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${file.size}`);\n headers.set("content-length", String(length));\n return new Response(req.method === "HEAD" ? null : file.body, { status: 206, headers });\n }\n headers.set("content-length", String(file.size));\n return new Response(req.method === "HEAD" ? null : file.body, { headers });\n}\n', "lib/kv.ts": '/**\n * env.KV: this app\'s key-value store (Cloudflare KV). Fast reads everywhere, writes visible within about a\n * minute, at most one write per second per key. Good for sessions, caches and feature flags; not for counters\n * or anything two people change at once (use env.DB, or a Durable Object for live state).\n *\n * await putJson(env.KV, `session:${id}`, { user }, { ttl: 60 * 60 * 24 });\n * const session = await getJson<{ user: string }>(env.KV, `session:${id}`);\n * const page = await cached(env.KV, "home:stats", 60, () => computeStats());\n */\n\n/** What this file needs from a KV binding (env.KV satisfies it). */\nexport interface KeyValue {\n get(key: string, type: "text"): Promise<string | null>;\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\n/** The value saved under `key`, parsed; null when there is none (or it expired). */\nexport async function getJson<T>(kv: KeyValue, key: string): Promise<T | null> {\n const text = await kv.get(key, "text");\n return text === null ? null : (JSON.parse(text) as T);\n}\n\n/** Saves `value` as JSON. `ttl`: seconds until it disappears (at least 60). */\nexport function putJson(kv: KeyValue, key: string, value: unknown, options: { ttl?: number } = {}): Promise<void> {\n return kv.put(key, JSON.stringify(value), options.ttl ? { expirationTtl: Math.max(60, options.ttl) } : undefined);\n}\n\nexport function remove(kv: KeyValue, key: string): Promise<void> {\n return kv.delete(key);\n}\n\n/** `load()`\'s result, kept for `ttl` seconds: a cache in front of something slow. */\nexport async function cached<T>(kv: KeyValue, key: string, ttl: number, load: () => Promise<T>): Promise<T> {\n const hit = await getJson<T>(kv, key);\n if (hit !== null) return hit;\n const value = await load();\n await putJson(kv, key, value, { ttl });\n return value;\n}\n', "lib/notifications.ts": '// Who turned notifications on, and how to reach them (docs/MINI-APPS.md \xA77). The hub sends a token per person\n// to the webhook route (api/ambasada-webhook.ts; app/api/ambasada/webhook/route.ts in Next); it lives in your\n// ambasada.store under the person\'s Ambasada user id.\nimport type { Ambasada, AppNotifyInput } from "@ambasada/sdk";\n\nconst key = (user: string) => `ambasada/notifications/${user}`;\n\nexport const saveToken = (app: Ambasada, user: string, token: string) => app.store.set(key(user), { token, enabled_at: new Date().toISOString() });\nexport const forgetToken = (app: Ambasada, user: string) => app.store.delete(key(user));\n\n/**\n * A push to one person (their Ambasada user id, e.g. `(await app.auth.user(req)).id`), if they turned\n * notifications on. False when they haven\'t, or the hub said the token is gone (then it is forgotten).\n * await notifyUser(app, user.id, { notification_id: `order-${id}`, title: "Your translation is ready", body: "\u2026", url: `/orders/${id}` });\n */\nexport async function notifyUser(app: Ambasada, user: string, notification: Omit<AppNotifyInput, "tokens" | "users">): Promise<boolean> {\n const saved = await app.store.get<{ token: string }>(key(user));\n if (!saved) return false;\n const result = await app.apps.notify({ ...notification, tokens: [saved.token] });\n if (result.invalid.includes(saved.token)) await forgetToken(app, user);\n return result.sent.includes(saved.token);\n}\n', "package.json": '{\n "name": "@ambasada/template-worker",\n "version": "0.1.0",\n "private": true,\n "description": "An Ambasada app: one bare Cloudflare Worker, web/ + api/*.ts fetch handlers.",\n "type": "module",\n "engines": {\n "node": ">=22"\n },\n "scripts": {\n "dev": "ambasada dev",\n "preview": "wrangler dev --port 3000",\n "typecheck": "tsc -p .",\n "ambasada": "ambasada"\n },\n "dependencies": {\n "@ambasada/sdk": "workspace:*"\n },\n "devDependencies": {\n "@ambasada/cli": "workspace:*",\n "@cloudflare/workers-types": "^5.20261102.1",\n "typescript": "~5.9.3",\n "wrangler": "^4.156.1"\n }\n}\n', "src/realtime/room.ts": '/**\n * A live room (Durable Object `Room`, bound as env.ROOMS): everyone connected to the same room id shares it.\n *\n * GET /api/rooms/<id>/ws (WebSocket) \u2192 env.ROOMS.getByName(id).fetch(req)\n * client \u2192 { "text": "hi" } server \u2192 { type: "history", messages } on join,\n * { type: "message", message } to everyone,\n * { type: "presence", online } when people come and go\n *\n * WebSocket hibernation: the object sleeps between messages and the connections stay open, so an idle room\n * costs nothing. Its own SQLite (`ctx.storage.sql`) keeps the last 50 messages, replayed to whoever joins.\n * The same shape fits presence, live counters, a game table, a queue or a rate limit: one object per thing.\n */\nimport { DurableObject } from "cloudflare:workers";\n\nconst KEEP = 50;\n\nexport type RoomMessage = { id: number; name: string; text: string; at: number };\nexport type RoomEvent = { type: "history"; messages: RoomMessage[] } | { type: "message"; message: RoomMessage } | { type: "presence"; online: number };\n\nexport class Room extends DurableObject<unknown> {\n constructor(ctx: DurableObjectState, env: unknown) {\n super(ctx, env);\n ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)");\n }\n\n async fetch(req: Request): Promise<Response> {\n if (req.headers.get("upgrade") !== "websocket") return Response.json({ online: this.ctx.getWebSockets().length, messages: this.history() });\n // Who is talking: the client says. Behind Sign in with Ambasada, pass the signed-in name from your route instead.\n const name = (new URL(req.url).searchParams.get("name") ?? "").trim().slice(0, 40) || "guest";\n const { 0: client, 1: server } = new WebSocketPair();\n this.ctx.acceptWebSocket(server);\n server.serializeAttachment({ name });\n server.send(JSON.stringify({ type: "history", messages: this.history() } satisfies RoomEvent));\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().length });\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n let text = "";\n try {\n text = String((JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)) as { text?: unknown }).text ?? "").trim().slice(0, 2000);\n } catch {\n return;\n }\n if (!text) return;\n const { name } = ws.deserializeAttachment() as { name: string };\n const at = Date.now();\n const { id } = this.ctx.storage.sql.exec<{ id: number }>("INSERT INTO messages (name, text, at) VALUES (?, ?, ?) RETURNING id", name, text, at).one();\n this.ctx.storage.sql.exec("DELETE FROM messages WHERE id <= ?", id - KEEP);\n this.broadcast({ type: "message", message: { id, name, text, at } });\n }\n\n async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n this.broadcast({ type: "presence", online: this.ctx.getWebSockets().filter((s) => s !== ws).length });\n }\n\n private history(): RoomMessage[] {\n return this.ctx.storage.sql.exec<RoomMessage>("SELECT id, name, text, at FROM messages ORDER BY id").toArray();\n }\n\n private broadcast(event: RoomEvent): void {\n const text = JSON.stringify(event);\n for (const ws of this.ctx.getWebSockets()) {\n try {\n ws.send(text);\n } catch {\n // a closing socket: webSocketClose cleans up\n }\n }\n }\n}\n', "src/worker.ts": '/**\n * The app: one Cloudflare Worker (`wrangler dev`, or `ambasada dev` with the mock API).\n *\n * /api/<name> \u2192 api/<name>.ts (fetch handlers; add yours to API below)\n * /api/auth/* \u2192 api/auth.ts: Sign in with Ambasada (login, callback, logout, me, shell)\n * /api/ambasada/webhook \u2192 api/ambasada-webhook.ts: the Ambasada app\'s events (notifications on/off, ping)\n * /api/notes, /api/files \u2192 api/notes.ts (env.DB + env.KV), api/files.ts (uploads to env.FILES)\n * /api/rooms/<id>/ws \u2192 the live room <id> (src/realtime/room.ts, a Durable Object); demo at /room\n * /files/* \u2192 files from env.FILES (lib/files.ts)\n * / \u2192 web/index.html with who is signed in filled in\n * the rest \u2192 static files from web/ (wrangler.jsonc \u2192 assets)\n */\nimport appWebhook from "../api/ambasada-webhook.ts";\nimport auth from "../api/auth.ts";\nimport files from "../api/files.ts";\nimport notes from "../api/notes.ts";\nimport { ambasada, type Env } from "../lib/ambasada.ts";\nimport { FILES_PATH, serveFile } from "../lib/files.ts";\n\nexport { Room } from "./realtime/room.ts";\n\ntype Handler = (req: Request, env: Env) => Promise<Response>;\nconst API: Record<string, Handler> = { files, notes };\n\n/** The landing page, with the header\'s "Sign in with Ambasada" button or, once signed in, the person\'s name and avatar. */\nasync function landing(req: Request, env: Env): Promise<Response> {\n const { auth } = ambasada(env);\n const user = await auth.user(req);\n return new HTMLRewriter()\n .on("[data-auth-signin]", { element: (el) => void ((user || !auth.configured) && el.remove()) })\n .on("[data-auth-user]", { element: (el) => void (user ? el.removeAttribute("hidden") : el.remove()) })\n .on("[data-auth-avatar]", { element: (el) => void (user?.avatar_url ? el.setAttribute("src", user.avatar_url) : el.remove()) })\n .on("[data-auth-name]", { element: (el) => void el.setInnerContent(user?.name ?? "Signed in") })\n .transform(await env.ASSETS.fetch(req));\n}\n\nexport default {\n async fetch(req, env) {\n const { pathname } = new URL(req.url);\n try {\n if (pathname === "/health") return Response.json({ ok: true });\n if (pathname.startsWith("/api/auth/")) return await auth(req, env);\n if (pathname === "/api/ambasada/webhook") return await appWebhook(req, env);\n const room = /^\\/api\\/rooms\\/([^/]+)\\/ws$/.exec(pathname);\n if (room) return await env.ROOMS.getByName(decodeURIComponent(room[1]!)).fetch(req);\n if (pathname.startsWith(FILES_PATH)) return await serveFile(env.FILES, req);\n const api = /^\\/api\\/([^/]+)\\/?$/.exec(pathname);\n if (api) {\n const handler = API[api[1]!];\n return handler ? await handler(req, env) : Response.json({ error: { code: "NOT_FOUND", message: "no such route" } }, { status: 404 });\n }\n if (pathname === "/") return await landing(req, env);\n return env.ASSETS.fetch(req);\n } catch (error) {\n console.error(error);\n return Response.json({ error: { code: "INTERNAL", message: "internal error" } }, { status: 500 });\n }\n },\n} satisfies ExportedHandler<Env>;\n', "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "module": "ESNext",\n "moduleResolution": "Bundler",\n "lib": ["ES2022"],\n "types": ["@cloudflare/workers-types"],\n "strict": true,\n "noEmit": true,\n "skipLibCheck": true,\n "isolatedModules": true,\n "verbatimModuleSyntax": true,\n "allowImportingTsExtensions": true\n },\n "include": ["ambasada.config.ts", "src", "api", "lib"]\n}\n', "web/index.html": `<!doctype html>
|
|
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.8/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">
|
|
@@ -217,7 +267,7 @@ a { color: var(--color-accent-text); }
|
|
|
217
267
|
<meta charset="utf-8" />
|
|
218
268
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
219
269
|
<title>Privacy \xB7 My App</title>
|
|
220
|
-
<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.
|
|
270
|
+
<link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />
|
|
221
271
|
</head>
|
|
222
272
|
<body>
|
|
223
273
|
<main class="ui-page">
|
|
@@ -232,7 +282,7 @@ a { color: var(--color-accent-text); }
|
|
|
232
282
|
</main>
|
|
233
283
|
</body>
|
|
234
284
|
</html>
|
|
235
|
-
`, "web/room.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>Live room</title>\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.
|
|
285
|
+
`, "web/room.html": '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>Live room</title>\n <link rel="stylesheet" href="https://unpkg.com/@ambasada/ui@0.8/dist/ambasada-ui.css" />\n <link rel="stylesheet" href="/style.css" />\n </head>\n <body>\n <!-- A live room (src/realtime/room.ts): open this page in two tabs and type. ?room=<id> picks the room. -->\n <main class="page">\n <h1>Live room</h1>\n <p class="ui-label" id="status">connecting\u2026</p>\n <ul id="messages"></ul>\n <form id="send"><input name="text" autocomplete="off" placeholder="Say something" /> <button class="ui-button">Send</button></form>\n <p><a href="/">\u2190 back</a></p>\n </main>\n <script>\n const room = new URLSearchParams(location.search).get("room") || "lobby";\n const list = document.getElementById("messages");\n const status = document.getElementById("status");\n const show = (m) => list.insertAdjacentHTML("beforeend", "<li></li>") || (list.lastElementChild.textContent = `${m.name}: ${m.text}`);\n let ws;\n function connect() {\n ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/rooms/${encodeURIComponent(room)}/ws?name=guest`);\n ws.onmessage = (e) => {\n const event = JSON.parse(e.data);\n if (event.type === "history") (list.textContent = "", event.messages.forEach(show));\n else if (event.type === "message") show(event.message);\n else status.textContent = `${event.online} online`;\n };\n ws.onclose = () => ((status.textContent = "reconnecting\u2026"), setTimeout(connect, 2000));\n }\n connect();\n document.getElementById("send").onsubmit = (e) => {\n e.preventDefault();\n if (ws.readyState === WebSocket.OPEN && e.target.text.value.trim()) ws.send(JSON.stringify({ text: e.target.text.value }));\n e.target.text.value = "";\n };\n </script>\n </body>\n</html>\n', "web/style.css": `/*
|
|
236
286
|
* This product's own styles, on top of @ambasada/ui: the Ambasada tokens (var(--color-\u2026), var(--space-\u2026))
|
|
237
287
|
* and components (ui-card, ui-button, ui-label, \u2026), loaded first by app/layout.tsx (next), src/main.tsx (spa)
|
|
238
288
|
* or a <link> in web/index.html (worker). Use the tokens rather than raw values. The same file in all three.
|
|
@@ -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.8/dist/ambasada-ui.css" />\n <link rel="stylesheet" href="/style.css" />\n <script src="https://hub.ambasada.id/miniapp.js" defer></script>\n <script src="/in-app.js" defer></script>\n </head>\n <body>\n <main class="ui-page">\n <p class="ui-label">My App</p>\n <h1>Hello<span data-in-app-name></span></h1>\n <section class="ui-card">\n <h2 class="ui-card-title">In the Ambasada app</h2>\n <p>This is <code>__ENTRY__</code>, the page the Ambasada app opens. Edit <code>web/__ENTRY_DIR__.html</code>.</p>\n </section>\n </main>\n </body>\n</html>\n' } };
|
|
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";
|
|
@@ -1098,6 +1168,8 @@ var ERROR_STATUS = {
|
|
|
1098
1168
|
FROZEN: 403,
|
|
1099
1169
|
// Exchange stream: an agent sent too many inbox messages
|
|
1100
1170
|
CAP_INBOX_RATE: 429,
|
|
1171
|
+
// The CEO chat: too many turns for one company
|
|
1172
|
+
CAP_CHAT_RATE: 429,
|
|
1101
1173
|
// SDK-side
|
|
1102
1174
|
NETWORK: 0,
|
|
1103
1175
|
CONFIG: 0,
|
|
@@ -1242,9 +1314,9 @@ function b64url(bytes) {
|
|
|
1242
1314
|
}
|
|
1243
1315
|
function fromB64url(text) {
|
|
1244
1316
|
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
|
|
1317
|
+
const out7 = new Uint8Array(s.length);
|
|
1318
|
+
for (let i = 0; i < s.length; i++) out7[i] = s.charCodeAt(i);
|
|
1319
|
+
return out7;
|
|
1248
1320
|
}
|
|
1249
1321
|
var random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
|
|
1250
1322
|
async function pkce() {
|
|
@@ -1685,21 +1757,98 @@ function cachedKeys(load, ttlMs = 10 * 6e4) {
|
|
|
1685
1757
|
};
|
|
1686
1758
|
}
|
|
1687
1759
|
|
|
1760
|
+
// ../sdk/src/define.ts
|
|
1761
|
+
var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
|
|
1762
|
+
|
|
1688
1763
|
// ../sdk/src/platform.ts
|
|
1689
1764
|
var ambasada = new Ambasada();
|
|
1690
1765
|
|
|
1691
1766
|
// src/commands.ts
|
|
1692
|
-
import { execFileSync as
|
|
1693
|
-
import { existsSync as
|
|
1694
|
-
import { appendFile, mkdir as
|
|
1695
|
-
import { basename as basename3, dirname as
|
|
1767
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
1768
|
+
import { existsSync as existsSync12, readFileSync as readFileSync6 } from "node:fs";
|
|
1769
|
+
import { appendFile as appendFile2, mkdir as mkdir6, readdir as readdir3, readFile as readFile8, writeFile as writeFile8 } from "node:fs/promises";
|
|
1770
|
+
import { basename as basename3, dirname as dirname7, join as join13, resolve as resolvePath5 } from "node:path";
|
|
1696
1771
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1697
1772
|
|
|
1698
1773
|
// src/config.ts
|
|
1699
|
-
import { existsSync } from "node:fs";
|
|
1700
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1774
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1775
|
+
import { chmod, mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1701
1776
|
import { homedir } from "node:os";
|
|
1777
|
+
import { join as join3 } from "node:path";
|
|
1778
|
+
|
|
1779
|
+
// src/manifest.ts
|
|
1780
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
1702
1781
|
import { join } from "node:path";
|
|
1782
|
+
var MANIFEST_FILES = {
|
|
1783
|
+
next: "public/.well-known/ambasada.json",
|
|
1784
|
+
spa: "public/.well-known/ambasada.json",
|
|
1785
|
+
worker: "web/.well-known/ambasada.json"
|
|
1786
|
+
};
|
|
1787
|
+
var STATIC_DIRS = ["public", "web", "static"];
|
|
1788
|
+
function findLocalManifest(dir) {
|
|
1789
|
+
for (const rel of Object.values(MANIFEST_FILES)) if (existsSync(join(dir, rel))) return join(dir, rel);
|
|
1790
|
+
const candidates = [join(dir, ".well-known/ambasada.json"), ...STATIC_DIRS.map((s) => join(dir, s, ".well-known/ambasada.json"))];
|
|
1791
|
+
for (const s of STATIC_DIRS) {
|
|
1792
|
+
const base = join(dir, s);
|
|
1793
|
+
if (existsSync(base) && statSync(base).isDirectory()) {
|
|
1794
|
+
for (const sub of readdirSync(base)) candidates.push(join(base, sub, ".well-known/ambasada.json"));
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return candidates.find((c2) => existsSync(c2)) ?? null;
|
|
1798
|
+
}
|
|
1799
|
+
var APP_WEBHOOK_PATH = "/api/ambasada/webhook";
|
|
1800
|
+
var oneLine = (s, max) => {
|
|
1801
|
+
const line = s.replace(/\s+/g, " ").trim();
|
|
1802
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}\u2026` : line;
|
|
1803
|
+
};
|
|
1804
|
+
function appManifest(app, miniapp2 = { entry: "/" }) {
|
|
1805
|
+
const description = app.description || app.name;
|
|
1806
|
+
const icon = miniapp2.icon ?? "/icon.png";
|
|
1807
|
+
const scopes = miniapp2.scopes ?? ["identity", "notifications"];
|
|
1808
|
+
return {
|
|
1809
|
+
schema: 2,
|
|
1810
|
+
id: app.id,
|
|
1811
|
+
name: oneLine(app.name, MANIFEST_LIMITS.name),
|
|
1812
|
+
subtitle: oneLine(description, MANIFEST_LIMITS.subtitle),
|
|
1813
|
+
description: description.length > MANIFEST_LIMITS.description ? oneLine(description, MANIFEST_LIMITS.description) : description,
|
|
1814
|
+
icon,
|
|
1815
|
+
splash: { image: icon, color: "#FFFFFF" },
|
|
1816
|
+
accent: miniapp2.accent ?? "#FF002E",
|
|
1817
|
+
entry: miniapp2.entry,
|
|
1818
|
+
kind: "product",
|
|
1819
|
+
category: miniapp2.category ?? "other",
|
|
1820
|
+
scopes,
|
|
1821
|
+
...scopes.includes("notifications") ? { notifications: { webhook: APP_WEBHOOK_PATH } } : {},
|
|
1822
|
+
...scopes.length ? { privacy_url: "/privacy" } : {},
|
|
1823
|
+
visibility: miniapp2.visibility ?? "public"
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
var manifestJson = (app, miniapp2) => `${JSON.stringify(appManifest(app, miniapp2), null, 2)}
|
|
1827
|
+
`;
|
|
1828
|
+
function readCompany(configSource) {
|
|
1829
|
+
const str4 = (re) => {
|
|
1830
|
+
const m = re.exec(configSource);
|
|
1831
|
+
return m ? JSON.parse(`"${m[1]}"`) : void 0;
|
|
1832
|
+
};
|
|
1833
|
+
const id = str4(/\bid\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
1834
|
+
const name = str4(/\bname\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
1835
|
+
const description = str4(/\bdescription\s*:\s*"((?:[^"\\]|\\.)*)"/) ?? str4(/\bidea\s*:\s*\{\s*title\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
1836
|
+
return id && name ? { id, name, description: description ?? "" } : null;
|
|
1837
|
+
}
|
|
1838
|
+
function readMiniapp(configSource) {
|
|
1839
|
+
const block = /\bminiapp\s*:\s*\{([^}]*)\}/.exec(configSource.replace(/(?<!:)\/\/[^\n]*|\/\*[\s\S]*?\*\//g, ""));
|
|
1840
|
+
if (!block) return null;
|
|
1841
|
+
const entry2 = /\bentry\s*:\s*["'`]([^"'`]*)["'`]/.exec(block[1])?.[1];
|
|
1842
|
+
const icon = /\bicon\s*:\s*["'`]([^"'`]*)["'`]/.exec(block[1])?.[1];
|
|
1843
|
+
return entry2 === void 0 ? null : { entry: entry2, ...icon ? { icon } : {} };
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// src/project.ts
|
|
1847
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1848
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
1849
|
+
import * as nodeModule from "node:module";
|
|
1850
|
+
import { dirname, join as join2, resolve as resolvePath } from "node:path";
|
|
1851
|
+
import { pathToFileURL } from "node:url";
|
|
1703
1852
|
|
|
1704
1853
|
// src/ui.ts
|
|
1705
1854
|
var useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -1709,8 +1858,9 @@ function table(rows, columns) {
|
|
|
1709
1858
|
if (!rows.length) return c.dim("(none)");
|
|
1710
1859
|
const cols = columns ?? Object.keys(rows[0]);
|
|
1711
1860
|
const cell = (v) => v === null || v === void 0 ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
1712
|
-
const widths = cols.map((col) => Math.min(60, Math.max(col.length, ...rows.map((r) => cell(r[col]).length))));
|
|
1713
|
-
const
|
|
1861
|
+
const widths = cols.map((col, i) => Math.min(i === cols.length - 1 ? Infinity : 60, Math.max(col.length, ...rows.map((r) => cell(r[col]).length))));
|
|
1862
|
+
const fit = (v, w) => v.length > w ? `${v.slice(0, w - 1)}\u2026` : v.padEnd(w);
|
|
1863
|
+
const line = (vals) => vals.map((v, i) => fit(v, widths[i])).join(" ").trimEnd();
|
|
1714
1864
|
return [c.dim(line(cols)), ...rows.map((r) => line(cols.map((col) => cell(r[col]))))].join("\n");
|
|
1715
1865
|
}
|
|
1716
1866
|
function ton(amount) {
|
|
@@ -1725,26 +1875,81 @@ var CliError = class extends Error {
|
|
|
1725
1875
|
exitCode;
|
|
1726
1876
|
};
|
|
1727
1877
|
|
|
1878
|
+
// src/project.ts
|
|
1879
|
+
var CONFIG_FILE = "ambasada.config.ts";
|
|
1880
|
+
function findRoot(from = process.cwd()) {
|
|
1881
|
+
let dir = resolvePath(from);
|
|
1882
|
+
for (; ; ) {
|
|
1883
|
+
if (existsSync2(join2(dir, CONFIG_FILE))) return dir;
|
|
1884
|
+
const parent = dirname(dir);
|
|
1885
|
+
if (parent === dir) return null;
|
|
1886
|
+
dir = parent;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
async function importTs(file) {
|
|
1890
|
+
const url = `${pathToFileURL(file).href}?t=${Date.now()}`;
|
|
1891
|
+
try {
|
|
1892
|
+
return (await import(url)).default;
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
const code = error.code;
|
|
1895
|
+
const missing = /Cannot find (?:package|module) '(@ambasada\/[a-z-]+)/.exec(String(error.message))?.[1];
|
|
1896
|
+
if (code === "ERR_MODULE_NOT_FOUND" && missing) {
|
|
1897
|
+
throw new CliError(`${file} imports ${missing}, which is not installed: run \`npm install\` in ${dirname(file)}`);
|
|
1898
|
+
}
|
|
1899
|
+
if (code !== "ERR_UNKNOWN_FILE_EXTENSION") throw error;
|
|
1900
|
+
const strip = nodeModule.stripTypeScriptTypes;
|
|
1901
|
+
if (!strip) throw new CliError(`can't load ${file}: use Node \u2265 22.18, or Bun (\`bunx ambasada \u2026\`)`);
|
|
1902
|
+
const tmp = file.replace(/\.ts$/, `.village-${process.pid}.mjs`);
|
|
1903
|
+
await writeFile(tmp, strip(await readFile(file, "utf8")));
|
|
1904
|
+
try {
|
|
1905
|
+
return (await import(`${pathToFileURL(tmp).href}?t=${Date.now()}`)).default;
|
|
1906
|
+
} finally {
|
|
1907
|
+
await rm(tmp, { force: true });
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
async function loadProject(from) {
|
|
1912
|
+
const root = findRoot(from);
|
|
1913
|
+
if (!root) throw new CliError(`no ${CONFIG_FILE} here or in a parent folder: run \`ambasada init\` first`);
|
|
1914
|
+
const config = await importTs(join2(root, CONFIG_FILE));
|
|
1915
|
+
if (!config || typeof config !== "object" || typeof config.id !== "string") {
|
|
1916
|
+
throw new CliError(`${CONFIG_FILE} must \`export default defineApp({ id, name, \u2026 })\``);
|
|
1917
|
+
}
|
|
1918
|
+
return { root, config };
|
|
1919
|
+
}
|
|
1920
|
+
async function setProductUrl(root, url) {
|
|
1921
|
+
const file = join2(root, CONFIG_FILE);
|
|
1922
|
+
const source = await readFile(file, "utf8");
|
|
1923
|
+
const next = source.replace(/(product\s*:\s*\{\s*url\s*:\s*)(["'`])[^"'`]*\2/, `$1"${url}"`);
|
|
1924
|
+
if (next === source) return source.includes(`url: "${url}"`) || source.includes(`url: '${url}'`);
|
|
1925
|
+
await writeFile(file, next);
|
|
1926
|
+
return true;
|
|
1927
|
+
}
|
|
1928
|
+
async function loadIdName(root) {
|
|
1929
|
+
const app = readCompany(await readFile(join2(root, CONFIG_FILE), "utf8"));
|
|
1930
|
+
return app ? { id: app.id, name: app.name } : null;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1728
1933
|
// src/config.ts
|
|
1729
1934
|
var OLD_DEFAULT_API_URL = "https://hackaton.ambasada.id";
|
|
1730
|
-
var baseDir = () => process.env.XDG_CONFIG_HOME ??
|
|
1935
|
+
var baseDir = () => process.env.XDG_CONFIG_HOME ?? join3(homedir(), ".config");
|
|
1731
1936
|
function configDir() {
|
|
1732
|
-
return process.env.AMBASADA_CONFIG_DIR ??
|
|
1937
|
+
return process.env.AMBASADA_CONFIG_DIR ?? join3(baseDir(), "ambasada");
|
|
1733
1938
|
}
|
|
1734
1939
|
function configPath() {
|
|
1735
|
-
return
|
|
1940
|
+
return join3(configDir(), "config.json");
|
|
1736
1941
|
}
|
|
1737
1942
|
function legacyPath() {
|
|
1738
|
-
return process.env.AMBASADA_CONFIG_DIR ? null :
|
|
1943
|
+
return process.env.AMBASADA_CONFIG_DIR ? null : join3(baseDir(), "village", "config.json");
|
|
1739
1944
|
}
|
|
1740
1945
|
async function readConfig() {
|
|
1741
1946
|
try {
|
|
1742
|
-
return JSON.parse(await
|
|
1947
|
+
return JSON.parse(await readFile2(configPath(), "utf8"));
|
|
1743
1948
|
} catch {
|
|
1744
1949
|
const legacy = legacyPath();
|
|
1745
|
-
if (!legacy || !
|
|
1950
|
+
if (!legacy || !existsSync3(legacy) || existsSync3(configPath())) return {};
|
|
1746
1951
|
try {
|
|
1747
|
-
const old = JSON.parse(await
|
|
1952
|
+
const old = JSON.parse(await readFile2(legacy, "utf8"));
|
|
1748
1953
|
const { apiUrl, company, ...rest } = old;
|
|
1749
1954
|
const migrated = { ...rest, ...apiUrl && apiUrl.replace(/\/+$/, "") !== OLD_DEFAULT_API_URL ? { apiUrl } : {}, ...company ? { project: company } : {} };
|
|
1750
1955
|
await writeConfig(migrated);
|
|
@@ -1756,19 +1961,59 @@ async function readConfig() {
|
|
|
1756
1961
|
}
|
|
1757
1962
|
async function writeConfig(config) {
|
|
1758
1963
|
await mkdir(configDir(), { recursive: true });
|
|
1759
|
-
await
|
|
1964
|
+
await writeFile2(configPath(), `${JSON.stringify(config, null, 2)}
|
|
1760
1965
|
`, { mode: 384 });
|
|
1761
1966
|
await chmod(configPath(), 384);
|
|
1762
1967
|
}
|
|
1763
1968
|
var str = (v) => typeof v === "string" && v ? v : void 0;
|
|
1969
|
+
async function folderApp() {
|
|
1970
|
+
const root = findRoot();
|
|
1971
|
+
if (!root) return void 0;
|
|
1972
|
+
try {
|
|
1973
|
+
return (await loadIdName(root))?.id;
|
|
1974
|
+
} catch {
|
|
1975
|
+
return void 0;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1764
1978
|
async function resolve(flags) {
|
|
1765
1979
|
const file = await readConfig();
|
|
1980
|
+
const here = await folderApp();
|
|
1981
|
+
const saved = here ? file.apps?.[here] : void 0;
|
|
1982
|
+
const given = str(flags.key) ?? process.env.AMBASADA_API_KEY;
|
|
1983
|
+
const named = process.env.AMBASADA_APP_ID ?? process.env.AMBASADA_COMPANY;
|
|
1766
1984
|
return {
|
|
1767
|
-
apiUrl: (str(flags["api-url"]) ?? process.env.AMBASADA_API_URL ?? file.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, ""),
|
|
1768
|
-
apiKey:
|
|
1769
|
-
|
|
1985
|
+
apiUrl: (str(flags["api-url"]) ?? process.env.AMBASADA_API_URL ?? saved?.apiUrl ?? file.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, ""),
|
|
1986
|
+
apiKey: given ?? saved?.apiKey ?? file.apiKey,
|
|
1987
|
+
// A key from a flag or the environment: only the environment can say whose it is. A saved one: whose it was at login.
|
|
1988
|
+
project: given ? named : named ?? (saved ? here : file.project ?? file.company),
|
|
1989
|
+
here
|
|
1770
1990
|
};
|
|
1771
1991
|
}
|
|
1992
|
+
async function whoseKey(apiUrl, key) {
|
|
1993
|
+
let res;
|
|
1994
|
+
try {
|
|
1995
|
+
res = await fetch(`${apiUrl.replace(/\/$/, "")}/v1/key`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" } });
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
throw new CliError(`can't reach ${apiUrl}: ${error.message}`);
|
|
1998
|
+
}
|
|
1999
|
+
if (res.status === 401) throw new CliError("that key was refused (UNAUTHORIZED)");
|
|
2000
|
+
if (res.ok) {
|
|
2001
|
+
const body = await res.json();
|
|
2002
|
+
return { id: body.id, name: body.name ?? null, kind: body.kind === "project" ? "project" : "company" };
|
|
2003
|
+
}
|
|
2004
|
+
throw new CliError(`${apiUrl}/v1/key answered ${res.status}: is --api-url an Ambasada API?`);
|
|
2005
|
+
}
|
|
2006
|
+
async function resolveForApp(flags) {
|
|
2007
|
+
const r = await resolve(flags);
|
|
2008
|
+
if (!r.apiKey || !r.here) return r;
|
|
2009
|
+
const owner = r.project ?? (await whoseKey(r.apiUrl, r.apiKey)).id;
|
|
2010
|
+
if (owner !== r.here) {
|
|
2011
|
+
throw new CliError(
|
|
2012
|
+
`this folder is the app ${r.here} (ambasada.config.ts), but you are logged in as ${owner}: run \`ambasada login --key vk_\u2026\` here with ${r.here}'s key (https://developer.ambasada.dev \u2192 ${r.here} \u2192 Settings \u2192 New key)`
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
return { ...r, project: owner };
|
|
2016
|
+
}
|
|
1772
2017
|
|
|
1773
2018
|
// src/mock/server.ts
|
|
1774
2019
|
import { createServer } from "node:http";
|
|
@@ -2009,8 +2254,8 @@ async function startMock(options = {}) {
|
|
|
2009
2254
|
}
|
|
2010
2255
|
if (path === "/dev/app/event" && method === "POST") {
|
|
2011
2256
|
const b = await body();
|
|
2012
|
-
const
|
|
2013
|
-
return json2(
|
|
2257
|
+
const out7 = await appEvent(String(b.event ?? "ping"), { user: typeof b.user === "string" ? b.user : void 0, webhook: typeof b.webhook === "string" ? b.webhook : void 0 });
|
|
2258
|
+
return json2(out7, out7.error ? 502 : 200);
|
|
2014
2259
|
}
|
|
2015
2260
|
if (path === "/dev/state") return json2(Object.assign({ company, notifications, events }, ...extensions.map((e) => e.state?.() ?? {})));
|
|
2016
2261
|
if (!path.startsWith("/v1/")) throw fail("NOT_FOUND", "no such route");
|
|
@@ -2114,118 +2359,12 @@ async function startMock(options = {}) {
|
|
|
2114
2359
|
}
|
|
2115
2360
|
|
|
2116
2361
|
// src/plugin.ts
|
|
2117
|
-
import { existsSync as
|
|
2362
|
+
import { existsSync as existsSync4, readFileSync } from "node:fs";
|
|
2118
2363
|
import { createRequire } from "node:module";
|
|
2119
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
2364
|
+
import { dirname as dirname2, isAbsolute, join as join4, resolve as resolvePath2 } from "node:path";
|
|
2120
2365
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
2121
|
-
|
|
2122
|
-
// src/manifest.ts
|
|
2123
|
-
var MANIFEST_FILES = {
|
|
2124
|
-
next: "public/.well-known/ambasada.json",
|
|
2125
|
-
spa: "public/.well-known/ambasada.json",
|
|
2126
|
-
worker: "web/.well-known/ambasada.json"
|
|
2127
|
-
};
|
|
2128
|
-
var APP_WEBHOOK_PATH = "/api/ambasada/webhook";
|
|
2129
|
-
var oneLine = (s, max) => {
|
|
2130
|
-
const line = s.replace(/\s+/g, " ").trim();
|
|
2131
|
-
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}\u2026` : line;
|
|
2132
|
-
};
|
|
2133
|
-
function appManifest(company) {
|
|
2134
|
-
return {
|
|
2135
|
-
schema: 2,
|
|
2136
|
-
id: company.id,
|
|
2137
|
-
name: oneLine(company.name, MANIFEST_LIMITS.name),
|
|
2138
|
-
subtitle: oneLine(company.description, MANIFEST_LIMITS.subtitle),
|
|
2139
|
-
description: company.description,
|
|
2140
|
-
icon: "/icon.png",
|
|
2141
|
-
splash: { image: "/icon.png", color: "#FFFFFF" },
|
|
2142
|
-
accent: "#FF002E",
|
|
2143
|
-
entry: "/",
|
|
2144
|
-
kind: "product",
|
|
2145
|
-
category: "other",
|
|
2146
|
-
// Sign in with Ambasada and notifications (docs/MINI-APPS.md §5, §7): the templates ship both routes.
|
|
2147
|
-
scopes: ["identity", "notifications"],
|
|
2148
|
-
notifications: { webhook: APP_WEBHOOK_PATH },
|
|
2149
|
-
privacy_url: "/privacy",
|
|
2150
|
-
visibility: "public"
|
|
2151
|
-
};
|
|
2152
|
-
}
|
|
2153
|
-
var manifestJson = (company) => `${JSON.stringify(appManifest(company), null, 2)}
|
|
2154
|
-
`;
|
|
2155
|
-
function readCompany(configSource) {
|
|
2156
|
-
const str4 = (re) => {
|
|
2157
|
-
const m = re.exec(configSource);
|
|
2158
|
-
return m ? JSON.parse(`"${m[1]}"`) : void 0;
|
|
2159
|
-
};
|
|
2160
|
-
const id = str4(/\bid\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
2161
|
-
const name = str4(/\bname\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
2162
|
-
const description = str4(/\bdescription\s*:\s*"((?:[^"\\]|\\.)*)"/) ?? str4(/\bidea\s*:\s*\{\s*title\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
2163
|
-
return id && name && description !== void 0 ? { id, name, description } : null;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
// src/project.ts
|
|
2167
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
2168
|
-
import { readFile as readFile2, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
2169
|
-
import * as nodeModule from "node:module";
|
|
2170
|
-
import { dirname, join as join2, resolve as resolvePath } from "node:path";
|
|
2171
|
-
import { pathToFileURL } from "node:url";
|
|
2172
|
-
var CONFIG_FILE = "ambasada.config.ts";
|
|
2173
|
-
function findRoot(from = process.cwd()) {
|
|
2174
|
-
let dir = resolvePath(from);
|
|
2175
|
-
for (; ; ) {
|
|
2176
|
-
if (existsSync2(join2(dir, CONFIG_FILE))) return dir;
|
|
2177
|
-
const parent = dirname(dir);
|
|
2178
|
-
if (parent === dir) return null;
|
|
2179
|
-
dir = parent;
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
async function importTs(file) {
|
|
2183
|
-
const url = `${pathToFileURL(file).href}?t=${Date.now()}`;
|
|
2184
|
-
try {
|
|
2185
|
-
return (await import(url)).default;
|
|
2186
|
-
} catch (error) {
|
|
2187
|
-
const code = error.code;
|
|
2188
|
-
const missing = /Cannot find (?:package|module) '(@ambasada\/[a-z-]+)/.exec(String(error.message))?.[1];
|
|
2189
|
-
if (code === "ERR_MODULE_NOT_FOUND" && missing) {
|
|
2190
|
-
throw new CliError(`${file} imports ${missing}, which is not installed: run \`npm install\` in ${dirname(file)}`);
|
|
2191
|
-
}
|
|
2192
|
-
if (code !== "ERR_UNKNOWN_FILE_EXTENSION") throw error;
|
|
2193
|
-
const strip = nodeModule.stripTypeScriptTypes;
|
|
2194
|
-
if (!strip) throw new CliError(`can't load ${file}: use Node \u2265 22.18, or Bun (\`bunx ambasada \u2026\`)`);
|
|
2195
|
-
const tmp = file.replace(/\.ts$/, `.village-${process.pid}.mjs`);
|
|
2196
|
-
await writeFile2(tmp, strip(await readFile2(file, "utf8")));
|
|
2197
|
-
try {
|
|
2198
|
-
return (await import(`${pathToFileURL(tmp).href}?t=${Date.now()}`)).default;
|
|
2199
|
-
} finally {
|
|
2200
|
-
await rm(tmp, { force: true });
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2204
|
-
async function loadProject(from) {
|
|
2205
|
-
const root = findRoot(from);
|
|
2206
|
-
if (!root) throw new CliError(`no ${CONFIG_FILE} here or in a parent folder: run \`ambasada init\` first`);
|
|
2207
|
-
const config = await importTs(join2(root, CONFIG_FILE));
|
|
2208
|
-
if (!config || typeof config !== "object" || typeof config.id !== "string") {
|
|
2209
|
-
throw new CliError(`${CONFIG_FILE} must \`export default defineApp({ id, name, \u2026 })\``);
|
|
2210
|
-
}
|
|
2211
|
-
return { root, config };
|
|
2212
|
-
}
|
|
2213
|
-
async function setProductUrl(root, url) {
|
|
2214
|
-
const file = join2(root, CONFIG_FILE);
|
|
2215
|
-
const source = await readFile2(file, "utf8");
|
|
2216
|
-
const next = source.replace(/(product\s*:\s*\{\s*url\s*:\s*)(["'`])[^"'`]*\2/, `$1"${url}"`);
|
|
2217
|
-
if (next === source) return source.includes(`url: "${url}"`) || source.includes(`url: '${url}'`);
|
|
2218
|
-
await writeFile2(file, next);
|
|
2219
|
-
return true;
|
|
2220
|
-
}
|
|
2221
|
-
async function loadIdName(root) {
|
|
2222
|
-
const app = readCompany(await readFile2(join2(root, CONFIG_FILE), "utf8"));
|
|
2223
|
-
return app ? { id: app.id, name: app.name } : null;
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
// src/plugin.ts
|
|
2227
2366
|
var PLATFORM_CONFIG_KEYS = ["id", "name", "product", "addons"];
|
|
2228
|
-
var VERSION = true ? "0.8.
|
|
2367
|
+
var VERSION = true ? "0.8.4" : "0.1.0-dev";
|
|
2229
2368
|
var shortName = (pkg) => pkg.replace(/^@ambasada\//, "").replace(/^ambasada-(addon-)?/, "");
|
|
2230
2369
|
var KNOWN_ADDONS = { village: "@ambasada/village" };
|
|
2231
2370
|
function readJson(file) {
|
|
@@ -2238,8 +2377,8 @@ function readJson(file) {
|
|
|
2238
2377
|
function packageDir(name, root) {
|
|
2239
2378
|
let dir = resolvePath2(root);
|
|
2240
2379
|
for (; ; ) {
|
|
2241
|
-
const candidate =
|
|
2242
|
-
if (
|
|
2380
|
+
const candidate = join4(dir, "node_modules", name);
|
|
2381
|
+
if (existsSync4(join4(candidate, "package.json"))) return candidate;
|
|
2243
2382
|
const parent = dirname2(dir);
|
|
2244
2383
|
if (parent === dir) return null;
|
|
2245
2384
|
dir = parent;
|
|
@@ -2249,11 +2388,11 @@ function findAddons(root, configAddons = []) {
|
|
|
2249
2388
|
const dirs = /* @__PURE__ */ new Set();
|
|
2250
2389
|
for (const dir of (process.env.AMBASADA_ADDONS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) dirs.add(resolvePath2(dir));
|
|
2251
2390
|
if (root) {
|
|
2252
|
-
const pkg = readJson(
|
|
2391
|
+
const pkg = readJson(join4(root, "package.json"));
|
|
2253
2392
|
const names2 = /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {}), ...configAddons]);
|
|
2254
2393
|
for (const name of names2) {
|
|
2255
2394
|
const dir = isAbsolute(name) || name.startsWith(".") ? resolvePath2(root, name) : packageDir(name, root);
|
|
2256
|
-
if (dir && readJson(
|
|
2395
|
+
if (dir && readJson(join4(dir, "package.json"))?.ambasada?.plugin) dirs.add(dir);
|
|
2257
2396
|
}
|
|
2258
2397
|
}
|
|
2259
2398
|
return [...dirs];
|
|
@@ -2266,11 +2405,11 @@ function besideCli(name) {
|
|
|
2266
2405
|
}
|
|
2267
2406
|
}
|
|
2268
2407
|
async function loadAddon(dir) {
|
|
2269
|
-
const pkg = readJson(
|
|
2408
|
+
const pkg = readJson(join4(dir, "package.json"));
|
|
2270
2409
|
const entry2 = pkg?.ambasada?.plugin;
|
|
2271
2410
|
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 (!
|
|
2411
|
+
const file = join4(dir, entry2);
|
|
2412
|
+
if (!existsSync4(file)) throw new CliError(`${pkg.name}: ${entry2} is missing (build the add-on, or reinstall it)`);
|
|
2274
2413
|
const mod = await import(pathToFileURL2(file).href);
|
|
2275
2414
|
const register = mod.register ?? mod.default?.register;
|
|
2276
2415
|
if (typeof register !== "function") throw new CliError(`${pkg.name}: ${entry2} exports no register(api)`);
|
|
@@ -2358,8 +2497,8 @@ function setAddons(a) {
|
|
|
2358
2497
|
|
|
2359
2498
|
// src/stack.ts
|
|
2360
2499
|
import { spawn } from "node:child_process";
|
|
2361
|
-
import { existsSync as
|
|
2362
|
-
import { dirname as dirname3, join as
|
|
2500
|
+
import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
|
|
2501
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
2363
2502
|
var STACKS = [
|
|
2364
2503
|
{
|
|
2365
2504
|
files: ["next.config.ts", "next.config.mjs", "next.config.js", "open-next.config.ts"],
|
|
@@ -2396,15 +2535,15 @@ var STACKS = [
|
|
|
2396
2535
|
}
|
|
2397
2536
|
];
|
|
2398
2537
|
function detectStack(root) {
|
|
2399
|
-
return STACKS.find((s) => s.files.some((f) =>
|
|
2538
|
+
return STACKS.find((s) => s.files.some((f) => existsSync5(join5(root, f))))?.stack ?? null;
|
|
2400
2539
|
}
|
|
2401
2540
|
function resolveBin(root, pkg, bin2) {
|
|
2402
2541
|
for (let dir = root; ; dir = dirname3(dir)) {
|
|
2403
|
-
const manifest =
|
|
2404
|
-
if (
|
|
2542
|
+
const manifest = join5(dir, "node_modules", pkg, "package.json");
|
|
2543
|
+
if (existsSync5(manifest)) {
|
|
2405
2544
|
const json2 = JSON.parse(readFileSync2(manifest, "utf8"));
|
|
2406
2545
|
const rel = typeof json2.bin === "string" ? json2.bin : json2.bin?.[bin2];
|
|
2407
|
-
return rel ?
|
|
2546
|
+
return rel ? join5(dirname3(manifest), rel) : null;
|
|
2408
2547
|
}
|
|
2409
2548
|
if (dirname3(dir) === dir) return null;
|
|
2410
2549
|
}
|
|
@@ -2445,55 +2584,17 @@ function startApp(root, stack, bin2, url, vars, print, prefix) {
|
|
|
2445
2584
|
}
|
|
2446
2585
|
|
|
2447
2586
|
// src/check.ts
|
|
2448
|
-
import { existsSync as
|
|
2449
|
-
import { dirname as
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
};
|
|
2454
|
-
|
|
2455
|
-
|
|
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
|
-
}
|
|
2587
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
|
|
2588
|
+
import { dirname as dirname5, join as join8, resolve as resolvePath3 } from "node:path";
|
|
2589
|
+
|
|
2590
|
+
// src/miniapp.ts
|
|
2591
|
+
import { execFileSync } from "node:child_process";
|
|
2592
|
+
import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
|
|
2593
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2594
|
+
import { dirname as dirname4, join as join7, relative as relative2 } from "node:path";
|
|
2494
2595
|
|
|
2495
2596
|
// src/template.ts
|
|
2496
|
-
import { readdirSync as readdirSync2, readFileSync as
|
|
2597
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
|
|
2497
2598
|
import { join as join6, relative } from "node:path";
|
|
2498
2599
|
import { fileURLToPath } from "node:url";
|
|
2499
2600
|
var TEMPLATES = ["next", "spa", "worker"];
|
|
@@ -2501,58 +2602,388 @@ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".next", ".open-next
|
|
|
2501
2602
|
var BINARY = /\.(png|ico|jpe?g|gif|webp|woff2?)$/i;
|
|
2502
2603
|
var isBinary = (path) => BINARY.test(path);
|
|
2503
2604
|
function readTemplateDir(dir) {
|
|
2504
|
-
const
|
|
2605
|
+
const out7 = {};
|
|
2505
2606
|
const walk2 = (d) => {
|
|
2506
2607
|
for (const name of readdirSync2(d)) {
|
|
2507
2608
|
if (SKIP.has(name)) continue;
|
|
2508
2609
|
const path = join6(d, name);
|
|
2509
2610
|
if (statSync2(path).isDirectory()) walk2(path);
|
|
2510
|
-
else
|
|
2611
|
+
else out7[relative(dir, path).split("\\").join("/")] = readFileSync3(path, isBinary(name) ? "base64" : "utf8");
|
|
2511
2612
|
}
|
|
2512
2613
|
};
|
|
2513
2614
|
walk2(dir);
|
|
2514
|
-
return
|
|
2615
|
+
return out7;
|
|
2515
2616
|
}
|
|
2516
2617
|
function templateFiles(name) {
|
|
2517
2618
|
if (typeof define_AMBASADA_TEMPLATES_default !== "undefined") return define_AMBASADA_TEMPLATES_default[name] ?? {};
|
|
2518
2619
|
return readTemplateDir(fileURLToPath(new URL(`../../../templates/${name}/`, import.meta.url)));
|
|
2519
2620
|
}
|
|
2621
|
+
var miniappFiles = () => templateFiles("miniapp");
|
|
2622
|
+
|
|
2623
|
+
// src/miniapp.ts
|
|
2624
|
+
var out = (s = "") => process.stdout.write(`${s}
|
|
2625
|
+
`);
|
|
2626
|
+
var GENERATED_RECORD = ".ambasada/manifest.json";
|
|
2627
|
+
var DEFAULT_ENTRY = "/app";
|
|
2628
|
+
var OFF = { on: false, entry: null, source: null, file: null };
|
|
2629
|
+
function templateOf(root) {
|
|
2630
|
+
const stack = detectStack(root);
|
|
2631
|
+
if (stack) return stack.name;
|
|
2632
|
+
return existsSync6(join7(root, "src/worker.ts")) ? "worker" : "next";
|
|
2633
|
+
}
|
|
2634
|
+
var staticDir = (template) => dirname4(dirname4(MANIFEST_FILES[template]));
|
|
2635
|
+
function appInfo(config) {
|
|
2636
|
+
const idea = config.idea;
|
|
2637
|
+
return { id: config.id, name: config.name, description: config.description ?? (typeof idea?.title === "string" ? idea.title : "") };
|
|
2638
|
+
}
|
|
2639
|
+
function readRecord(root) {
|
|
2640
|
+
try {
|
|
2641
|
+
return JSON.parse(readFileSync4(join7(root, GENERATED_RECORD), "utf8"));
|
|
2642
|
+
} catch {
|
|
2643
|
+
return null;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
function isOurs(root, file) {
|
|
2647
|
+
const record = readRecord(root);
|
|
2648
|
+
if (!record || join7(root, record.path) !== file || !existsSync6(file)) return false;
|
|
2649
|
+
return readFileSync4(file, "utf8") === record.content;
|
|
2650
|
+
}
|
|
2651
|
+
function handWritten(root) {
|
|
2652
|
+
const file = findLocalManifest(root);
|
|
2653
|
+
return file && !isOurs(root, file) ? file : null;
|
|
2654
|
+
}
|
|
2655
|
+
function fileEntry(file) {
|
|
2656
|
+
try {
|
|
2657
|
+
const entry2 = JSON.parse(readFileSync4(file, "utf8")).entry;
|
|
2658
|
+
return typeof entry2 === "string" && entry2.startsWith("/") ? entry2 : "/";
|
|
2659
|
+
} catch {
|
|
2660
|
+
return "/";
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
function miniappState(root, miniapp2) {
|
|
2664
|
+
const file = handWritten(root);
|
|
2665
|
+
if (file) return { on: true, entry: fileEntry(file), source: "file", file };
|
|
2666
|
+
return miniapp2 ? { on: true, entry: miniapp2.entry, source: "config", file: null } : OFF;
|
|
2667
|
+
}
|
|
2668
|
+
function miniappOfSource(root) {
|
|
2669
|
+
const file = join7(root, CONFIG_FILE);
|
|
2670
|
+
const source = existsSync6(file) ? readFileSync4(file, "utf8") : "";
|
|
2671
|
+
return { app: readCompany(source), miniapp: readMiniapp(source) };
|
|
2672
|
+
}
|
|
2673
|
+
async function ensureGitignored(root, rel) {
|
|
2674
|
+
const file = join7(root, ".gitignore");
|
|
2675
|
+
if (!existsSync6(file)) return;
|
|
2676
|
+
const text = await readFile3(file, "utf8");
|
|
2677
|
+
if (text.split("\n").some((l) => l.trim() === `/${rel}`)) return;
|
|
2678
|
+
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)
|
|
2679
|
+
/${rel}
|
|
2680
|
+
`);
|
|
2681
|
+
}
|
|
2682
|
+
async function writeGenerated(root, template, app, miniapp2) {
|
|
2683
|
+
const rel = MANIFEST_FILES[template];
|
|
2684
|
+
const text = manifestJson(app, miniapp2);
|
|
2685
|
+
await mkdir2(dirname4(join7(root, rel)), { recursive: true });
|
|
2686
|
+
if (!existsSync6(join7(root, rel)) || readFileSync4(join7(root, rel), "utf8") !== text) await writeFile3(join7(root, rel), text);
|
|
2687
|
+
await mkdir2(join7(root, ".ambasada"), { recursive: true });
|
|
2688
|
+
await writeFile3(join7(root, GENERATED_RECORD), `${JSON.stringify({ path: rel, content: text }, null, 2)}
|
|
2689
|
+
`);
|
|
2690
|
+
await ensureGitignored(root, rel);
|
|
2691
|
+
}
|
|
2692
|
+
async function syncManifest(root, config) {
|
|
2693
|
+
const template = templateOf(root);
|
|
2694
|
+
const target2 = join7(root, MANIFEST_FILES[template]);
|
|
2695
|
+
const state = miniappState(root, config.miniapp);
|
|
2696
|
+
if (state.source === "config") await writeGenerated(root, template, appInfo(config), config.miniapp);
|
|
2697
|
+
else if (!config.miniapp && isOurs(root, target2)) {
|
|
2698
|
+
await rm2(target2, { force: true });
|
|
2699
|
+
await rm2(join7(root, GENERATED_RECORD), { force: true });
|
|
2700
|
+
}
|
|
2701
|
+
return state;
|
|
2702
|
+
}
|
|
2703
|
+
function stateLine(state) {
|
|
2704
|
+
if (!state.on) return `mini app: off ${c.dim("(`ambasada miniapp enable` adds one)")}`;
|
|
2705
|
+
return `mini app: on at ${c.bold(state.entry ?? "/")}${state.source === "file" ? c.dim(` (manifest: ${state.file})`) : ""}`;
|
|
2706
|
+
}
|
|
2707
|
+
var COMMENT = "// The Ambasada mini app, at a path of this app: `ambasada dev` and `deploy` serve its manifest from this.";
|
|
2708
|
+
function setMiniappEntry(source, entry2) {
|
|
2709
|
+
const existing = /(\bminiapp\s*:\s*\{[^}]*?\bentry\s*:\s*)(["'`])[^"'`]*\2/;
|
|
2710
|
+
if (existing.test(source)) return source.replace(existing, `$1"${entry2}"`);
|
|
2711
|
+
const product = /^([ \t]*product\s*:\s*\{[^}]*\})(,?)([^\n]*\n)/m.exec(source);
|
|
2712
|
+
if (product) {
|
|
2713
|
+
const indent = /^[ \t]*/.exec(product[1])[0];
|
|
2714
|
+
const line = `${product[1]},${product[3]}`;
|
|
2715
|
+
return `${source.slice(0, product.index)}${line}${indent}${COMMENT}
|
|
2716
|
+
${indent}miniapp: { entry: "${entry2}" },
|
|
2717
|
+
${source.slice(product.index + product[0].length)}`;
|
|
2718
|
+
}
|
|
2719
|
+
const close = /\n([ \t]*)\}\)/.exec(source.slice(source.indexOf("defineApp(")));
|
|
2720
|
+
if (!source.includes("defineApp(") || !close) throw new CliError(`could not find defineApp({ \u2026 }) in ${CONFIG_FILE}: add \`miniapp: { entry: "${entry2}" }\` to it by hand`);
|
|
2721
|
+
const at = source.indexOf("defineApp(") + close.index;
|
|
2722
|
+
const body = source.slice(0, at).trimEnd();
|
|
2723
|
+
return `${body}${body.endsWith(",") || body.endsWith("{") ? "" : ","}
|
|
2724
|
+
${COMMENT}
|
|
2725
|
+
miniapp: { entry: "${entry2}" },${source.slice(at)}`;
|
|
2726
|
+
}
|
|
2727
|
+
async function patch(root, rel, done, apply, manual, placed) {
|
|
2728
|
+
const file = join7(root, rel);
|
|
2729
|
+
const text = existsSync6(file) ? await readFile3(file, "utf8") : null;
|
|
2730
|
+
if (text !== null && done.test(text)) return;
|
|
2731
|
+
const next = text === null ? null : apply(text);
|
|
2732
|
+
if (next === null || next === text) placed.manual.push(manual);
|
|
2733
|
+
else {
|
|
2734
|
+
await writeFile3(file, next);
|
|
2735
|
+
placed.patched.push(rel);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
async function placeFiles(root, template, entry2, name, placed) {
|
|
2739
|
+
const pieces = miniappFiles();
|
|
2740
|
+
const dir = entry2.slice(1);
|
|
2741
|
+
const write = async (piece, rel) => {
|
|
2742
|
+
const dest = join7(root, rel);
|
|
2743
|
+
if (existsSync6(dest)) return;
|
|
2744
|
+
const back = relative2(dirname4(dest), root).split("\\").join("/") || ".";
|
|
2745
|
+
const text = pieces[`${template}/${piece}`];
|
|
2746
|
+
if (text === void 0) throw new CliError(`this CLI has no ${template}/${piece} mini-app piece`);
|
|
2747
|
+
await mkdir2(dirname4(dest), { recursive: true });
|
|
2748
|
+
await writeFile3(dest, text.replaceAll("__ENTRY_DIR__", dir).replaceAll("__ENTRY__", entry2).replaceAll("__ROOT__", back).replaceAll("My App", name));
|
|
2749
|
+
placed.written.push(rel);
|
|
2750
|
+
};
|
|
2751
|
+
if (template === "next") {
|
|
2752
|
+
if (entry2 === "/") {
|
|
2753
|
+
await write("in-app.tsx", "app/in-app.tsx");
|
|
2754
|
+
await patch(
|
|
2755
|
+
root,
|
|
2756
|
+
"app/layout.tsx",
|
|
2757
|
+
/<InApp\b/,
|
|
2758
|
+
(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,
|
|
2759
|
+
"app/layout.tsx: render <InApp /> (app/in-app.tsx) inside <body>",
|
|
2760
|
+
placed
|
|
2761
|
+
);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
for (const taken of [`app/${dir}/page.tsx`, `app/(site)/${dir}/page.tsx`]) {
|
|
2765
|
+
if (existsSync6(join7(root, taken)) && !existsSync6(join7(root, `app/${dir}/in-app.tsx`))) throw new CliError(`${taken} is a page already: pick another --entry`);
|
|
2766
|
+
}
|
|
2767
|
+
for (const piece of ["layout.tsx", "page.tsx", "in-app.tsx"]) await write(piece, `app/${dir}/${piece}`);
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
if (template === "spa") {
|
|
2771
|
+
await write("shell.ts", "src/in-app/shell.ts");
|
|
2772
|
+
if (entry2 === "/") {
|
|
2773
|
+
await patch(
|
|
2774
|
+
root,
|
|
2775
|
+
"src/main.tsx",
|
|
2776
|
+
/startInApp/,
|
|
2777
|
+
(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()}
|
|
2778
|
+
// Inside the Ambasada app: drop the splash and sign in silently (src/in-app/shell.ts). On the web: a no-op.
|
|
2779
|
+
void startInApp(() => location.reload());
|
|
2780
|
+
` : null,
|
|
2781
|
+
"src/main.tsx: call startInApp(() => location.reload()) from src/in-app/shell.ts",
|
|
2782
|
+
placed
|
|
2783
|
+
);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
await write("InApp.tsx", "src/in-app/InApp.tsx");
|
|
2787
|
+
await patch(
|
|
2788
|
+
root,
|
|
2789
|
+
"src/main.tsx",
|
|
2790
|
+
/isInApp/,
|
|
2791
|
+
(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,
|
|
2792
|
+
`src/main.tsx: render <InApp /> (src/in-app/InApp.tsx) when isInApp(location.pathname)`,
|
|
2793
|
+
placed
|
|
2794
|
+
);
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
const web = staticDir("worker");
|
|
2798
|
+
await write("in-app.js", `${web}/in-app.js`);
|
|
2799
|
+
if (entry2 === "/") {
|
|
2800
|
+
await patch(
|
|
2801
|
+
root,
|
|
2802
|
+
`${web}/index.html`,
|
|
2803
|
+
/in-app\.js/,
|
|
2804
|
+
(t) => t.includes(" </head>") ? t.replace(
|
|
2805
|
+
" </head>",
|
|
2806
|
+
' <!-- 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>'
|
|
2807
|
+
) : null,
|
|
2808
|
+
`${web}/index.html: <script src="https://hub.ambasada.id/miniapp.js" defer></script> and <script src="/in-app.js" defer></script> in <head>`,
|
|
2809
|
+
placed
|
|
2810
|
+
);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if (existsSync6(join7(root, web, `${dir}.html`)) || existsSync6(join7(root, web, dir, "index.html"))) {
|
|
2814
|
+
if (!readFileSync4(join7(root, web, existsSync6(join7(root, web, `${dir}.html`)) ? `${dir}.html` : `${dir}/index.html`), "utf8").includes("in-app.js")) throw new CliError(`${web}/${dir}.html is a page already: pick another --entry`);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
await write("page.html", `${web}/${dir}.html`);
|
|
2818
|
+
}
|
|
2819
|
+
function miniappSpec(deps, flags, fallback) {
|
|
2820
|
+
const given = typeof flags["miniapp-spec"] === "string" ? flags["miniapp-spec"] : process.env.AMBASADA_MINIAPP_SPEC;
|
|
2821
|
+
if (given) return given;
|
|
2822
|
+
const like = [deps["@ambasada/ui"], deps["@ambasada/sdk"]].find((v) => v && /^[\^~]?\d/.test(v));
|
|
2823
|
+
return like ?? fallback;
|
|
2824
|
+
}
|
|
2825
|
+
async function enableMiniapp(root, entry2, flags, opts) {
|
|
2826
|
+
if (!MINIAPP_ENTRY.test(entry2) || entry2.split("/").includes("..")) throw new CliError(`--entry must be a path like "/app" or "/" (got ${JSON.stringify(entry2)})`);
|
|
2827
|
+
const template = opts.template ?? templateOf(root);
|
|
2828
|
+
const placed = { written: [], patched: [], manual: [] };
|
|
2829
|
+
const configFile = join7(root, CONFIG_FILE);
|
|
2830
|
+
const source = await readFile3(configFile, "utf8");
|
|
2831
|
+
const next = setMiniappEntry(source, entry2);
|
|
2832
|
+
if (next !== source) {
|
|
2833
|
+
await writeFile3(configFile, next);
|
|
2834
|
+
placed.patched.push(CONFIG_FILE);
|
|
2835
|
+
}
|
|
2836
|
+
const app = readCompany(next) ?? { id: "my-app", name: "My App", description: "" };
|
|
2837
|
+
const miniapp2 = readMiniapp(next) ?? { entry: entry2 };
|
|
2838
|
+
await placeFiles(root, template, entry2, app.name, placed);
|
|
2839
|
+
const icon = join7(root, staticDir(template), (miniapp2.icon ?? "/icon.png").slice(1));
|
|
2840
|
+
if (!existsSync6(icon)) {
|
|
2841
|
+
const png = miniappFiles()["icon.png"];
|
|
2842
|
+
if (png && isBinary("icon.png")) {
|
|
2843
|
+
await mkdir2(dirname4(icon), { recursive: true });
|
|
2844
|
+
await writeFile3(icon, Buffer.from(png, "base64"));
|
|
2845
|
+
placed.written.push(relative2(root, icon));
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
let installNeeded = false;
|
|
2849
|
+
if (template !== "worker") {
|
|
2850
|
+
const pkgFile = join7(root, "package.json");
|
|
2851
|
+
const pkg = JSON.parse(await readFile3(pkgFile, "utf8"));
|
|
2852
|
+
const deps = pkg.dependencies ?? {};
|
|
2853
|
+
if (!deps["@ambasada/miniapp"]) {
|
|
2854
|
+
pkg.dependencies = Object.fromEntries(Object.entries({ ...deps, "@ambasada/miniapp": miniappSpec(deps, flags, opts.releaseLine) }).sort(([a], [b]) => a.localeCompare(b)));
|
|
2855
|
+
await writeFile3(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
2856
|
+
`);
|
|
2857
|
+
placed.patched.push("package.json");
|
|
2858
|
+
installNeeded = true;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
if (!handWritten(root)) await writeGenerated(root, template, app, miniapp2);
|
|
2862
|
+
return { ...placed, entry: entry2, installNeeded };
|
|
2863
|
+
}
|
|
2864
|
+
async function miniappCommand(args, flags, releaseLine) {
|
|
2865
|
+
const root = findRoot();
|
|
2866
|
+
if (!root) throw new CliError(`run \`ambasada miniapp\` inside your app (no ${CONFIG_FILE} found)`);
|
|
2867
|
+
const [sub] = args;
|
|
2868
|
+
const { miniapp: miniapp2 } = miniappOfSource(root);
|
|
2869
|
+
if (!sub || sub === "status") {
|
|
2870
|
+
const state = miniappState(root, miniapp2);
|
|
2871
|
+
if (flags.json) out(JSON.stringify(state, null, 2));
|
|
2872
|
+
else out(stateLine(state));
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
if (sub !== "enable") throw new CliError("usage: ambasada miniapp [enable [--entry /app]]");
|
|
2876
|
+
const current2 = miniappState(root, miniapp2);
|
|
2877
|
+
if (current2.source === "file") throw new CliError(`the mini app is on already, from ${current2.file} (yours, it wins): change its "entry" there`);
|
|
2878
|
+
const entry2 = typeof flags.entry === "string" ? flags.entry : miniapp2?.entry ?? DEFAULT_ENTRY;
|
|
2879
|
+
const moved = miniapp2 && miniapp2.entry !== entry2 ? miniapp2.entry : null;
|
|
2880
|
+
const done = await enableMiniapp(root, entry2, flags, { releaseLine });
|
|
2881
|
+
if (done.installNeeded && existsSync6(join7(root, "node_modules")) && !flags["no-install"]) {
|
|
2882
|
+
out(c.dim("npm install \u2026"));
|
|
2883
|
+
try {
|
|
2884
|
+
execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
|
|
2885
|
+
done.installNeeded = false;
|
|
2886
|
+
} catch {
|
|
2887
|
+
out(c.yellow("! npm install failed: run it yourself (@ambasada/miniapp)"));
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
printEnabled(done, root);
|
|
2891
|
+
if (moved) out(c.yellow(`! the entry moved from ${moved}: move what you built there, and delete the old in-app files if you don't need them`));
|
|
2892
|
+
}
|
|
2893
|
+
function printEnabled(done, root) {
|
|
2894
|
+
out(`${c.green("\u2713")} mini app on at ${c.bold(done.entry)}`);
|
|
2895
|
+
for (const f of done.patched) out(` ${c.dim("changed")} ${f}`);
|
|
2896
|
+
for (const f of done.written) out(` ${c.dim("added")} ${f}`);
|
|
2897
|
+
out(` ${c.dim("/.well-known/ambasada.json: written from ambasada.config.ts by `ambasada dev` and `deploy`")}`);
|
|
2898
|
+
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")}`);
|
|
2899
|
+
for (const m of done.manual) out(c.yellow(`! by hand: ${m}`));
|
|
2900
|
+
if (done.installNeeded && existsSync6(join7(root, "node_modules"))) out(` then: npm install ${c.dim("(@ambasada/miniapp)")}`);
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
// src/check.ts
|
|
2904
|
+
var merge2 = (into, from) => {
|
|
2905
|
+
into.problems.push(...from.problems);
|
|
2906
|
+
into.warnings.push(...from.warnings);
|
|
2907
|
+
};
|
|
2908
|
+
async function checkLocal(dir, opts) {
|
|
2909
|
+
const target2 = resolvePath3(dir);
|
|
2910
|
+
const report = { ok: true, target: target2, problems: [], warnings: [], notes: [] };
|
|
2911
|
+
const { app, miniapp: miniapp2 } = opts ?? miniappOfSource(target2);
|
|
2912
|
+
const state = miniappState(target2, miniapp2);
|
|
2913
|
+
report.miniapp = { on: state.on, entry: state.entry };
|
|
2914
|
+
if (!state.on) {
|
|
2915
|
+
report.notes.push("mini app: off (`ambasada miniapp enable` adds one)");
|
|
2916
|
+
report.notes.push("the product rules (https, no redirects, response time) need a URL: `ambasada check https://\u2026`");
|
|
2917
|
+
return report;
|
|
2918
|
+
}
|
|
2919
|
+
let manifest = null;
|
|
2920
|
+
let staticRoot;
|
|
2921
|
+
if (state.file) {
|
|
2922
|
+
staticRoot = dirname5(dirname5(state.file));
|
|
2923
|
+
try {
|
|
2924
|
+
manifest = JSON.parse(readFileSync5(state.file, "utf8"));
|
|
2925
|
+
merge2(report, validateManifest(manifest));
|
|
2926
|
+
} catch (error) {
|
|
2927
|
+
report.problems.push({ code: "manifest_json", message: `${state.file} is not valid JSON: ${error.message}` });
|
|
2928
|
+
}
|
|
2929
|
+
if (miniapp2 && manifest && manifest.entry !== miniapp2.entry) {
|
|
2930
|
+
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` });
|
|
2931
|
+
}
|
|
2932
|
+
} else {
|
|
2933
|
+
staticRoot = join8(target2, staticDir(templateOf(target2)));
|
|
2934
|
+
manifest = appManifest(app ?? { id: "my-app", name: "My App", description: "" }, miniapp2);
|
|
2935
|
+
merge2(report, validateManifest(manifest));
|
|
2936
|
+
report.notes.push("manifest: generated from ambasada.config.ts (`ambasada dev` and `deploy` serve it)");
|
|
2937
|
+
}
|
|
2938
|
+
const iconPath = typeof manifest?.icon === "string" && manifest.icon.startsWith("/") ? join8(staticRoot, manifest.icon) : null;
|
|
2939
|
+
if (iconPath) {
|
|
2940
|
+
if (!existsSync7(iconPath)) report.problems.push({ code: "icon_missing", message: `the manifest's icon ${manifest.icon} is not in ${staticRoot}` });
|
|
2941
|
+
else merge2(report, checkIcon(readFileSync5(iconPath)));
|
|
2942
|
+
} else if (manifest && typeof manifest.icon === "string") report.notes.push(`icon ${manifest.icon}: not a local path, not checked`);
|
|
2943
|
+
const entry2 = state.entry ?? "/";
|
|
2944
|
+
const pages = entry2 === "/" ? [join8(staticRoot, "index.html")] : [join8(staticRoot, `${entry2.slice(1)}.html`), join8(staticRoot, entry2.slice(1), "index.html")];
|
|
2945
|
+
const page = pages.find((p) => existsSync7(p));
|
|
2946
|
+
if (page) merge2(report, checkHtml(readFileSync5(page, "utf8")));
|
|
2947
|
+
else report.notes.push(`no static ${pages[0]}: the entry page is rendered by the app, checked by \`ambasada check <url>\` once deployed`);
|
|
2948
|
+
report.ok = report.problems.length === 0;
|
|
2949
|
+
return report;
|
|
2950
|
+
}
|
|
2520
2951
|
|
|
2521
2952
|
// src/storage.ts
|
|
2522
2953
|
import { createHash } from "node:crypto";
|
|
2523
|
-
import { existsSync as
|
|
2524
|
-
import { mkdtemp, readdir as readdir2, readFile as
|
|
2954
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
2955
|
+
import { mkdtemp, readdir as readdir2, readFile as readFile6, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2525
2956
|
import { tmpdir } from "node:os";
|
|
2526
|
-
import { basename as basename2, extname as extname2, join as
|
|
2957
|
+
import { basename as basename2, extname as extname2, join as join11 } from "node:path";
|
|
2527
2958
|
import { createInterface } from "node:readline";
|
|
2528
2959
|
|
|
2529
2960
|
// src/deploy.ts
|
|
2530
|
-
import { execFileSync as
|
|
2531
|
-
import { existsSync as
|
|
2532
|
-
import { mkdir as
|
|
2961
|
+
import { execFileSync as execFileSync3, spawn as spawn2 } from "node:child_process";
|
|
2962
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
2963
|
+
import { mkdir as mkdir4, readdir, readFile as readFile5, rm as rm4, stat, writeFile as writeFile5 } from "node:fs/promises";
|
|
2533
2964
|
import { createServer as createServer2 } from "node:net";
|
|
2534
|
-
import { basename, dirname as
|
|
2965
|
+
import { basename, dirname as dirname6, extname, join as join10, relative as relative4, resolve as resolvePath4, sep } from "node:path";
|
|
2535
2966
|
|
|
2536
2967
|
// src/upgrade.ts
|
|
2537
|
-
import { execFileSync } from "node:child_process";
|
|
2538
|
-
import { existsSync as
|
|
2539
|
-
import { mkdir as
|
|
2540
|
-
import { join as
|
|
2541
|
-
var VERSION2 = true ? "0.8.
|
|
2968
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2969
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync3 } from "node:fs";
|
|
2970
|
+
import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
2971
|
+
import { join as join9, relative as relative3 } from "node:path";
|
|
2972
|
+
var VERSION2 = true ? "0.8.4" : "0.1.0-dev";
|
|
2542
2973
|
var RELEASE_SPEC = /^\d+\.\d+\.\d+$/.test(VERSION2) ? `^${VERSION2}` : "latest";
|
|
2543
|
-
var PROJECT_FILE =
|
|
2544
|
-
var
|
|
2974
|
+
var PROJECT_FILE = join9(".ambasada", "project.json");
|
|
2975
|
+
var out2 = (s = "") => process.stdout.write(`${s}
|
|
2545
2976
|
`);
|
|
2546
2977
|
async function readProjectInfo(root) {
|
|
2547
2978
|
try {
|
|
2548
|
-
return JSON.parse(await
|
|
2979
|
+
return JSON.parse(await readFile4(join9(root, PROJECT_FILE), "utf8"));
|
|
2549
2980
|
} catch {
|
|
2550
2981
|
return null;
|
|
2551
2982
|
}
|
|
2552
2983
|
}
|
|
2553
2984
|
async function writeProjectInfo(root, info) {
|
|
2554
|
-
await
|
|
2555
|
-
await
|
|
2985
|
+
await mkdir3(join9(root, ".ambasada"), { recursive: true });
|
|
2986
|
+
await writeFile4(join9(root, PROJECT_FILE), `${JSON.stringify(info, null, 2)}
|
|
2556
2987
|
`);
|
|
2557
2988
|
}
|
|
2558
2989
|
var devkitVersion = () => VERSION2;
|
|
@@ -2643,16 +3074,20 @@ AMBASADA_VILLAGE_API_URL=${OLD_VILLAGE_URL}`);
|
|
|
2643
3074
|
return source.replace(m[0], `${m[0]}
|
|
2644
3075
|
AMBASADA_VILLAGE_API_URL=${url}`);
|
|
2645
3076
|
}
|
|
3077
|
+
function rewriteUiLink(source, line) {
|
|
3078
|
+
return source.replace(/(unpkg\.com\/@ambasada\/ui@)(\d+\.\d+)(?=\/)/g, (m, prefix, at) => at === line ? m : `${prefix}${line}`);
|
|
3079
|
+
}
|
|
2646
3080
|
var SOURCE = /\.(m?[jt]sx?)$/;
|
|
3081
|
+
var WEB = /\.(m?[jt]sx?|html?)$/;
|
|
2647
3082
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".open-next", ".wrangler", "dist", ".git", ".ambasada", ".claude"]);
|
|
2648
|
-
function sourceFiles(root) {
|
|
3083
|
+
function sourceFiles(root, pattern = SOURCE) {
|
|
2649
3084
|
const found = [];
|
|
2650
3085
|
const walk2 = (dir) => {
|
|
2651
3086
|
for (const name of readdirSync3(dir)) {
|
|
2652
3087
|
if (SKIP_DIRS.has(name)) continue;
|
|
2653
|
-
const path =
|
|
3088
|
+
const path = join9(dir, name);
|
|
2654
3089
|
if (statSync3(path).isDirectory()) walk2(path);
|
|
2655
|
-
else if (
|
|
3090
|
+
else if (pattern.test(name) && !name.endsWith(".d.ts")) found.push(path);
|
|
2656
3091
|
}
|
|
2657
3092
|
};
|
|
2658
3093
|
walk2(root);
|
|
@@ -2660,12 +3095,12 @@ function sourceFiles(root) {
|
|
|
2660
3095
|
}
|
|
2661
3096
|
async function usesVillage(root, pkg) {
|
|
2662
3097
|
if (pkg.dependencies?.["@ambasada/village"] || pkg.devDependencies?.["@ambasada/village"]) return true;
|
|
2663
|
-
if (
|
|
2664
|
-
const config = await
|
|
3098
|
+
if (existsSync8(join9(root, "offerings.ts")) || existsSync8(join9(root, "product/offerings.ts"))) return true;
|
|
3099
|
+
const config = await readFile4(join9(root, CONFIG_FILE), "utf8").catch(() => "");
|
|
2665
3100
|
return /\bdefineCompany\b|\bidea\s*:/.test(config);
|
|
2666
3101
|
}
|
|
2667
|
-
function
|
|
2668
|
-
return detectStack(root)?.name ?? (
|
|
3102
|
+
function templateOf2(root) {
|
|
3103
|
+
return detectStack(root)?.name ?? (existsSync8(join9(root, "src/worker.ts")) ? "worker" : "next");
|
|
2669
3104
|
}
|
|
2670
3105
|
var specFor = (name) => process.env[`AMBASADA_${name.replace(/^@ambasada\//, "").toUpperCase()}_SPEC`] ?? RELEASE_SPEC;
|
|
2671
3106
|
async function upgrade(_args, flags) {
|
|
@@ -2673,16 +3108,16 @@ async function upgrade(_args, flags) {
|
|
|
2673
3108
|
if (!root) throw new CliError(`run \`ambasada upgrade\` inside your app (no ${CONFIG_FILE} found)`);
|
|
2674
3109
|
const checkOnly = flags.check === true;
|
|
2675
3110
|
const info = await readProjectInfo(root);
|
|
2676
|
-
const pkgFile =
|
|
2677
|
-
const pkg = JSON.parse(await
|
|
3111
|
+
const pkgFile = join9(root, "package.json");
|
|
3112
|
+
const pkg = JSON.parse(await readFile4(pkgFile, "utf8"));
|
|
2678
3113
|
const village = await usesVillage(root, pkg);
|
|
2679
|
-
const template = info?.template && TEMPLATES.includes(info.template) ? info.template :
|
|
3114
|
+
const template = info?.template && TEMPLATES.includes(info.template) ? info.template : templateOf2(root);
|
|
2680
3115
|
const changes = [];
|
|
2681
|
-
const rel = (p) =>
|
|
3116
|
+
const rel = (p) => relative3(root, p);
|
|
2682
3117
|
const before08 = !info || /^0\.[0-7]\./.test(info.devkit) && !info.devkit.includes("-dev");
|
|
2683
3118
|
const edits = /* @__PURE__ */ new Map();
|
|
2684
3119
|
for (const file of before08 ? sourceFiles(root) : []) {
|
|
2685
|
-
const text = await
|
|
3120
|
+
const text = await readFile4(file, "utf8");
|
|
2686
3121
|
if (!text.includes("@ambasada/sdk")) continue;
|
|
2687
3122
|
const next = rewriteImports(text, village);
|
|
2688
3123
|
if (next !== text) {
|
|
@@ -2691,15 +3126,27 @@ async function upgrade(_args, flags) {
|
|
|
2691
3126
|
}
|
|
2692
3127
|
}
|
|
2693
3128
|
for (const name of before08 ? [".env", ".dev.vars"] : []) {
|
|
2694
|
-
const file =
|
|
2695
|
-
if (!
|
|
2696
|
-
const text = await
|
|
3129
|
+
const file = join9(root, name);
|
|
3130
|
+
if (!existsSync8(file)) continue;
|
|
3131
|
+
const text = await readFile4(file, "utf8");
|
|
2697
3132
|
const next = rewriteEnv(text, village);
|
|
2698
3133
|
if (next !== text) {
|
|
2699
3134
|
edits.set(file, next);
|
|
2700
3135
|
changes.push(`${name}: AMBASADA_VILLAGE_API_URL`);
|
|
2701
3136
|
}
|
|
2702
3137
|
}
|
|
3138
|
+
const uiLine = VERSION2.split(".").slice(0, 2).join(".");
|
|
3139
|
+
if (/^\d+\.\d+$/.test(uiLine) && !VERSION2.includes("-dev")) {
|
|
3140
|
+
for (const file of sourceFiles(root, WEB)) {
|
|
3141
|
+
const text = edits.get(file) ?? await readFile4(file, "utf8");
|
|
3142
|
+
if (!text.includes("@ambasada/ui@")) continue;
|
|
3143
|
+
const next = rewriteUiLink(text, uiLine);
|
|
3144
|
+
if (next !== text) {
|
|
3145
|
+
edits.set(file, next);
|
|
3146
|
+
changes.push(`${rel(file)}: @ambasada/ui link \u2192 ${uiLine}`);
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
2703
3150
|
const deps = { ...pkg.dependencies };
|
|
2704
3151
|
const devDeps = { ...pkg.devDependencies };
|
|
2705
3152
|
for (const table2 of [deps, devDeps]) {
|
|
@@ -2720,10 +3167,10 @@ async function upgrade(_args, flags) {
|
|
|
2720
3167
|
const skills = {};
|
|
2721
3168
|
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
3169
|
const stale = [];
|
|
2723
|
-
const skillsDir =
|
|
2724
|
-
if (
|
|
3170
|
+
const skillsDir = join9(root, ".claude/skills");
|
|
3171
|
+
if (existsSync8(join9(skillsDir, "ambasada-payments"))) stale.push("ambasada-payments");
|
|
2725
3172
|
for (const [path, text] of Object.entries(skills)) {
|
|
2726
|
-
const current2 = await
|
|
3173
|
+
const current2 = await readFile4(join9(root, path), "utf8").catch(() => null);
|
|
2727
3174
|
if (current2 !== text) changes.push(`${path.split("/").slice(0, 3).join("/")}: re-synced`);
|
|
2728
3175
|
}
|
|
2729
3176
|
const nextInfo = { template, devkit: VERSION2, addons: village ? ["village"] : info?.addons ?? [] };
|
|
@@ -2734,28 +3181,28 @@ async function upgrade(_args, flags) {
|
|
|
2734
3181
|
const from = info?.devkit ?? "0.7";
|
|
2735
3182
|
if (checkOnly) {
|
|
2736
3183
|
if (!uniqueChanges.length && !stale.length) {
|
|
2737
|
-
|
|
3184
|
+
out2(`${c.green("\u2713")} up to date with the devkit ${VERSION2}`);
|
|
2738
3185
|
return;
|
|
2739
3186
|
}
|
|
2740
|
-
|
|
2741
|
-
for (const line of uniqueChanges)
|
|
2742
|
-
for (const s of stale)
|
|
3187
|
+
out2(`devkit ${from} \u2192 ${VERSION2}:`);
|
|
3188
|
+
for (const line of uniqueChanges) out2(` ${line}`);
|
|
3189
|
+
for (const s of stale) out2(` .claude/skills/${s}: removed`);
|
|
2743
3190
|
throw new CliError("`ambasada upgrade` would change the above", 1);
|
|
2744
3191
|
}
|
|
2745
|
-
for (const [file, text] of edits) await
|
|
3192
|
+
for (const [file, text] of edits) await writeFile4(file, text);
|
|
2746
3193
|
pkg.dependencies = deps;
|
|
2747
3194
|
if (pkg.devDependencies) pkg.devDependencies = devDeps;
|
|
2748
|
-
await
|
|
3195
|
+
await writeFile4(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
2749
3196
|
`);
|
|
2750
3197
|
for (const [path, text] of Object.entries(skills)) {
|
|
2751
|
-
await
|
|
2752
|
-
await
|
|
3198
|
+
await mkdir3(join9(root, path, ".."), { recursive: true });
|
|
3199
|
+
await writeFile4(join9(root, path), text);
|
|
2753
3200
|
}
|
|
2754
|
-
for (const s of stale) await
|
|
3201
|
+
for (const s of stale) await rm3(join9(skillsDir, s), { recursive: true, force: true });
|
|
2755
3202
|
if (!flags["no-install"]) {
|
|
2756
|
-
|
|
3203
|
+
out2(c.dim("npm install \u2026"));
|
|
2757
3204
|
try {
|
|
2758
|
-
|
|
3205
|
+
execFileSync2(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
|
|
2759
3206
|
} catch {
|
|
2760
3207
|
throw new CliError(`npm install failed in ${root}: fix it, then \`ambasada upgrade\` again`);
|
|
2761
3208
|
}
|
|
@@ -2764,33 +3211,33 @@ async function upgrade(_args, flags) {
|
|
|
2764
3211
|
setAddons(addons2);
|
|
2765
3212
|
for (const addon of addons2.list) {
|
|
2766
3213
|
for (const [path, text] of Object.entries(addon.skills)) {
|
|
2767
|
-
const file =
|
|
2768
|
-
await
|
|
2769
|
-
await
|
|
3214
|
+
const file = join9(skillsDir, path);
|
|
3215
|
+
await mkdir3(join9(file, ".."), { recursive: true });
|
|
3216
|
+
await writeFile4(file, text);
|
|
2770
3217
|
}
|
|
2771
3218
|
}
|
|
2772
|
-
if (village && !addons2.get("village"))
|
|
3219
|
+
if (village && !addons2.get("village")) out2(c.yellow("! @ambasada/village is in package.json but not installed yet: run `npm install`, then `ambasada upgrade` again for its skill"));
|
|
2773
3220
|
await writeProjectInfo(root, nextInfo);
|
|
2774
|
-
for (const line of uniqueChanges)
|
|
2775
|
-
for (const s of stale)
|
|
3221
|
+
for (const line of uniqueChanges) out2(`${c.green("\u2713")} ${line}`);
|
|
3222
|
+
for (const s of stale) out2(`${c.green("\u2713")} .claude/skills/${s}: removed (now ambasada-village)`);
|
|
2776
3223
|
const report = await checkLocal(root).catch(() => null);
|
|
2777
|
-
for (const p of report?.problems ?? [])
|
|
2778
|
-
|
|
3224
|
+
for (const p of report?.problems ?? []) out2(`${c.yellow("!")} ${p.message} ${c.dim(`(${p.code}, \`ambasada check\`)`)}`);
|
|
3225
|
+
out2(`${c.green("\u2713")} devkit ${from} \u2192 ${VERSION2}`);
|
|
2779
3226
|
if (before08) {
|
|
2780
|
-
|
|
2781
|
-
|
|
3227
|
+
out2("");
|
|
3228
|
+
out2(`Then ask your coding agent: ${c.bold(`"run the Ambasada upgrade steps for 0.7 \u2192 0.8"`)} (.claude/skills/ambasada-product/reference/upgrading.md)`);
|
|
2782
3229
|
}
|
|
2783
3230
|
}
|
|
2784
3231
|
|
|
2785
3232
|
// src/deploy.ts
|
|
2786
|
-
var
|
|
3233
|
+
var out3 = (s = "") => process.stdout.write(`${s}
|
|
2787
3234
|
`);
|
|
2788
3235
|
var LIMITS = { assets: 1e3, assetBytes: 25 * 1024 * 1024 };
|
|
2789
3236
|
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
3237
|
var PLATFORM_STORAGE = { d1: "DB", kv: "KV", r2: "FILES" };
|
|
2791
3238
|
async function deploy(_args, flags) {
|
|
2792
3239
|
const project = await loadProject();
|
|
2793
|
-
const hooks = (url = project.config.product?.url) => addons().afterDeploy({ event: "deploy", project, url, flags, out:
|
|
3240
|
+
const hooks = (url = project.config.product?.url) => addons().afterDeploy({ event: "deploy", project, url, flags, out: out3 });
|
|
2794
3241
|
if (flags["agent-only"] || flags["offerings-only"]) {
|
|
2795
3242
|
if (!addons().get("village")) throw new CliError("--agent-only and --offerings-only belong to the village add-on: `ambasada add village`");
|
|
2796
3243
|
return hooks();
|
|
@@ -2798,47 +3245,52 @@ async function deploy(_args, flags) {
|
|
|
2798
3245
|
const dir = findProductDir(project.root);
|
|
2799
3246
|
if (!dir) {
|
|
2800
3247
|
if (!project.config.product?.url || !addons().list.length) productDir(project.root);
|
|
2801
|
-
|
|
3248
|
+
out3(c.dim(`no wrangler.jsonc: not hosting ${project.config.product.url}`));
|
|
2802
3249
|
return hooks();
|
|
2803
3250
|
}
|
|
2804
3251
|
const template = detectTemplate(dir);
|
|
2805
3252
|
if (!flags.own && await isMockApi((await resolve(flags)).apiUrl)) {
|
|
2806
|
-
|
|
3253
|
+
out3(c.dim("ambasada dev does not host apps (deploy without --api-url to go live)"));
|
|
2807
3254
|
return hooks();
|
|
2808
3255
|
}
|
|
3256
|
+
const r = flags.own || flags["dry-run"] ? await resolve(flags) : await resolveForApp(flags);
|
|
3257
|
+
if (!flags.own && !flags["dry-run"] && !r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
3258
|
+
const inApp = await syncManifest(project.root, project.config);
|
|
3259
|
+
out3(stateLine(inApp));
|
|
2809
3260
|
if (!flags["no-build"]) await build(dir, template);
|
|
2810
3261
|
if (flags.own) {
|
|
2811
|
-
|
|
3262
|
+
out3(c.bold("wrangler deploy (your Cloudflare account)"));
|
|
2812
3263
|
const output = await run(bin(dir, "wrangler"), ["deploy"], dir, { capture: true });
|
|
2813
3264
|
const url = /https:\/\/[^\s]+\.workers\.dev|https:\/\/[a-z0-9.-]+\.[a-z]{2,}(?=\s|$)/m.exec(output)?.[0];
|
|
2814
3265
|
if (!url) throw new CliError("wrangler deployed, but I could not read the URL from its output: run `ambasada link https://\u2026` yourself");
|
|
2815
3266
|
await link([url], flags);
|
|
2816
|
-
return addons().afterDeploy({ event: "deploy", project: await loadProject(project.root), url, flags: { ...flags, "agent-only": true }, out:
|
|
3267
|
+
return addons().afterDeploy({ event: "deploy", project: await loadProject(project.root), url, flags: { ...flags, "agent-only": true }, out: out3 });
|
|
2817
3268
|
}
|
|
2818
3269
|
const built = await bundle(dir, template);
|
|
2819
3270
|
durableObjects(built.config);
|
|
2820
|
-
for (const warning of storageWarnings(built.config))
|
|
2821
|
-
|
|
3271
|
+
for (const warning of storageWarnings(built.config)) out3(c.yellow(`! ${warning}`));
|
|
3272
|
+
out3(`${c.green("\u2713")} built ${c.bold(template)}: ${built.modules.length} module(s), ${built.assets.length} static file(s)`);
|
|
2822
3273
|
const smoked = flags["no-smoke"] ? { ok: false, skipped: true, checks: [], ms: null } : await smoke(built, project.config.id);
|
|
2823
|
-
const r = await resolve(flags);
|
|
2824
3274
|
const metadata = { ...deployMetadata(built, project.root), report: { template, devkit: devkitVersion(), smoke: smoked, git: gitInfo(project.root) } };
|
|
2825
3275
|
if (flags["dry-run"]) {
|
|
2826
|
-
|
|
3276
|
+
out3(`would POST ${r.apiUrl}/v1/product/deploy ${JSON.stringify({ ...metadata, modules: built.modules.map((m) => m.name), assets: built.assets.length })}`);
|
|
2827
3277
|
return hooks();
|
|
2828
3278
|
}
|
|
2829
|
-
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
2830
3279
|
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.
|
|
3280
|
+
out3(`${c.green("\u2713")} live at ${c.bold(c.cyan(deployment.url))} ${c.dim(`v${deployment.version}, ${kb(deployment.bundle_bytes)} gzipped`)}`);
|
|
3281
|
+
for (const line of resourceLines(deployment.resources)) out3(line);
|
|
3282
|
+
if (deployment.offerings_moved?.length) out3(` offerings moved to it: ${deployment.offerings_moved.join(", ")}`);
|
|
3283
|
+
if (deployment.webhook) out3(` webhooks \u2192 ${deployment.webhook}`);
|
|
3284
|
+
if (deployment.miniapp === false) {
|
|
3285
|
+
out3(` ${c.dim("mini app: off, not in the Ambasada app (`ambasada miniapp enable` adds one)")}`);
|
|
3286
|
+
for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out3(c.yellow(`! ${issue.message}`));
|
|
3287
|
+
} else if (deployment.tier) {
|
|
2836
3288
|
const line = listingLine(deployment.tier, deployment.check);
|
|
2837
|
-
|
|
2838
|
-
for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? [])
|
|
3289
|
+
out3(` ${deployment.tier === "listed" || deployment.tier === "verified" ? c.green("\u2713") : c.yellow("!")} in the Ambasada app: ${line}`);
|
|
3290
|
+
for (const issue of deployment.check?.issues.filter((i) => i.level === "problem") ?? []) out3(c.dim(` ${issue.message}`));
|
|
2839
3291
|
}
|
|
2840
3292
|
if (!await setProductUrl(project.root, deployment.url)) {
|
|
2841
|
-
|
|
3293
|
+
out3(c.yellow(`! add \`product: { url: "${deployment.url}" }\` to ambasada.config.ts`));
|
|
2842
3294
|
}
|
|
2843
3295
|
if (deployment.kind === "project") return;
|
|
2844
3296
|
await hooks(deployment.url);
|
|
@@ -2853,41 +3305,41 @@ async function isMockApi(apiUrl) {
|
|
|
2853
3305
|
}
|
|
2854
3306
|
}
|
|
2855
3307
|
function findProductDir(root) {
|
|
2856
|
-
for (const d of [root,
|
|
3308
|
+
for (const d of [root, join10(root, "product")]) if (["wrangler.jsonc", "wrangler.json"].some((f) => existsSync9(join10(d, f)))) return d;
|
|
2857
3309
|
return null;
|
|
2858
3310
|
}
|
|
2859
3311
|
function productDir(root) {
|
|
2860
3312
|
const dir = findProductDir(root);
|
|
2861
3313
|
if (dir) return dir;
|
|
2862
|
-
if (
|
|
3314
|
+
if (existsSync9(join10(root, "wrangler.toml"))) throw new CliError("wrangler.toml is not supported by `ambasada deploy`: use wrangler.jsonc");
|
|
2863
3315
|
throw new CliError("no wrangler.jsonc here, so `ambasada deploy` can't host this app. Host it anywhere and `ambasada link <url>`");
|
|
2864
3316
|
}
|
|
2865
3317
|
function detectTemplate(dir) {
|
|
2866
|
-
const has = (...files2) => files2.some((f) =>
|
|
3318
|
+
const has = (...files2) => files2.some((f) => existsSync9(join10(dir, f)));
|
|
2867
3319
|
if (has("open-next.config.ts", "open-next.config.js", "next.config.ts", "next.config.js", "next.config.mjs")) return "next";
|
|
2868
3320
|
if (has("vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts")) return "spa";
|
|
2869
3321
|
return "worker";
|
|
2870
3322
|
}
|
|
2871
3323
|
async function build(dir, template) {
|
|
2872
3324
|
if (template === "next") {
|
|
2873
|
-
|
|
3325
|
+
out3(c.bold("opennextjs-cloudflare build"));
|
|
2874
3326
|
await run(bin(dir, "opennextjs-cloudflare", "@opennextjs/cloudflare"), ["build"], dir);
|
|
2875
3327
|
} else if (template === "spa") {
|
|
2876
|
-
|
|
3328
|
+
out3(c.bold("vite build"));
|
|
2877
3329
|
await run(bin(dir, "vite"), ["build"], dir);
|
|
2878
3330
|
}
|
|
2879
3331
|
}
|
|
2880
3332
|
async function bundle(dir, template) {
|
|
2881
3333
|
const { file: configFile, config } = await readWranglerConfig(dir);
|
|
2882
|
-
const outdir =
|
|
2883
|
-
await
|
|
3334
|
+
const outdir = join10(dir, ".ambasada", "deploy");
|
|
3335
|
+
await rm4(outdir, { recursive: true, force: true });
|
|
2884
3336
|
await run(bin(dir, "wrangler"), ["deploy", "--dry-run", "--outdir", outdir], dir, { capture: true });
|
|
2885
|
-
const modules = (await walk(outdir)).map((path) => ({ name:
|
|
3337
|
+
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
3338
|
const wanted = config.main ? `${basename(config.main, extname(config.main))}.js` : "index.js";
|
|
2887
3339
|
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
3340
|
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 &&
|
|
3341
|
+
const assetsDir = config.assets?.directory ? resolvePath4(dirname6(configFile), config.assets.directory) : null;
|
|
3342
|
+
const assets = assetsDir && existsSync9(assetsDir) ? await collectAssets(assetsDir) : [];
|
|
2891
3343
|
if (assets.length > LIMITS.assets) throw new CliError(`${assets.length} static files in ${assetsDir}; ambasada.dev hosts up to ${LIMITS.assets}`);
|
|
2892
3344
|
const big = assets.find((a) => a.size > LIMITS.assetBytes);
|
|
2893
3345
|
if (big) throw new CliError(`${big.file} is ${kb(big.size)}; static files are at most 25 MB`);
|
|
@@ -2897,7 +3349,7 @@ function deployMetadata(built, root) {
|
|
|
2897
3349
|
const a = built.config.assets ?? {};
|
|
2898
3350
|
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
3351
|
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) =>
|
|
3352
|
+
const webhook = WEBHOOK_FILES.some((f) => existsSync9(join10(root, f)) || existsSync9(join10(built.dir, f)));
|
|
2901
3353
|
return {
|
|
2902
3354
|
main_module: built.mainModule,
|
|
2903
3355
|
compatibility_date: built.config.compatibility_date,
|
|
@@ -2911,9 +3363,9 @@ function deployMetadata(built, root) {
|
|
|
2911
3363
|
}
|
|
2912
3364
|
function durableObjects(config) {
|
|
2913
3365
|
const bindings = config.durable_objects?.bindings ?? [];
|
|
2914
|
-
const
|
|
3366
|
+
const out7 = {};
|
|
2915
3367
|
if (bindings.length) {
|
|
2916
|
-
|
|
3368
|
+
out7.durable_objects = bindings.map((b) => {
|
|
2917
3369
|
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
3370
|
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
3371
|
return { name: b.name, class_name: b.class_name };
|
|
@@ -2921,13 +3373,13 @@ function durableObjects(config) {
|
|
|
2921
3373
|
}
|
|
2922
3374
|
const migrations = config.migrations ?? [];
|
|
2923
3375
|
if (migrations.length) {
|
|
2924
|
-
|
|
3376
|
+
out7.migrations = migrations.map((m) => {
|
|
2925
3377
|
if (!m || typeof m.tag !== "string" || !m.tag) throw new CliError("every entry of `migrations` in wrangler.jsonc needs a tag");
|
|
2926
3378
|
const keep = ["tag", "new_sqlite_classes", "new_classes", "renamed_classes", "deleted_classes", "transferred_classes"];
|
|
2927
3379
|
return Object.fromEntries(keep.filter((k) => m[k] !== void 0).map((k) => [k, m[k]]));
|
|
2928
3380
|
});
|
|
2929
3381
|
}
|
|
2930
|
-
return
|
|
3382
|
+
return out7;
|
|
2931
3383
|
}
|
|
2932
3384
|
function storageWarnings(config) {
|
|
2933
3385
|
const warnings = [];
|
|
@@ -2942,17 +3394,17 @@ function storageWarnings(config) {
|
|
|
2942
3394
|
return warnings;
|
|
2943
3395
|
}
|
|
2944
3396
|
function localBindings(config) {
|
|
2945
|
-
const
|
|
3397
|
+
const out7 = {};
|
|
2946
3398
|
const d1 = (config.d1_databases ?? []).filter((b) => b.binding);
|
|
2947
|
-
if (d1.length)
|
|
3399
|
+
if (d1.length) out7.d1_databases = d1.map((b) => ({ binding: b.binding, database_name: b.database_name ?? `local-${b.binding.toLowerCase()}`, database_id: b.database_id ?? `local-${b.binding.toLowerCase()}` }));
|
|
2948
3400
|
const kv2 = (config.kv_namespaces ?? []).filter((b) => b.binding);
|
|
2949
|
-
if (kv2.length)
|
|
3401
|
+
if (kv2.length) out7.kv_namespaces = kv2.map((b) => ({ binding: b.binding, id: b.id ?? `local-${b.binding.toLowerCase()}` }));
|
|
2950
3402
|
const r2 = (config.r2_buckets ?? []).filter((b) => b.binding);
|
|
2951
|
-
if (r2.length)
|
|
3403
|
+
if (r2.length) out7.r2_buckets = r2.map((b) => ({ binding: b.binding, bucket_name: b.bucket_name ?? `local-${b.binding.toLowerCase()}` }));
|
|
2952
3404
|
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
|
|
3405
|
+
if (dos.length) out7.durable_objects = { bindings: dos.map((b) => ({ name: b.name, class_name: b.class_name })) };
|
|
3406
|
+
if (config.migrations?.length) out7.migrations = config.migrations;
|
|
3407
|
+
return out7;
|
|
2956
3408
|
}
|
|
2957
3409
|
function resourceLines(r) {
|
|
2958
3410
|
if (!r) return [];
|
|
@@ -2979,9 +3431,9 @@ function resourceLines(r) {
|
|
|
2979
3431
|
async function upload(apiUrl, apiKey, built, metadata) {
|
|
2980
3432
|
const form = new FormData();
|
|
2981
3433
|
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
|
-
|
|
3434
|
+
for (const m of built.modules) form.append(`module:${m.name}`, new Blob([await readFile5(m.path)], { type: /\.m?js$/.test(m.name) ? "application/javascript+module" : "" }), m.name);
|
|
3435
|
+
for (const a of built.assets) form.append(`asset:${a.path}`, new Blob([await readFile5(a.file)]), basename(a.file));
|
|
3436
|
+
out3(c.dim(`uploading to ${apiUrl} \u2026`));
|
|
2985
3437
|
let res;
|
|
2986
3438
|
try {
|
|
2987
3439
|
res = await fetch(`${apiUrl}/v1/product/deploy`, { method: "POST", headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }, body: form, signal: AbortSignal.timeout(3e5) });
|
|
@@ -2994,14 +3446,14 @@ async function upload(apiUrl, apiKey, built, metadata) {
|
|
|
2994
3446
|
}
|
|
2995
3447
|
async function smoke(built, company) {
|
|
2996
3448
|
const started = Date.now();
|
|
2997
|
-
const smokeDir =
|
|
2998
|
-
await
|
|
2999
|
-
await
|
|
3000
|
-
const outdir =
|
|
3449
|
+
const smokeDir = join10(built.dir, ".ambasada", "smoke");
|
|
3450
|
+
await rm4(smokeDir, { recursive: true, force: true });
|
|
3451
|
+
await mkdir4(smokeDir, { recursive: true });
|
|
3452
|
+
const outdir = dirname6(built.modules.find((m) => m.name === built.mainModule).path);
|
|
3001
3453
|
const a = built.config.assets;
|
|
3002
3454
|
const config = {
|
|
3003
3455
|
name: built.config.name ?? company,
|
|
3004
|
-
main:
|
|
3456
|
+
main: join10(outdir, built.mainModule),
|
|
3005
3457
|
compatibility_date: built.config.compatibility_date,
|
|
3006
3458
|
compatibility_flags: built.config.compatibility_flags ?? [],
|
|
3007
3459
|
no_bundle: true,
|
|
@@ -3013,20 +3465,20 @@ async function smoke(built, company) {
|
|
|
3013
3465
|
// DB, KV, FILES and the Durable Objects as wrangler simulates them locally, in a fresh state under .ambasada/smoke.
|
|
3014
3466
|
...localBindings(built.config)
|
|
3015
3467
|
};
|
|
3016
|
-
const configPath2 =
|
|
3017
|
-
await
|
|
3018
|
-
const persistTo =
|
|
3468
|
+
const configPath2 = join10(smokeDir, "wrangler.json");
|
|
3469
|
+
await writeFile5(configPath2, JSON.stringify(config, null, 2));
|
|
3470
|
+
const persistTo = join10(smokeDir, "state");
|
|
3019
3471
|
if (config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) {
|
|
3020
3472
|
const root = findRoot(built.dir) ?? built.dir;
|
|
3021
3473
|
try {
|
|
3022
3474
|
await migrateLocal({ dir: built.dir, root, config: configPath2, persistTo, wrangler: bin(built.dir, "wrangler") });
|
|
3023
3475
|
} catch (error) {
|
|
3024
|
-
|
|
3476
|
+
out3(c.yellow(`! smoke test: could not apply db/migrations locally: ${error.message.split("\n")[0]}`));
|
|
3025
3477
|
}
|
|
3026
3478
|
}
|
|
3027
3479
|
const port = await freePort();
|
|
3028
|
-
|
|
3029
|
-
const child = spawn2(bin(built.dir, "wrangler"), ["dev", "--config",
|
|
3480
|
+
out3(c.dim(`smoke test: wrangler dev on :${port} \u2026`));
|
|
3481
|
+
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
3482
|
cwd: built.dir,
|
|
3031
3483
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3032
3484
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false", CI: "1" }
|
|
@@ -3048,17 +3500,17 @@ ${(await res.text()).slice(0, 500)}`);
|
|
|
3048
3500
|
await res.body?.cancel();
|
|
3049
3501
|
checks.push({ method: check2.method, path: check2.path, status: res.status });
|
|
3050
3502
|
}
|
|
3051
|
-
|
|
3503
|
+
out3(`${c.green("\u2713")} smoke test: ${checks.map((k) => `${k.method} ${k.path} ${k.status}`).join(", ")}`);
|
|
3052
3504
|
return { ok: true, skipped: false, checks, ms: Date.now() - started };
|
|
3053
3505
|
} finally {
|
|
3054
3506
|
child.kill("SIGTERM");
|
|
3055
|
-
await
|
|
3507
|
+
await rm4(smokeDir, { recursive: true, force: true });
|
|
3056
3508
|
}
|
|
3057
3509
|
}
|
|
3058
3510
|
function gitInfo(dir) {
|
|
3059
3511
|
const git = (...args) => {
|
|
3060
3512
|
try {
|
|
3061
|
-
return
|
|
3513
|
+
return execFileSync3("git", args, { cwd: dir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 3e3 }).trim() || null;
|
|
3062
3514
|
} catch {
|
|
3063
3515
|
return null;
|
|
3064
3516
|
}
|
|
@@ -3092,16 +3544,16 @@ function freePort() {
|
|
|
3092
3544
|
});
|
|
3093
3545
|
}
|
|
3094
3546
|
async function readWranglerConfig(dir) {
|
|
3095
|
-
const redirect2 =
|
|
3547
|
+
const redirect2 = join10(dir, ".wrangler", "deploy", "config.json");
|
|
3096
3548
|
let file;
|
|
3097
|
-
if (
|
|
3098
|
-
const target2 = JSON.parse(await
|
|
3099
|
-
if (target2) file = resolvePath4(
|
|
3549
|
+
if (existsSync9(redirect2)) {
|
|
3550
|
+
const target2 = JSON.parse(await readFile5(redirect2, "utf8")).configPath;
|
|
3551
|
+
if (target2) file = resolvePath4(dirname6(redirect2), target2);
|
|
3100
3552
|
}
|
|
3101
|
-
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) =>
|
|
3553
|
+
file ??= ["wrangler.jsonc", "wrangler.json"].map((f) => join10(dir, f)).find((f) => existsSync9(f));
|
|
3102
3554
|
if (!file) throw new CliError(`no wrangler.jsonc in ${dir}`);
|
|
3103
3555
|
try {
|
|
3104
|
-
return { file, config: JSON.parse(stripJsonc(await
|
|
3556
|
+
return { file, config: JSON.parse(stripJsonc(await readFile5(file, "utf8"))) };
|
|
3105
3557
|
} catch (error) {
|
|
3106
3558
|
throw new CliError(`${file} is not valid JSON(C): ${error.message}`);
|
|
3107
3559
|
}
|
|
@@ -3126,11 +3578,11 @@ function stripJsonc(text) {
|
|
|
3126
3578
|
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
3127
3579
|
}
|
|
3128
3580
|
async function collectAssets(dir) {
|
|
3129
|
-
const ignore =
|
|
3581
|
+
const ignore = existsSync9(join10(dir, ".assetsignore")) ? (await readFile5(join10(dir, ".assetsignore"), "utf8")).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) : [];
|
|
3130
3582
|
const patterns = ignore.map((p) => new RegExp(`^${p.replace(/^\//, "").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*")}(/.*)?$`));
|
|
3131
3583
|
const files2 = [];
|
|
3132
3584
|
for (const file of await walk(dir)) {
|
|
3133
|
-
const rel =
|
|
3585
|
+
const rel = relative4(dir, file).split(sep).join("/");
|
|
3134
3586
|
const name = basename(rel);
|
|
3135
3587
|
if (name === ".assetsignore" || name === ".DS_Store" || name === "_headers" || name === "_redirects" || rel.startsWith("node_modules/")) continue;
|
|
3136
3588
|
if (patterns.some((p) => p.test(rel) || p.test(name))) continue;
|
|
@@ -3141,17 +3593,17 @@ async function collectAssets(dir) {
|
|
|
3141
3593
|
async function walk(dir) {
|
|
3142
3594
|
const files2 = [];
|
|
3143
3595
|
for (const entry2 of await readdir(dir, { withFileTypes: true })) {
|
|
3144
|
-
const path =
|
|
3596
|
+
const path = join10(dir, entry2.name);
|
|
3145
3597
|
if (entry2.isDirectory()) files2.push(...await walk(path));
|
|
3146
3598
|
else if (entry2.isFile()) files2.push(path);
|
|
3147
3599
|
}
|
|
3148
3600
|
return files2.sort();
|
|
3149
3601
|
}
|
|
3150
3602
|
function bin(dir, name, pkg = name) {
|
|
3151
|
-
for (let d = dir; ; d =
|
|
3152
|
-
const local =
|
|
3153
|
-
if (
|
|
3154
|
-
if (
|
|
3603
|
+
for (let d = dir; ; d = dirname6(d)) {
|
|
3604
|
+
const local = join10(d, "node_modules", ".bin", name);
|
|
3605
|
+
if (existsSync9(local)) return local;
|
|
3606
|
+
if (dirname6(d) === d) return `npx --yes ${pkg}`;
|
|
3155
3607
|
}
|
|
3156
3608
|
}
|
|
3157
3609
|
function run(command, args, cwd, opts = {}) {
|
|
@@ -3172,10 +3624,10 @@ ${output.slice(-3e3)}` : ""}`));
|
|
|
3172
3624
|
var kb = (n) => n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`;
|
|
3173
3625
|
|
|
3174
3626
|
// src/storage.ts
|
|
3175
|
-
var
|
|
3627
|
+
var out4 = (s = "") => void process.stdout.write(`${s}
|
|
3176
3628
|
`);
|
|
3177
3629
|
var str2 = (v) => typeof v === "string" && v ? v : void 0;
|
|
3178
|
-
var MIGRATIONS_DIR =
|
|
3630
|
+
var MIGRATIONS_DIR = join11("db", "migrations");
|
|
3179
3631
|
var MIGRATIONS_TABLE = "_ambasada_migrations";
|
|
3180
3632
|
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
3633
|
var PRODUCTS_DOMAIN = "ambasada.dev";
|
|
@@ -3183,11 +3635,11 @@ async function target(flags) {
|
|
|
3183
3635
|
const r = await resolve(flags);
|
|
3184
3636
|
let local = flags.local === true;
|
|
3185
3637
|
if (!local && await isMockApi(r.apiUrl)) {
|
|
3186
|
-
|
|
3638
|
+
out4(c.dim(`${r.apiUrl} is ambasada dev: using wrangler's local state (--local)`));
|
|
3187
3639
|
local = true;
|
|
3188
3640
|
}
|
|
3189
3641
|
if (!local && !r.apiKey && findProductDir(findRoot() ?? process.cwd())) {
|
|
3190
|
-
|
|
3642
|
+
out4(c.dim("not logged in: using wrangler's local state (--local); `ambasada login` for the hosted app"));
|
|
3191
3643
|
local = true;
|
|
3192
3644
|
}
|
|
3193
3645
|
if (local) {
|
|
@@ -3197,6 +3649,7 @@ async function target(flags) {
|
|
|
3197
3649
|
return { local: true, dir, root, wrangler: bin(dir, "wrangler") };
|
|
3198
3650
|
}
|
|
3199
3651
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY), or add --local for wrangler's local state");
|
|
3652
|
+
await resolveForApp(flags);
|
|
3200
3653
|
return { local: false, apiUrl: r.apiUrl, apiKey: r.apiKey };
|
|
3201
3654
|
}
|
|
3202
3655
|
async function api(t, method, path, body) {
|
|
@@ -3246,23 +3699,23 @@ ${output.slice(-1e3)}`);
|
|
|
3246
3699
|
}
|
|
3247
3700
|
}
|
|
3248
3701
|
async function readMigrations(root) {
|
|
3249
|
-
const dir =
|
|
3250
|
-
if (!
|
|
3702
|
+
const dir = join11(root, MIGRATIONS_DIR);
|
|
3703
|
+
if (!existsSync10(dir)) return [];
|
|
3251
3704
|
const names2 = (await readdir2(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
3252
|
-
return Promise.all(names2.map(async (name) => ({ name, sql: await
|
|
3705
|
+
return Promise.all(names2.map(async (name) => ({ name, sql: await readFile6(join11(dir, name), "utf8") })));
|
|
3253
3706
|
}
|
|
3254
3707
|
var sha2562 = (s) => createHash("sha256").update(s).digest("hex");
|
|
3255
3708
|
var names = (list) => Array.isArray(list) ? list.map((x) => typeof x === "string" ? x : String(x?.name ?? "")).filter(Boolean) : [];
|
|
3256
3709
|
var sqlString = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
3257
3710
|
async function localQuery(t, sql, extra = {}) {
|
|
3258
|
-
const dir = await mkdtemp(
|
|
3711
|
+
const dir = await mkdtemp(join11(tmpdir(), "ambasada-sql-"));
|
|
3259
3712
|
let rows;
|
|
3260
3713
|
try {
|
|
3261
|
-
const file =
|
|
3262
|
-
await
|
|
3714
|
+
const file = join11(dir, "query.sql");
|
|
3715
|
+
await writeFile6(file, sql);
|
|
3263
3716
|
rows = wranglerJson(await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--json", "--yes"], extra));
|
|
3264
3717
|
} finally {
|
|
3265
|
-
await
|
|
3718
|
+
await rm5(dir, { recursive: true, force: true });
|
|
3266
3719
|
}
|
|
3267
3720
|
return rows.map((r) => {
|
|
3268
3721
|
const results = r.results ?? [];
|
|
@@ -3275,13 +3728,13 @@ async function localQuery(t, sql, extra = {}) {
|
|
|
3275
3728
|
});
|
|
3276
3729
|
}
|
|
3277
3730
|
async function localExec(t, sql, extra = {}) {
|
|
3278
|
-
const dir = await mkdtemp(
|
|
3731
|
+
const dir = await mkdtemp(join11(tmpdir(), "ambasada-sql-"));
|
|
3279
3732
|
try {
|
|
3280
|
-
const file =
|
|
3281
|
-
await
|
|
3733
|
+
const file = join11(dir, "migration.sql");
|
|
3734
|
+
await writeFile6(file, sql);
|
|
3282
3735
|
await wrangler(t, ["d1", "execute", PLATFORM_STORAGE.d1, "--file", file, "--yes"], extra);
|
|
3283
3736
|
} finally {
|
|
3284
|
-
await
|
|
3737
|
+
await rm5(dir, { recursive: true, force: true });
|
|
3285
3738
|
}
|
|
3286
3739
|
}
|
|
3287
3740
|
async function migrateLocal(opts) {
|
|
@@ -3321,7 +3774,7 @@ INSERT INTO ${MIGRATIONS_TABLE} ${hasSha ? `(name, sha256) VALUES (${sqlString(m
|
|
|
3321
3774
|
}
|
|
3322
3775
|
async function migrateBeforeDev(root, print) {
|
|
3323
3776
|
const dir = findProductDir(root);
|
|
3324
|
-
if (!dir || !
|
|
3777
|
+
if (!dir || !existsSync10(join11(root, MIGRATIONS_DIR))) return;
|
|
3325
3778
|
try {
|
|
3326
3779
|
const { config } = await readWranglerConfig(dir);
|
|
3327
3780
|
if (!config.d1_databases?.some((d) => d.binding === PLATFORM_STORAGE.d1)) return;
|
|
@@ -3334,26 +3787,26 @@ async function migrateBeforeDev(root, print) {
|
|
|
3334
3787
|
}
|
|
3335
3788
|
function printMigrate(r, dryRun, where) {
|
|
3336
3789
|
if (dryRun) {
|
|
3337
|
-
|
|
3790
|
+
out4(r.pending.length ? `would apply to ${where}: ${r.pending.join(", ")}` : `${where}: nothing to apply`);
|
|
3338
3791
|
} else {
|
|
3339
|
-
for (const name of r.applied)
|
|
3340
|
-
if (!r.applied.length)
|
|
3792
|
+
for (const name of r.applied) out4(`${c.green("\u2713")} applied ${c.bold(name)}`);
|
|
3793
|
+
if (!r.applied.length) out4(`${c.green("\u2713")} ${where}: up to date`);
|
|
3341
3794
|
}
|
|
3342
|
-
if (r.skipped.length)
|
|
3343
|
-
if (r.changed.length)
|
|
3795
|
+
if (r.skipped.length) out4(c.dim(` already applied: ${r.skipped.join(", ")}`));
|
|
3796
|
+
if (r.changed.length) out4(c.yellow(`! edited after they were applied (they don't run again; add a new migration instead): ${r.changed.join(", ")}`));
|
|
3344
3797
|
}
|
|
3345
3798
|
function printResults(results) {
|
|
3346
3799
|
results.forEach((r, i) => {
|
|
3347
|
-
if (results.length > 1)
|
|
3800
|
+
if (results.length > 1) out4(c.dim(`-- statement ${i + 1}`));
|
|
3348
3801
|
if (r.columns.length) {
|
|
3349
3802
|
const cols = r.columns.map((col, j) => r.columns.indexOf(col) === j ? col : `${col}:${j}`);
|
|
3350
|
-
|
|
3803
|
+
out4(table(r.rows.map((row) => Object.fromEntries(cols.map((col, j) => [col, row[j]]))), cols));
|
|
3351
3804
|
}
|
|
3352
3805
|
const m = r.meta ?? {};
|
|
3353
3806
|
const parts = [`${r.rows.length} row${r.rows.length === 1 ? "" : "s"}`];
|
|
3354
3807
|
if (m.changes) parts.push(`${m.changes} changed`);
|
|
3355
3808
|
if (typeof m.duration_ms === "number") parts.push(`${Math.round(m.duration_ms * 100) / 100} ms`);
|
|
3356
|
-
|
|
3809
|
+
out4(c.dim(parts.join(" \xB7 ")));
|
|
3357
3810
|
});
|
|
3358
3811
|
}
|
|
3359
3812
|
async function query(t, sql) {
|
|
@@ -3370,7 +3823,7 @@ async function tables(t) {
|
|
|
3370
3823
|
}
|
|
3371
3824
|
async function shell(t, flags) {
|
|
3372
3825
|
const where = t.local ? "local" : "ambasada.dev";
|
|
3373
|
-
|
|
3826
|
+
out4(`${c.bold("ambasada db shell")} ${c.dim(`(${where}) \xB7 end statements with ; \xB7 .tables \xB7 .schema [table] \xB7 .quit`)}`);
|
|
3374
3827
|
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY, historySize: 200, prompt: "db> " });
|
|
3375
3828
|
let buffer = "";
|
|
3376
3829
|
const run2 = async (line) => {
|
|
@@ -3378,19 +3831,19 @@ async function shell(t, flags) {
|
|
|
3378
3831
|
if (!buffer && trimmed.startsWith(".")) {
|
|
3379
3832
|
const [cmd, arg] = trimmed.split(/\s+/);
|
|
3380
3833
|
if (cmd === ".quit" || cmd === ".exit") return rl.close();
|
|
3381
|
-
if (cmd === ".tables") return
|
|
3834
|
+
if (cmd === ".tables") return out4(table((await tables(t)).map((x) => ({ table: x.name, rows: x.rows }))));
|
|
3382
3835
|
if (cmd === ".schema") {
|
|
3383
|
-
for (const x of (await tables(t)).filter((x2) => !arg || x2.name === arg))
|
|
3836
|
+
for (const x of (await tables(t)).filter((x2) => !arg || x2.name === arg)) out4(`${x.sql ?? `-- ${x.name}`};`);
|
|
3384
3837
|
return;
|
|
3385
3838
|
}
|
|
3386
|
-
return
|
|
3839
|
+
return out4(c.yellow(`unknown command ${cmd}: .tables, .schema [table], .quit`));
|
|
3387
3840
|
}
|
|
3388
3841
|
buffer += `${line}
|
|
3389
3842
|
`;
|
|
3390
3843
|
if (!/;\s*$/.test(trimmed)) return;
|
|
3391
3844
|
const sql = buffer;
|
|
3392
3845
|
buffer = "";
|
|
3393
|
-
if (flags.json)
|
|
3846
|
+
if (flags.json) out4(JSON.stringify(await query(t, sql), null, 2));
|
|
3394
3847
|
else printResults(await query(t, sql));
|
|
3395
3848
|
};
|
|
3396
3849
|
rl.prompt();
|
|
@@ -3398,7 +3851,7 @@ async function shell(t, flags) {
|
|
|
3398
3851
|
try {
|
|
3399
3852
|
await run2(line);
|
|
3400
3853
|
} catch (error) {
|
|
3401
|
-
|
|
3854
|
+
out4(c.red(`\u2717 ${error.message}`));
|
|
3402
3855
|
}
|
|
3403
3856
|
rl.setPrompt(buffer ? " > " : "db> ");
|
|
3404
3857
|
rl.prompt();
|
|
@@ -3415,11 +3868,11 @@ async function db(args, flags) {
|
|
|
3415
3868
|
const dryRun = flags["dry-run"] === true;
|
|
3416
3869
|
if (t.local) {
|
|
3417
3870
|
const r2 = await migrateLocal({ dir: t.dir, root, wrangler: t.wrangler, dryRun });
|
|
3418
|
-
if (flags.json) return
|
|
3871
|
+
if (flags.json) return out4(JSON.stringify(r2, null, 2));
|
|
3419
3872
|
return printMigrate(r2, dryRun, "local DB");
|
|
3420
3873
|
}
|
|
3421
3874
|
const r = await api(t, "POST", "/v1/dev/db/migrate", { json: { migrations, dry_run: dryRun } });
|
|
3422
|
-
if (flags.json) return
|
|
3875
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3423
3876
|
return printMigrate({ applied: names(r.applied), skipped: names(r.skipped), changed: names(r.changed), pending: names(r.pending) }, dryRun, "the app's DB");
|
|
3424
3877
|
}
|
|
3425
3878
|
case "shell":
|
|
@@ -3428,19 +3881,19 @@ async function db(args, flags) {
|
|
|
3428
3881
|
const sql = rest.join(" ").trim();
|
|
3429
3882
|
if (!sql) return shell(t, flags);
|
|
3430
3883
|
const results = await query(t, sql);
|
|
3431
|
-
if (flags.json) return
|
|
3884
|
+
if (flags.json) return out4(JSON.stringify({ results }, null, 2));
|
|
3432
3885
|
return printResults(results);
|
|
3433
3886
|
}
|
|
3434
3887
|
case "tables": {
|
|
3435
3888
|
const list = await tables(t);
|
|
3436
|
-
if (flags.json) return
|
|
3437
|
-
return
|
|
3889
|
+
if (flags.json) return out4(JSON.stringify({ tables: list }, null, 2));
|
|
3890
|
+
return out4(table(list.map((x) => ({ table: x.name, rows: x.rows, columns: x.columns.length ? x.columns.map((col) => col.name).join(", ") : "" }))));
|
|
3438
3891
|
}
|
|
3439
3892
|
case "migrations": {
|
|
3440
3893
|
const applied = t.local ? ((await localQuery(t, `${CREATE_MIGRATIONS}
|
|
3441
3894
|
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
|
|
3895
|
+
if (flags.json) return out4(JSON.stringify({ applied }, null, 2));
|
|
3896
|
+
return out4(table(applied, ["name", "applied_at"]));
|
|
3444
3897
|
}
|
|
3445
3898
|
default:
|
|
3446
3899
|
throw new CliError(`usage: ambasada db migrate [--dry-run] | shell ["<sql>"] | tables | migrations (--local: wrangler's local DB)`);
|
|
@@ -3468,18 +3921,18 @@ async function kv(args, flags) {
|
|
|
3468
3921
|
cursor = r.cursor;
|
|
3469
3922
|
}
|
|
3470
3923
|
}
|
|
3471
|
-
if (flags.json) return
|
|
3472
|
-
return
|
|
3924
|
+
if (flags.json) return out4(JSON.stringify({ keys: keys3 }, null, 2));
|
|
3925
|
+
return out4(table(keys3.map((k) => ({ key: k.name, expires: k.expiration ? new Date(k.expiration * 1e3).toISOString() : "" })), ["key", "expires"]));
|
|
3473
3926
|
}
|
|
3474
3927
|
case "get": {
|
|
3475
3928
|
if (!key) throw new CliError("usage: ambasada kv get <key>");
|
|
3476
3929
|
if (t.local) return void process.stdout.write(await wrangler(t, ["kv", "key", "get", key, "--binding", PLATFORM_STORAGE.kv, "--text"]));
|
|
3477
3930
|
const r = await api(t, "GET", kvPath(key));
|
|
3478
|
-
if (flags.json) return
|
|
3931
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3479
3932
|
if (r.binary) {
|
|
3480
|
-
|
|
3933
|
+
out4(c.dim(`(${r.size} bytes, binary; base64:)`));
|
|
3481
3934
|
}
|
|
3482
|
-
return
|
|
3935
|
+
return out4(r.value ?? "");
|
|
3483
3936
|
}
|
|
3484
3937
|
case "put":
|
|
3485
3938
|
case "set": {
|
|
@@ -3488,19 +3941,19 @@ async function kv(args, flags) {
|
|
|
3488
3941
|
const fromFile = value.startsWith("@") ? value.slice(1) : null;
|
|
3489
3942
|
if (t.local) {
|
|
3490
3943
|
await wrangler(t, ["kv", "key", "put", key, ...fromFile ? ["--path", fromFile] : [value], "--binding", PLATFORM_STORAGE.kv, ...ttl ? ["--ttl", ttl] : []]);
|
|
3491
|
-
return
|
|
3944
|
+
return out4(`${c.green("\u2713")} ${key} (local)`);
|
|
3492
3945
|
}
|
|
3493
|
-
const raw = fromFile ? new Uint8Array(await
|
|
3946
|
+
const raw = fromFile ? new Uint8Array(await readFile6(fromFile)) : value;
|
|
3494
3947
|
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
|
|
3948
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
3949
|
+
return out4(`${c.green("\u2713")} ${r.key} ${c.dim(`${r.size} bytes${ttl ? `, expires in ${ttl} s` : ""}`)}`);
|
|
3497
3950
|
}
|
|
3498
3951
|
case "delete":
|
|
3499
3952
|
case "rm": {
|
|
3500
3953
|
if (!key) throw new CliError("usage: ambasada kv delete <key>");
|
|
3501
3954
|
if (t.local) await wrangler(t, ["kv", "key", "delete", key, "--binding", PLATFORM_STORAGE.kv]);
|
|
3502
3955
|
else await api(t, "DELETE", kvPath(key));
|
|
3503
|
-
return
|
|
3956
|
+
return out4(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
|
|
3504
3957
|
}
|
|
3505
3958
|
default:
|
|
3506
3959
|
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 +4020,22 @@ async function files(args, flags) {
|
|
|
3567
4020
|
if (!r.truncated || !r.cursor) break;
|
|
3568
4021
|
cursor = r.cursor;
|
|
3569
4022
|
}
|
|
3570
|
-
if (flags.json) return
|
|
3571
|
-
return
|
|
4023
|
+
if (flags.json) return out4(JSON.stringify({ objects }, null, 2));
|
|
4024
|
+
return out4(table(objects.map((o) => ({ key: o.key, size: size(o.size), type: o.content_type ?? "", uploaded: o.uploaded ?? "" })), ["key", "size", "type", "uploaded"]));
|
|
3572
4025
|
}
|
|
3573
4026
|
case "put":
|
|
3574
4027
|
case "upload": {
|
|
3575
4028
|
if (!a) throw new CliError("usage: ambasada files put <path> [key] [--content-type type]");
|
|
3576
|
-
if (!
|
|
4029
|
+
if (!existsSync10(a)) throw new CliError(`no file ${a}`);
|
|
3577
4030
|
const key = checkKey(b ?? basename2(a));
|
|
3578
4031
|
const type = str2(flags["content-type"]) ?? contentTypeOf(a);
|
|
3579
4032
|
if (t.local) {
|
|
3580
4033
|
await wrangler(t, ["r2", "object", "put", `${await localBucket(t)}/${key}`, "--file", a, "--content-type", type]);
|
|
3581
|
-
return
|
|
4034
|
+
return out4(`${c.green("\u2713")} ${key} (local) \u2192 ${await fileUrl(t, key, flags)}`);
|
|
3582
4035
|
}
|
|
3583
|
-
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await
|
|
3584
|
-
if (flags.json) return
|
|
3585
|
-
return
|
|
4036
|
+
const r = await api(t, "PUT", `/v1/dev/files/${keyPath(key)}`, { raw: new Uint8Array(await readFile6(a)), type });
|
|
4037
|
+
if (flags.json) return out4(JSON.stringify(r, null, 2));
|
|
4038
|
+
return out4(`${c.green("\u2713")} ${r.key} ${c.dim(size(r.size))} \u2192 ${c.cyan(r.url ?? await fileUrl(t, key, flags))}`);
|
|
3586
4039
|
}
|
|
3587
4040
|
case "delete":
|
|
3588
4041
|
case "rm": {
|
|
@@ -3590,11 +4043,11 @@ async function files(args, flags) {
|
|
|
3590
4043
|
const key = checkKey(a);
|
|
3591
4044
|
if (t.local) await wrangler(t, ["r2", "object", "delete", `${await localBucket(t)}/${key}`]);
|
|
3592
4045
|
else await api(t, "DELETE", `/v1/dev/files/${keyPath(key)}`);
|
|
3593
|
-
return
|
|
4046
|
+
return out4(`${c.green("\u2713")} deleted ${key}${t.local ? " (local)" : ""}`);
|
|
3594
4047
|
}
|
|
3595
4048
|
case "url": {
|
|
3596
4049
|
if (!a) throw new CliError("usage: ambasada files url <key>");
|
|
3597
|
-
return
|
|
4050
|
+
return out4(await fileUrl(t, checkKey(a), flags));
|
|
3598
4051
|
}
|
|
3599
4052
|
default:
|
|
3600
4053
|
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 +4057,11 @@ async function files(args, flags) {
|
|
|
3604
4057
|
// src/tunnel.ts
|
|
3605
4058
|
import { spawn as spawn3, spawnSync } from "node:child_process";
|
|
3606
4059
|
import { createHash as createHash2 } from "node:crypto";
|
|
3607
|
-
import { existsSync as
|
|
3608
|
-
import { chmod as chmod2, mkdir as
|
|
4060
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
4061
|
+
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
4062
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3610
|
-
import { join as
|
|
3611
|
-
var
|
|
4063
|
+
import { join as join12 } from "node:path";
|
|
4064
|
+
var out5 = (s = "") => process.stdout.write(`${s}
|
|
3612
4065
|
`);
|
|
3613
4066
|
var CLOUDFLARED_VERSION = "2026.10.7";
|
|
3614
4067
|
var CLOUDFLARED_ASSETS = {
|
|
@@ -3619,11 +4072,11 @@ var CLOUDFLARED_ASSETS = {
|
|
|
3619
4072
|
"win32-x64": { file: "cloudflared-windows-amd64.exe", sha256: "2493dbc168923bb3e59e5e7716f71deced92992eea9acd6e9d74f84ac7751343" }
|
|
3620
4073
|
};
|
|
3621
4074
|
var RELEASES = "https://github.com/cloudflare/cloudflared/releases/download";
|
|
3622
|
-
async function cloudflaredBin(print =
|
|
4075
|
+
async function cloudflaredBin(print = out5) {
|
|
3623
4076
|
if (process.env.AMBASADA_CLOUDFLARED) return process.env.AMBASADA_CLOUDFLARED;
|
|
3624
4077
|
if (spawnSync("cloudflared", ["--version"], { stdio: "ignore" }).status === 0) return "cloudflared";
|
|
3625
|
-
const dest =
|
|
3626
|
-
if (
|
|
4078
|
+
const dest = join12(configDir(), "bin", `cloudflared-${CLOUDFLARED_VERSION}${process.platform === "win32" ? ".exe" : ""}`);
|
|
4079
|
+
if (existsSync11(dest)) return dest;
|
|
3627
4080
|
print(c.dim(`downloading cloudflared ${CLOUDFLARED_VERSION} (once, ~20\u201340 MB) \u2026`));
|
|
3628
4081
|
await downloadCloudflared(dest);
|
|
3629
4082
|
return dest;
|
|
@@ -3643,22 +4096,22 @@ async function downloadCloudflared(dest, opts = {}) {
|
|
|
3643
4096
|
}
|
|
3644
4097
|
const digest = createHash2("sha256").update(bytes).digest("hex");
|
|
3645
4098
|
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(
|
|
4099
|
+
const dir = join12(dest, "..");
|
|
4100
|
+
await mkdir5(dir, { recursive: true });
|
|
4101
|
+
const work = await mkdtemp2(join12(tmpdir2(), "ambasada-cloudflared-"));
|
|
3649
4102
|
try {
|
|
3650
|
-
const binary =
|
|
4103
|
+
const binary = join12(work, "cloudflared");
|
|
3651
4104
|
if (asset.file.endsWith(".tgz")) {
|
|
3652
|
-
await
|
|
3653
|
-
const tar = spawnSync("tar", ["-xzf",
|
|
3654
|
-
if (tar.status !== 0 || !
|
|
3655
|
-
} else await
|
|
4105
|
+
await writeFile7(join12(work, asset.file), bytes);
|
|
4106
|
+
const tar = spawnSync("tar", ["-xzf", join12(work, asset.file), "-C", work], { encoding: "utf8" });
|
|
4107
|
+
if (tar.status !== 0 || !existsSync11(binary)) throw new CliError(`could not unpack ${asset.file}: ${tar.stderr || "no cloudflared inside"}`);
|
|
4108
|
+
} else await writeFile7(binary, bytes);
|
|
3656
4109
|
await chmod2(binary, 493);
|
|
3657
4110
|
const tmp = `${dest}.${process.pid}`;
|
|
3658
|
-
await
|
|
4111
|
+
await writeFile7(tmp, await readFile7(binary), { mode: 493 });
|
|
3659
4112
|
await rename(tmp, dest);
|
|
3660
4113
|
} finally {
|
|
3661
|
-
await
|
|
4114
|
+
await rm6(work, { recursive: true, force: true });
|
|
3662
4115
|
}
|
|
3663
4116
|
}
|
|
3664
4117
|
async function call(opts, method, path, body) {
|
|
@@ -3676,7 +4129,7 @@ async function call(opts, method, path, body) {
|
|
|
3676
4129
|
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
3677
4130
|
}
|
|
3678
4131
|
async function startTunnel(opts) {
|
|
3679
|
-
const print = opts.print ??
|
|
4132
|
+
const print = opts.print ?? out5;
|
|
3680
4133
|
const bin2 = await cloudflaredBin(print);
|
|
3681
4134
|
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
4135
|
if (opened.status !== 201) throw new CliError(`tunnel refused: ${opened.json.error?.code ?? opened.status}: ${opened.json.error?.message ?? "no answer"}`);
|
|
@@ -3757,7 +4210,7 @@ async function waitForPublic(url, timeoutMs = 18e4, streak = 1) {
|
|
|
3757
4210
|
}
|
|
3758
4211
|
}
|
|
3759
4212
|
async function startTunnelAndWait(opts) {
|
|
3760
|
-
const print = opts.print ??
|
|
4213
|
+
const print = opts.print ?? out5;
|
|
3761
4214
|
const session = await startTunnel(opts);
|
|
3762
4215
|
const { info } = session;
|
|
3763
4216
|
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 +4240,7 @@ function untilStopped(ended) {
|
|
|
3787
4240
|
});
|
|
3788
4241
|
});
|
|
3789
4242
|
}
|
|
3790
|
-
function printOpened(info, print =
|
|
4243
|
+
function printOpened(info, print = out5) {
|
|
3791
4244
|
print(`${c.green("\u2713")} ${c.bold(c.cyan(info.url))} \u2192 ${info.local} ${c.dim("(press Ctrl+C to stop)")}`);
|
|
3792
4245
|
if (info.registered) {
|
|
3793
4246
|
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 +4248,7 @@ function printOpened(info, print = out4) {
|
|
|
3795
4248
|
if (info.webhook) print(` webhooks \u2192 ${info.webhook.url}`);
|
|
3796
4249
|
} else print(c.dim(" only exposed: the product URL, offerings and webhook stay where they are (--no-register)"));
|
|
3797
4250
|
}
|
|
3798
|
-
function printClosed(closed, print =
|
|
4251
|
+
function printClosed(closed, print = out5) {
|
|
3799
4252
|
if (!closed) print(c.yellow("! could not reach the village to close the tunnel: it closes itself within 5 minutes"));
|
|
3800
4253
|
else print(`${c.green("\u2713")} tunnel closed${closed.url ? `; product URL back to ${closed.url}` : ""}`);
|
|
3801
4254
|
}
|
|
@@ -3815,42 +4268,42 @@ async function tunnelPort(flags) {
|
|
|
3815
4268
|
return 3e3;
|
|
3816
4269
|
}
|
|
3817
4270
|
function webhookPath(root) {
|
|
3818
|
-
return root && WEBHOOK_FILES.some((f) =>
|
|
4271
|
+
return root && WEBHOOK_FILES.some((f) => existsSync11(join12(root, f))) ? "/api/webhook" : void 0;
|
|
3819
4272
|
}
|
|
3820
4273
|
async function tunnel(_args, flags) {
|
|
3821
|
-
const r = await
|
|
4274
|
+
const r = await resolveForApp(flags);
|
|
3822
4275
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
3823
4276
|
const port = await tunnelPort(flags);
|
|
3824
4277
|
const local = `http://localhost:${port}`;
|
|
3825
4278
|
try {
|
|
3826
4279
|
await fetch(local, { signal: AbortSignal.timeout(2e3) });
|
|
3827
4280
|
} catch {
|
|
3828
|
-
|
|
4281
|
+
out5(c.yellow(`! nothing answers on ${local} yet: start your dev server there (or pass --port)`));
|
|
3829
4282
|
}
|
|
3830
4283
|
const root = findRoot();
|
|
3831
|
-
if (root &&
|
|
3832
|
-
|
|
4284
|
+
if (root && existsSync11(join12(root, ".env")) && /AMBASADA_API_URL=http:\/\/(127\.0\.0\.1|localhost)/.test(await readFile7(join12(root, ".env"), "utf8"))) {
|
|
4285
|
+
out5(c.yellow("! your .env points AMBASADA_API_URL at the mock: real buyers' receipts won't verify. `ambasada dev --tunnel` runs your dev server against the village instead"));
|
|
3833
4286
|
}
|
|
3834
4287
|
const register = flags["no-register"] !== true;
|
|
3835
4288
|
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)
|
|
4289
|
+
if (flags.json) out5(JSON.stringify({ ...session.info, token: void 0 }, null, 2));
|
|
3837
4290
|
else printOpened(session.info);
|
|
3838
4291
|
const reason = await untilStopped(session.ended);
|
|
3839
|
-
if (reason)
|
|
3840
|
-
else
|
|
4292
|
+
if (reason) out5(c.yellow(`! ${reason}`));
|
|
4293
|
+
else out5("\nclosing the tunnel");
|
|
3841
4294
|
printClosed(await session.close());
|
|
3842
4295
|
if (reason) throw new CliError(reason);
|
|
3843
4296
|
}
|
|
3844
4297
|
|
|
3845
4298
|
// src/commands.ts
|
|
3846
|
-
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.
|
|
4299
|
+
var SDK_VERSION = /^\d+\.\d+\.\d+$/.test("0.8.4") ? `^${"0.8.4"}` : "latest";
|
|
3847
4300
|
var DEV_PORT = 8788;
|
|
3848
|
-
var
|
|
4301
|
+
var out6 = (s = "") => process.stdout.write(`${s}
|
|
3849
4302
|
`);
|
|
3850
4303
|
var str3 = (v) => typeof v === "string" && v ? v : void 0;
|
|
3851
4304
|
var json = (flags, value) => {
|
|
3852
4305
|
if (!flags.json) return false;
|
|
3853
|
-
|
|
4306
|
+
out6(JSON.stringify(value, null, 2));
|
|
3854
4307
|
return true;
|
|
3855
4308
|
};
|
|
3856
4309
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -3869,8 +4322,8 @@ async function deviceLogin(apiUrl) {
|
|
|
3869
4322
|
if (res.status === 404 || res.status === 405 || !(res.headers.get("content-type") ?? "").includes("json")) return null;
|
|
3870
4323
|
if (!res.ok) return null;
|
|
3871
4324
|
const start = await res.json();
|
|
3872
|
-
|
|
3873
|
-
|
|
4325
|
+
out6(`Open ${c.bold(start.verification_uri_complete ?? start.verification_uri)}`);
|
|
4326
|
+
out6(`and confirm the code ${c.bold(c.cyan(start.user_code))} with your Ambasada ID.`);
|
|
3874
4327
|
let interval = Math.max(1, start.interval ?? 5) * 1e3;
|
|
3875
4328
|
const deadline = Date.now() + (start.expires_in ?? 600) * 1e3;
|
|
3876
4329
|
while (Date.now() < deadline) {
|
|
@@ -3901,47 +4354,38 @@ async function prompt(question) {
|
|
|
3901
4354
|
}
|
|
3902
4355
|
}
|
|
3903
4356
|
async function login(_args, flags) {
|
|
3904
|
-
const { apiUrl } = await resolve(flags);
|
|
4357
|
+
const { apiUrl, here } = await resolve(flags);
|
|
3905
4358
|
let key = str3(flags.key);
|
|
3906
4359
|
if (!key && !flags["no-device"]) {
|
|
3907
4360
|
const device = await deviceLogin(apiUrl);
|
|
3908
4361
|
if (device) key = device.token;
|
|
3909
4362
|
}
|
|
3910
4363
|
if (!key) {
|
|
3911
|
-
|
|
4364
|
+
out6(`Paste your app's key: ${c.bold("https://developer.ambasada.dev")} \u2192 New app (or Settings \u2192 New key).`);
|
|
3912
4365
|
key = await prompt("API key: ");
|
|
3913
4366
|
}
|
|
3914
4367
|
if (!key) throw new CliError("no key given");
|
|
3915
4368
|
const who = await whoseKey(apiUrl, key);
|
|
3916
|
-
const { company: _old, ...stored } = await readConfig();
|
|
3917
|
-
await writeConfig({ ...stored, apiUrl, apiKey: key, project: who.id });
|
|
3918
|
-
|
|
3919
|
-
}
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
}
|
|
3925
|
-
|
|
3926
|
-
}
|
|
3927
|
-
|
|
3928
|
-
if (res.ok) {
|
|
3929
|
-
const body = await res.json();
|
|
3930
|
-
return { id: body.id, name: body.name ?? null, kind: body.kind === "project" ? "project" : "company" };
|
|
3931
|
-
}
|
|
3932
|
-
throw new CliError(`${apiUrl}/v1/key answered ${res.status}: is --api-url an Ambasada API?`);
|
|
3933
|
-
}
|
|
3934
|
-
async function logout() {
|
|
3935
|
-
const { apiKey: _k, company: _c, project: _p, ...rest } = await readConfig();
|
|
3936
|
-
await writeConfig(rest);
|
|
3937
|
-
out5("logged out");
|
|
4369
|
+
const { company: _old, apps, ...stored } = await readConfig();
|
|
4370
|
+
await writeConfig({ ...stored, apiUrl, apiKey: key, project: who.id, apps: { ...apps, [who.id]: { apiKey: key, apiUrl } } });
|
|
4371
|
+
out6(`${c.green("\u2713")} logged in as ${c.bold(who.id)} on ${apiUrl} ${c.dim(`(saved to ${configPath()})`)}`);
|
|
4372
|
+
if (here && here !== who.id) out6(c.yellow(`! this folder is the app ${here}, and the key is ${who.id}'s: deploy and storage commands here will refuse it`));
|
|
4373
|
+
}
|
|
4374
|
+
async function logout(_args = [], flags = {}) {
|
|
4375
|
+
const { here } = await resolve(flags);
|
|
4376
|
+
const { apiKey: _k, company: _c, project, apps = {}, ...rest } = await readConfig();
|
|
4377
|
+
const { [here ?? ""]: _here, ...others } = apps;
|
|
4378
|
+
const kept = flags.all ? {} : here ? others : apps;
|
|
4379
|
+
await writeConfig({ ...rest, ...Object.keys(kept).length ? { apps: kept } : {} });
|
|
4380
|
+
out6(flags.all ? "logged out of every app" : here && _here ? `logged out of ${here}` : "logged out");
|
|
3938
4381
|
}
|
|
3939
4382
|
async function whoami(_args, flags) {
|
|
3940
4383
|
const r = await resolve(flags);
|
|
3941
4384
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
3942
4385
|
const who = await whoseKey(r.apiUrl, r.apiKey);
|
|
3943
|
-
if (json(flags, { apiUrl: r.apiUrl, project: who.id, kind: who.kind })) return;
|
|
3944
|
-
|
|
4386
|
+
if (json(flags, { apiUrl: r.apiUrl, project: who.id, kind: who.kind, folder: r.here ?? null })) return;
|
|
4387
|
+
out6(`${c.bold(who.id)} on ${r.apiUrl}`);
|
|
4388
|
+
if (r.here && r.here !== who.id) out6(c.yellow(`! this folder is the app ${r.here}: \`ambasada login --key \u2026\` here with its key`));
|
|
3945
4389
|
}
|
|
3946
4390
|
function addonFlag(flags) {
|
|
3947
4391
|
if (flags.village || str3(flags.for) === "hackaton" || str3(flags.for) === "village") return "village";
|
|
@@ -3951,11 +4395,11 @@ var addonSpec = (short, flags) => str3(flags[`${short}-spec`]) ?? process.env[`A
|
|
|
3951
4395
|
async function writeFiles(root, files2, map, force = true) {
|
|
3952
4396
|
const written = [];
|
|
3953
4397
|
for (const [rel, content] of Object.entries(files2)) {
|
|
3954
|
-
const dest =
|
|
3955
|
-
if (!force &&
|
|
3956
|
-
await
|
|
3957
|
-
if (isBinary(rel)) await
|
|
3958
|
-
else await
|
|
4398
|
+
const dest = join13(root, rel === "gitignore" ? ".gitignore" : rel);
|
|
4399
|
+
if (!force && existsSync12(dest)) continue;
|
|
4400
|
+
await mkdir6(dirname7(dest), { recursive: true });
|
|
4401
|
+
if (isBinary(rel)) await writeFile8(dest, Buffer.from(content, "base64"));
|
|
4402
|
+
else await writeFile8(dest, map(rel, content));
|
|
3959
4403
|
written.push(rel);
|
|
3960
4404
|
}
|
|
3961
4405
|
return written;
|
|
@@ -3964,7 +4408,7 @@ async function init(args, flags) {
|
|
|
3964
4408
|
const target2 = resolvePath5(args[0] ?? ".");
|
|
3965
4409
|
const name = str3(flags.name) ?? (args[0] ? basename3(target2) : "My App");
|
|
3966
4410
|
const id = slug(str3(flags.id) ?? name);
|
|
3967
|
-
if (
|
|
4411
|
+
if (existsSync12(target2) && (await readdir3(target2)).filter((f) => f !== ".git").length && !flags.force) {
|
|
3968
4412
|
throw new CliError(`${target2} is not empty (use --force to scaffold anyway)`);
|
|
3969
4413
|
}
|
|
3970
4414
|
const template = str3(flags.template) ?? TEMPLATES[0];
|
|
@@ -3977,15 +4421,15 @@ async function init(args, flags) {
|
|
|
3977
4421
|
pkg.name = id;
|
|
3978
4422
|
pkg.dependencies["@ambasada/sdk"] = sdkSpec;
|
|
3979
4423
|
const releaseLine = /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec;
|
|
3980
|
-
if (pkg.dependencies["@ambasada/miniapp"]) pkg.dependencies["@ambasada/miniapp"] = str3(flags
|
|
4424
|
+
if (pkg.dependencies["@ambasada/miniapp"]) pkg.dependencies["@ambasada/miniapp"] = str3(flags["miniapp-spec"]) ?? process.env.AMBASADA_MINIAPP_SPEC ?? releaseLine;
|
|
3981
4425
|
if (pkg.dependencies["@ambasada/ui"]) pkg.dependencies["@ambasada/ui"] = str3(flags.ui) ?? process.env.AMBASADA_UI_SPEC ?? releaseLine;
|
|
3982
4426
|
if (pkg.devDependencies?.["@ambasada/cli"]) pkg.devDependencies["@ambasada/cli"] = str3(flags.cli) ?? process.env.AMBASADA_CLI_SPEC ?? SDK_VERSION;
|
|
3983
4427
|
return `${JSON.stringify(pkg, null, 2)}
|
|
3984
4428
|
`;
|
|
3985
4429
|
});
|
|
3986
|
-
const env2 =
|
|
3987
|
-
if (!
|
|
3988
|
-
await
|
|
4430
|
+
const env2 = join13(target2, ".env");
|
|
4431
|
+
if (!existsSync12(env2)) {
|
|
4432
|
+
await writeFile8(
|
|
3989
4433
|
env2,
|
|
3990
4434
|
[
|
|
3991
4435
|
"# Local development against `ambasada dev`. On ambasada.dev the deploy sets the real values.",
|
|
@@ -4006,38 +4450,35 @@ async function init(args, flags) {
|
|
|
4006
4450
|
const addonName = addonFlag(flags);
|
|
4007
4451
|
let addon = null;
|
|
4008
4452
|
if (addonName) addon = await applyAddon(target2, template, addonName, flags, true);
|
|
4009
|
-
const
|
|
4010
|
-
|
|
4453
|
+
const entry2 = flags.miniapp ? str3(flags.entry) ?? DEFAULT_ENTRY : addon?.overlay?.miniapp?.entry;
|
|
4454
|
+
const miniapp2 = entry2 ? await enableMiniapp(target2, entry2, flags, { template, releaseLine: /^(file|link):/.test(sdkSpec) ? SDK_VERSION : sdkSpec }) : null;
|
|
4011
4455
|
await writeProjectInfo(target2, { template, devkit: devkitVersion(), addons: addon ? [addon.short] : [] });
|
|
4012
4456
|
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";
|
|
4457
|
+
out6(`${c.green("\u2713")} ${c.bold(name)} (${id}) scaffolded in ${target2} ${c.dim(`(template ${template}${addon ? `, ${addon.short}` : ""})`)}`);
|
|
4458
|
+
out6(miniapp2 ? ` mini app: on at ${c.bold(miniapp2.entry)} ${c.dim(`(${miniapp2.entry === "/" ? "the in-app glue" : "the in-app layout"}, icon.png, the manifest from ambasada.config.ts)`)}` : ` ${stateLine({ on: false, entry: null, source: null, file: null })}`);
|
|
4459
|
+
for (const m of miniapp2?.manual ?? []) out6(c.yellow(`! by hand: ${m}`));
|
|
4460
|
+
out6("");
|
|
4461
|
+
out6("Next:");
|
|
4462
|
+
if (rel !== ".") out6(` cd ${rel}`);
|
|
4463
|
+
if (!addon) out6(" npm install");
|
|
4464
|
+
out6(` npm run dev ${c.dim("# ambasada dev: the mock API on :8788 + your app on http://localhost:3000")}`);
|
|
4465
|
+
for (const line of addon?.overlay?.next ?? []) out6(` ${line}`);
|
|
4466
|
+
out6("");
|
|
4467
|
+
out6(`Then ${c.bold("ambasada login")} (a key from https://developer.ambasada.dev \u2192 New app) and ${c.bold("ambasada deploy")}. See README.md.`);
|
|
4027
4468
|
}
|
|
4028
4469
|
async function applyAddon(root, template, short, flags, fresh) {
|
|
4029
4470
|
const pkgName = KNOWN_ADDONS[short] ?? short;
|
|
4030
|
-
const pkgFile =
|
|
4031
|
-
const pkg = JSON.parse(await
|
|
4471
|
+
const pkgFile = join13(root, "package.json");
|
|
4472
|
+
const pkg = JSON.parse(await readFile8(pkgFile, "utf8"));
|
|
4032
4473
|
pkg.dependencies = { ...pkg.dependencies, [pkgName]: pkg.dependencies?.[pkgName] ?? addonSpec(shortName(pkgName), flags) };
|
|
4033
|
-
await
|
|
4474
|
+
await writeFile8(pkgFile, `${JSON.stringify(pkg, null, 2)}
|
|
4034
4475
|
`);
|
|
4035
4476
|
const find = () => findAddons(root).find((dir2) => readJsonName(dir2) === pkgName) ?? besideCli(pkgName);
|
|
4036
4477
|
let dir = find() ?? packageDir(pkgName, root);
|
|
4037
4478
|
if (!dir && !flags["no-install"]) {
|
|
4038
|
-
|
|
4479
|
+
out6(c.dim(`npm install ${pkgName} \u2026`));
|
|
4039
4480
|
try {
|
|
4040
|
-
|
|
4481
|
+
execFileSync4(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
|
|
4041
4482
|
} catch {
|
|
4042
4483
|
throw new CliError(`npm install failed in ${root}: install ${pkgName} yourself, then \`ambasada add ${shortName(pkgName)}\``);
|
|
4043
4484
|
}
|
|
@@ -4050,37 +4491,37 @@ async function applyAddon(root, template, short, flags, fresh) {
|
|
|
4050
4491
|
const id = await loadIdName(root) ?? { id: "my-app", name: "My App" };
|
|
4051
4492
|
await writeFiles(root, overlay.files(template), (_rel, text) => text.replaceAll("my-app", id.id).replaceAll("My App", id.name), fresh || flags.force === true);
|
|
4052
4493
|
if (overlay.config) {
|
|
4053
|
-
const file =
|
|
4054
|
-
await
|
|
4494
|
+
const file = join13(root, CONFIG_FILE);
|
|
4495
|
+
await writeFile8(file, overlay.config(await readFile8(file, "utf8"), template));
|
|
4055
4496
|
}
|
|
4056
4497
|
if (overlay.env?.length) {
|
|
4057
|
-
const env2 =
|
|
4058
|
-
const current2 =
|
|
4498
|
+
const env2 = join13(root, ".env");
|
|
4499
|
+
const current2 = existsSync12(env2) ? await readFile8(env2, "utf8") : "";
|
|
4059
4500
|
const missing = overlay.env.filter((line) => line.startsWith("#") || !current2.includes(`${line.split("=")[0]}=`));
|
|
4060
|
-
if (missing.some((l) => !l.startsWith("#"))) await
|
|
4501
|
+
if (missing.some((l) => !l.startsWith("#"))) await appendFile2(env2, `${current2 && !current2.endsWith("\n") ? "\n" : ""}${missing.join("\n")}
|
|
4061
4502
|
`);
|
|
4062
4503
|
}
|
|
4063
4504
|
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
|
|
4505
|
+
const file = join13(root, rel);
|
|
4506
|
+
const current2 = existsSync12(file) ? await readFile8(file, "utf8") : "";
|
|
4507
|
+
if (!current2.includes(text.trim().split("\n")[0])) await writeFile8(file, `${current2.trimEnd()}
|
|
4067
4508
|
|
|
4068
4509
|
${text.trim()}
|
|
4069
4510
|
`);
|
|
4070
4511
|
}
|
|
4071
4512
|
if (overlay.dependencies) {
|
|
4072
|
-
const next = JSON.parse(await
|
|
4513
|
+
const next = JSON.parse(await readFile8(pkgFile, "utf8"));
|
|
4073
4514
|
next.dependencies = { ...overlay.dependencies, ...next.dependencies };
|
|
4074
|
-
await
|
|
4515
|
+
await writeFile8(pkgFile, `${JSON.stringify(next, null, 2)}
|
|
4075
4516
|
`);
|
|
4076
4517
|
}
|
|
4077
4518
|
}
|
|
4078
|
-
await writeFiles(
|
|
4519
|
+
await writeFiles(join13(root, ".claude/skills"), addon.skills, (_rel, text) => text, true);
|
|
4079
4520
|
return addon;
|
|
4080
4521
|
}
|
|
4081
4522
|
function readJsonName(dir) {
|
|
4082
4523
|
try {
|
|
4083
|
-
return JSON.parse(
|
|
4524
|
+
return JSON.parse(readFileSync6(join13(dir, "package.json"), "utf8")).name;
|
|
4084
4525
|
} catch {
|
|
4085
4526
|
return void 0;
|
|
4086
4527
|
}
|
|
@@ -4091,34 +4532,42 @@ async function add(args, flags) {
|
|
|
4091
4532
|
if (!KNOWN_ADDONS[short] && !short.includes("/")) throw new CliError(`unknown add-on ${short} (known: ${Object.keys(KNOWN_ADDONS).join(", ")})`);
|
|
4092
4533
|
const root = findRoot();
|
|
4093
4534
|
if (!root) throw new CliError(`run \`ambasada add ${short}\` inside your app (no ${CONFIG_FILE} found)`);
|
|
4094
|
-
const template =
|
|
4535
|
+
const template = templateOf(root);
|
|
4095
4536
|
const addon = await applyAddon(root, template, short, flags, false);
|
|
4096
|
-
const
|
|
4097
|
-
|
|
4098
|
-
if (app && !existsSync10(manifest)) await writeFile7(manifest, manifestJson(app));
|
|
4537
|
+
const entry2 = addon.overlay?.miniapp?.entry;
|
|
4538
|
+
if (entry2 && !miniappState(root, miniappOfSource(root).miniapp).on) printEnabled(await enableMiniapp(root, entry2, flags, { template, releaseLine: SDK_VERSION }), root);
|
|
4099
4539
|
const info = await readProjectInfo(root) ?? { template, devkit: devkitVersion(), addons: [] };
|
|
4100
4540
|
await writeProjectInfo(root, { ...info, addons: [.../* @__PURE__ */ new Set([...info.addons, addon.short])] });
|
|
4101
|
-
|
|
4102
|
-
for (const line of addon.overlay?.next ?? [])
|
|
4103
|
-
|
|
4541
|
+
out6(`${c.green("\u2713")} ${c.bold(addon.name)} added ${c.dim(`(${CONFIG_FILE}, package.json, .env, .claude/skills; existing files kept, --force overwrites)`)}`);
|
|
4542
|
+
for (const line of addon.overlay?.next ?? []) out6(` ${line}`);
|
|
4543
|
+
out6(` ambasada ${addon.short} --help`);
|
|
4104
4544
|
}
|
|
4105
4545
|
async function check(args, flags) {
|
|
4106
4546
|
const target2 = args[0];
|
|
4107
4547
|
let report;
|
|
4108
|
-
if (target2 && /^https?:\/\//i.test(target2))
|
|
4109
|
-
|
|
4548
|
+
if (target2 && /^https?:\/\//i.test(target2)) {
|
|
4549
|
+
const root = findRoot();
|
|
4550
|
+
const on = root ? miniappState(root, miniappOfSource(root).miniapp).on : false;
|
|
4551
|
+
report = await checkRemote(target2, void 0, on ? { miniapp: true } : {});
|
|
4552
|
+
} else {
|
|
4110
4553
|
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.
|
|
4554
|
+
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
4555
|
report = await checkLocal(dir);
|
|
4556
|
+
const url = /\bproduct\s*:\s*\{\s*url\s*:\s*["'`](https:\/\/[^"'`]+)["'`]/.exec(existsSync12(join13(dir, CONFIG_FILE)) ? readFileSync6(join13(dir, CONFIG_FILE), "utf8") : "")?.[1];
|
|
4557
|
+
if (!report.miniapp?.on && url) report = await checkRemote(url, void 0, { miniapp: false });
|
|
4113
4558
|
}
|
|
4114
4559
|
if (!json(flags, report)) {
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
for (const
|
|
4118
|
-
|
|
4560
|
+
const on = report.miniapp?.on !== false;
|
|
4561
|
+
out6(on ? `mini app: on at ${c.bold(report.miniapp?.entry ?? "/")}` : stateLine({ on: false, entry: null, source: null, file: null }));
|
|
4562
|
+
for (const p of report.problems) out6(`${c.red("\u2717")} ${p.message} ${c.dim(`(${p.code})`)}`);
|
|
4563
|
+
for (const w of report.warnings) out6(`${c.yellow("!")} ${w.message} ${c.dim(`(${w.code})`)}`);
|
|
4564
|
+
for (const n of report.notes.filter((n2) => !n2.startsWith("mini app: off"))) out6(`${c.dim("\xB7")} ${c.dim(n)}`);
|
|
4565
|
+
const warnings = report.warnings.length ? c.dim(` (${report.warnings.length} warning${report.warnings.length === 1 ? "" : "s"})`) : "";
|
|
4566
|
+
if (report.ok) out6(`${c.green("\u2713")} ${report.target} passes the ${on ? "mini-app" : "product"} checks${warnings}`);
|
|
4119
4567
|
}
|
|
4120
4568
|
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
4569
|
}
|
|
4570
|
+
var miniapp = (args, flags) => miniappCommand(args, flags, SDK_VERSION);
|
|
4122
4571
|
async function dev(_args, flags) {
|
|
4123
4572
|
const port = Number(str3(flags.port) ?? DEV_PORT);
|
|
4124
4573
|
if (flags.tunnel) return devTunnel(flags);
|
|
@@ -4130,18 +4579,20 @@ async function dev(_args, flags) {
|
|
|
4130
4579
|
const local = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(new URL(productUrl).hostname);
|
|
4131
4580
|
if (stack && !local) throw new CliError(`--product ${productUrl} is not local: \`ambasada dev --mock-only\` to point the mock at it`);
|
|
4132
4581
|
const bin2 = stack ? stackBin(project.root, stack) : null;
|
|
4133
|
-
const
|
|
4582
|
+
const inApp = project ? await syncManifest(project.root, project.config) : null;
|
|
4583
|
+
const appWebhook = project && inApp?.on ? manifestWebhook(project.root, productUrl) : null;
|
|
4134
4584
|
const mock = await startMock({
|
|
4135
4585
|
port,
|
|
4136
4586
|
host: str3(flags.host) ?? "127.0.0.1",
|
|
4137
4587
|
company,
|
|
4138
4588
|
appWebhook: appWebhook ?? void 0,
|
|
4139
4589
|
extensions: addons().mocks,
|
|
4140
|
-
log: (line) =>
|
|
4590
|
+
log: (line) => out6(`${c.dim((/* @__PURE__ */ new Date()).toISOString().slice(11, 19))} ${line}`)
|
|
4141
4591
|
});
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4592
|
+
out6(`${c.bold("ambasada dev")} \xB7 mock Ambasada API on ${c.cyan(mock.url)} ${c.dim("(dashboard: open it in a browser)")}`);
|
|
4593
|
+
out6(` app ${c.bold(company)} \xB7 key ${c.bold(DEV_KEY)} \xB7 sign-in as a fake visitor \xB7 notifications to the mock hub`);
|
|
4594
|
+
out6(` in your app: AMBASADA_API_URL=${mock.url} AMBASADA_API_KEY=${DEV_KEY}`);
|
|
4595
|
+
if (inApp) out6(` ${stateLine(inApp)}`);
|
|
4145
4596
|
const env2 = {
|
|
4146
4597
|
AMBASADA_API_URL: mock.url,
|
|
4147
4598
|
AMBASADA_API_KEY: DEV_KEY,
|
|
@@ -4155,33 +4606,33 @@ async function dev(_args, flags) {
|
|
|
4155
4606
|
AMBASADA_HUB_URL: mock.url,
|
|
4156
4607
|
AMBASADA_APP_ORIGIN: new URL(productUrl).origin
|
|
4157
4608
|
};
|
|
4158
|
-
const ctx = { project, mockUrl: mock.url, productUrl, company, env: env2, hints: [], once: flags.once === true, out:
|
|
4609
|
+
const ctx = { project, mockUrl: mock.url, productUrl, company, env: env2, hints: [], once: flags.once === true, out: out6 };
|
|
4159
4610
|
await addons().dev(ctx);
|
|
4160
|
-
if (!project)
|
|
4161
|
-
const hints = () => ctx.hints.forEach((h) =>
|
|
4611
|
+
if (!project) out6(c.dim(` (no ${CONFIG_FILE} here: only the mock)`));
|
|
4612
|
+
const hints = () => ctx.hints.forEach((h) => out6(h));
|
|
4162
4613
|
if (flags.once) {
|
|
4163
4614
|
hints();
|
|
4164
4615
|
await mock.close();
|
|
4165
4616
|
return;
|
|
4166
4617
|
}
|
|
4167
|
-
if (stack && project) await migrateBeforeDev(project.root,
|
|
4168
|
-
const app = stack && bin2 ? startApp(project.root, stack, bin2, new URL(productUrl), env2,
|
|
4618
|
+
if (stack && project) await migrateBeforeDev(project.root, out6);
|
|
4619
|
+
const app = stack && bin2 ? startApp(project.root, stack, bin2, new URL(productUrl), env2, out6, c.dim(`${stack.pkg} \u2502`)) : null;
|
|
4169
4620
|
if (app) {
|
|
4170
|
-
|
|
4621
|
+
out6(` starting ${c.bold(stack.label)} on ${productUrl} ${c.dim("(your app, with AMBASADA_* pointed at the mock)")}`);
|
|
4171
4622
|
void app.ready.then((ok) => {
|
|
4172
4623
|
if (ok) {
|
|
4173
|
-
|
|
4174
|
-
|
|
4624
|
+
out6(`${c.green("\u2713")} app on ${c.cyan(productUrl)}`);
|
|
4625
|
+
if (inApp?.on) out6(` inside the Ambasada app: ${c.cyan(new URL(`${inApp.entry ?? "/"}?ambasada_shell=mock`, productUrl).toString())} ${c.dim("(fake shell: host bar insets, silent sign-in)")}`);
|
|
4175
4626
|
hints();
|
|
4176
4627
|
if (appWebhook) void playHub(mock, appWebhook);
|
|
4177
|
-
} else if (app.child.exitCode === null)
|
|
4628
|
+
} else if (app.child.exitCode === null) out6(c.yellow(`! ${productUrl} does not answer yet`));
|
|
4178
4629
|
});
|
|
4179
4630
|
} else hints();
|
|
4180
4631
|
let closing;
|
|
4181
4632
|
const close = () => closing ??= mock.close();
|
|
4182
4633
|
const code = await new Promise((resolveStop) => {
|
|
4183
4634
|
const stop2 = () => {
|
|
4184
|
-
|
|
4635
|
+
out6("\nstopping ambasada dev");
|
|
4185
4636
|
app?.child.kill("SIGTERM");
|
|
4186
4637
|
void close().then(() => resolveStop(null));
|
|
4187
4638
|
};
|
|
@@ -4196,7 +4647,7 @@ async function devTunnel(flags) {
|
|
|
4196
4647
|
const project = await loadProject();
|
|
4197
4648
|
const stack = detectStack(project.root);
|
|
4198
4649
|
if (!stack) throw new CliError("no dev server to start here (no next, vite or wrangler config): run it yourself and `ambasada tunnel --port <port>`");
|
|
4199
|
-
const r = await
|
|
4650
|
+
const r = await resolveForApp(flags);
|
|
4200
4651
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` first (the tunnel is registered with the real API)");
|
|
4201
4652
|
const productUrl = new URL(str3(flags.product) ?? project.config.product?.url ?? "http://localhost:3000");
|
|
4202
4653
|
if (!/^(localhost|127\.0\.0\.1|\[::1\])$/.test(productUrl.hostname)) throw new CliError(`product.url is ${productUrl.origin}: \`ambasada dev --tunnel --product http://localhost:3000\``);
|
|
@@ -4212,18 +4663,18 @@ async function devTunnel(flags) {
|
|
|
4212
4663
|
...info.webhook ? { AMBASADA_WEBHOOK_SECRET: info.webhook.secret } : {},
|
|
4213
4664
|
...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
4665
|
};
|
|
4215
|
-
|
|
4666
|
+
out6(`${c.bold("ambasada dev --tunnel")} \xB7 ${c.bold(company)} on ${r.apiUrl} ${c.dim("(the real API, no mock)")}`);
|
|
4216
4667
|
printOpened(info);
|
|
4217
|
-
if (!info.sign_in)
|
|
4218
|
-
const app = startApp(project.root, stack, bin2, productUrl, env2,
|
|
4219
|
-
|
|
4668
|
+
if (!info.sign_in) out6(c.dim(" Sign in with Ambasada is not set up on this API: login keeps the .env values"));
|
|
4669
|
+
const app = startApp(project.root, stack, bin2, productUrl, env2, out6, c.dim(`${stack.pkg} \u2502`));
|
|
4670
|
+
out6(` starting ${c.bold(stack.label)} on ${productUrl.origin}`);
|
|
4220
4671
|
void app.ready.then((ok) => {
|
|
4221
|
-
if (ok)
|
|
4222
|
-
else if (app.child.exitCode === null)
|
|
4672
|
+
if (ok) out6(`${c.green("\u2713")} product on ${productUrl.origin}, live at ${c.cyan(info.url)}`);
|
|
4673
|
+
else if (app.child.exitCode === null) out6(c.yellow(`! ${productUrl.origin} does not answer yet`));
|
|
4223
4674
|
});
|
|
4224
4675
|
const appExit = new Promise((resolveExit) => app.child.once("exit", (code) => resolveExit(`${stack.label} exited${code ? ` with code ${code}` : ""}`)));
|
|
4225
4676
|
const reason = await untilStopped(Promise.race([session.ended, appExit]));
|
|
4226
|
-
|
|
4677
|
+
out6(reason ? c.yellow(`! ${reason}`) : "\nstopping ambasada dev --tunnel");
|
|
4227
4678
|
app.child.kill("SIGTERM");
|
|
4228
4679
|
printClosed(await session.close());
|
|
4229
4680
|
if (reason) throw new CliError(reason);
|
|
@@ -4232,7 +4683,7 @@ function manifestWebhook(root, productUrl) {
|
|
|
4232
4683
|
const file = findLocalManifest(root);
|
|
4233
4684
|
if (!file) return null;
|
|
4234
4685
|
try {
|
|
4235
|
-
const m = JSON.parse(
|
|
4686
|
+
const m = JSON.parse(readFileSync6(file, "utf8"));
|
|
4236
4687
|
const path = m.notifications?.webhook;
|
|
4237
4688
|
return Array.isArray(m.scopes) && m.scopes.includes("notifications") && typeof path === "string" && path ? new URL(path, productUrl).toString() : null;
|
|
4238
4689
|
} catch {
|
|
@@ -4242,12 +4693,12 @@ function manifestWebhook(root, productUrl) {
|
|
|
4242
4693
|
async function playHub(mock, webhook) {
|
|
4243
4694
|
const ping = await mock.appEvent("ping");
|
|
4244
4695
|
if (ping.status !== 200) {
|
|
4245
|
-
|
|
4696
|
+
out6(c.yellow(`! ${webhook} answered the signed ping with ${ping.status || ping.error}: it must answer 200 (ambasada.apps.verifyWebhook)`));
|
|
4246
4697
|
return;
|
|
4247
4698
|
}
|
|
4248
4699
|
const enabled = await mock.appEvent("notifications_enabled");
|
|
4249
|
-
if (enabled.status === 200)
|
|
4250
|
-
else
|
|
4700
|
+
if (enabled.status === 200) out6(`${c.green("\u2713")} app webhook: ping and notifications_enabled delivered to ${webhook} ${c.dim(`(token ${enabled.token})`)}`);
|
|
4701
|
+
else out6(c.yellow(`! ${webhook} answered notifications_enabled with ${enabled.status || enabled.error}`));
|
|
4251
4702
|
}
|
|
4252
4703
|
async function link(args, flags) {
|
|
4253
4704
|
const url = args[0];
|
|
@@ -4255,17 +4706,17 @@ async function link(args, flags) {
|
|
|
4255
4706
|
const root = findRoot();
|
|
4256
4707
|
if (!root) throw new CliError(`run \`ambasada link\` inside your app (no ${CONFIG_FILE} found)`);
|
|
4257
4708
|
if (!await setProductUrl(root, url.replace(/\/$/, ""))) throw new CliError(`could not find \`product: { url: "\u2026" }\` in ${CONFIG_FILE}: set it by hand`);
|
|
4258
|
-
|
|
4709
|
+
out6(`${c.green("\u2713")} product.url = ${url}`);
|
|
4259
4710
|
try {
|
|
4260
4711
|
const res = await fetch(new URL("/health", url), { signal: AbortSignal.timeout(5e3) });
|
|
4261
|
-
|
|
4712
|
+
out6(res.ok ? `${c.green("\u2713")} ${url} answers` : c.yellow(`! ${url}/health answered ${res.status}`));
|
|
4262
4713
|
} catch {
|
|
4263
|
-
|
|
4714
|
+
out6(c.yellow(`! ${url} is not reachable from here yet`));
|
|
4264
4715
|
}
|
|
4265
|
-
await addons().afterDeploy({ event: "link", project: await loadProject(root), url, flags, out:
|
|
4716
|
+
await addons().afterDeploy({ event: "link", project: await loadProject(root), url, flags, out: out6 });
|
|
4266
4717
|
}
|
|
4267
4718
|
async function hosted(flags) {
|
|
4268
|
-
const r = await
|
|
4719
|
+
const r = await resolveForApp(flags);
|
|
4269
4720
|
if (!r.apiKey) throw new CliError("not logged in: run `ambasada login` (or set AMBASADA_API_KEY)");
|
|
4270
4721
|
return { local: false, apiUrl: r.apiUrl, apiKey: r.apiKey };
|
|
4271
4722
|
}
|
|
@@ -4277,7 +4728,7 @@ async function env(args, flags) {
|
|
|
4277
4728
|
if (sub === "list") {
|
|
4278
4729
|
const res = await api(t, "GET", "/v1/product/env");
|
|
4279
4730
|
if (json(flags, res)) return;
|
|
4280
|
-
|
|
4731
|
+
out6(table(res.vars.map((v) => ({ name: v.name, type: v.type, set_by: v.platform ? "ambasada" : "you", updated: v.updated_at?.slice(0, 19).replace("T", " ") ?? "" }))));
|
|
4281
4732
|
return;
|
|
4282
4733
|
}
|
|
4283
4734
|
if ((sub === "set" || sub === "unset") && name) {
|
|
@@ -4285,14 +4736,14 @@ async function env(args, flags) {
|
|
|
4285
4736
|
if (PLATFORM_ENV.test(name)) throw new CliError(`${name} is set by the platform at deploy`);
|
|
4286
4737
|
if (sub === "unset") {
|
|
4287
4738
|
const res2 = await api(t, "DELETE", `/v1/product/env/${name}`);
|
|
4288
|
-
|
|
4739
|
+
out6(res2.deleted ? `${c.green("\u2713")} ${name} removed${res2.worker_updated ? " from the live Worker" : ""}` : c.dim(`${name} was not set`));
|
|
4289
4740
|
return;
|
|
4290
4741
|
}
|
|
4291
4742
|
let text = value.join(" ");
|
|
4292
4743
|
if (!text && !process.stdin.isTTY) text = (await new Response(process.stdin).text()).replace(/\n$/, "");
|
|
4293
4744
|
if (!text) throw new CliError(`usage: ambasada env set ${name} <value> (or pipe the value on stdin)`);
|
|
4294
4745
|
const res = await api(t, "PUT", `/v1/product/env/${name}`, { json: { value: text } });
|
|
4295
|
-
|
|
4746
|
+
out6(`${c.green("\u2713")} ${name} set ${c.dim(res.worker_updated ? "(live within about 30 s, and on every deploy)" : "(on the next deploy)")}`);
|
|
4296
4747
|
return;
|
|
4297
4748
|
}
|
|
4298
4749
|
throw new CliError("usage: ambasada env list | set NAME <value> | unset NAME");
|
|
@@ -4304,14 +4755,14 @@ async function logs(_args, flags) {
|
|
|
4304
4755
|
const res = await api(t, "GET", "/v1/product/logs");
|
|
4305
4756
|
if (json(flags, res) && !flags.follow) return;
|
|
4306
4757
|
if (first) {
|
|
4307
|
-
|
|
4308
|
-
|
|
4758
|
+
out6(res.last_deploy ? `last deploy v${res.last_deploy.version} at ${res.last_deploy.deployed_at.slice(0, 19).replace("T", " ")}` : c.dim("not deployed yet: `ambasada deploy`"));
|
|
4759
|
+
out6(c.dim(`${res.errors.total_24h} error${res.errors.total_24h === 1 ? "" : "s"} in the last 24 h`));
|
|
4309
4760
|
}
|
|
4310
4761
|
for (const e of [...res.errors.recent].reverse()) {
|
|
4311
4762
|
const key = `${e.at} ${e.message}`;
|
|
4312
4763
|
if (seen.has(key)) continue;
|
|
4313
4764
|
seen.add(key);
|
|
4314
|
-
|
|
4765
|
+
out6(`${c.dim(e.at.slice(0, 19).replace("T", " "))} ${c.red(String(e.status ?? "error"))} ${e.method ?? ""} ${e.path ?? ""} ${e.message ?? ""}`.trimEnd());
|
|
4315
4766
|
}
|
|
4316
4767
|
if (!flags.follow) return;
|
|
4317
4768
|
await sleep(5e3);
|
|
@@ -4319,18 +4770,19 @@ async function logs(_args, flags) {
|
|
|
4319
4770
|
}
|
|
4320
4771
|
|
|
4321
4772
|
// src/main.ts
|
|
4322
|
-
var VERSION3 = true ? "0.8.
|
|
4773
|
+
var VERSION3 = true ? "0.8.4" : "0.1.0-dev";
|
|
4323
4774
|
var MAJOR_MINOR = VERSION3.split(".").slice(0, 2).join(".");
|
|
4324
4775
|
var HELP = `${c.bold("ambasada")} ${MAJOR_MINOR}: build and host apps on Ambasada
|
|
4325
4776
|
|
|
4326
4777
|
init [dir] [--template next|spa|worker] [--village] scaffold an app (one Cloudflare Worker): next (default,
|
|
4327
|
-
|
|
4778
|
+
[--miniapp [--entry /app]] Next.js via OpenNext), spa (Vite + React) or worker
|
|
4328
4779
|
login [--key vk_\u2026] | logout | whoami Ambasada ID, or a project key from developer.ambasada.dev
|
|
4329
4780
|
dev [--port] [--tunnel] [--mock-only] your dev server + the platform mock (:8788): sign-in as a
|
|
4330
4781
|
fake visitor, notifications, store, local D1/KV/R2/DO
|
|
4331
4782
|
deploy [--own] [--no-smoke] [--dry-run] host it at https://<slug>.ambasada.dev
|
|
4332
4783
|
tunnel [--port] | link <url> | check [url|dir] your laptop at https://<slug>-dev.ambasada.dev; hosted
|
|
4333
|
-
elsewhere; the mini-app checks
|
|
4784
|
+
elsewhere; the product (and mini-app) checks
|
|
4785
|
+
miniapp [enable [--entry /app]] the Ambasada mini app at a path of your app: off by default
|
|
4334
4786
|
env list|set|unset | logs [--follow] the hosted Worker
|
|
4335
4787
|
db migrate|shell|tables | kv \u2026 | files \u2026 storage (env.DB, env.KV, env.FILES); --local: wrangler's
|
|
4336
4788
|
add <addon> install an add-on (today: village)
|
|
@@ -4348,7 +4800,7 @@ ${ns.help}`)).join("\n");
|
|
|
4348
4800
|
return `${HELP}${extra || VILLAGE_HINT}
|
|
4349
4801
|
${FOOTER}`;
|
|
4350
4802
|
}
|
|
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"]);
|
|
4803
|
+
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
4804
|
function parseArgs(argv) {
|
|
4353
4805
|
const args = [];
|
|
4354
4806
|
const flags = {};
|
|
@@ -4379,6 +4831,7 @@ var COMMANDS = {
|
|
|
4379
4831
|
tunnel,
|
|
4380
4832
|
link,
|
|
4381
4833
|
check,
|
|
4834
|
+
miniapp,
|
|
4382
4835
|
env,
|
|
4383
4836
|
logs,
|
|
4384
4837
|
db,
|
|
@@ -4390,7 +4843,7 @@ var COMMANDS = {
|
|
|
4390
4843
|
var VILLAGE_COMMANDS = /* @__PURE__ */ new Set(["wallet", "ledger", "offerings", "market", "steer", "webhook", "links", "village"]);
|
|
4391
4844
|
async function villageHint(root, addons2) {
|
|
4392
4845
|
if (!root || addons2.get("village")) return;
|
|
4393
|
-
let uses =
|
|
4846
|
+
let uses = existsSync13(join14(root, "offerings.ts"));
|
|
4394
4847
|
if (!uses) {
|
|
4395
4848
|
try {
|
|
4396
4849
|
uses = (await loadProject(root)).config.idea !== void 0;
|