@ambasada/sdk 0.2.0 → 0.4.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 +29 -0
- package/dist/auth.d.ts +95 -0
- package/dist/client.d.ts +31 -6
- package/dist/index.d.ts +3 -0
- package/dist/index.js +234 -13
- package/dist/types.d.ts +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,10 +21,39 @@ import { village } from "@ambasada/sdk"; // AMBASADA_API_KEY, AMBASADA_API_URL (
|
|
|
21
21
|
| `village.listings.put(name, def)` / `.list()` | offerings |
|
|
22
22
|
| `village.wallet()`, `village.ledger(since)`, `village.transfer(to, amount)` | money |
|
|
23
23
|
| `village.agent.config/steer/transcript`, `village.market.search(q)` | agent and market (CLI) |
|
|
24
|
+
| `village.auth.login(req)` / `.callback(req)` / `.user(req)` / `.logout(req)` | Sign in with Ambasada (below) |
|
|
24
25
|
|
|
25
26
|
Errors are `VillageError` with `code` from `@ambasada/protocol` (`isVillageError(e, "PRICE_ABOVE_MAX")`), plus
|
|
26
27
|
`ReceiptInvalidError` (402) and `WebhookInvalidError` (400). Workers: `createAmbasada({ env })`.
|
|
27
28
|
|
|
29
|
+
## Sign in with Ambasada
|
|
30
|
+
|
|
31
|
+
People sign in to your product with their Ambasada ID (OAuth 2.0 code flow + PKCE, consent screen). Zero
|
|
32
|
+
setup on `https://<slug>.ambasada.dev`: `ambasada deploy` injects `AMBASADA_AUTH_CLIENT_ID`,
|
|
33
|
+
`AMBASADA_AUTH_CLIENT_SECRET` and `AMBASADA_AUTH_ISSUER`; `ambasada dev` injects fake ones and its mock signs you in
|
|
34
|
+
as "Fake Visitor". Fetch-style handlers for Workers and Next.js route handlers, WebCrypto only.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const village = createAmbasada({ env });
|
|
38
|
+
// GET /api/auth/login?returnTo=/path → redirect to the Ambasada ID consent screen
|
|
39
|
+
return village.auth.login(req, { returnTo: "/", email: true }); // email: also ask for their email
|
|
40
|
+
// GET /api/auth/callback → checks state, exchanges the code, reads the profile, sets the cookie
|
|
41
|
+
return village.auth.callback(req);
|
|
42
|
+
// anywhere on the server
|
|
43
|
+
const user = await village.auth.user(req); // AmbasadaUser | null
|
|
44
|
+
// GET /api/auth/logout
|
|
45
|
+
return village.auth.logout(req, { returnTo: "/" });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`AmbasadaUser` is `{ id, name, avatar_url, builder, builder_since, member_since, pass, email? }`: `id` is the
|
|
49
|
+
stable Ambasada ID (`user_…`), `pass` is `"builder" | "visitor" | "staff" | null` for the current event, `email`
|
|
50
|
+
only if you asked and they agreed. The session is an HttpOnly `SameSite=Lax` cookie (`__Host-ambasada_session`
|
|
51
|
+
on https) holding the signed profile (HMAC-SHA256, key derived from the client secret), valid 7 days; no token is
|
|
52
|
+
kept. `returnTo` must be a path on your site. `user()` also takes `{ headers }` (Next server components:
|
|
53
|
+
`village.auth.user({ headers: await headers() })`). `village.auth.configured` says whether credentials are set.
|
|
54
|
+
Override them with `createAmbasada({ auth: { clientId, clientSecret, issuer, publicUrl } })`; `PUBLIC_URL` sets the
|
|
55
|
+
origin of the callback URL when the Worker sees another host. More: docs/AUTH.md.
|
|
56
|
+
|
|
28
57
|
`pnpm build` writes `dist/` (one ESM file + `.d.ts`); `publishConfig` points the published package at it.
|
|
29
58
|
|
|
30
59
|
Built by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id). MIT.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Sign in with Ambasada" (docs/AUTH.md): OAuth 2.0 authorization code + PKCE against Ambasada ID,
|
|
3
|
+
* for products hosted at https://<slug>.ambasada.dev. The deploy injects AMBASADA_AUTH_CLIENT_ID,
|
|
4
|
+
* AMBASADA_AUTH_CLIENT_SECRET and AMBASADA_AUTH_ISSUER; `ambasada dev` injects fake ones that point at
|
|
5
|
+
* the mock. Four fetch handlers, WebCrypto only, no dependencies:
|
|
6
|
+
*
|
|
7
|
+
* GET /api/auth/login → return village.auth.login(req, { returnTo: "/" })
|
|
8
|
+
* GET /api/auth/callback → return village.auth.callback(req)
|
|
9
|
+
* GET /api/auth/logout → return village.auth.logout(req)
|
|
10
|
+
* anywhere → const user = await village.auth.user(req) // AmbasadaUser | null
|
|
11
|
+
*
|
|
12
|
+
* The session is an HttpOnly cookie holding the signed public profile (HMAC-SHA256 under a key
|
|
13
|
+
* derived from the client secret), host-only (`__Host-` on https), so no other *.ambasada.dev product
|
|
14
|
+
* can read, set or forge it. No access token is kept.
|
|
15
|
+
*/
|
|
16
|
+
/** The person signed in, as `GET /v1/auth/profile` returns them. */
|
|
17
|
+
export interface AmbasadaUser {
|
|
18
|
+
/** Ambasada ID user id (`user_…`): stable, use it as your user key. */
|
|
19
|
+
id: string;
|
|
20
|
+
name: string | null;
|
|
21
|
+
avatar_url: string | null;
|
|
22
|
+
/** Has the builder flag on their Ambasada ID. */
|
|
23
|
+
builder: boolean;
|
|
24
|
+
builder_since: string | null;
|
|
25
|
+
member_since: string | null;
|
|
26
|
+
/** Their pass at the current event, if any. */
|
|
27
|
+
pass: "builder" | "visitor" | "staff" | null;
|
|
28
|
+
/** Only when you asked for it (`login(req, { email: true })`) and they agreed. */
|
|
29
|
+
email?: string | null;
|
|
30
|
+
}
|
|
31
|
+
export interface AuthConfig {
|
|
32
|
+
clientId?: string;
|
|
33
|
+
clientSecret?: string;
|
|
34
|
+
issuer?: string;
|
|
35
|
+
/** Where `/v1/auth/profile` lives (the village API). */
|
|
36
|
+
apiUrl: string;
|
|
37
|
+
/** Your public origin if the Worker sees another host (behind a proxy). Default: the request's origin. */
|
|
38
|
+
publicUrl?: string;
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
}
|
|
41
|
+
export interface LoginOptions {
|
|
42
|
+
/** Path on your site to land on after signing in (default "/"). Only same-site paths are honored. */
|
|
43
|
+
returnTo?: string;
|
|
44
|
+
/** Also ask for their email (the consent screen says so). */
|
|
45
|
+
email?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare const AUTH_PATHS: {
|
|
48
|
+
readonly login: "/api/auth/login";
|
|
49
|
+
readonly callback: "/api/auth/callback";
|
|
50
|
+
readonly logout: "/api/auth/logout";
|
|
51
|
+
};
|
|
52
|
+
/** How long a sign-in lasts (the cookie holds a profile snapshot; signing in again refreshes it). */
|
|
53
|
+
export declare const SESSION_TTL_S: number;
|
|
54
|
+
export declare function b64url(bytes: Uint8Array): string;
|
|
55
|
+
/** PKCE (RFC 7636): a random verifier and its S256 challenge. */
|
|
56
|
+
export declare function pkce(): Promise<{
|
|
57
|
+
verifier: string;
|
|
58
|
+
challenge: string;
|
|
59
|
+
}>;
|
|
60
|
+
export declare function pkceChallenge(verifier: string): Promise<string>;
|
|
61
|
+
/** `<payload>.<signature>`, both base64url; the payload is JSON with `exp` (seconds). */
|
|
62
|
+
export declare function signCookie(secret: string, purpose: string, payload: Record<string, unknown>): Promise<string>;
|
|
63
|
+
/** The payload if the signature is ours and it has not expired; null otherwise. */
|
|
64
|
+
export declare function verifyCookie<T extends {
|
|
65
|
+
exp: number;
|
|
66
|
+
}>(secret: string, purpose: string, value: string | null | undefined, now?: number): Promise<T | null>;
|
|
67
|
+
/** A fetch `Request`, or anything with its headers (Next: `{ headers: await headers() }`). */
|
|
68
|
+
export type WithHeaders = Pick<Request, "headers">;
|
|
69
|
+
export declare function readCookie(req: WithHeaders, name: string): string | null;
|
|
70
|
+
/**
|
|
71
|
+
* A relative path on this site, or "/". No open redirects: "//evil", "/\\evil", "https://…" are refused, and so is
|
|
72
|
+
* any control character (browsers drop tabs and newlines, so "/\t/evil" would become "//evil").
|
|
73
|
+
*/
|
|
74
|
+
export declare function safeReturnTo(value: string | null | undefined): string;
|
|
75
|
+
export declare class AmbasadaAuth {
|
|
76
|
+
private readonly config;
|
|
77
|
+
constructor(config: () => AuthConfig);
|
|
78
|
+
/** True when the product has credentials (hosted on ambasada.dev, or `ambasada dev`). */
|
|
79
|
+
get configured(): boolean;
|
|
80
|
+
private settings;
|
|
81
|
+
/** Redirects to Ambasada ID (consent screen) with PKCE; a short-lived signed cookie holds the state. */
|
|
82
|
+
login(req: Request, options?: LoginOptions): Promise<Response>;
|
|
83
|
+
/**
|
|
84
|
+
* Ambasada ID sends the person back here: checks the state, exchanges the code (with the PKCE
|
|
85
|
+
* verifier and the client secret), reads their profile from the village, sets the session cookie
|
|
86
|
+
* and redirects to `returnTo`. A refused consent goes back to `returnTo` signed out.
|
|
87
|
+
*/
|
|
88
|
+
callback(req: Request): Promise<Response>;
|
|
89
|
+
/** Whoever is signed in on this request, or null (also null when sign-in is not set up). */
|
|
90
|
+
user(req: WithHeaders): Promise<AmbasadaUser | null>;
|
|
91
|
+
/** Clears the session and redirects to `returnTo` (default "/"). Signs out of this product only. */
|
|
92
|
+
logout(req: Request, options?: {
|
|
93
|
+
returnTo?: string;
|
|
94
|
+
}): Response;
|
|
95
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { AmbasadaAuth } from "./auth.js";
|
|
1
2
|
import { type IncomingRequest } from "./receipt.js";
|
|
2
|
-
import type { AgentConfigInput, BuyOptions, BuyResult, ChatRequest, ChatResponse, CheckoutInput, CheckoutReceiptClaims, CheckoutSession, FileOptions, ImageRequest, ImageResult, Jwk, LedgerPage, Listing, ListingInput, MarketOffering, PaidCall, PaymentLink, PaymentLinkInput, ToolListing, ToolResult, TranscriptEntry, TransferOptions, Wallet, WebhookDelivery, WebhookEvent } from "./types.js";
|
|
3
|
+
import type { AgentConfigInput, BuyOptions, BuyResult, ChatRequest, ChatResponse, CheckoutInput, CheckoutReceiptClaims, CheckoutSession, FileOptions, StoredFile, StoreList, StoreWrite, ImageRequest, ImageResult, Jwk, LedgerPage, Listing, ListingInput, MarketOffering, PaidCall, PaymentLink, PaymentLinkInput, ToolListing, ToolResult, TranscriptEntry, TransferOptions, Wallet, WebhookDelivery, WebhookEvent } from "./types.js";
|
|
3
4
|
export declare const DEFAULT_API_URL = "https://hackaton.ambasada.id";
|
|
4
5
|
export declare const DEFAULT_MODEL = "deepseek-v4.1-flash";
|
|
5
6
|
export interface VillageOptions {
|
|
@@ -19,6 +20,13 @@ export interface VillageOptions {
|
|
|
19
20
|
timeoutMs?: number;
|
|
20
21
|
/** Extra env source, e.g. a Worker's `env`. Default: `process.env` where it exists. */
|
|
21
22
|
env?: Record<string, unknown>;
|
|
23
|
+
/** Sign in with Ambasada credentials. Default: `AMBASADA_AUTH_CLIENT_ID`, `_CLIENT_SECRET`, `_ISSUER`, and `PUBLIC_URL`. */
|
|
24
|
+
auth?: {
|
|
25
|
+
clientId?: string;
|
|
26
|
+
clientSecret?: string;
|
|
27
|
+
issuer?: string;
|
|
28
|
+
publicUrl?: string;
|
|
29
|
+
};
|
|
22
30
|
}
|
|
23
31
|
export interface VerifyReceiptOptions {
|
|
24
32
|
/** Pin the offering name (`translate`), so a receipt for another of your offerings is refused. */
|
|
@@ -40,6 +48,11 @@ export declare class Village {
|
|
|
40
48
|
private env;
|
|
41
49
|
get apiUrl(): string;
|
|
42
50
|
private get apiKey();
|
|
51
|
+
/**
|
|
52
|
+
* Sign in with Ambasada (docs/AUTH.md): `login(req)`, `callback(req)`, `user(req)`, `logout(req)`.
|
|
53
|
+
* Credentials come with the deploy to ambasada.dev, and from `ambasada dev` locally.
|
|
54
|
+
*/
|
|
55
|
+
readonly auth: AmbasadaAuth;
|
|
43
56
|
private send;
|
|
44
57
|
private call;
|
|
45
58
|
/** Your company id (from options, `AMBASADA_COMPANY`, or the wallet the API key belongs to). */
|
|
@@ -128,12 +141,24 @@ export declare class Village {
|
|
|
128
141
|
};
|
|
129
142
|
readonly files: {
|
|
130
143
|
/**
|
|
131
|
-
* Stores bytes, a Blob, or a URL to copy, for the event
|
|
132
|
-
*
|
|
144
|
+
* Stores bytes, a Blob, or a URL to copy, for the event (`POST /v1/files`, 0.1 TON). Returns a
|
|
145
|
+
* public URL other products can fetch.
|
|
133
146
|
*/
|
|
134
|
-
put: (data: Uint8Array | ArrayBuffer | Blob | string, options?: FileOptions) => Promise<
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
put: (data: Uint8Array | ArrayBuffer | Blob | string, options?: FileOptions) => Promise<StoredFile>;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Your company's JSON key-value store (`/v1/store`): 1 MB per company (keys + values), free. State for
|
|
151
|
+
* a hosted product that needs no database.
|
|
152
|
+
*/
|
|
153
|
+
readonly store: {
|
|
154
|
+
/** The value, or null when the key is not set. */
|
|
155
|
+
get: <T = unknown>(key: string) => Promise<T | null>;
|
|
156
|
+
/** Saves any JSON value (fails with INVALID_REQUEST when the store would pass 1 MB). */
|
|
157
|
+
set: (key: string, value: unknown) => Promise<StoreWrite>;
|
|
158
|
+
/** True when the key existed. */
|
|
159
|
+
delete: (key: string) => Promise<boolean>;
|
|
160
|
+
/** Keys (up to 1000, optionally under a prefix) and how much of the 1 MB is used. */
|
|
161
|
+
list: (prefix?: string) => Promise<StoreList>;
|
|
137
162
|
};
|
|
138
163
|
readonly listings: {
|
|
139
164
|
/** Creates or updates an offering (price changes are versioned). */
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* import { village } from "@ambasada/sdk"; // AMBASADA_API_KEY, AMBASADA_API_URL from the env
|
|
5
5
|
* const { url } = await village.checkout({ amount: 3, description: "Coffee", success_url });
|
|
6
6
|
* export const POST = village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`Translate to ${to}: ${text}`) }));
|
|
7
|
+
* const user = await village.auth.user(req); // Sign in with Ambasada (docs/AUTH.md)
|
|
7
8
|
*
|
|
8
9
|
* Works in Node ≥ 22, Bun, Cloudflare Workers (`createAmbasada({ env })`) and Next.js route handlers.
|
|
9
10
|
*/
|
|
@@ -16,6 +17,8 @@ export { verifyWebhookPayload, webhookHeaders, generateWebhookSecret, WEBHOOK_HE
|
|
|
16
17
|
export { RECEIPT_HEADER, readIncoming, verifyCallReceipt, verifyCheckoutReceiptToken } from "./receipt.js";
|
|
17
18
|
export type { IncomingRequest } from "./receipt.js";
|
|
18
19
|
export { defineCompany, offering } from "./define.js";
|
|
20
|
+
export { AmbasadaAuth, AUTH_PATHS, SESSION_TTL_S } from "./auth.js";
|
|
21
|
+
export type { AmbasadaUser, AuthConfig, LoginOptions, WithHeaders } from "./auth.js";
|
|
19
22
|
export type { CompanyConfig, OfferingDef } from "./define.js";
|
|
20
23
|
/** The default client, configured from the environment at call time. */
|
|
21
24
|
export declare const village: Village;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/errors.ts
|
|
1
|
+
// ../sdk/src/errors.ts
|
|
2
2
|
var ERROR_STATUS = {
|
|
3
3
|
// EXCHANGE.md §5 vocabulary
|
|
4
4
|
INSUFFICIENT_FUNDS: 402,
|
|
@@ -102,6 +102,188 @@ function errorFromResponse(status, body, fallback) {
|
|
|
102
102
|
return new VillageError(code, fallback, status);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// ../sdk/src/auth.ts
|
|
106
|
+
var AUTH_PATHS = { login: "/api/auth/login", callback: "/api/auth/callback", logout: "/api/auth/logout" };
|
|
107
|
+
var SESSION_TTL_S = 7 * 24 * 3600;
|
|
108
|
+
var STATE_TTL_S = 600;
|
|
109
|
+
var SESSION_COOKIE = "ambasada_session";
|
|
110
|
+
var STATE_COOKIE = "ambasada_auth";
|
|
111
|
+
var enc = new TextEncoder();
|
|
112
|
+
function b64url(bytes) {
|
|
113
|
+
let s = "";
|
|
114
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
115
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
116
|
+
}
|
|
117
|
+
function fromB64url(text) {
|
|
118
|
+
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
|
|
119
|
+
const out = new Uint8Array(s.length);
|
|
120
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
var random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
|
|
124
|
+
async function pkce() {
|
|
125
|
+
const verifier = random(32);
|
|
126
|
+
return { verifier, challenge: await pkceChallenge(verifier) };
|
|
127
|
+
}
|
|
128
|
+
async function pkceChallenge(verifier) {
|
|
129
|
+
return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(verifier))));
|
|
130
|
+
}
|
|
131
|
+
var keys = /* @__PURE__ */ new Map();
|
|
132
|
+
function cookieKey(secret, purpose) {
|
|
133
|
+
const id = `${purpose}
|
|
134
|
+
${secret}`;
|
|
135
|
+
let key = keys.get(id);
|
|
136
|
+
if (!key) {
|
|
137
|
+
key = crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]).then(
|
|
138
|
+
(ikm) => crypto.subtle.deriveKey(
|
|
139
|
+
{ name: "HKDF", hash: "SHA-256", salt: enc.encode("ambasada auth cookie v1"), info: enc.encode(purpose) },
|
|
140
|
+
ikm,
|
|
141
|
+
{ name: "HMAC", hash: "SHA-256", length: 256 },
|
|
142
|
+
false,
|
|
143
|
+
["sign", "verify"]
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
keys.set(id, key);
|
|
147
|
+
}
|
|
148
|
+
return key;
|
|
149
|
+
}
|
|
150
|
+
async function signCookie(secret, purpose, payload) {
|
|
151
|
+
const body = b64url(enc.encode(JSON.stringify(payload)));
|
|
152
|
+
const sig = await crypto.subtle.sign("HMAC", await cookieKey(secret, purpose), enc.encode(body));
|
|
153
|
+
return `${body}.${b64url(new Uint8Array(sig))}`;
|
|
154
|
+
}
|
|
155
|
+
async function verifyCookie(secret, purpose, value, now = Date.now()) {
|
|
156
|
+
if (!value) return null;
|
|
157
|
+
const [body, sig, extra] = value.split(".");
|
|
158
|
+
if (!body || !sig || extra !== void 0) return null;
|
|
159
|
+
try {
|
|
160
|
+
const ok = await crypto.subtle.verify("HMAC", await cookieKey(secret, purpose), fromB64url(sig), enc.encode(body));
|
|
161
|
+
if (!ok) return null;
|
|
162
|
+
const payload = JSON.parse(new TextDecoder().decode(fromB64url(body)));
|
|
163
|
+
return typeof payload.exp === "number" && payload.exp * 1e3 > now ? payload : null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function readCookie(req, name) {
|
|
169
|
+
for (const part of (req.headers.get("cookie") ?? "").split(";")) {
|
|
170
|
+
const i = part.indexOf("=");
|
|
171
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
var cookieName = (base, secure) => secure ? `__Host-${base}` : base;
|
|
176
|
+
var readOurCookie = (req, base) => readCookie(req, cookieName(base, true)) ?? readCookie(req, base);
|
|
177
|
+
function setCookie(base, value, secure, maxAge) {
|
|
178
|
+
return `${cookieName(base, secure)}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
179
|
+
}
|
|
180
|
+
function safeReturnTo(value) {
|
|
181
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\") || /[\u0000-\u001f\u007f]/.test(value)) return "/";
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
function redirect(location, cookies = []) {
|
|
185
|
+
const headers = new Headers({ location, "cache-control": "no-store" });
|
|
186
|
+
for (const c of cookies) headers.append("set-cookie", c);
|
|
187
|
+
return new Response(null, { status: 303, headers });
|
|
188
|
+
}
|
|
189
|
+
var AmbasadaAuth = class {
|
|
190
|
+
constructor(config) {
|
|
191
|
+
this.config = config;
|
|
192
|
+
}
|
|
193
|
+
config;
|
|
194
|
+
/** True when the product has credentials (hosted on ambasada.dev, or `ambasada dev`). */
|
|
195
|
+
get configured() {
|
|
196
|
+
const c = this.config();
|
|
197
|
+
return !!(c.clientId && c.clientSecret && c.issuer);
|
|
198
|
+
}
|
|
199
|
+
settings(req) {
|
|
200
|
+
const c = this.config();
|
|
201
|
+
if (!c.clientId || !c.clientSecret || !c.issuer) {
|
|
202
|
+
throw new VillageError("CONFIG", "Sign in with Ambasada is not set up: it works on https://<slug>.ambasada.dev after `ambasada deploy`, and locally under `ambasada dev` (AMBASADA_AUTH_CLIENT_ID / _SECRET / _ISSUER)");
|
|
203
|
+
}
|
|
204
|
+
const origin = (c.publicUrl ? new URL(c.publicUrl) : new URL(req.url)).origin;
|
|
205
|
+
return {
|
|
206
|
+
clientId: c.clientId,
|
|
207
|
+
clientSecret: c.clientSecret,
|
|
208
|
+
issuer: c.issuer.replace(/\/+$/, ""),
|
|
209
|
+
apiUrl: c.apiUrl.replace(/\/+$/, ""),
|
|
210
|
+
origin,
|
|
211
|
+
secure: origin.startsWith("https:"),
|
|
212
|
+
redirectUri: `${origin}${AUTH_PATHS.callback}`,
|
|
213
|
+
// Called as a plain function: workerd refuses `fetch` invoked as a method of another object.
|
|
214
|
+
fetch: c.fetch ?? ((input, init) => fetch(input, init))
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** Redirects to Ambasada ID (consent screen) with PKCE; a short-lived signed cookie holds the state. */
|
|
218
|
+
async login(req, options = {}) {
|
|
219
|
+
const s = this.settings(req);
|
|
220
|
+
const { verifier, challenge } = await pkce();
|
|
221
|
+
const state = random(16);
|
|
222
|
+
const returnTo = safeReturnTo(options.returnTo ?? new URL(req.url).searchParams.get("returnTo"));
|
|
223
|
+
const url = new URL(`${s.issuer}/oauth/authorize`);
|
|
224
|
+
url.search = new URLSearchParams({
|
|
225
|
+
response_type: "code",
|
|
226
|
+
client_id: s.clientId,
|
|
227
|
+
redirect_uri: s.redirectUri,
|
|
228
|
+
scope: options.email ? "openid profile email" : "openid profile",
|
|
229
|
+
state,
|
|
230
|
+
code_challenge: challenge,
|
|
231
|
+
code_challenge_method: "S256"
|
|
232
|
+
}).toString();
|
|
233
|
+
const value = await signCookie(s.clientSecret, "state", { state, verifier, returnTo, exp: Math.floor(Date.now() / 1e3) + STATE_TTL_S });
|
|
234
|
+
return redirect(url.toString(), [setCookie(STATE_COOKIE, value, s.secure, STATE_TTL_S)]);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Ambasada ID sends the person back here: checks the state, exchanges the code (with the PKCE
|
|
238
|
+
* verifier and the client secret), reads their profile from the village, sets the session cookie
|
|
239
|
+
* and redirects to `returnTo`. A refused consent goes back to `returnTo` signed out.
|
|
240
|
+
*/
|
|
241
|
+
async callback(req) {
|
|
242
|
+
const s = this.settings(req);
|
|
243
|
+
const params = new URL(req.url).searchParams;
|
|
244
|
+
const clear = setCookie(STATE_COOKIE, "", s.secure, 0);
|
|
245
|
+
const saved = await verifyCookie(s.clientSecret, "state", readOurCookie(req, STATE_COOKIE));
|
|
246
|
+
if (!saved || !params.get("state") || params.get("state") !== saved.state) {
|
|
247
|
+
throw new VillageError("UNAUTHORIZED", "sign-in expired or was started elsewhere: try again", 400);
|
|
248
|
+
}
|
|
249
|
+
const returnTo = safeReturnTo(saved.returnTo);
|
|
250
|
+
if (params.get("error")) return redirect(returnTo, [clear]);
|
|
251
|
+
const code = params.get("code");
|
|
252
|
+
if (!code) throw new VillageError("INVALID_REQUEST", "no authorization code", 400);
|
|
253
|
+
const token = await s.fetch(`${s.issuer}/oauth/token`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: {
|
|
256
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
257
|
+
accept: "application/json",
|
|
258
|
+
authorization: `Basic ${btoa(`${encodeURIComponent(s.clientId)}:${encodeURIComponent(s.clientSecret)}`)}`
|
|
259
|
+
},
|
|
260
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: s.redirectUri, code_verifier: saved.verifier })
|
|
261
|
+
});
|
|
262
|
+
const tokens = await token.json().catch(() => ({}));
|
|
263
|
+
if (!token.ok || !tokens.access_token) throw new VillageError("UNAUTHORIZED", `Ambasada ID refused the sign-in (${tokens.error ?? token.status})`, 401);
|
|
264
|
+
const res = await s.fetch(`${s.apiUrl}/v1/auth/profile`, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: "application/json" } });
|
|
265
|
+
if (!res.ok) throw new VillageError("UPSTREAM_ERROR", `could not read the Ambasada profile (${res.status})`, 502);
|
|
266
|
+
const user = await res.json();
|
|
267
|
+
const exp = Math.floor(Date.now() / 1e3) + SESSION_TTL_S;
|
|
268
|
+
const session = await signCookie(s.clientSecret, "session", { user, exp });
|
|
269
|
+
return redirect(returnTo, [clear, setCookie(SESSION_COOKIE, session, s.secure, SESSION_TTL_S)]);
|
|
270
|
+
}
|
|
271
|
+
/** Whoever is signed in on this request, or null (also null when sign-in is not set up). */
|
|
272
|
+
async user(req) {
|
|
273
|
+
const secret = this.config().clientSecret;
|
|
274
|
+
if (!this.configured || !secret) return null;
|
|
275
|
+
return (await verifyCookie(secret, "session", readOurCookie(req, SESSION_COOKIE)))?.user ?? null;
|
|
276
|
+
}
|
|
277
|
+
/** Clears the session and redirects to `returnTo` (default "/"). Signs out of this product only. */
|
|
278
|
+
logout(req, options = {}) {
|
|
279
|
+
const url = new URL(req.url);
|
|
280
|
+
const publicUrl = this.config().publicUrl;
|
|
281
|
+
const secure = (publicUrl ? new URL(publicUrl) : url).protocol === "https:";
|
|
282
|
+
const returnTo = safeReturnTo(options.returnTo ?? url.searchParams.get("returnTo"));
|
|
283
|
+
return redirect(returnTo, [setCookie(SESSION_COOKIE, "", secure, 0)]);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
105
287
|
// ../protocol/src/encoding.ts
|
|
106
288
|
var encoder = new TextEncoder();
|
|
107
289
|
var decoder = new TextDecoder();
|
|
@@ -184,8 +366,8 @@ async function verifySigned(token, options) {
|
|
|
184
366
|
}
|
|
185
367
|
if (header2.alg !== "EdDSA") throw new ReceiptError("malformed", `unsupported alg ${header2.alg}`);
|
|
186
368
|
if (typeof claims !== "object" || claims === null) throw new ReceiptError("malformed", "receipt claims are not an object");
|
|
187
|
-
const
|
|
188
|
-
const jwk =
|
|
369
|
+
const keys2 = typeof options.keys === "function" ? await options.keys() : options.keys;
|
|
370
|
+
const jwk = keys2.find((k) => (k.kid ?? k.x.slice(0, 16)) === header2.kid) ?? (keys2.length === 1 && !header2.kid ? keys2[0] : void 0);
|
|
189
371
|
if (!jwk) throw new ReceiptError("unknown_key", `no village key with kid ${header2.kid}`);
|
|
190
372
|
const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, ED25519, false, ["verify"]);
|
|
191
373
|
const ok = await crypto.subtle.verify(ED25519, key, fromBase64url(signaturePart), utf8(`${headerPart}.${payloadPart}`));
|
|
@@ -278,7 +460,7 @@ function fromStdBase64(text) {
|
|
|
278
460
|
return bytes;
|
|
279
461
|
}
|
|
280
462
|
|
|
281
|
-
// src/webhook.ts
|
|
463
|
+
// ../sdk/src/webhook.ts
|
|
282
464
|
function getHeader(headers, name) {
|
|
283
465
|
if (typeof headers.get === "function") return headers.get(name);
|
|
284
466
|
const record = headers;
|
|
@@ -309,7 +491,7 @@ async function verifyWebhookPayload(secret, headers, body, options = {}) {
|
|
|
309
491
|
return { id: plain["webhook-id"], type: String(parsed.type), timestamp: String(parsed.timestamp ?? ""), data: parsed.data };
|
|
310
492
|
}
|
|
311
493
|
|
|
312
|
-
// src/receipt.ts
|
|
494
|
+
// ../sdk/src/receipt.ts
|
|
313
495
|
var RECEIPT_HEADER2 = "x-village-receipt";
|
|
314
496
|
async function readIncoming(req) {
|
|
315
497
|
if (typeof req.clone === "function" && typeof req.arrayBuffer === "function") {
|
|
@@ -394,7 +576,7 @@ function concat(chunks) {
|
|
|
394
576
|
return out;
|
|
395
577
|
}
|
|
396
578
|
|
|
397
|
-
// src/client.ts
|
|
579
|
+
// ../sdk/src/client.ts
|
|
398
580
|
var DEFAULT_API_URL = "https://hackaton.ambasada.id";
|
|
399
581
|
var DEFAULT_MODEL = "deepseek-v4.1-flash";
|
|
400
582
|
function processEnv() {
|
|
@@ -434,6 +616,18 @@ var Village = class {
|
|
|
434
616
|
if (!key) throw new VillageError("CONFIG", "no API key: set AMBASADA_API_KEY (from the village app, or `ambasada login`) or pass { apiKey }");
|
|
435
617
|
return key;
|
|
436
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Sign in with Ambasada (docs/AUTH.md): `login(req)`, `callback(req)`, `user(req)`, `logout(req)`.
|
|
621
|
+
* Credentials come with the deploy to ambasada.dev, and from `ambasada dev` locally.
|
|
622
|
+
*/
|
|
623
|
+
auth = new AmbasadaAuth(() => ({
|
|
624
|
+
clientId: this.options.auth?.clientId ?? this.env("AMBASADA_AUTH_CLIENT_ID"),
|
|
625
|
+
clientSecret: this.options.auth?.clientSecret ?? this.env("AMBASADA_AUTH_CLIENT_SECRET"),
|
|
626
|
+
issuer: this.options.auth?.issuer ?? this.env("AMBASADA_AUTH_ISSUER"),
|
|
627
|
+
publicUrl: this.options.auth?.publicUrl ?? this.env("PUBLIC_URL"),
|
|
628
|
+
apiUrl: this.apiUrl,
|
|
629
|
+
fetch: this.options.fetch
|
|
630
|
+
}));
|
|
437
631
|
// ── HTTP ──────────────────────────────────────────────────────────────────
|
|
438
632
|
async send(method, path, opts = {}) {
|
|
439
633
|
const url = new URL(this.apiUrl + path);
|
|
@@ -693,8 +887,8 @@ var Village = class {
|
|
|
693
887
|
};
|
|
694
888
|
files = {
|
|
695
889
|
/**
|
|
696
|
-
* Stores bytes, a Blob, or a URL to copy, for the event
|
|
697
|
-
*
|
|
890
|
+
* Stores bytes, a Blob, or a URL to copy, for the event (`POST /v1/files`, 0.1 TON). Returns a
|
|
891
|
+
* public URL other products can fetch.
|
|
698
892
|
*/
|
|
699
893
|
put: (data, options = {}) => {
|
|
700
894
|
const query = { ttl_seconds: options.ttlSeconds };
|
|
@@ -703,6 +897,30 @@ var Village = class {
|
|
|
703
897
|
return this.call("POST", "/v1/files", { raw: data, headers: { "content-type": contentType }, query });
|
|
704
898
|
}
|
|
705
899
|
};
|
|
900
|
+
/**
|
|
901
|
+
* Your company's JSON key-value store (`/v1/store`): 1 MB per company (keys + values), free. State for
|
|
902
|
+
* a hosted product that needs no database.
|
|
903
|
+
*/
|
|
904
|
+
store = {
|
|
905
|
+
/** The value, or null when the key is not set. */
|
|
906
|
+
get: async (key) => {
|
|
907
|
+
try {
|
|
908
|
+
return (await this.call("GET", `/v1/store/${encodeURIComponent(key)}`)).value;
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (error instanceof VillageError && error.status === 404) return null;
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
/** Saves any JSON value (fails with INVALID_REQUEST when the store would pass 1 MB). */
|
|
915
|
+
set: (key, value) => {
|
|
916
|
+
if (value === void 0) throw new VillageError("INVALID_REQUEST", "store.set needs a JSON value (use store.delete to remove a key)");
|
|
917
|
+
return this.call("PUT", `/v1/store/${encodeURIComponent(key)}`, { body: value });
|
|
918
|
+
},
|
|
919
|
+
/** True when the key existed. */
|
|
920
|
+
delete: async (key) => (await this.call("DELETE", `/v1/store/${encodeURIComponent(key)}`)).deleted,
|
|
921
|
+
/** Keys (up to 1000, optionally under a prefix) and how much of the 1 MB is used. */
|
|
922
|
+
list: (prefix) => this.call("GET", "/v1/store", { query: { prefix } })
|
|
923
|
+
};
|
|
706
924
|
// ── listings, wallet, ledger ──────────────────────────────────────────────
|
|
707
925
|
listings = {
|
|
708
926
|
/** Creates or updates an offering (price changes are versioned). */
|
|
@@ -742,10 +960,10 @@ function cachedKeys(load, ttlMs = 10 * 6e4) {
|
|
|
742
960
|
let cached;
|
|
743
961
|
return () => {
|
|
744
962
|
if (!cached || Date.now() - cached.at > ttlMs) {
|
|
745
|
-
const
|
|
746
|
-
const entry = { at: Date.now(), keys };
|
|
963
|
+
const keys2 = load();
|
|
964
|
+
const entry = { at: Date.now(), keys: keys2 };
|
|
747
965
|
cached = entry;
|
|
748
|
-
|
|
966
|
+
keys2.catch(() => {
|
|
749
967
|
if (cached === entry) cached = void 0;
|
|
750
968
|
});
|
|
751
969
|
}
|
|
@@ -756,7 +974,7 @@ function createAmbasada(options = {}) {
|
|
|
756
974
|
return new Village(options);
|
|
757
975
|
}
|
|
758
976
|
|
|
759
|
-
// src/define.ts
|
|
977
|
+
// ../sdk/src/define.ts
|
|
760
978
|
function defineCompany(config) {
|
|
761
979
|
return config;
|
|
762
980
|
}
|
|
@@ -766,15 +984,18 @@ function offering(def) {
|
|
|
766
984
|
return def;
|
|
767
985
|
}
|
|
768
986
|
|
|
769
|
-
// src/index.ts
|
|
987
|
+
// ../sdk/src/index.ts
|
|
770
988
|
var village = new Village();
|
|
771
989
|
var index_default = village;
|
|
772
990
|
export {
|
|
991
|
+
AUTH_PATHS,
|
|
992
|
+
AmbasadaAuth,
|
|
773
993
|
DEFAULT_API_URL,
|
|
774
994
|
DEFAULT_MODEL,
|
|
775
995
|
ERROR_STATUS,
|
|
776
996
|
RECEIPT_HEADER2 as RECEIPT_HEADER,
|
|
777
997
|
ReceiptInvalidError,
|
|
998
|
+
SESSION_TTL_S,
|
|
778
999
|
Village,
|
|
779
1000
|
VillageError,
|
|
780
1001
|
WEBHOOK_HEADERS,
|
package/dist/types.d.ts
CHANGED
|
@@ -218,6 +218,31 @@ export interface FileOptions {
|
|
|
218
218
|
contentType?: string;
|
|
219
219
|
ttlSeconds?: number;
|
|
220
220
|
}
|
|
221
|
+
/** `POST /v1/files` (0.1 TON a file). */
|
|
222
|
+
export interface StoredFile {
|
|
223
|
+
id: string;
|
|
224
|
+
url: string;
|
|
225
|
+
size?: number;
|
|
226
|
+
content_type?: string;
|
|
227
|
+
cost?: number;
|
|
228
|
+
call_id?: string;
|
|
229
|
+
}
|
|
230
|
+
export interface StoreKey {
|
|
231
|
+
key: string;
|
|
232
|
+
size: number;
|
|
233
|
+
updated_at: string;
|
|
234
|
+
}
|
|
235
|
+
export interface StoreList {
|
|
236
|
+
keys: StoreKey[];
|
|
237
|
+
used_bytes: number;
|
|
238
|
+
limit_bytes: number;
|
|
239
|
+
}
|
|
240
|
+
export interface StoreWrite {
|
|
241
|
+
key: string;
|
|
242
|
+
size: number;
|
|
243
|
+
used_bytes: number;
|
|
244
|
+
limit_bytes: number;
|
|
245
|
+
}
|
|
221
246
|
/** Offering field schema shorthand: `{ text: "string", to: "string", tone: "string?" }`. */
|
|
222
247
|
export type FieldSchema = Record<string, string>;
|
|
223
248
|
export interface ListingInput {
|
package/package.json
CHANGED