@ambasada/skills 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +22 -2
- package/package.json +34 -3
- package/skills/ambasada-payments/SKILL.md +74 -0
- package/skills/ambasada-payments/recipes/lovable-bolt.md +37 -0
- package/skills/ambasada-payments/recipes/nextjs.md +150 -0
- package/skills/ambasada-payments/recipes/other-stacks.md +159 -0
- package/skills/ambasada-payments/recipes/static-payjs.md +58 -0
- package/skills/ambasada-payments/reference/checkout.md +75 -0
- package/skills/ambasada-payments/reference/need-vs-buy.md +26 -0
- package/skills/ambasada-payments/reference/primitives.md +73 -0
- package/skills/ambasada-payments/reference/receipts.md +57 -0
- package/skills/ambasada-payments/reference/test-mode.md +36 -0
- 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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CENTYSO (https://centyso.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ambasada/skills
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Skills for AI coding tools (Claude Code, Cursor, Codex) building products for the Hack A Ton Village.
|
|
4
|
+
|
|
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.
|
|
22
|
+
|
|
23
|
+
Built by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id). MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ambasada/skills",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.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
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"skills",
|
|
8
|
+
"LICENSE",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "CENTYSO (https://centyso.com)",
|
|
13
|
+
"homepage": "https://ambasada.id",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/ambasada/cli.git",
|
|
17
|
+
"directory": "packages/skills"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/ambasada/cli/issues"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ambasada",
|
|
24
|
+
"hack-a-ton",
|
|
25
|
+
"ton",
|
|
26
|
+
"payments",
|
|
27
|
+
"cloudflare",
|
|
28
|
+
"agents"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"sync": "node scripts/sync.mjs",
|
|
35
|
+
"test": "node scripts/sync.mjs --check"
|
|
36
|
+
}
|
|
6
37
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
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 on Cloudflare (the default template), static pages with pay.js, Lovable/Bolt, and self-hosted stacks (Express, FastAPI, Workers, Astro).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Village payments
|
|
7
|
+
|
|
8
|
+
The Hack A Ton village has its own currency, **TON** (not the TON blockchain: a play currency held by
|
|
9
|
+
the village bank). Every product in the village gets paid in TON two ways:
|
|
10
|
+
|
|
11
|
+
| Who pays | Primitive | You write |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **People** (visitors, builders) | **Checkout**: redirect to the village approve page, or a pay.js button | create a session on your server; deliver on the signed webhook |
|
|
14
|
+
| **Agents and other products** | **Pay**: the Gateway calls your endpoint with a signed receipt | an HTTP POST endpoint that verifies `x-village-receipt` |
|
|
15
|
+
|
|
16
|
+
API base: `https://hackaton.ambasada.id` (`AMBASADA_API_URL`). Auth: `Authorization: Bearer <server key>`
|
|
17
|
+
(`AMBASADA_API_KEY`, from the village app → your team → Developers). **Never put the key in browser code.**
|
|
18
|
+
Amounts are TON as decimal numbers. Errors are always `{ "error": { "code", "message", ... } }`.
|
|
19
|
+
|
|
20
|
+
## Decide what the user needs
|
|
21
|
+
|
|
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
|
+
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
|
+
3. *"My product needs something another team sells"* → [reference/need-vs-buy.md](reference/need-vs-buy.md).
|
|
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
|
+
5. Always set up **test mode** first → [reference/test-mode.md](reference/test-mode.md) (`ambasada dev`, no login, fake money).
|
|
27
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
## Rules that prevent real bugs
|
|
40
|
+
|
|
41
|
+
- **Verify on the server.** A paid endpoint must verify the receipt before doing paid work; a checkout
|
|
42
|
+
must be confirmed by the webhook or `GET /v1/checkout/sessions/:id` (`status: "paid"`), never by the
|
|
43
|
+
`?session=` in the redirect URL alone. Anything unlocked purely in the browser can be unlocked by anyone.
|
|
44
|
+
- **Raw body.** Receipts and webhooks are signed over the exact bytes. Read the raw body before any JSON
|
|
45
|
+
parser (Express: `express.raw({ type: "*/*" })` on those routes).
|
|
46
|
+
- **Answer 2xx only when you delivered.** Any non-2xx (or output not matching the declared `output`
|
|
47
|
+
schema) makes the Gateway refund the buyer. Throwing is the refund path.
|
|
48
|
+
- **Idempotency.** Send `Idempotency-Key` on purchases and transfers; retries with the same key never charge twice.
|
|
49
|
+
- **One receipt, one call.** Dedupe on `call_id` (the SDK does it per process; persist it if you run several instances).
|
|
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)).
|
|
51
|
+
|
|
52
|
+
## The SDK in 20 lines
|
|
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
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { village } from "@ambasada/sdk";
|
|
61
|
+
|
|
62
|
+
// sell to agents: a fetch handler that verifies the receipt and answers JSON (402 if unpaid)
|
|
63
|
+
export const POST = village.sell("translate", async ({ text, to }, call) => {
|
|
64
|
+
return { text: await village.llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`) };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// sell to people
|
|
68
|
+
const session = await village.checkout({ amount: 3, description: "Coffee for the team", success_url: "https://my.app/thanks" });
|
|
69
|
+
// → redirect to session.url; then in the webhook handler:
|
|
70
|
+
const event = await village.verifyWebhook(req); // checkout.paid
|
|
71
|
+
|
|
72
|
+
// buy
|
|
73
|
+
const { output } = await village.need("image.square", { prompt: "a lighthouse" }, { maxPrice: 3 });
|
|
74
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Lovable, Bolt, v0 and other no-code builders
|
|
2
|
+
|
|
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`.
|
|
6
|
+
|
|
7
|
+
**1. Static "Pay with TON" button (works everywhere, no backend):** create a payment link with
|
|
8
|
+
`ambasada links create --amount 3 --description "…" --success-url https://your.app/thanks`, then paste this
|
|
9
|
+
into the builder's chat:
|
|
10
|
+
|
|
11
|
+
> Add a "Pay 3 TON" button. Include `<script src="https://hackaton.ambasada.id/pay.js" defer></script>` in the
|
|
12
|
+
> page head and render `<button data-village-pay="pl_XXXX">Pay 3 TON</button>` (add `data-mode="popup"` to
|
|
13
|
+
> stay on the page). Listen for the `village:paid` DOM event on `document`; `event.detail` has `session` and
|
|
14
|
+
> `receipt`. When it fires, show a thank-you message; on `village:cancelled` do nothing. The page that the
|
|
15
|
+
> payment returns to must also load pay.js. Do not add any other payment provider.
|
|
16
|
+
|
|
17
|
+
**2. Paid features that matter (credits, downloads, a paid API) need a server.** Lovable/Bolt apps usually have
|
|
18
|
+
Supabase Edge Functions or similar; paste this:
|
|
19
|
+
|
|
20
|
+
> Create an edge function `village-webhook` that verifies Standard Webhooks signatures (library
|
|
21
|
+
> `standardwebhooks`, secret from env `AMBASADA_WEBHOOK_SECRET`) over the raw request body, and when
|
|
22
|
+
> `type == "checkout.paid"` marks the order `data.session.client_reference_id` as paid (dedupe on the
|
|
23
|
+
> `webhook-id` header). Create an edge function `buy` that POSTs to
|
|
24
|
+
> `https://hackaton.ambasada.id/v1/checkout/sessions` with header `Authorization: Bearer ${AMBASADA_API_KEY}`
|
|
25
|
+
> and JSON `{ amount, description, success_url, client_reference_id }`, then returns the `url` from the
|
|
26
|
+
> response for the browser to redirect to. Never expose AMBASADA_API_KEY to the browser.
|
|
27
|
+
|
|
28
|
+
Then register the webhook URL: `ambasada webhook set https://…/functions/v1/village-webhook`.
|
|
29
|
+
|
|
30
|
+
**3. Selling to agents** needs an endpoint that verifies `x-village-receipt` ([reference/receipts.md](../reference/receipts.md)). In an
|
|
31
|
+
edge function with npm support: `import { village } from "npm:@ambasada/sdk"` and
|
|
32
|
+
`Deno.serve(village.sell("translate", async (input) => ({ … })))`.
|
|
33
|
+
|
|
34
|
+
**4. MCP:** the village MCP server (`https://hackaton.ambasada.id/mcp`) works as a Lovable custom chat
|
|
35
|
+
connector while building; a published app calls the HTTP API with its key or uses pay.js.
|
|
36
|
+
|
|
37
|
+
If the builder supports custom knowledge files, upload this skill's `SKILL.md` and `reference/*.md`.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Next.js on Cloudflare (the default template)
|
|
2
|
+
|
|
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
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// app/api/translate/route.ts (offering endpoint "/api/translate")
|
|
39
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
40
|
+
|
|
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);
|
|
45
|
+
```
|
|
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
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// app/api/checkout/route.ts (the landing page: <form action="/api/checkout" method="post">)
|
|
54
|
+
import { ambasada } from "../../../lib/ambasada.ts";
|
|
55
|
+
|
|
56
|
+
export async function POST(req: Request) {
|
|
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);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or as a server action:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
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";
|
|
93
|
+
|
|
94
|
+
export async function POST(req: Request) {
|
|
95
|
+
const a = ambasada();
|
|
96
|
+
let event;
|
|
97
|
+
try {
|
|
98
|
+
event = await a.verifyWebhook(req); // reads the raw body itself
|
|
99
|
+
} catch {
|
|
100
|
+
return new Response("bad signature", { status: 400 });
|
|
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
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## The thanks page: check on the server
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// app/thanks/page.tsx
|
|
115
|
+
import { ambasada } from "../../lib/ambasada.ts";
|
|
116
|
+
|
|
117
|
+
export default async function Thanks({ searchParams }: { searchParams: Promise<{ session?: string }> }) {
|
|
118
|
+
const { session } = await searchParams;
|
|
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>;
|
|
122
|
+
}
|
|
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.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# A static page: pay.js (no backend)
|
|
2
|
+
|
|
3
|
+
For plain HTML, Astro, a page built in Lovable, Bolt or v0, Carrd, anything that can include a script.
|
|
4
|
+
pay.js is served by the village (`https://hackaton.ambasada.id/pay.js`; `ambasada dev` serves the same script at
|
|
5
|
+
`http://127.0.0.1:8788/pay.js`, and it talks to whichever origin it was loaded from). It turns a button into
|
|
6
|
+
"Pay with TON" using a **payment link**, so the page never needs your key.
|
|
7
|
+
|
|
8
|
+
1. Create a link once, from your terminal:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
ambasada links create --amount 3 --description "A coffee for the team" --success-url https://my.site/thanks
|
|
12
|
+
# → pl_… (or POST /v1/checkout/links with curl)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
2. On the page (plain HTML, `index.html`, or `src/pages/index.astro`):
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<script src="https://hackaton.ambasada.id/pay.js" defer></script>
|
|
19
|
+
|
|
20
|
+
<button data-village-pay="pl_…" data-ref="table-4">Pay 3 TON</button>
|
|
21
|
+
<!-- data-mode="popup" keeps the person on the page (desktop); redirect works best on phones -->
|
|
22
|
+
|
|
23
|
+
<script>
|
|
24
|
+
// Fired on the button (bubbles) in popup mode, and on document when the person comes back from a redirect.
|
|
25
|
+
document.addEventListener("village:paid", (e) => {
|
|
26
|
+
// e.detail.session = "cs_…", e.detail.receipt = signed checkout receipt (JWT)
|
|
27
|
+
document.querySelector("#thanks").hidden = false;
|
|
28
|
+
});
|
|
29
|
+
document.addEventListener("village:cancelled", (e) => console.log("not paid:", e.detail.reason)); // "cancelled" | "closed"
|
|
30
|
+
</script>
|
|
31
|
+
<p id="thanks" hidden>Mulțumim!</p>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
How it flows: the button goes to `https://hackaton.ambasada.id/pay/pl_…` (`?ref=` from `data-ref`). After approving,
|
|
35
|
+
the person returns to the link's `success_url` with `?session=cs_…`; pay.js on that page reads
|
|
36
|
+
`GET /v1/web/checkout/cs_…` (public) and fires `village:paid`. In popup mode the approve page posts the result
|
|
37
|
+
to your page's origin and closes. Each click is a new payment; a double tap is not.
|
|
38
|
+
|
|
39
|
+
From code: `const { session, receipt } = await Village.pay("pl_…", { ref: "order-42", mode: "popup" })` (rejects
|
|
40
|
+
with `cancelled` or `closed`), and `await Village.session("cs_…")` → `{ status, amount, description, receipt, … }`.
|
|
41
|
+
`ambasada links create` makes a link; `POST /v1/checkout/links/<id>/deactivate` turns one off.
|
|
42
|
+
|
|
43
|
+
**Anything unlocked purely in the browser can be unlocked by anyone who reads your JavaScript.** If the
|
|
44
|
+
purchase matters (a download, credits), send `e.detail.receipt` to a server or edge function and verify it:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
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) {
|
|
50
|
+
const { receipt } = await request.json();
|
|
51
|
+
const claims = await village.verifyCheckoutReceipt(receipt); // throws if forged / for another company
|
|
52
|
+
return Response.json({ ok: true, amount: claims.amount });
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
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`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Checkout: people pay with a redirect, you deliver on the webhook
|
|
2
|
+
|
|
3
|
+
Same shape as Stripe Checkout.
|
|
4
|
+
|
|
5
|
+
1. **Your server** creates a session (never the browser: it needs your key).
|
|
6
|
+
2. Redirect the person to `session.url`. They approve with their Ambasada ID wallet on hackaton.ambasada.id.
|
|
7
|
+
3. They come back to `success_url` with `?session=cs_…` (or wherever you put `{CHECKOUT_SESSION_ID}`).
|
|
8
|
+
4. The village POSTs a signed `checkout.paid` webhook to your webhook URL. **Deliver there**, or after
|
|
9
|
+
`GET /v1/checkout/sessions/:id` says `"status": "paid"`. Never on the redirect alone.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { village } from "@ambasada/sdk";
|
|
13
|
+
|
|
14
|
+
// 1–2: POST /buy
|
|
15
|
+
export async function buy(req: Request) {
|
|
16
|
+
const order = await db.orders.create({ item: "post-kit" });
|
|
17
|
+
const session = await village.checkout(
|
|
18
|
+
{
|
|
19
|
+
amount: 12, // or { offering: "post_kit" } to charge an offering's price
|
|
20
|
+
description: "Launch post kit: 3 posts + images", // shown to the person AND public in the ledger: no personal data
|
|
21
|
+
success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
|
|
22
|
+
cancel_url: "https://my.app/cart",
|
|
23
|
+
client_reference_id: order.id, // comes back to you, never shown
|
|
24
|
+
},
|
|
25
|
+
{ idempotencyKey: order.id }, // a double click returns the same session
|
|
26
|
+
);
|
|
27
|
+
return Response.redirect(session.url, 303);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 4: POST /village-webhook (register once: `ambasada webhook set https://my.app/village-webhook`)
|
|
31
|
+
export async function webhook(req: Request) {
|
|
32
|
+
const event = await village.verifyWebhook(req); // AMBASADA_WEBHOOK_SECRET; throws if forged or older than 5 min
|
|
33
|
+
if (event.type === "checkout.paid") {
|
|
34
|
+
const s = event.data.session; // same shape as GET /v1/checkout/sessions/:id
|
|
35
|
+
await db.orders.markPaidOnce(s.client_reference_id, event.id); // dedupe on event.id: retries reuse it
|
|
36
|
+
}
|
|
37
|
+
return new Response("ok"); // 2xx within 10 s, or it is retried (1 min … 6 h)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3: GET /thanks
|
|
41
|
+
export async function thanks(req: Request) {
|
|
42
|
+
const s = await village.getCheckout(new URL(req.url).searchParams.get("session")!);
|
|
43
|
+
return s.status === "paid" ? deliver(s.client_reference_id) : pending();
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Webhook signature (any language)
|
|
48
|
+
|
|
49
|
+
[Standard Webhooks](https://www.standardwebhooks.com): headers `webhook-id`, `webhook-timestamp`,
|
|
50
|
+
`webhook-signature: v1,<base64>`, where the signature is HMAC-SHA256 with the bytes of the secret after
|
|
51
|
+
`whsec_` (base64-decoded) over `"<webhook-id>.<webhook-timestamp>.<raw body>"`. Off-the-shelf
|
|
52
|
+
libraries verify it: `npm i standardwebhooks`, `pip install standardwebhooks`, Go, PHP, Ruby.
|
|
53
|
+
Reject timestamps more than 5 minutes off.
|
|
54
|
+
|
|
55
|
+
## Managing it
|
|
56
|
+
|
|
57
|
+
| | HTTP | SDK |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| cancel an open session | `POST /v1/checkout/sessions/:id/expire` | `village.expireCheckout(id)` |
|
|
60
|
+
| payment links | `POST /v1/checkout/links`, `GET /v1/checkout/links`, `POST /v1/checkout/links/:id/deactivate` | `createPaymentLink`, `listPaymentLinks`, `deactivatePaymentLink` |
|
|
61
|
+
| webhook URL + secret | `PUT /v1/webhook { url, rotate_secret? }` → `{ url, secret }`, `GET /v1/webhook` (+ last deliveries), `DELETE /v1/webhook` | `setWebhook`, `getWebhook`, `deleteWebhook` |
|
|
62
|
+
| send a test event | `POST /v1/webhook/test` (event `webhook.test`) | `village.testWebhook()` |
|
|
63
|
+
| resend a delivery | `POST /v1/webhook/deliveries/:id/retry` (same `webhook-id`) | `village.retryWebhookDelivery(id)` |
|
|
64
|
+
|
|
65
|
+
Events: `{ "type": "checkout.paid", "timestamp": ISO, "data": { "session": <the session> } }` and
|
|
66
|
+
`{ "type": "webhook.test", "data": { "company", "message" } }`.
|
|
67
|
+
|
|
68
|
+
## Rules
|
|
69
|
+
|
|
70
|
+
- `amount` 0.1–500 TON. A person can spend at most 30 TON a day at one team.
|
|
71
|
+
- `metadata`: at most 20 string values. `expires_in`: 300–86400 seconds.
|
|
72
|
+
- No automatic refunds: refund with `village.transfer("p_…", amount, { reason })` (the buyer id is `session.buyer`).
|
|
73
|
+
- Sessions expire after `expires_in` seconds (default 3600).
|
|
74
|
+
- Full API guide: `docs/PAYMENTS.md` in the village repo. Without the SDK, `@ambasada/protocol` exports the same
|
|
75
|
+
`verifyWebhook(secret, headers, rawBody)` and `verifyCheckoutReceipt(token, { keys, seller, session })`.
|