@ambasada/skills 0.1.0 → 0.3.0
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/README.md +19 -6
- package/package.json +2 -2
- package/skills/ambasada-payments/SKILL.md +19 -6
- package/skills/ambasada-payments/recipes/lovable-bolt.md +6 -3
- package/skills/ambasada-payments/recipes/nextjs.md +123 -27
- package/skills/ambasada-payments/recipes/other-stacks.md +159 -0
- package/skills/ambasada-payments/recipes/{astro-static-payjs.md → static-payjs.md} +10 -7
- package/skills/ambasada-payments/reference/primitives.md +21 -4
- package/skills/ambasada-payments/reference/receipts.md +1 -1
- package/skills/ambasada-payments/reference/test-mode.md +9 -5
- package/skills/ambasada-product/SKILL.md +60 -0
- package/skills/ambasada-product/checklists/sellable.md +51 -0
- package/skills/ambasada-product/reference/build-elsewhere.md +82 -0
- package/skills/ambasada-product/reference/design.md +51 -0
- package/skills/ambasada-product/reference/dev-and-deploy.md +105 -0
- package/skills/ambasada-product/reference/stack.md +109 -0
- package/skills/ambasada-payments/recipes/express.md +0 -48
- package/skills/ambasada-payments/recipes/fastapi.md +0 -68
- package/skills/ambasada-payments/recipes/workers.md +0 -27
package/README.md
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
# ambasada
|
|
1
|
+
# @ambasada/skills
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Install it in a project for Claude Code (`.claude/skills/ambasada-payments/`), Cursor or Codex
|
|
5
|
-
(point the agent at `SKILL.md`). `ambasada init` vendors it into every new company.
|
|
3
|
+
Skills for AI coding tools (Claude Code, Cursor, Codex) building products for the Hack A Ton Village.
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
| Skill | Use it to |
|
|
6
|
+
|---|---|
|
|
7
|
+
| [`ambasada-product`](skills/ambasada-product/SKILL.md) | build a product that sells in the Village: the Next.js-on-Cloudflare layout, allowed features, data and secrets, `ambasada dev` / `deploy` / `link`, design, a sellable checklist, prompts for ChatGPT and Lovable |
|
|
8
|
+
| [`ambasada-payments`](skills/ambasada-payments/SKILL.md) | take and spend TON: Checkout, pay.js, webhooks, receipts, `buy` / `need`, LLM, images, tools, files; recipes for Next.js, static pages, Lovable/Bolt and self-hosted stacks |
|
|
9
|
+
|
|
10
|
+
Each skill is a folder with `SKILL.md` (frontmatter `name`, `description`) plus `reference/`, `recipes/` or
|
|
11
|
+
`checklists/`.
|
|
12
|
+
|
|
13
|
+
Install:
|
|
14
|
+
|
|
15
|
+
- New project: `npm create ambasada@latest my-shop` vendors both into `.claude/skills/`.
|
|
16
|
+
- Existing project: `npx skills add centyso-dev/ambasada-skills` (public mirror), or copy `skills/*` into
|
|
17
|
+
`.claude/skills/`. Cursor and Codex: point the agent at the `SKILL.md` files.
|
|
18
|
+
|
|
19
|
+
After editing, run `pnpm --filter @ambasada/skills sync`: it copies every skill into each
|
|
20
|
+
`templates/*/.claude/skills/` (and the legacy `template/.claude/skills/` while it exists).
|
|
21
|
+
`pnpm test` (`node scripts/sync.mjs --check`) fails on bad frontmatter, a broken relative link, or a stale copy.
|
|
9
22
|
|
|
10
23
|
Built by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id). MIT.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ambasada/skills",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Skills for Claude Code, Cursor and Codex: ambasada-product (build a product that sells in the Hack A Ton Village) and ambasada-payments (TON Checkout, receipts, webhooks).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"skills",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ambasada-payments
|
|
3
|
-
description: Add TON payments to any app for the Hack A Ton village. Use when the user wants to accept TON from people (Checkout, pay.js button, webhooks), sell an HTTP endpoint that agents pay per call (verify the x-village-receipt), buy from other companies (village.buy / village.need), use the village LLM, images, tools or files, list offerings, or test all of it offline with `ambasada dev`. Covers Next.js
|
|
3
|
+
description: Add TON payments to any app for the Hack A Ton village. Use when the user wants to accept TON from people (Checkout, pay.js button, webhooks), sell an HTTP endpoint that agents pay per call (verify the x-village-receipt), buy from other companies (village.buy / village.need), use the village LLM, images, tools or files, list offerings, or test all of it offline with `ambasada dev`. Covers Next.js on Cloudflare (the default template), static pages with pay.js, Lovable/Bolt, and self-hosted stacks (Express, FastAPI, Workers, Astro).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Village payments
|
|
@@ -19,14 +19,22 @@ Amounts are TON as decimal numbers. Errors are always `{ "error": { "code", "mes
|
|
|
19
19
|
|
|
20
20
|
## Decide what the user needs
|
|
21
21
|
|
|
22
|
-
1. *"Let people pay on my site"* → [reference/checkout.md](reference/checkout.md). Static page without a backend → [recipes/
|
|
22
|
+
1. *"Let people pay on my site"* → [reference/checkout.md](reference/checkout.md). Static page without a backend → [recipes/static-payjs.md](recipes/static-payjs.md).
|
|
23
23
|
2. *"Let agents buy my service"* → [reference/receipts.md](reference/receipts.md), then list it as an offering ([reference/primitives.md](reference/primitives.md#listings)).
|
|
24
24
|
3. *"My product needs something another team sells"* → [reference/need-vs-buy.md](reference/need-vs-buy.md).
|
|
25
25
|
4. *"Use an LLM / generate images / search the web"* → [reference/primitives.md](reference/primitives.md#utilities) (paid in TON from the company wallet).
|
|
26
26
|
5. Always set up **test mode** first → [reference/test-mode.md](reference/test-mode.md) (`ambasada dev`, no login, fake money).
|
|
27
27
|
|
|
28
|
-
Stack recipes:
|
|
29
|
-
|
|
28
|
+
Stack recipes:
|
|
29
|
+
|
|
30
|
+
- [Next.js on Cloudflare](recipes/nextjs.md): the default (`npm create ambasada@latest`), hosted by `ambasada deploy`.
|
|
31
|
+
- [Static page + pay.js](recipes/static-payjs.md): no backend, a payment link and a button.
|
|
32
|
+
- [Lovable / Bolt / v0](recipes/lovable-bolt.md): prompts to paste into the builder.
|
|
33
|
+
- [Other stacks](recipes/other-stacks.md): Express, FastAPI, a bare Worker, Astro; power users who host
|
|
34
|
+
it themselves, then `ambasada link`.
|
|
35
|
+
|
|
36
|
+
Building the whole product (layout, allowed Next.js features, data, deploy, design)? Use the
|
|
37
|
+
**ambasada-product** skill as well.
|
|
30
38
|
|
|
31
39
|
## Rules that prevent real bugs
|
|
32
40
|
|
|
@@ -39,12 +47,17 @@ Stack recipes: [Next.js](recipes/nextjs.md) · [Express](recipes/express.md) ·
|
|
|
39
47
|
schema) makes the Gateway refund the buyer. Throwing is the refund path.
|
|
40
48
|
- **Idempotency.** Send `Idempotency-Key` on purchases and transfers; retries with the same key never charge twice.
|
|
41
49
|
- **One receipt, one call.** Dedupe on `call_id` (the SDK does it per process; persist it if you run several instances).
|
|
42
|
-
- TypeScript: use `@ambasada/sdk` (`npm i @ambasada/sdk`). Other languages: plain HTTP
|
|
50
|
+
- TypeScript: use `@ambasada/sdk` (`npm i @ambasada/sdk`). Other languages: plain HTTP ([recipes/other-stacks.md](recipes/other-stacks.md#fastapi-or-any-language-over-plain-http)).
|
|
43
51
|
|
|
44
52
|
## The SDK in 20 lines
|
|
45
53
|
|
|
54
|
+
The examples call the client `village`. `import { village } from "@ambasada/sdk"` reads `process.env`
|
|
55
|
+
(`AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET`); on Cloudflare make
|
|
56
|
+
one with `createAmbasada({ env })`. In the Next template it is `ambasada()` from `lib/ambasada.ts`.
|
|
57
|
+
The SDK still reads the old `VILLAGE_*` names as a fallback.
|
|
58
|
+
|
|
46
59
|
```ts
|
|
47
|
-
import { village } from "@ambasada/sdk";
|
|
60
|
+
import { village } from "@ambasada/sdk";
|
|
48
61
|
|
|
49
62
|
// sell to agents: a fetch handler that verifies the receipt and answers JSON (402 if unpaid)
|
|
50
63
|
export const POST = village.sell("translate", async ({ text, to }, call) => {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Lovable, Bolt, v0 and other no-code builders
|
|
2
2
|
|
|
3
|
-
These tools build from a prompt, so give them the docs instead of code.
|
|
3
|
+
These tools build from a prompt, so give them the docs instead of code. Two options: keep hosting there
|
|
4
|
+
(below), or export the code into the `spa` template and use `ambasada deploy` (the ambasada-product skill,
|
|
5
|
+
`reference/build-elsewhere.md`). Either way, finish with `ambasada link https://your.app` or `ambasada deploy`.
|
|
4
6
|
|
|
5
7
|
**1. Static "Pay with TON" button (works everywhere, no backend):** create a payment link with
|
|
6
|
-
`ambasada links create
|
|
8
|
+
`ambasada links create --amount 3 --description "…" --success-url https://your.app/thanks`, then paste this
|
|
9
|
+
into the builder's chat:
|
|
7
10
|
|
|
8
11
|
> Add a "Pay 3 TON" button. Include `<script src="https://hackaton.ambasada.id/pay.js" defer></script>` in the
|
|
9
12
|
> page head and render `<button data-village-pay="pl_XXXX">Pay 3 TON</button>` (add `data-mode="popup"` to
|
|
@@ -24,7 +27,7 @@ Supabase Edge Functions or similar; paste this:
|
|
|
24
27
|
|
|
25
28
|
Then register the webhook URL: `ambasada webhook set https://…/functions/v1/village-webhook`.
|
|
26
29
|
|
|
27
|
-
**3. Selling to agents** needs an endpoint that verifies `x-village-receipt` (reference/receipts.md). In an
|
|
30
|
+
**3. Selling to agents** needs an endpoint that verifies `x-village-receipt` ([reference/receipts.md](../reference/receipts.md)). In an
|
|
28
31
|
edge function with npm support: `import { village } from "npm:@ambasada/sdk"` and
|
|
29
32
|
`Deno.serve(village.sell("translate", async (input) => ({ … })))`.
|
|
30
33
|
|
|
@@ -1,54 +1,150 @@
|
|
|
1
|
-
# Next.js (
|
|
1
|
+
# Next.js on Cloudflare (the default template)
|
|
2
2
|
|
|
3
|
-
`npm
|
|
3
|
+
`npm create ambasada@latest my-shop` gives you all of this already: Next.js 16.4 App Router, built into one
|
|
4
|
+
Cloudflare Worker by `@opennextjs/cloudflare` 1.20 with `nodejs_compat`. Local secrets in `.env`
|
|
5
|
+
(`AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET`); `.dev.vars` works too. What runs on
|
|
6
|
+
Workers and what doesn't: the ambasada-product skill, `reference/stack.md`.
|
|
7
|
+
|
|
8
|
+
## The client: `lib/ambasada.ts`
|
|
9
|
+
|
|
10
|
+
Server code only. One client per Worker instance: it caches the receipt keys and remembers used receipts.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// lib/ambasada.ts (the template ships it; this is the core)
|
|
14
|
+
import { createAmbasada, type Village } from "@ambasada/sdk";
|
|
15
|
+
import { getCloudflareContext } from "@opennextjs/cloudflare";
|
|
16
|
+
|
|
17
|
+
function cloudflareEnv(): Record<string, unknown> | undefined {
|
|
18
|
+
try {
|
|
19
|
+
return getCloudflareContext().env as unknown as Record<string, unknown>;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined; // `next dev`: the SDK reads process.env
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let client: Village | undefined;
|
|
26
|
+
|
|
27
|
+
export function ambasada(): Village {
|
|
28
|
+
return (client ??= createAmbasada({ env: cloudflareEnv() }));
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It also exports `env(name)` (a variable from the Worker env, else `process.env`) and `villageUrl()`
|
|
33
|
+
(the Village, or the mock under `ambasada dev`).
|
|
34
|
+
|
|
35
|
+
## An offering agents pay for
|
|
4
36
|
|
|
5
37
|
```ts
|
|
6
|
-
// app/api/translate/route.ts
|
|
7
|
-
import {
|
|
8
|
-
export const runtime = "nodejs";
|
|
38
|
+
// app/api/translate/route.ts (offering endpoint "/api/translate")
|
|
39
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
9
40
|
|
|
10
|
-
export const POST =
|
|
11
|
-
text:
|
|
12
|
-
})
|
|
41
|
+
export const POST = (req: Request) =>
|
|
42
|
+
ambasada().sell<{ text: string; to: string }>("translate", async ({ text, to }) => ({
|
|
43
|
+
text: await ambasada().llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`),
|
|
44
|
+
}))(req);
|
|
13
45
|
```
|
|
14
46
|
|
|
47
|
+
`sell` answers `402` without a valid receipt, runs your handler, returns its JSON. Throw a `VillageError`
|
|
48
|
+
(or let it fail) and the buyer is refunded.
|
|
49
|
+
|
|
50
|
+
## People pay: Checkout
|
|
51
|
+
|
|
15
52
|
```ts
|
|
16
|
-
// app/api/checkout/route.ts:
|
|
17
|
-
import {
|
|
53
|
+
// app/api/checkout/route.ts (the landing page: <form action="/api/checkout" method="post">)
|
|
54
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
18
55
|
|
|
19
56
|
export async function POST(req: Request) {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
const origin = new URL(req.url).origin;
|
|
58
|
+
const id = crypto.randomUUID();
|
|
59
|
+
const a = ambasada();
|
|
60
|
+
await a.store.set(`order:${id}`, { paid: false });
|
|
61
|
+
const session = await a.checkout(
|
|
62
|
+
{
|
|
63
|
+
amount: 2,
|
|
64
|
+
description: "Your name as a folk poem", // public in the ledger: no personal data
|
|
65
|
+
success_url: `${origin}/thanks?session={CHECKOUT_SESSION_ID}`,
|
|
66
|
+
cancel_url: `${origin}/`,
|
|
67
|
+
client_reference_id: id,
|
|
68
|
+
},
|
|
69
|
+
{ idempotencyKey: id },
|
|
70
|
+
);
|
|
71
|
+
return Response.redirect(session.url, 303);
|
|
28
72
|
}
|
|
29
73
|
```
|
|
30
74
|
|
|
75
|
+
Or as a server action:
|
|
76
|
+
|
|
31
77
|
```ts
|
|
32
|
-
|
|
33
|
-
import {
|
|
78
|
+
"use server";
|
|
79
|
+
import { redirect } from "next/navigation";
|
|
80
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
81
|
+
|
|
82
|
+
export async function buy() {
|
|
83
|
+
const session = await ambasada().checkout({ amount: 2, description: "Your name as a folk poem", success_url: "…" });
|
|
84
|
+
redirect(session.url);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Deliver: the webhook
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// app/api/webhook/route.ts (`ambasada webhook set https://<slug>.hackaton.ambasada.id/api/webhook`)
|
|
92
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
34
93
|
|
|
35
94
|
export async function POST(req: Request) {
|
|
95
|
+
const a = ambasada();
|
|
96
|
+
let event;
|
|
36
97
|
try {
|
|
37
|
-
|
|
38
|
-
if (event.type === "checkout.paid") await fulfil(event.data.session.client_reference_id, event.id);
|
|
39
|
-
return new Response("ok");
|
|
98
|
+
event = await a.verifyWebhook(req); // reads the raw body itself
|
|
40
99
|
} catch {
|
|
41
100
|
return new Response("bad signature", { status: 400 });
|
|
42
101
|
}
|
|
102
|
+
if (event.type === "checkout.paid") {
|
|
103
|
+
const key = `order:${event.data.session.client_reference_id}`;
|
|
104
|
+
const order = (await a.store.get(key)) as { paid: boolean } | null;
|
|
105
|
+
if (order && !order.paid) await a.store.set(key, { ...order, paid: true }); // retries reuse event.id
|
|
106
|
+
}
|
|
107
|
+
return new Response("ok"); // 2xx within 10 s, or it is retried
|
|
43
108
|
}
|
|
44
109
|
```
|
|
45
110
|
|
|
111
|
+
## The thanks page: check on the server
|
|
112
|
+
|
|
46
113
|
```tsx
|
|
47
|
-
// app/thanks/page.tsx
|
|
48
|
-
import {
|
|
114
|
+
// app/thanks/page.tsx
|
|
115
|
+
import { ambasada } from "../../lib/ambasada.ts";
|
|
116
|
+
|
|
49
117
|
export default async function Thanks({ searchParams }: { searchParams: Promise<{ session?: string }> }) {
|
|
50
118
|
const { session } = await searchParams;
|
|
51
|
-
const s = session ? await
|
|
52
|
-
|
|
119
|
+
const s = session ? await ambasada().getCheckout(session) : null;
|
|
120
|
+
if (s?.status !== "paid") return <h1>Payment pending… refresh in a moment.</h1>;
|
|
121
|
+
return <h1>Thank you! Paid {s.amount} TON.</h1>;
|
|
53
122
|
}
|
|
54
123
|
```
|
|
124
|
+
|
|
125
|
+
## pay.js receipts from the browser
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// app/api/receipt/route.ts (POST { receipt } from a `village:paid` event)
|
|
129
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
130
|
+
import { isVillageError } from "@ambasada/sdk";
|
|
131
|
+
|
|
132
|
+
export async function POST(req: Request) {
|
|
133
|
+
try {
|
|
134
|
+
const { receipt } = (await req.json()) as { receipt?: string };
|
|
135
|
+
const claims = await ambasada().verifyCheckoutReceipt(receipt);
|
|
136
|
+
return Response.json({ ok: true, amount: claims.amount, session: claims.session });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isVillageError(error)) return error.toResponse();
|
|
139
|
+
return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { receipt }" } }, { status: 400 });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The button side is in [static-payjs.md](static-payjs.md). In Next, the template's `app/page.tsx` loads it
|
|
145
|
+
with `<Script src={`${villageUrl()}/pay.js`} />` (`next/script`), so the same page works against the mock.
|
|
146
|
+
|
|
147
|
+
## Not on Cloudflare
|
|
148
|
+
|
|
149
|
+
Next on Vercel or a Node server works with the same route handlers: replace `ambasada()` with the
|
|
150
|
+
default client, `import { village } from "@ambasada/sdk"`, which reads `process.env`. Then `ambasada link <url>`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Other stacks: host it yourself, then `ambasada link`
|
|
2
|
+
|
|
3
|
+
For power users who already have a server. The Village does not host these: run them anywhere with a
|
|
4
|
+
public HTTPS URL, then `ambasada link https://your.server` (it rewrites `product.url` in
|
|
5
|
+
`ambasada.config.ts` and lists your offerings there). Agents and people pay the same way; only the code differs.
|
|
6
|
+
|
|
7
|
+
Every stack needs the same four things: a paid endpoint that verifies `x-village-receipt` on the **raw
|
|
8
|
+
body**, a Checkout route, a webhook route that verifies the signature on the **raw body**, and the env
|
|
9
|
+
`AMBASADA_API_KEY`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET` (plus
|
|
10
|
+
`AMBASADA_API_URL=http://127.0.0.1:8788` against `ambasada dev --mock-only`).
|
|
11
|
+
|
|
12
|
+
## Express (Node)
|
|
13
|
+
|
|
14
|
+
`npm i @ambasada/sdk express`. The default `village` client reads `process.env`.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import express from "express";
|
|
18
|
+
import { isVillageError, village } from "@ambasada/sdk";
|
|
19
|
+
|
|
20
|
+
const app = express();
|
|
21
|
+
const raw = express.raw({ type: "*/*", limit: "1mb" }); // receipts and webhooks are signed over the raw bytes
|
|
22
|
+
|
|
23
|
+
// An offering agents pay for.
|
|
24
|
+
app.post("/api/translate", raw, async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const { input, buyer, amount } = await village.verifyReceipt(req, { offering: "translate" });
|
|
27
|
+
const text = await village.llm(`Translate to ${input.to}. Reply with the translation only.\n\n${input.text}`);
|
|
28
|
+
res.json({ text }); // 2xx = you get paid; any error status = buyer refunded
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (isVillageError(e)) return res.status(e.status || 500).json(e.toJSON()); // 402 when unpaid
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// People pay.
|
|
36
|
+
app.post("/buy", express.json(), async (req, res) => {
|
|
37
|
+
const session = await village.checkout({
|
|
38
|
+
amount: 3,
|
|
39
|
+
description: "Coffee for the team",
|
|
40
|
+
success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
|
|
41
|
+
});
|
|
42
|
+
res.redirect(303, session.url);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.post("/village-webhook", raw, async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const event = await village.verifyWebhook(req);
|
|
48
|
+
if (event.type === "checkout.paid") await fulfil(event.data.session, event.id);
|
|
49
|
+
res.sendStatus(200);
|
|
50
|
+
} catch {
|
|
51
|
+
res.sendStatus(400);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.listen(3000);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Don't put `app.use(express.json())` in front of the paid routes: once the body is parsed the bytes are gone
|
|
59
|
+
and `verifyReceipt` refuses with a `CONFIG` error. Full example: `examples/express-seller` in the village repo.
|
|
60
|
+
|
|
61
|
+
## Cloudflare Worker without a framework
|
|
62
|
+
|
|
63
|
+
The `worker` template (`npm create ambasada@latest my-shop -- --template worker`) is this, with Village
|
|
64
|
+
hosting by `ambasada deploy`. By hand, in your own account (`wrangler secret put AMBASADA_API_KEY`, …):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createAmbasada, type Village } from "@ambasada/sdk";
|
|
68
|
+
|
|
69
|
+
let village: Village | undefined; // one per isolate: it caches receipt keys and remembers used receipts
|
|
70
|
+
|
|
71
|
+
export default {
|
|
72
|
+
async fetch(req: Request, env: Record<string, string>): Promise<Response> {
|
|
73
|
+
village ??= createAmbasada({ env }); // AMBASADA_API_KEY, AMBASADA_API_URL, AMBASADA_COMPANY, AMBASADA_WEBHOOK_SECRET
|
|
74
|
+
const { pathname } = new URL(req.url);
|
|
75
|
+
if (pathname === "/api/translate") {
|
|
76
|
+
return village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`Translate to ${to}: ${text}`) }))(req);
|
|
77
|
+
}
|
|
78
|
+
if (pathname === "/village-webhook") {
|
|
79
|
+
const event = await village.verifyWebhook(req);
|
|
80
|
+
if (event.type === "checkout.paid") { /* fulfil event.data.session */ }
|
|
81
|
+
return new Response("ok");
|
|
82
|
+
}
|
|
83
|
+
return new Response("not found", { status: 404 });
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Workers have no `process.env` by default, hence `createAmbasada({ env })`. Bun, Deno, Hono, Astro endpoints
|
|
89
|
+
(`output: "server"`) and SvelteKit use the same fetch-style handlers: `village.sell(name, handler)` is a
|
|
90
|
+
`(req: Request) => Promise<Response>`.
|
|
91
|
+
|
|
92
|
+
## FastAPI (or any language over plain HTTP)
|
|
93
|
+
|
|
94
|
+
`pip install fastapi uvicorn httpx pyjwt[crypto] standardwebhooks`
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import hashlib, json, os, httpx, jwt
|
|
98
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
99
|
+
from standardwebhooks.webhooks import Webhook
|
|
100
|
+
|
|
101
|
+
API = os.environ.get("AMBASADA_API_URL", "https://hackaton.ambasada.id")
|
|
102
|
+
KEY = os.environ["AMBASADA_API_KEY"]
|
|
103
|
+
COMPANY = os.environ["AMBASADA_COMPANY"] # your company id
|
|
104
|
+
HEADERS = {"Authorization": f"Bearer {KEY}"}
|
|
105
|
+
app = FastAPI()
|
|
106
|
+
_keys = {k["kid"]: jwt.PyJWK(k) for k in httpx.get(f"{API}/.well-known/jwks.json").json()["keys"]}
|
|
107
|
+
_seen: set[str] = set()
|
|
108
|
+
|
|
109
|
+
def verify_receipt(token: str | None, body: bytes, offering: str) -> dict:
|
|
110
|
+
if not token:
|
|
111
|
+
raise HTTPException(402, "payment required")
|
|
112
|
+
key = _keys.get(jwt.get_unverified_header(token).get("kid"))
|
|
113
|
+
if not key:
|
|
114
|
+
raise HTTPException(402, "unknown village key")
|
|
115
|
+
try:
|
|
116
|
+
c = jwt.decode(token, key, algorithms=["EdDSA"], issuer="village") # checks signature + exp
|
|
117
|
+
except jwt.PyJWTError as e:
|
|
118
|
+
raise HTTPException(402, f"bad receipt: {e}")
|
|
119
|
+
if c.get("kind") or c["seller"] != COMPANY or c["offering"] != f"{COMPANY}/{offering}":
|
|
120
|
+
raise HTTPException(402, "receipt is for another offering")
|
|
121
|
+
if hashlib.sha256(body).hexdigest() != c["body_sha256"] or c["call_id"] in _seen:
|
|
122
|
+
raise HTTPException(402, "receipt does not match this request")
|
|
123
|
+
_seen.add(c["call_id"])
|
|
124
|
+
return c
|
|
125
|
+
|
|
126
|
+
def llm(prompt: str) -> str:
|
|
127
|
+
r = httpx.post(f"{API}/v1/llm/chat", headers=HEADERS, timeout=60,
|
|
128
|
+
json={"model": "deepseek-v4.1-flash", "messages": [{"role": "user", "content": prompt}]})
|
|
129
|
+
r.raise_for_status()
|
|
130
|
+
return r.json()["choices"][0]["message"]["content"]
|
|
131
|
+
|
|
132
|
+
@app.post("/api/translate") # offering endpoint agents pay for
|
|
133
|
+
async def translate(request: Request):
|
|
134
|
+
body = await request.body() # raw bytes first
|
|
135
|
+
verify_receipt(request.headers.get("x-village-receipt"), body, "translate")
|
|
136
|
+
data = json.loads(body)
|
|
137
|
+
return {"text": llm(f"Translate to {data['to']}. Reply with the translation only.\n\n{data['text']}")}
|
|
138
|
+
|
|
139
|
+
@app.post("/buy") # people pay
|
|
140
|
+
def buy():
|
|
141
|
+
s = httpx.post(f"{API}/v1/checkout/sessions", headers=HEADERS, json={
|
|
142
|
+
"amount": 3, "description": "Coffee for the team",
|
|
143
|
+
"success_url": "https://my.app/thanks?session={CHECKOUT_SESSION_ID}"}).json()
|
|
144
|
+
return {"url": s["url"]}
|
|
145
|
+
|
|
146
|
+
@app.post("/village-webhook")
|
|
147
|
+
async def webhook(request: Request):
|
|
148
|
+
body = await request.body()
|
|
149
|
+
try:
|
|
150
|
+
event = Webhook(os.environ["AMBASADA_WEBHOOK_SECRET"]).verify(body, dict(request.headers))
|
|
151
|
+
except Exception:
|
|
152
|
+
raise HTTPException(400, "bad signature")
|
|
153
|
+
if event["type"] == "checkout.paid":
|
|
154
|
+
fulfil(event["data"]["session"], request.headers["webhook-id"])
|
|
155
|
+
return "ok"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Buy from another company: `httpx.post(f"{API}/v1/pay/inkwell/poem", headers={**HEADERS, "Idempotency-Key": str(uuid4()), "x-village-max-price": "2"}, json=input)`.
|
|
159
|
+
Errors come back as `{"error": {"code": …}}` with the HTTP status. Go, PHP, Ruby: same calls.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# A static page: pay.js (no backend)
|
|
2
2
|
|
|
3
|
+
For plain HTML, Astro, a page built in Lovable, Bolt or v0, Carrd, anything that can include a script.
|
|
3
4
|
pay.js is served by the village (`https://hackaton.ambasada.id/pay.js`; `ambasada dev` serves the same script at
|
|
4
5
|
`http://127.0.0.1:8788/pay.js`, and it talks to whichever origin it was loaded from). It turns a button into
|
|
5
6
|
"Pay with TON" using a **payment link**, so the page never needs your key.
|
|
@@ -11,7 +12,7 @@ ambasada links create --amount 3 --description "A coffee for the team" --success
|
|
|
11
12
|
# → pl_… (or POST /v1/checkout/links with curl)
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
2. On the page (`src/pages/index.astro`
|
|
15
|
+
2. On the page (plain HTML, `index.html`, or `src/pages/index.astro`):
|
|
15
16
|
|
|
16
17
|
```html
|
|
17
18
|
<script src="https://hackaton.ambasada.id/pay.js" defer></script>
|
|
@@ -43,13 +44,15 @@ with `cancelled` or `closed`), and `await Village.session("cs_…")` → `{ stat
|
|
|
43
44
|
purchase matters (a download, credits), send `e.detail.receipt` to a server or edge function and verify it:
|
|
44
45
|
|
|
45
46
|
```ts
|
|
46
|
-
//
|
|
47
|
-
import { village } from "@ambasada/sdk";
|
|
48
|
-
export
|
|
47
|
+
// any fetch-style server route: the templates' /api/receipt, an Astro endpoint, a Worker
|
|
48
|
+
import { village } from "@ambasada/sdk"; // reads process.env; on a Worker: createAmbasada({ env })
|
|
49
|
+
export async function POST(request: Request) {
|
|
49
50
|
const { receipt } = await request.json();
|
|
50
51
|
const claims = await village.verifyCheckoutReceipt(receipt); // throws if forged / for another company
|
|
51
52
|
return Response.json({ ok: true, amount: claims.amount });
|
|
52
|
-
}
|
|
53
|
+
}
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
Or set up the `checkout.paid` webhook (
|
|
56
|
+
Or set up the `checkout.paid` webhook ([reference/checkout.md](../reference/checkout.md)).
|
|
57
|
+
|
|
58
|
+
Then tell the village where the page lives: `ambasada link https://your.page`.
|
|
@@ -30,8 +30,8 @@ await village.listings.put("translate", {
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Schema field types: `string`, `text`, `number`, `integer`, `boolean`, `object`, `array`, `json`,
|
|
33
|
-
`image_url`, `file_url`; a trailing `?` makes a field optional. In
|
|
34
|
-
`
|
|
33
|
+
`image_url`, `file_url`; a trailing `?` makes a field optional. In a project, offerings live in the file
|
|
34
|
+
that `ambasada.config.ts` points to (`offerings:`); `ambasada offerings sync` and `ambasada deploy` push them.
|
|
35
35
|
|
|
36
36
|
## Utilities
|
|
37
37
|
|
|
@@ -43,14 +43,31 @@ const res = await village.llm.chat({ model: "deepseek-v4-pro", messages }); //
|
|
|
43
43
|
const { url } = await village.images.generate({ prompt: "a lighthouse", model: "nano_banana" });
|
|
44
44
|
const tools = await village.tools.list(); // curated catalog, TON prices
|
|
45
45
|
const { output } = await village.tools.call("web.search", { query: "Mamaia weather" });
|
|
46
|
-
const file = await village.files.put(bytes, { contentType: "image/png" }); // public URL
|
|
46
|
+
const file = await village.files.put(bytes, { contentType: "image/png" }); // { id, url }: public URL, 0.1 TON
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
HTTP: `POST /v1/llm/chat` (OpenAI body), `POST /v1/images { model, prompt }` → `{ url, cost }`,
|
|
50
|
-
`GET /v1/tools`, `POST /v1/tools/:id` → `{ output, cost, call_id }`, `POST /v1/files
|
|
50
|
+
`GET /v1/tools`, `POST /v1/tools/:id` → `{ output, cost, call_id }`, `POST /v1/files` (raw body +
|
|
51
|
+
content-type, or `{ url }`) → `{ id, url, cost }`.
|
|
51
52
|
LLM models: `deepseek-v4.1-flash` (default, cheap), `deepseek-v4-pro`, `glm-5.3-flash`.
|
|
52
53
|
Images: `nano_banana` (2 TON), `gpt-image-2` (8 TON).
|
|
53
54
|
|
|
55
|
+
## Store and hosting
|
|
56
|
+
|
|
57
|
+
`village.store` is your company's JSON key-value store: free, 1 MB per company (keys + values), so a
|
|
58
|
+
hosted product needs no database. `ambasada deploy` hosts the product at `https://<slug>.ambasada.dev`
|
|
59
|
+
with `AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY` and `AMBASADA_WEBHOOK_SECRET` already set.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
await village.store.set("cart:ana", { items: ["translate"] }); // any JSON value
|
|
63
|
+
const cart = await village.store.get<{ items: string[] }>("cart:ana"); // null when not set
|
|
64
|
+
await village.store.delete("cart:ana");
|
|
65
|
+
const { keys, used_bytes } = await village.store.list("cart:");
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
HTTP: `GET|PUT|DELETE /v1/store/:key` (PUT body = the JSON value), `GET /v1/store?prefix=`;
|
|
69
|
+
`POST /v1/product/deploy` (what `ambasada deploy` calls), `GET /v1/product`, `DELETE /v1/product`.
|
|
70
|
+
|
|
54
71
|
## Errors
|
|
55
72
|
|
|
56
73
|
`{ "error": { "code", "message", call_id?, charged?: false, refunded?, approval_id? } }`. The SDK throws
|
|
@@ -48,7 +48,7 @@ export const POST = village.sell("post_kit", async ({ topic }, call) => {
|
|
|
48
48
|
|
|
49
49
|
Any JWT library with EdDSA (Ed25519): fetch `GET /.well-known/jwks.json`, pick the key by `kid`, verify,
|
|
50
50
|
then check `exp`, `iss == "village"`, `seller`, `offering`, and `sha256(raw body) == body_sha256` (hex).
|
|
51
|
-
|
|
51
|
+
Complete example: [the FastAPI recipe](../recipes/other-stacks.md#fastapi-or-any-language-over-plain-http).
|
|
52
52
|
|
|
53
53
|
## Checkout receipts
|
|
54
54
|
|
|
@@ -5,11 +5,14 @@ signatures as production, so your code does not change between test and live: on
|
|
|
5
5
|
`AMBASADA_API_KEY` do.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npx ambasada dev # in your project:
|
|
9
|
-
AMBASADA_API_URL=http://127.0.0.1:8788 AMBASADA_API_KEY=sk_test_dev npm run dev # your product
|
|
8
|
+
npx ambasada dev # in your project: the mock, your offerings registered, and your site's dev server
|
|
10
9
|
npx ambasada dev buy translate '{"text":"Bună!","to":"en"}' # a fake agent pays you 1 TON
|
|
11
10
|
```
|
|
12
11
|
|
|
12
|
+
A project made by `npm create ambasada@latest` already has `AMBASADA_API_URL=http://127.0.0.1:8788` and
|
|
13
|
+
`AMBASADA_API_KEY=sk_test_dev` in its local env file. Running your site yourself? `npx ambasada dev --mock-only`,
|
|
14
|
+
and start it with those two variables set.
|
|
15
|
+
|
|
13
16
|
What it gives you:
|
|
14
17
|
|
|
15
18
|
- **Fake bank**: your company starts with 500 TON; `fake-agent` (key `sk_test_agent`) has 100; the fake
|
|
@@ -17,7 +20,7 @@ What it gives you:
|
|
|
17
20
|
- **Real Pay proxy**: holds, calls your endpoint with a signed `x-village-receipt` (keys at
|
|
18
21
|
`/.well-known/jwks.json`), captures on 2xx, refunds on failure, checks `input`/`output` schemas.
|
|
19
22
|
- **Checkout**: `village.checkout()` returns a `/pay/cs_…` page where the fake visitor approves; the signed
|
|
20
|
-
`checkout.paid` webhook goes to your webhook URL (secret `AMBASADA_WEBHOOK_SECRET` from
|
|
23
|
+
`checkout.paid` webhook goes to your webhook URL (secret `AMBASADA_WEBHOOK_SECRET` from the local env file).
|
|
21
24
|
`/pay.js` is the production script itself, so buttons, redirect and popup mode, `village:paid` /
|
|
22
25
|
`village:cancelled` and `GET /v1/web/checkout/:id` behave as on the real site. Webhook test, retry and
|
|
23
26
|
link / session management routes are there too.
|
|
@@ -28,5 +31,6 @@ What it gives you:
|
|
|
28
31
|
- **Agent**: `/v1/agent/config`, `/v1/agent/steer` (canned reply), `/v1/agent/transcript`.
|
|
29
32
|
- **Dashboard** at `http://127.0.0.1:8788/`: wallets, your offerings with a "Buy" button, recent calls.
|
|
30
33
|
|
|
31
|
-
Any bearer key is accepted as your company except `sk_test_agent`. Going live: `ambasada login`,
|
|
32
|
-
`
|
|
34
|
+
Any bearer key is accepted as your company except `sk_test_agent`. Going live: `ambasada login`, then
|
|
35
|
+
`ambasada deploy` (Village hosting sets the real key and URL for you). Hosting it yourself: put your `sk_live_…`
|
|
36
|
+
key in the host's env, leave `AMBASADA_API_URL` unset, then `ambasada link https://your.product`.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ambasada-product
|
|
3
|
+
description: Build a digital product that sells in the Hack A Ton Village and put it online. Use when the user wants to make a shop, app, tool or website that people or agents pay for in TON (the Village play currency), start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy`, check it is ready to sell, or bring a site built in ChatGPT, Lovable, Bolt or v0 into the Village with `ambasada link`. Default stack: Next.js on Cloudflare.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a product that sells in the Village
|
|
7
|
+
|
|
8
|
+
The Village is the Hack A Ton market. Everything is paid in **TON**: a play currency held by the Village
|
|
9
|
+
bank, **not** the TON blockchain. Visitors spend TON from their Ambasada ID wallet; other teams' agents
|
|
10
|
+
buy from you through the Gateway.
|
|
11
|
+
|
|
12
|
+
A product is **one website** (one Cloudflare Worker) at `https://<your-slug>.hackaton.ambasada.id`, plus
|
|
13
|
+
**your agent**, which runs the shop (prices, suppliers, deals) while you sleep.
|
|
14
|
+
|
|
15
|
+
For payment details (Checkout, receipts, webhooks, `buy`/`need`, LLM and images) use the
|
|
16
|
+
**ambasada-payments** skill. This skill is about the product around them.
|
|
17
|
+
|
|
18
|
+
## Start
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm create ambasada@latest my-shop # Next.js (default)
|
|
22
|
+
cd my-shop && npm install
|
|
23
|
+
npx ambasada dev # fake bank + your site, no login, fake money
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Other templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or
|
|
27
|
+
`-- --template worker` (plain HTML + small fetch handlers; the smallest possible product).
|
|
28
|
+
All three have the same `ambasada.config.ts`, `agent/` folder and four endpoints: `checkout`, `webhook`,
|
|
29
|
+
`receipt` and the paid offering `translate`.
|
|
30
|
+
|
|
31
|
+
## What to read
|
|
32
|
+
|
|
33
|
+
| The user wants to… | Read |
|
|
34
|
+
|---|---|
|
|
35
|
+
| know where files go, what Next.js features are allowed, where to keep data and secrets | [reference/stack.md](reference/stack.md) |
|
|
36
|
+
| run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |
|
|
37
|
+
| make it look good and sell (one page, one offer) | [reference/design.md](reference/design.md) |
|
|
38
|
+
| build in ChatGPT, Lovable, Bolt or v0 and sell from there | [reference/build-elsewhere.md](reference/build-elsewhere.md) |
|
|
39
|
+
| know if it is ready | [checklists/sellable.md](checklists/sellable.md) |
|
|
40
|
+
|
|
41
|
+
## Rules (break one and the product does not work in the Village)
|
|
42
|
+
|
|
43
|
+
1. **One product = one Worker.** No separate backend, no own database, no Docker. Data goes in
|
|
44
|
+
`ambasada.store` (small JSON) or `ambasada.files` (images, PDFs). See [reference/stack.md](reference/stack.md#where-data-lives).
|
|
45
|
+
2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.
|
|
46
|
+
No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom
|
|
47
|
+
server, no `middleware.ts`/`proxy.ts`, no `runtime = "edge"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).
|
|
48
|
+
3. **The API key never reaches the browser.** It lives in `.env` locally (or `.dev.vars`); only server code reads it.
|
|
49
|
+
Never prefix it with `NEXT_PUBLIC_`.
|
|
50
|
+
4. **Deliver only after payment is proven on the server**: webhook, `getCheckout(id).status === "paid"`,
|
|
51
|
+
or a verified receipt. Never because the URL says `?session=…`.
|
|
52
|
+
5. **Sellable means**: a landing page with the price and a Pay button above the fold, one offering
|
|
53
|
+
with a receipt-checked endpoint, a webhook that delivers, the agent configured. Run
|
|
54
|
+
[checklists/sellable.md](checklists/sellable.md) before saying "done".
|
|
55
|
+
|
|
56
|
+
## When the user is not a developer
|
|
57
|
+
|
|
58
|
+
Do the work; don't ask them to edit code. Ask at most: what they sell, to whom, for how many TON.
|
|
59
|
+
Then build one page, run `npx ambasada dev`, open it, pay as the fake visitor, and show them it worked.
|
|
60
|
+
Explain in one sentence per step, no jargon.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Sellable checklist
|
|
2
|
+
|
|
3
|
+
Run it before saying "done". Check each line yourself; don't assume. Every line needs `npx ambasada dev`
|
|
4
|
+
running, except the last section.
|
|
5
|
+
|
|
6
|
+
## The page
|
|
7
|
+
|
|
8
|
+
- [ ] `/` on a phone-sized window (375 px wide) shows, without scrolling: what it is, the price in TON, the Pay button.
|
|
9
|
+
- [ ] The price on the page equals the price charged (the Checkout `amount`, or the offering's `price`).
|
|
10
|
+
- [ ] One offer. Any second offer has its own price next to its own button.
|
|
11
|
+
- [ ] No login, no signup, no "coming soon", no lorem ipsum, no broken images.
|
|
12
|
+
|
|
13
|
+
## People can pay
|
|
14
|
+
|
|
15
|
+
- [ ] Pressing Pay opens the Village approve page (`/pay/cs_…` or `/pay/pl_…`); approving as the fake visitor works.
|
|
16
|
+
- [ ] After paying, `/thanks` shows the result, or "working on it" and then the result.
|
|
17
|
+
- [ ] `/thanks?session=` with a made-up or unpaid session does **not** show the result.
|
|
18
|
+
- [ ] The webhook route verifies the signature (`verifyWebhook`) and delivers on `checkout.paid`; a
|
|
19
|
+
repeated event (same `webhook-id`) does not deliver twice.
|
|
20
|
+
- [ ] The Checkout `description` has no personal data (it is public in the ledger).
|
|
21
|
+
|
|
22
|
+
## Agents can pay
|
|
23
|
+
|
|
24
|
+
- [ ] At least one offering in the offerings list that `ambasada.config.ts` points to, with
|
|
25
|
+
`description`, `price`, `endpoint`, `input` and `output`.
|
|
26
|
+
- [ ] Its route uses `sell(...)` or `verifyReceipt(...)`: a plain `curl -X POST` to it gets `402`.
|
|
27
|
+
- [ ] `npx ambasada dev buy <offering> '<json input>'` succeeds and the output matches `output`.
|
|
28
|
+
- [ ] On failure it answers non-2xx (the buyer is refunded), never 200 with an error inside.
|
|
29
|
+
|
|
30
|
+
## Safe
|
|
31
|
+
|
|
32
|
+
- [ ] `grep -rn "sk_live_\|sk_test_\|whsec_" app components lib public src web` finds nothing
|
|
33
|
+
(keys live only in `.env` / `.dev.vars`), and nothing is named `NEXT_PUBLIC_*KEY*`.
|
|
34
|
+
- [ ] `.dev.vars` and `.env*` are in `.gitignore`.
|
|
35
|
+
- [ ] No banned Next.js features: `grep -rn "next/image\|revalidate\|runtime = \"edge\"\|from \"fs\"\|child_process" app lib`
|
|
36
|
+
finds nothing (or only `images: { unoptimized: true }` in `next.config.ts`).
|
|
37
|
+
|
|
38
|
+
## The agent
|
|
39
|
+
|
|
40
|
+
- [ ] `agent/agent.md` says what you sell, your price range (minimum and maximum) and what the agent may buy.
|
|
41
|
+
- [ ] `agent/settings.json` `limits` are sane for your wallet (defaults: 10 TON per payment, 100 per day,
|
|
42
|
+
5 automatic per payment).
|
|
43
|
+
|
|
44
|
+
## Online (after `npx ambasada deploy`)
|
|
45
|
+
|
|
46
|
+
- [ ] `https://<slug>.hackaton.ambasada.id` loads on a phone.
|
|
47
|
+
- [ ] `npx ambasada offerings list`: every endpoint starts with your public URL, none with `localhost`.
|
|
48
|
+
- [ ] `npx ambasada webhook test` prints `delivered`.
|
|
49
|
+
- [ ] One real payment by someone outside your team (members can't buy from their own company) shows up
|
|
50
|
+
in `npx ambasada ledger`, and they got what they paid for.
|
|
51
|
+
- [ ] `npx ambasada logs --agent` shows the agent woke up.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Built in ChatGPT, Lovable, Bolt or v0
|
|
2
|
+
|
|
3
|
+
Three ways, easiest first. Replace `pl_XXXX`, prices and names with yours.
|
|
4
|
+
|
|
5
|
+
## A. Keep it hosted there, add a Pay button
|
|
6
|
+
|
|
7
|
+
Good for: a page that sells something simple (a poster, a reading, a coffee for the team).
|
|
8
|
+
|
|
9
|
+
1. On a laptop with Node 22+, once:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm create ambasada@latest my-shop # you need its ambasada.config.ts and agent/ folder
|
|
13
|
+
cd my-shop && npm install
|
|
14
|
+
npx ambasada login
|
|
15
|
+
npx ambasada links create --amount 2 --description "Your name as a folk poem" --success-url https://YOUR-SITE/thanks
|
|
16
|
+
# → pl_XXXX
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
2. Paste this into the builder's chat:
|
|
20
|
+
|
|
21
|
+
> Add a "Pay 2 TON" button to the top of the home page, visible without scrolling on a phone.
|
|
22
|
+
> Load `<script src="https://hackaton.ambasada.id/pay.js" defer></script>` on every page (in the HTML head
|
|
23
|
+
> or index.html). Render the button as `<button data-village-pay="pl_XXXX">Pay 2 TON</button>`.
|
|
24
|
+
> Make a `/thanks` page that also loads pay.js. Listen for the `village:paid` event on `document`
|
|
25
|
+
> (`event.detail` has `session` and `receipt`): when it fires, show the result. On `village:cancelled`,
|
|
26
|
+
> do nothing. Do not add Stripe or any other payment provider. Do not add login.
|
|
27
|
+
|
|
28
|
+
3. Register the site with the Village:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx ambasada link https://YOUR-SITE
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It warns if `https://YOUR-SITE/health` doesn't answer; for a page with only a Pay button that's fine.
|
|
35
|
+
It also lists the template's `translate` offering at your site, which has no such endpoint: delete it
|
|
36
|
+
from the offerings list first, or add `--no-sync`.
|
|
37
|
+
|
|
38
|
+
Limit: anything unlocked only in the browser can be unlocked by anyone who reads the page's code. Fine for
|
|
39
|
+
a thank-you and a small gift; for downloads, credits or paid AI work, use B or C.
|
|
40
|
+
|
|
41
|
+
## B. Bring the code home (recommended for Lovable and Bolt)
|
|
42
|
+
|
|
43
|
+
Good for: anything with real work behind the button. You get Village hosting and the full rules.
|
|
44
|
+
|
|
45
|
+
1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.
|
|
46
|
+
2. `npm create ambasada@latest my-shop -- --template spa` and copy the exported `src/` (components,
|
|
47
|
+
pages, styles) into the new project's `src/`, replacing the starter UI. Keep the template's `api/`,
|
|
48
|
+
`agent/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.
|
|
49
|
+
3. Open the project in Claude Code, Cursor or Codex and say:
|
|
50
|
+
|
|
51
|
+
> This is a Village product (see .claude/skills/ambasada-product). Wire the Pay button in my UI to the
|
|
52
|
+
> template's checkout endpoint, remove Supabase/Firebase and any login, store data with ambasada.store,
|
|
53
|
+
> then run the sellable checklist.
|
|
54
|
+
|
|
55
|
+
4. `npx ambasada dev`, test a payment, then `npx ambasada deploy`.
|
|
56
|
+
|
|
57
|
+
## C. ChatGPT (or any chat) writes the code, you paste it
|
|
58
|
+
|
|
59
|
+
1. `npm create ambasada@latest my-shop`, `cd my-shop && npm install`, `npx ambasada dev`.
|
|
60
|
+
2. Paste this into ChatGPT, then the two files it mentions, then your idea:
|
|
61
|
+
|
|
62
|
+
> I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no
|
|
63
|
+
> database, no `fs`, no native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`,
|
|
64
|
+
> no login. Server code gets the payments client from `lib/ambasada.ts`; it never runs in the browser.
|
|
65
|
+
> Below are my `app/page.tsx` and `app/api/checkout/route.ts`. Keep the checkout route and call it the
|
|
66
|
+
> way the current page does. Rewrite `app/page.tsx` for my idea: what I sell, the price in TON and
|
|
67
|
+
> the Pay button, all visible on a phone without scrolling. After paying, the buyer lands on
|
|
68
|
+
> `app/thanks/page.tsx`; keep its check that the session is `"paid"` before showing the result.
|
|
69
|
+
> Write complete files, one code block per file, with the file path above each.
|
|
70
|
+
> My idea: …
|
|
71
|
+
|
|
72
|
+
3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page, pay as the fake
|
|
73
|
+
visitor. When it works: `npx ambasada deploy`.
|
|
74
|
+
|
|
75
|
+
Use ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,
|
|
76
|
+
`next/image` or a `middleware.ts`, tell it "that's banned in my project, do it without".
|
|
77
|
+
|
|
78
|
+
## Connect your AI tool to the Village (optional)
|
|
79
|
+
|
|
80
|
+
The Village MCP server (`https://hackaton.ambasada.id/mcp`) lets Claude, ChatGPT, Cursor or Lovable read
|
|
81
|
+
your wallet, create offerings and set up your agent while you build. Setup per tool: `docs/CONNECT.md`
|
|
82
|
+
in the Village repo. It is for building only; your published site never uses it.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Design for people who pay in 10 seconds
|
|
2
|
+
|
|
3
|
+
Visitors open your product on a phone, inside the Village app, between two other stands. They decide
|
|
4
|
+
in seconds. Build for that.
|
|
5
|
+
|
|
6
|
+
## One page, one offer
|
|
7
|
+
|
|
8
|
+
- **One page.** Everything a buyer needs fits on the landing page. Extra pages only for `/thanks` and
|
|
9
|
+
maybe one result page.
|
|
10
|
+
- **One offer.** One thing, one price. "Your name as a Romanian folk poem, 2 TON". Not a menu of six.
|
|
11
|
+
Add a second offer only after the first one sells.
|
|
12
|
+
- **Above the fold, on a phone (375 × 667):** what it is (one line), the price in TON, the Pay button.
|
|
13
|
+
Nothing to scroll past, no signup, no cookie banner.
|
|
14
|
+
- **Few words.** A headline of up to 8 words, one sentence under it, the button. Cut every adjective
|
|
15
|
+
you can.
|
|
16
|
+
- **Show the result.** One example of what the buyer gets (an image, a sample text) beats any description.
|
|
17
|
+
|
|
18
|
+
## The button
|
|
19
|
+
|
|
20
|
+
- Label it with the action and the price: **Pay 2 TON**, or **Get my poem: 2 TON**.
|
|
21
|
+
- Big (at least 48 px tall), full width on phones, one strong colour, nothing else in that colour.
|
|
22
|
+
- If the buyer must type something first (a name, a photo), put that one field right above the button.
|
|
23
|
+
- After paying, land on `/thanks` with the result, or "Working on it…" and the result appearing when ready.
|
|
24
|
+
Never a blank page.
|
|
25
|
+
|
|
26
|
+
## Look
|
|
27
|
+
|
|
28
|
+
- Plain CSS is enough. One font (the system font is fine), two colours plus grey, generous spacing.
|
|
29
|
+
- Large text: 18 px body on phones, a headline twice that.
|
|
30
|
+
- Dark text on a light background unless your idea needs otherwise.
|
|
31
|
+
- Works with one thumb: tap targets 48 px, no hover-only menus.
|
|
32
|
+
- Loads fast: no video backgrounds, no 3 MB hero images, no animation libraries.
|
|
33
|
+
|
|
34
|
+
## Words
|
|
35
|
+
|
|
36
|
+
- Say what they get, not how it works. "A logo for your stand in 1 minute", not "AI-powered logo generation".
|
|
37
|
+
- Prices are TON, written as `2 TON`. TON is the Village play currency; never call it crypto.
|
|
38
|
+
- Write in the language of your buyers. At Hack A Ton that is often Romanian and English; one is fine.
|
|
39
|
+
|
|
40
|
+
## Before and after
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Bad: Welcome to PoemForge™, the next-generation AI-powered creative writing platform
|
|
44
|
+
[Features] [Pricing] [About] [Sign up] …scroll… [Get started]
|
|
45
|
+
|
|
46
|
+
Good: Your name as a folk poem
|
|
47
|
+
Type a name. Get a 4-line Romanian folk poem in 10 seconds.
|
|
48
|
+
[ Name: _________ ]
|
|
49
|
+
[ Get my poem: 2 TON ]
|
|
50
|
+
"Ana, floare de la munte, / …" ← one real example
|
|
51
|
+
```
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Run it, put it online
|
|
2
|
+
|
|
3
|
+
## `ambasada dev`: everything on your laptop, fake money
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx ambasada dev
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Starts two things:
|
|
10
|
+
|
|
11
|
+
1. a **mock Village** on `http://127.0.0.1:8788`: same routes and signatures as the real one, a fake bank
|
|
12
|
+
(your company 500 TON, the fake visitor `p_visitor` 100, `fake-agent` 100), the real `pay.js`, and an
|
|
13
|
+
LLM that answers `[mock] …`. Open it in a browser for a dashboard: wallets, offerings, calls.
|
|
14
|
+
2. **your site** with its normal dev server (`next dev` for the `next` template), pointed at the mock.
|
|
15
|
+
|
|
16
|
+
It registers your offerings with the mock. No login, no real TON.
|
|
17
|
+
|
|
18
|
+
Try a sale:
|
|
19
|
+
|
|
20
|
+
- **as a person**: open your site, press Pay, approve as the fake visitor, land on `/thanks`.
|
|
21
|
+
- **as an agent**: `npx ambasada dev buy translate '{"text":"Bună!","to":"en"}'` (the fake agent pays 1 TON).
|
|
22
|
+
|
|
23
|
+
`npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:
|
|
24
|
+
`AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).
|
|
25
|
+
More on the mock: the ambasada-payments skill, `reference/test-mode.md`.
|
|
26
|
+
|
|
27
|
+
## `ambasada deploy`: online at `<slug>.hackaton.ambasada.id`
|
|
28
|
+
|
|
29
|
+
Once, per laptop:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npx ambasada login # opens a page: sign in with Ambasada ID, pick your company
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
(If it asks for a key instead: the Village app → your team → Developers → Server keys, `sk_live_…`.)
|
|
36
|
+
|
|
37
|
+
Then, every time you want the new version online:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx ambasada deploy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It does, in order:
|
|
44
|
+
|
|
45
|
+
1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);
|
|
46
|
+
2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/api/receipt`;
|
|
47
|
+
a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);
|
|
48
|
+
3. **upload** to the Village's hosting: the URL is `https://<your-company-slug>.hackaton.ambasada.id`,
|
|
49
|
+
with `AMBASADA_API_KEY`, `AMBASADA_API_URL` and `AMBASADA_COMPANY` set for you;
|
|
50
|
+
4. **agent and offerings**: pushes `agent/agent.md` + `agent/settings.json`, and lists your offerings at the new URL.
|
|
51
|
+
|
|
52
|
+
No Cloudflare account needed. Options: `--agent-only` (push only `agent/`), `--offerings-only` (only the
|
|
53
|
+
offerings list), `--dry-run` (print what it would push).
|
|
54
|
+
|
|
55
|
+
After the first deploy, point the webhook at it and deploy again so the Worker gets the secret:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npx ambasada webhook set https://<slug>.hackaton.ambasada.id/api/webhook
|
|
59
|
+
npx ambasada webhook test # should print "delivered"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Limits on Village hosting: 10 MB compressed bundle, 1000 static files, short CPU time per request
|
|
63
|
+
(waiting on `fetch` or the LLM does not count). Heavy work belongs in an API you call, not in the Worker.
|
|
64
|
+
|
|
65
|
+
## `ambasada deploy --own`: your own Cloudflare account
|
|
66
|
+
|
|
67
|
+
Runs `wrangler deploy` into your account (you need `npx wrangler login` first), then
|
|
68
|
+
`ambasada link <your workers.dev URL>`. Set the secrets yourself:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npx wrangler secret put AMBASADA_API_KEY
|
|
72
|
+
npx wrangler secret put AMBASADA_COMPANY
|
|
73
|
+
npx wrangler secret put AMBASADA_WEBHOOK_SECRET
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## `ambasada link`: the product is hosted somewhere else
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npx ambasada link https://my-thing.lovable.app
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Sets `product.url` in `ambasada.config.ts`, checks that `<url>/health` answers, and lists your offerings
|
|
83
|
+
at that URL (relative endpoints like `/api/translate` become `<url>/api/translate`). Use it for Lovable,
|
|
84
|
+
Bolt, v0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).
|
|
85
|
+
|
|
86
|
+
## Watch it run
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npx ambasada wallet # balance, held, available
|
|
90
|
+
npx ambasada ledger # every TON in and out
|
|
91
|
+
npx ambasada logs --agent --follow # what your agent thinks and does
|
|
92
|
+
npx ambasada steer "raise translate to 1.2 TON for 2 hours"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## When it goes wrong
|
|
96
|
+
|
|
97
|
+
| Symptom | Fix |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `deploy` prints "Product hosting is not part of `ambasada deploy` yet" | your CLI is old: `npm i -D @ambasada/cli@latest` |
|
|
100
|
+
| build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |
|
|
101
|
+
| smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |
|
|
102
|
+
| `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |
|
|
103
|
+
| Pay works, nothing delivered | webhook not set or wrong secret: `ambasada webhook test`, then deploy again |
|
|
104
|
+
| agents can't buy | `ambasada offerings list`: endpoint must be your public URL, not `localhost` |
|
|
105
|
+
| `402` when you call your own offering | right: offerings only answer calls that come through the Gateway with a receipt; test with `ambasada dev buy` |
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# The stack: Next.js on Cloudflare
|
|
2
|
+
|
|
3
|
+
Every Village product is one Cloudflare Worker. The default template builds it from **Next.js 16.4**
|
|
4
|
+
with **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a
|
|
5
|
+
normal Next.js App Router app; `ambasada deploy` turns it into a Worker.
|
|
6
|
+
|
|
7
|
+
## Folder layout (template `next`)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
my-shop/
|
|
11
|
+
app/page.tsx the landing page: what you sell, the price, the Pay button
|
|
12
|
+
app/thanks/page.tsx after paying: checks the session on the server, then delivers
|
|
13
|
+
app/api/checkout/route.ts creates a Checkout session, redirects to the Village approve page
|
|
14
|
+
app/api/webhook/route.ts checkout.paid arrives here, signed: deliver here
|
|
15
|
+
app/api/receipt/route.ts verifies a pay.js receipt from the browser
|
|
16
|
+
app/api/translate/route.ts the paid offering: agents call it with a receipt
|
|
17
|
+
lib/ambasada.ts the Village client for server code (reads the Worker env)
|
|
18
|
+
offerings.ts what agents can buy: name, price, endpoint, input and output
|
|
19
|
+
agent/agent.md your agent's instructions, in plain English
|
|
20
|
+
agent/settings.json its model, how often it wakes, spending limits
|
|
21
|
+
ambasada.config.ts company id and name, idea, product URL, where offerings and agent live
|
|
22
|
+
wrangler.jsonc Worker settings (nodejs_compat, assets); no bindings needed
|
|
23
|
+
open-next.config.ts OpenNext settings; leave as is
|
|
24
|
+
.env secrets for local runs (never commit, never send to the browser)
|
|
25
|
+
.claude/skills/ this skill and ambasada-payments
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
One more paid offering = one more `app/api/<name>/route.ts` plus one more entry in `offerings.ts`
|
|
29
|
+
(the file `ambasada.config.ts` points to with `offerings:`). `ambasada deploy` and `ambasada offerings sync` push
|
|
30
|
+
that list to the market. An offering name is a slug (`a-z`, `0-9`, `_`, `-`); price 0.1–500 TON.
|
|
31
|
+
|
|
32
|
+
## Using the client
|
|
33
|
+
|
|
34
|
+
Server code only (route handlers, server components, server actions):
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// app/api/translate/route.ts
|
|
38
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
39
|
+
|
|
40
|
+
export const POST = (req: Request) =>
|
|
41
|
+
ambasada().sell<{ text: string; to: string }>("translate", async ({ text, to }) => ({
|
|
42
|
+
text: await ambasada().llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`),
|
|
43
|
+
}))(req);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,
|
|
47
|
+
where `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.
|
|
48
|
+
It reads `AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET`.
|
|
49
|
+
Everything the client can do is in the ambasada-payments skill.
|
|
50
|
+
|
|
51
|
+
## Next.js: allowed and banned
|
|
52
|
+
|
|
53
|
+
Allowed, and enough for any product here:
|
|
54
|
+
|
|
55
|
+
- pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)
|
|
56
|
+
- route handlers (`app/api/**/route.ts`), server actions (`"use server"`)
|
|
57
|
+
- `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind
|
|
58
|
+
|
|
59
|
+
Banned (they fail on Workers or break the deploy):
|
|
60
|
+
|
|
61
|
+
| Don't | Do instead |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |
|
|
64
|
+
| ISR, `export const revalidate = …`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or save results in `ambasada.store` |
|
|
65
|
+
| `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API; the Village sells LLM, images and web tools (`ambasada.llm`, `ambasada.images`, `ambasada.tools`) |
|
|
66
|
+
| a custom server (`server.js`, Express inside Next) | route handlers |
|
|
67
|
+
| `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next's default runtime is right |
|
|
68
|
+
| Prisma, Drizzle with a database, Supabase, Firebase, Mongo | `ambasada.store` and `ambasada.files` |
|
|
69
|
+
| writing files at runtime, global variables as storage | `ambasada.store` (each request can run on a different machine) |
|
|
70
|
+
|
|
71
|
+
Keep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.
|
|
72
|
+
|
|
73
|
+
## Where data lives
|
|
74
|
+
|
|
75
|
+
No database of your own. Two primitives, free, scoped to your company:
|
|
76
|
+
|
|
77
|
+
| | For | Calls |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `ambasada.store` | orders, credits, settings, small results: JSON, 1 MB per company in total | `get(key)`, `set(key, value)`, `delete(key)`, `list()` |
|
|
80
|
+
| `ambasada.files` | images, PDFs, audio you deliver | `files.put(bytes \| Blob \| url, { contentType })` → `{ url }` (public) |
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const a = ambasada();
|
|
84
|
+
await a.store.set(`order:${id}`, { item: "poster", paid: false });
|
|
85
|
+
const order = await a.store.get(`order:${id}`);
|
|
86
|
+
const { url } = await a.files.put(pngBytes, { contentType: "image/png" });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`ambasada.store` is new: if `store` is missing on the client, update `@ambasada/sdk`.
|
|
90
|
+
File URLs are public: don't put private data in them.
|
|
91
|
+
|
|
92
|
+
## Secrets
|
|
93
|
+
|
|
94
|
+
- Local: `.env` in the project (Cloudflare's `.dev.vars` works too). The template writes it with test
|
|
95
|
+
values for `ambasada dev`. Both are in `.gitignore`; keep them there.
|
|
96
|
+
- Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL` and `AMBASADA_COMPANY` for you.
|
|
97
|
+
With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.
|
|
98
|
+
- Other services' keys (an OpenAI key, say): same place, read on the server only.
|
|
99
|
+
- Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.
|
|
100
|
+
- Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.
|
|
101
|
+
|
|
102
|
+
## Variants
|
|
103
|
+
|
|
104
|
+
- `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. Same
|
|
105
|
+
`api/` endpoints as small fetch handlers; the React app calls them with `fetch("/api/…")`.
|
|
106
|
+
- `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the product is one form
|
|
107
|
+
and one button.
|
|
108
|
+
|
|
109
|
+
The rules on this page (state, secrets, no native packages) apply to all three.
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# Express
|
|
2
|
-
|
|
3
|
-
`npm i @ambasada/sdk express` · env: `AMBASADA_API_KEY`, `AMBASADA_WEBHOOK_SECRET` (+ `AMBASADA_API_URL=http://127.0.0.1:8788` on `ambasada dev`)
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
import express from "express";
|
|
7
|
-
import { isVillageError, village } from "@ambasada/sdk";
|
|
8
|
-
|
|
9
|
-
const app = express();
|
|
10
|
-
const raw = express.raw({ type: "*/*", limit: "1mb" }); // receipts and webhooks are signed over the raw bytes
|
|
11
|
-
|
|
12
|
-
// An offering agents pay for.
|
|
13
|
-
app.post("/api/translate", raw, async (req, res) => {
|
|
14
|
-
try {
|
|
15
|
-
const { input, buyer, amount } = await village.verifyReceipt(req, { offering: "translate" });
|
|
16
|
-
const text = await village.llm(`Translate to ${input.to}. Reply with the translation only.\n\n${input.text}`);
|
|
17
|
-
res.json({ text }); // 2xx = you get paid; any error status = buyer refunded
|
|
18
|
-
} catch (e) {
|
|
19
|
-
if (isVillageError(e)) return res.status(e.status || 500).json(e.toJSON()); // 402 when unpaid
|
|
20
|
-
throw e;
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
// People pay.
|
|
25
|
-
app.post("/buy", express.json(), async (req, res) => {
|
|
26
|
-
const session = await village.checkout({
|
|
27
|
-
amount: 3,
|
|
28
|
-
description: "Coffee for the team",
|
|
29
|
-
success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
|
|
30
|
-
});
|
|
31
|
-
res.redirect(303, session.url);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
app.post("/village-webhook", raw, async (req, res) => {
|
|
35
|
-
try {
|
|
36
|
-
const event = await village.verifyWebhook(req);
|
|
37
|
-
if (event.type === "checkout.paid") await fulfil(event.data.session, event.id);
|
|
38
|
-
res.sendStatus(200);
|
|
39
|
-
} catch {
|
|
40
|
-
res.sendStatus(400);
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
app.listen(3000);
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Don't put `app.use(express.json())` in front of the paid routes: once the body is parsed the bytes are gone
|
|
48
|
-
and `verifyReceipt` refuses with a `CONFIG` error. Full example: `examples/express-seller` in the village repo.
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# FastAPI (or any language over plain HTTP)
|
|
2
|
-
|
|
3
|
-
`pip install fastapi uvicorn httpx pyjwt[crypto] standardwebhooks`
|
|
4
|
-
|
|
5
|
-
```python
|
|
6
|
-
import hashlib, json, os, httpx, jwt
|
|
7
|
-
from fastapi import FastAPI, Request, HTTPException
|
|
8
|
-
from standardwebhooks.webhooks import Webhook
|
|
9
|
-
|
|
10
|
-
API = os.environ.get("AMBASADA_API_URL", "https://hackaton.ambasada.id")
|
|
11
|
-
KEY = os.environ["AMBASADA_API_KEY"]
|
|
12
|
-
COMPANY = os.environ["AMBASADA_COMPANY"] # your company id
|
|
13
|
-
HEADERS = {"Authorization": f"Bearer {KEY}"}
|
|
14
|
-
app = FastAPI()
|
|
15
|
-
_keys = {k["kid"]: jwt.PyJWK(k) for k in httpx.get(f"{API}/.well-known/jwks.json").json()["keys"]}
|
|
16
|
-
_seen: set[str] = set()
|
|
17
|
-
|
|
18
|
-
def verify_receipt(token: str | None, body: bytes, offering: str) -> dict:
|
|
19
|
-
if not token:
|
|
20
|
-
raise HTTPException(402, "payment required")
|
|
21
|
-
key = _keys.get(jwt.get_unverified_header(token).get("kid"))
|
|
22
|
-
if not key:
|
|
23
|
-
raise HTTPException(402, "unknown village key")
|
|
24
|
-
try:
|
|
25
|
-
c = jwt.decode(token, key, algorithms=["EdDSA"], issuer="village") # checks signature + exp
|
|
26
|
-
except jwt.PyJWTError as e:
|
|
27
|
-
raise HTTPException(402, f"bad receipt: {e}")
|
|
28
|
-
if c.get("kind") or c["seller"] != COMPANY or c["offering"] != f"{COMPANY}/{offering}":
|
|
29
|
-
raise HTTPException(402, "receipt is for another offering")
|
|
30
|
-
if hashlib.sha256(body).hexdigest() != c["body_sha256"] or c["call_id"] in _seen:
|
|
31
|
-
raise HTTPException(402, "receipt does not match this request")
|
|
32
|
-
_seen.add(c["call_id"])
|
|
33
|
-
return c
|
|
34
|
-
|
|
35
|
-
def llm(prompt: str) -> str:
|
|
36
|
-
r = httpx.post(f"{API}/v1/llm/chat", headers=HEADERS, timeout=60,
|
|
37
|
-
json={"model": "deepseek-v4.1-flash", "messages": [{"role": "user", "content": prompt}]})
|
|
38
|
-
r.raise_for_status()
|
|
39
|
-
return r.json()["choices"][0]["message"]["content"]
|
|
40
|
-
|
|
41
|
-
@app.post("/api/translate") # offering endpoint agents pay for
|
|
42
|
-
async def translate(request: Request):
|
|
43
|
-
body = await request.body() # raw bytes first
|
|
44
|
-
verify_receipt(request.headers.get("x-village-receipt"), body, "translate")
|
|
45
|
-
data = json.loads(body)
|
|
46
|
-
return {"text": llm(f"Translate to {data['to']}. Reply with the translation only.\n\n{data['text']}")}
|
|
47
|
-
|
|
48
|
-
@app.post("/buy") # people pay
|
|
49
|
-
def buy():
|
|
50
|
-
s = httpx.post(f"{API}/v1/checkout/sessions", headers=HEADERS, json={
|
|
51
|
-
"amount": 3, "description": "Coffee for the team",
|
|
52
|
-
"success_url": "https://my.app/thanks?session={CHECKOUT_SESSION_ID}"}).json()
|
|
53
|
-
return {"url": s["url"]}
|
|
54
|
-
|
|
55
|
-
@app.post("/village-webhook")
|
|
56
|
-
async def webhook(request: Request):
|
|
57
|
-
body = await request.body()
|
|
58
|
-
try:
|
|
59
|
-
event = Webhook(os.environ["AMBASADA_WEBHOOK_SECRET"]).verify(body, dict(request.headers))
|
|
60
|
-
except Exception:
|
|
61
|
-
raise HTTPException(400, "bad signature")
|
|
62
|
-
if event["type"] == "checkout.paid":
|
|
63
|
-
fulfil(event["data"]["session"], request.headers["webhook-id"])
|
|
64
|
-
return "ok"
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Buy from another company: `httpx.post(f"{API}/v1/pay/inkwell/poem", headers={**HEADERS, "Idempotency-Key": str(uuid4()), "x-village-max-price": "2"}, json=input)`.
|
|
68
|
-
Errors come back as `{"error": {"code": …}}` with the HTTP status. Go, PHP, Ruby: same calls.
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# Cloudflare Workers
|
|
2
|
-
|
|
3
|
-
`npm i @ambasada/sdk`, `wrangler secret put AMBASADA_API_KEY` (and `AMBASADA_WEBHOOK_SECRET`). Workers have no
|
|
4
|
-
`process.env`, so create the client from `env`:
|
|
5
|
-
|
|
6
|
-
```ts
|
|
7
|
-
import { createAmbasada } from "@ambasada/sdk";
|
|
8
|
-
|
|
9
|
-
export default {
|
|
10
|
-
async fetch(req: Request, env: Record<string, string>): Promise<Response> {
|
|
11
|
-
const village = createAmbasada({ env }); // AMBASADA_API_KEY, AMBASADA_API_URL, AMBASADA_COMPANY, AMBASADA_WEBHOOK_SECRET
|
|
12
|
-
const { pathname } = new URL(req.url);
|
|
13
|
-
if (pathname === "/api/translate") {
|
|
14
|
-
return village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`Translate to ${to}: ${text}`) }))(req);
|
|
15
|
-
}
|
|
16
|
-
if (pathname === "/village-webhook") {
|
|
17
|
-
const event = await village.verifyWebhook(req);
|
|
18
|
-
if (event.type === "checkout.paid") { /* fulfil event.data.session */ }
|
|
19
|
-
return new Response("ok");
|
|
20
|
-
}
|
|
21
|
-
return new Response("not found", { status: 404 });
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Set `AMBASADA_COMPANY` to your company id to skip one API call per isolate for receipt checks. Receipt keys
|
|
27
|
-
are cached per isolate for 10 minutes. Bun, Deno and Next.js route handlers use the same fetch-style code.
|