@ambasada/sdk 0.3.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,3 +1,4 @@
1
+ import { AmbasadaAuth } from "./auth.js";
1
2
  import { type IncomingRequest } from "./receipt.js";
2
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";
@@ -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). */
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);
@@ -766,10 +960,10 @@ function cachedKeys(load, ttlMs = 10 * 6e4) {
766
960
  let cached;
767
961
  return () => {
768
962
  if (!cached || Date.now() - cached.at > ttlMs) {
769
- const keys = load();
770
- const entry = { at: Date.now(), keys };
963
+ const keys2 = load();
964
+ const entry = { at: Date.now(), keys: keys2 };
771
965
  cached = entry;
772
- keys.catch(() => {
966
+ keys2.catch(() => {
773
967
  if (cached === entry) cached = void 0;
774
968
  });
775
969
  }
@@ -780,7 +974,7 @@ function createAmbasada(options = {}) {
780
974
  return new Village(options);
781
975
  }
782
976
 
783
- // src/define.ts
977
+ // ../sdk/src/define.ts
784
978
  function defineCompany(config) {
785
979
  return config;
786
980
  }
@@ -790,15 +984,18 @@ function offering(def) {
790
984
  return def;
791
985
  }
792
986
 
793
- // src/index.ts
987
+ // ../sdk/src/index.ts
794
988
  var village = new Village();
795
989
  var index_default = village;
796
990
  export {
991
+ AUTH_PATHS,
992
+ AmbasadaAuth,
797
993
  DEFAULT_API_URL,
798
994
  DEFAULT_MODEL,
799
995
  ERROR_STATUS,
800
996
  RECEIPT_HEADER2 as RECEIPT_HEADER,
801
997
  ReceiptInvalidError,
998
+ SESSION_TTL_S,
802
999
  Village,
803
1000
  VillageError,
804
1001
  WEBHOOK_HEADERS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambasada/sdk",
3
- "version": "0.3.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",