@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 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. Returns a public URL other products can fetch.
132
- * (`POST /v1/files`: assumed route; the utilities stream serves `GET /files/:id`.)
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
- url: string;
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 keys = typeof options.keys === "function" ? await options.keys() : options.keys;
188
- const jwk = keys.find((k) => (k.kid ?? k.x.slice(0, 16)) === header2.kid) ?? (keys.length === 1 && !header2.kid ? keys[0] : void 0);
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. Returns a public URL other products can fetch.
697
- * (`POST /v1/files`: assumed route; the utilities stream serves `GET /files/:id`.)
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 keys = load();
746
- const entry = { at: Date.now(), keys };
963
+ const keys2 = load();
964
+ const entry = { at: Date.now(), keys: keys2 };
747
965
  cached = entry;
748
- keys.catch(() => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambasada/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "TypeScript SDK for the Hack A Ton village: Checkout, webhooks, receipts, Pay, needs, LLM, images, tools, files, listings, wallet.",
5
5
  "type": "module",
6
6
  "license": "MIT",