@ambasada/sdk 0.8.1 → 0.8.3

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.
@@ -0,0 +1,821 @@
1
+ // ../protocol/src/apps.ts
2
+ var APP_SIGNATURE_HEADER = "x-ambasada-signature";
3
+ var APP_TIMESTAMP_HEADER = "x-ambasada-timestamp";
4
+ var APP_WEBHOOK_TOLERANCE_S = 300;
5
+ var DEFAULT_HUB_URL = "https://hub.ambasada.id";
6
+ var APP_NOTIFY_PATH = "/api/apps/notify";
7
+ var AppWebhookError = class extends Error {
8
+ constructor(reason, message) {
9
+ super(message);
10
+ this.reason = reason;
11
+ this.name = "AppWebhookError";
12
+ }
13
+ reason;
14
+ };
15
+ var encoder = new TextEncoder();
16
+ var keys = /* @__PURE__ */ new Map();
17
+ function hmacKey(secret) {
18
+ let key = keys.get(secret);
19
+ if (!key) {
20
+ key = crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
21
+ keys.set(secret, key);
22
+ }
23
+ return key;
24
+ }
25
+ var hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
26
+ async function mac(secret, timestamp, body) {
27
+ return new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(`${timestamp}.${body}`)));
28
+ }
29
+ async function signAppWebhook(secret, timestamp, body) {
30
+ if (!secret) throw new AppWebhookError("bad_secret", "no app secret");
31
+ return `v1=${hex(await mac(secret, String(timestamp), body))}`;
32
+ }
33
+ async function appWebhookHeaders(secret, body, timestamp = Math.floor(Date.now() / 1e3)) {
34
+ return { [APP_TIMESTAMP_HEADER]: String(timestamp), [APP_SIGNATURE_HEADER]: await signAppWebhook(secret, timestamp, body), "content-type": "application/json" };
35
+ }
36
+ function decodeSignature(value) {
37
+ if (/^[0-9a-f]{64}$/i.test(value)) return Uint8Array.from(value.match(/../g), (h) => parseInt(h, 16));
38
+ try {
39
+ const std = value.replace(/-/g, "+").replace(/_/g, "/");
40
+ const bin = atob(std + "===".slice((std.length + 3) % 4));
41
+ return bin.length === 32 ? Uint8Array.from(bin, (c) => c.charCodeAt(0)) : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+ function equal(a, b) {
47
+ if (a.length !== b.length) return false;
48
+ let diff = 0;
49
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
50
+ return diff === 0;
51
+ }
52
+ function header(headers, name) {
53
+ if (typeof headers.get === "function") return headers.get(name);
54
+ const record = headers;
55
+ const key = Object.keys(record).find((k) => k.toLowerCase() === name);
56
+ const value = key === void 0 ? void 0 : record[key];
57
+ return Array.isArray(value) ? value[0] ?? null : value ?? null;
58
+ }
59
+ async function verifyAppWebhook(secret, headers, body, { toleranceSeconds = APP_WEBHOOK_TOLERANCE_S, now = Math.floor(Date.now() / 1e3) } = {}) {
60
+ if (!secret) throw new AppWebhookError("bad_secret", "no app secret: set AMBASADA_APP_SECRET (injected on ambasada.dev; from the hub dashboard elsewhere)");
61
+ const ts = header(headers, APP_TIMESTAMP_HEADER);
62
+ const sig = header(headers, APP_SIGNATURE_HEADER);
63
+ if (!ts || !sig) throw new AppWebhookError("missing_headers", `missing ${APP_TIMESTAMP_HEADER} or ${APP_SIGNATURE_HEADER}`);
64
+ if (!/^\d+$/.test(ts.trim())) throw new AppWebhookError("bad_timestamp", `${APP_TIMESTAMP_HEADER} is not unix seconds`);
65
+ let timestamp = Number(ts.trim());
66
+ if (timestamp > 1e12) timestamp = Math.floor(timestamp / 1e3);
67
+ if (timestamp < now - toleranceSeconds) throw new AppWebhookError("too_old", "webhook timestamp is too old");
68
+ if (timestamp > now + toleranceSeconds) throw new AppWebhookError("too_new", "webhook timestamp is in the future");
69
+ const expected = await mac(secret, ts.trim(), body);
70
+ const candidates = sig.split(/[\s,]+/).map((part) => /^v1=(.+)$/.exec(part.trim())?.[1]).filter((v) => !!v).map(decodeSignature).filter((v) => v !== null);
71
+ if (!candidates.some((c) => equal(c, expected))) throw new AppWebhookError("bad_signature", "webhook signature does not match: check AMBASADA_APP_SECRET");
72
+ let event;
73
+ try {
74
+ event = JSON.parse(body);
75
+ } catch {
76
+ throw new AppWebhookError("bad_body", "webhook body is not JSON");
77
+ }
78
+ if (!event || typeof event !== "object" || typeof event.event !== "string") throw new AppWebhookError("bad_body", 'webhook body has no "event"');
79
+ return event;
80
+ }
81
+ var NOTIFY_LIMITS = { notification_id: 128, title: 40, body: 140, recipients: 100, collapse_key: 128, ttl_seconds: 7 * 24 * 3600 };
82
+ var CATEGORIES = ["transactional", "social", "marketing"];
83
+ function appNotifyBody(input, origin) {
84
+ const fail = (message) => {
85
+ throw new Error(message);
86
+ };
87
+ const id = typeof input.notification_id === "string" ? input.notification_id : "";
88
+ if (!id || id.length > NOTIFY_LIMITS.notification_id) fail(`notification_id must be 1\u2013${NOTIFY_LIMITS.notification_id} characters`);
89
+ const title = typeof input.title === "string" ? input.title.trim() : "";
90
+ if (!title || title.length > NOTIFY_LIMITS.title) fail(`title must be 1\u2013${NOTIFY_LIMITS.title} characters (got ${title.length})`);
91
+ const body = typeof input.body === "string" ? input.body.trim() : "";
92
+ if (body.length > NOTIFY_LIMITS.body) fail(`body must be at most ${NOTIFY_LIMITS.body} characters (got ${body.length})`);
93
+ const url = typeof input.url === "string" ? input.url.trim() : "";
94
+ if (!url) fail("url is required: a path on your app (/orders/184) or a URL on its origin");
95
+ if (url.startsWith("/")) {
96
+ if (url.startsWith("//") || url.startsWith("/\\") || /[\u0000-\u001f\u007f]/.test(url)) fail(`url ${JSON.stringify(url)} is not a path on your app`);
97
+ } else {
98
+ let parsed = null;
99
+ try {
100
+ parsed = new URL(url);
101
+ } catch {
102
+ fail(`url ${JSON.stringify(url)} is not a URL or a path`);
103
+ }
104
+ if (parsed.protocol !== "https:" && !(origin && parsed.origin === new URL(origin).origin && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(parsed.origin))) fail("url must be https");
105
+ if (parsed.username || parsed.password) fail("url must not carry credentials");
106
+ if (origin && parsed.origin !== new URL(origin).origin) fail(`url must be on your app's origin ${new URL(origin).origin} (got ${parsed.origin})`);
107
+ }
108
+ const tokens = input.tokens ?? [];
109
+ const users = input.users ?? [];
110
+ if (!Array.isArray(tokens) || !Array.isArray(users)) fail("tokens and users must be arrays");
111
+ if (tokens.length && users.length) fail("send tokens or users, not both");
112
+ const recipients = tokens.length ? tokens : users;
113
+ if (!recipients.length) fail("no recipients: pass tokens (from notifications_enabled) or users");
114
+ if (recipients.length > NOTIFY_LIMITS.recipients) fail(`at most ${NOTIFY_LIMITS.recipients} recipients per call (got ${recipients.length})`);
115
+ if (recipients.some((r) => typeof r !== "string" || !r)) fail("recipients must be non-empty strings");
116
+ const category = input.category ?? "transactional";
117
+ if (!CATEGORIES.includes(category)) fail(`category must be one of ${CATEGORIES.join(", ")}`);
118
+ if (input.collapse_key !== void 0 && (typeof input.collapse_key !== "string" || !input.collapse_key || input.collapse_key.length > NOTIFY_LIMITS.collapse_key)) fail(`collapse_key must be 1\u2013${NOTIFY_LIMITS.collapse_key} characters`);
119
+ if (input.ttl_seconds !== void 0 && (!Number.isInteger(input.ttl_seconds) || input.ttl_seconds < 0 || input.ttl_seconds > NOTIFY_LIMITS.ttl_seconds)) fail(`ttl_seconds must be an integer 0\u2013${NOTIFY_LIMITS.ttl_seconds}`);
120
+ return {
121
+ notification_id: id,
122
+ title,
123
+ body,
124
+ url,
125
+ ...tokens.length ? { tokens } : { users },
126
+ category,
127
+ ...input.collapse_key !== void 0 ? { collapse_key: input.collapse_key } : {},
128
+ ...input.ttl_seconds !== void 0 ? { ttl_seconds: input.ttl_seconds } : {}
129
+ };
130
+ }
131
+ function appNotifyResult(json) {
132
+ const o = json && typeof json === "object" ? json : {};
133
+ const list = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
134
+ return { sent: list(o.sent), invalid: list(o.invalid), rate_limited: list(o.rate_limited) };
135
+ }
136
+
137
+ // src/errors.ts
138
+ var ERROR_STATUS = {
139
+ // EXCHANGE.md §5 vocabulary
140
+ INSUFFICIENT_FUNDS: 402,
141
+ OFFLINE: 503,
142
+ TIMEOUT: 504,
143
+ SCHEMA_INVALID: 502,
144
+ CAP_BUYER_HOURLY: 429,
145
+ CAP_SELLER_RATE: 429,
146
+ DEAL_EXHAUSTED: 409,
147
+ DEPTH_EXCEEDED: 422,
148
+ BUDGET_EXCEEDED: 402,
149
+ // Gateway and bank
150
+ UPSTREAM_ERROR: 502,
151
+ PRICE_ABOVE_MAX: 409,
152
+ SELF_PURCHASE: 403,
153
+ INVALID_REQUEST: 400,
154
+ UNAUTHORIZED: 401,
155
+ NOT_FOUND: 404,
156
+ CONFLICT: 409,
157
+ IDEMPOTENCY_CONFLICT: 422,
158
+ IDEMPOTENCY_IN_PROGRESS: 409,
159
+ HOLD_NOT_ACTIVE: 409,
160
+ INTERNAL: 500,
161
+ // Spending limits and approvals (checkout stream)
162
+ CAP_PER_PAYMENT: 403,
163
+ CAP_BUYER_DAILY: 429,
164
+ CAP_PAYEE_DAILY: 429,
165
+ CAP_VISITOR_DAILY: 429,
166
+ APPROVAL_REQUIRED: 403,
167
+ APPROVAL_INVALID: 403,
168
+ APPROVALS_PENDING: 429,
169
+ // LLMOK mapping (utilities stream, docs/LLMOK.md)
170
+ PROVIDER_BUDGET: 503,
171
+ PROVIDER_DENIED: 502,
172
+ // Clock windows (agent stream): deploys / steering refused while frozen
173
+ FROZEN: 403,
174
+ // Exchange stream: an agent sent too many inbox messages
175
+ CAP_INBOX_RATE: 429,
176
+ // SDK-side
177
+ NETWORK: 0,
178
+ CONFIG: 0,
179
+ RECEIPT_INVALID: 402,
180
+ WEBHOOK_INVALID: 400
181
+ };
182
+ var BRAND = /* @__PURE__ */ Symbol.for("ambasada.error");
183
+ var AmbasadaError = class _AmbasadaError extends Error {
184
+ code;
185
+ /** HTTP status (0 when the request never got an answer). */
186
+ status;
187
+ details;
188
+ constructor(code, message, status, details = {}) {
189
+ super(message);
190
+ this.name = "AmbasadaError";
191
+ this.code = code;
192
+ this.status = status ?? ERROR_STATUS[code] ?? 500;
193
+ this.details = details;
194
+ Object.defineProperty(this, BRAND, { value: true });
195
+ }
196
+ /** `instanceof AmbasadaError` holds across bundled copies; subclasses keep the usual prototype check. */
197
+ static [Symbol.hasInstance](value) {
198
+ if (this !== _AmbasadaError) return Function.prototype[Symbol.hasInstance].call(this, value);
199
+ return typeof value === "object" && value !== null && value[BRAND] === true;
200
+ }
201
+ get callId() {
202
+ return this.details.call_id;
203
+ }
204
+ get refunded() {
205
+ return this.details.refunded === true;
206
+ }
207
+ get approvalId() {
208
+ return this.details.approval_id;
209
+ }
210
+ /** The wire shape, e.g. to pass an error on from your own endpoint. */
211
+ toJSON() {
212
+ return { error: { code: this.code, message: this.message, ...this.details } };
213
+ }
214
+ /** A `Response` with the wire shape and status (402 for receipt problems). */
215
+ toResponse() {
216
+ return Response.json(this.toJSON(), { status: this.status || 500 });
217
+ }
218
+ };
219
+ var WebhookInvalidError = class extends AmbasadaError {
220
+ reason;
221
+ constructor(reason, message) {
222
+ super("WEBHOOK_INVALID", message, 400, { reason });
223
+ this.name = "WebhookInvalidError";
224
+ this.reason = reason;
225
+ }
226
+ };
227
+ function isAmbasadaError(value, code) {
228
+ return value instanceof AmbasadaError && (code === void 0 || value.code === code);
229
+ }
230
+ function errorFromResponse(status, body, fallback) {
231
+ const error = typeof body === "object" && body !== null ? body.error : void 0;
232
+ if (typeof error === "object" && error !== null && typeof error.code === "string") {
233
+ const { code: code2, message, ...details } = error;
234
+ return new AmbasadaError(code2, typeof message === "string" ? message : code2, status, details);
235
+ }
236
+ const code = status === 401 ? "UNAUTHORIZED" : status === 404 ? "NOT_FOUND" : status >= 500 ? "INTERNAL" : "INVALID_REQUEST";
237
+ return new AmbasadaError(code, fallback, status);
238
+ }
239
+
240
+ // src/apps.ts
241
+ var APP_SIGNATURE_HEADER2 = "x-ambasada-signature";
242
+ var APP_TIMESTAMP_HEADER2 = "x-ambasada-timestamp";
243
+ var signAppWebhook2 = (secret, timestamp, body) => signAppWebhook(secret, timestamp, body);
244
+ var appWebhookHeaders2 = (secret, body, timestamp) => appWebhookHeaders(secret, body, timestamp);
245
+ var AmbasadaApps = class {
246
+ constructor(config) {
247
+ this.config = config;
248
+ }
249
+ config;
250
+ /** True when the app has its secret (hosted on ambasada.dev, `ambasada dev`, or set by hand). */
251
+ get configured() {
252
+ return !!this.config().appSecret;
253
+ }
254
+ /**
255
+ * Verifies a webhook from the hub (signature, 5-minute window) and returns the event, or throws
256
+ * `WebhookInvalidError` (answer it with `error.toResponse()`, a 400). Read nothing from the request before.
257
+ */
258
+ async verifyWebhook(input, secret, options = {}) {
259
+ const key = secret ?? this.config().appSecret;
260
+ if (!key) throw new WebhookInvalidError("bad_secret", "no app secret: set AMBASADA_APP_SECRET (injected on ambasada.dev, from the hub dashboard elsewhere) or pass it");
261
+ const isRequest = typeof input.text === "function" && input.headers instanceof Headers;
262
+ const headers = isRequest ? input.headers : input.headers;
263
+ const raw = isRequest ? await input.text() : input.body;
264
+ const body = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
265
+ try {
266
+ return await verifyAppWebhook(key, headers, body, options);
267
+ } catch (error) {
268
+ if (error instanceof AppWebhookError) throw new WebhookInvalidError(error.reason, error.message);
269
+ throw error;
270
+ }
271
+ }
272
+ /**
273
+ * Sends a push to the people behind `tokens` (or `users`, for Verified apps with `identity`) through the hub.
274
+ * Checks the limits first (title ≤ 40, body ≤ 140, id ≤ 128, ≤ 100 recipients, `url` on your origin) and
275
+ * throws `AmbasadaError("INVALID_REQUEST")` before sending anything. Tokens in `invalid` are gone: forget them.
276
+ */
277
+ async notify(input) {
278
+ const c = this.config();
279
+ if (!c.appSecret) throw new AmbasadaError("CONFIG", "no app secret: set AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` sets one for the mock hub)");
280
+ let body;
281
+ try {
282
+ body = appNotifyBody(input, c.origin);
283
+ } catch (error) {
284
+ throw new AmbasadaError("INVALID_REQUEST", error.message, 400);
285
+ }
286
+ const url = `${(c.hubUrl ?? DEFAULT_HUB_URL).replace(/\/+$/, "")}${APP_NOTIFY_PATH}`;
287
+ const doFetch = c.fetch ?? ((i, init) => fetch(i, init));
288
+ let res;
289
+ try {
290
+ res = await doFetch(url, {
291
+ method: "POST",
292
+ headers: { authorization: `Bearer ${c.appSecret}`, "content-type": "application/json", accept: "application/json" },
293
+ body: JSON.stringify(body),
294
+ signal: AbortSignal.timeout(15e3)
295
+ });
296
+ } catch (error) {
297
+ throw new AmbasadaError("NETWORK", `POST ${url}: ${error.message}`, 0);
298
+ }
299
+ const json = await res.json().catch(() => null);
300
+ if (!res.ok) {
301
+ const err = json?.error;
302
+ const code = typeof err === "object" && err?.code ? err.code : res.status === 401 || res.status === 403 ? "UNAUTHORIZED" : res.status === 429 ? "CAP_SELLER_RATE" : res.status >= 500 ? "UPSTREAM_ERROR" : "INVALID_REQUEST";
303
+ const message = typeof err === "string" ? err : err?.message ?? `the hub answered ${res.status}`;
304
+ throw new AmbasadaError(code, `notify: ${message}`, res.status);
305
+ }
306
+ return appNotifyResult(json);
307
+ }
308
+ };
309
+
310
+ // src/auth.ts
311
+ var AUTH_PATHS = { login: "/api/auth/login", callback: "/api/auth/callback", logout: "/api/auth/logout", shell: "/api/auth/shell" };
312
+ var SESSION_TTL_S = 7 * 24 * 3600;
313
+ var STATE_TTL_S = 600;
314
+ var SESSION_COOKIE = "ambasada_session";
315
+ var STATE_COOKIE = "ambasada_auth";
316
+ var enc = new TextEncoder();
317
+ function b64url(bytes) {
318
+ let s = "";
319
+ for (const b of bytes) s += String.fromCharCode(b);
320
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
321
+ }
322
+ function fromB64url(text) {
323
+ const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
324
+ const out = new Uint8Array(s.length);
325
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
326
+ return out;
327
+ }
328
+ var random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
329
+ async function pkce() {
330
+ const verifier = random(32);
331
+ return { verifier, challenge: await pkceChallenge(verifier) };
332
+ }
333
+ async function pkceChallenge(verifier) {
334
+ return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(verifier))));
335
+ }
336
+ var keys2 = /* @__PURE__ */ new Map();
337
+ function cookieKey(secret, purpose) {
338
+ const id = `${purpose}
339
+ ${secret}`;
340
+ let key = keys2.get(id);
341
+ if (!key) {
342
+ key = crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]).then(
343
+ (ikm) => crypto.subtle.deriveKey(
344
+ { name: "HKDF", hash: "SHA-256", salt: enc.encode("ambasada auth cookie v1"), info: enc.encode(purpose) },
345
+ ikm,
346
+ { name: "HMAC", hash: "SHA-256", length: 256 },
347
+ false,
348
+ ["sign", "verify"]
349
+ )
350
+ );
351
+ keys2.set(id, key);
352
+ }
353
+ return key;
354
+ }
355
+ async function signCookie(secret, purpose, payload) {
356
+ const body = b64url(enc.encode(JSON.stringify(payload)));
357
+ const sig = await crypto.subtle.sign("HMAC", await cookieKey(secret, purpose), enc.encode(body));
358
+ return `${body}.${b64url(new Uint8Array(sig))}`;
359
+ }
360
+ async function verifyCookie(secret, purpose, value, now = Date.now()) {
361
+ if (!value) return null;
362
+ const [body, sig, extra] = value.split(".");
363
+ if (!body || !sig || extra !== void 0) return null;
364
+ try {
365
+ const ok = await crypto.subtle.verify("HMAC", await cookieKey(secret, purpose), fromB64url(sig), enc.encode(body));
366
+ if (!ok) return null;
367
+ const payload = JSON.parse(new TextDecoder().decode(fromB64url(body)));
368
+ return typeof payload.exp === "number" && payload.exp * 1e3 > now ? payload : null;
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+ function readCookie(req, name) {
374
+ for (const part of (req.headers.get("cookie") ?? "").split(";")) {
375
+ const i = part.indexOf("=");
376
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
377
+ }
378
+ return null;
379
+ }
380
+ var cookieName = (base, secure) => secure ? `__Host-${base}` : base;
381
+ var readOurCookie = (req, base) => readCookie(req, cookieName(base, true)) ?? readCookie(req, base);
382
+ function setCookie(base, value, secure, maxAge) {
383
+ return `${cookieName(base, secure)}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
384
+ }
385
+ function safeReturnTo(value) {
386
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\") || /[\u0000-\u001f\u007f]/.test(value)) return "/";
387
+ return value;
388
+ }
389
+ function redirect(location, cookies = []) {
390
+ const headers = new Headers({ location, "cache-control": "no-store" });
391
+ for (const c of cookies) headers.append("set-cookie", c);
392
+ return new Response(null, { status: 303, headers });
393
+ }
394
+ var JWKS_TTL_MS = 36e5;
395
+ var LEEWAY_S = 30;
396
+ var jwksCache = /* @__PURE__ */ new Map();
397
+ async function jwks(issuer, doFetch, refresh = false) {
398
+ const cached = jwksCache.get(issuer);
399
+ if (cached && !refresh && Date.now() - cached.at < JWKS_TTL_MS) return cached.keys;
400
+ if (cached && refresh && Date.now() - cached.at < 6e4) return cached.keys;
401
+ const res = await doFetch(`${issuer}/.well-known/jwks.json`, { headers: { accept: "application/json" } });
402
+ if (!res.ok) throw new AmbasadaError("UPSTREAM_ERROR", `could not read the Ambasada ID keys (${res.status})`, 502);
403
+ const body = await res.json();
404
+ const keys3 = (body.keys ?? []).filter((k) => k.kty === "RSA" && k.n && k.e);
405
+ jwksCache.set(issuer, { at: Date.now(), keys: keys3 });
406
+ return keys3;
407
+ }
408
+ var shellRefused = (message) => new AmbasadaError("UNAUTHORIZED", `shell token refused: ${message}`, 401);
409
+ var AmbasadaAuth = class {
410
+ constructor(config) {
411
+ this.config = config;
412
+ }
413
+ config;
414
+ /** True when the product has credentials (hosted on ambasada.dev, or `ambasada dev`). */
415
+ get configured() {
416
+ const c = this.config();
417
+ return !!(c.clientId && c.clientSecret && c.issuer);
418
+ }
419
+ settings(req) {
420
+ const c = this.config();
421
+ if (!c.clientId || !c.clientSecret || !c.issuer) {
422
+ throw new AmbasadaError("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)");
423
+ }
424
+ const origin = (c.publicUrl ? new URL(c.publicUrl) : new URL(req.url)).origin;
425
+ return {
426
+ clientId: c.clientId,
427
+ clientSecret: c.clientSecret,
428
+ issuer: c.issuer.replace(/\/+$/, ""),
429
+ apiUrl: c.apiUrl.replace(/\/+$/, ""),
430
+ origin,
431
+ secure: origin.startsWith("https:"),
432
+ redirectUri: `${origin}${AUTH_PATHS.callback}`,
433
+ // Called as a plain function: workerd refuses `fetch` invoked as a method of another object.
434
+ fetch: c.fetch ?? ((input, init) => fetch(input, init))
435
+ };
436
+ }
437
+ /** Redirects to Ambasada ID (consent screen) with PKCE; a short-lived signed cookie holds the state. */
438
+ async login(req, options = {}) {
439
+ const s = this.settings(req);
440
+ const { verifier, challenge } = await pkce();
441
+ const state = random(16);
442
+ const returnTo = safeReturnTo(options.returnTo ?? new URL(req.url).searchParams.get("returnTo"));
443
+ const url = new URL(`${s.issuer}/oauth/authorize`);
444
+ url.search = new URLSearchParams({
445
+ response_type: "code",
446
+ client_id: s.clientId,
447
+ redirect_uri: s.redirectUri,
448
+ scope: options.email ? "openid profile email" : "openid profile",
449
+ state,
450
+ code_challenge: challenge,
451
+ code_challenge_method: "S256"
452
+ }).toString();
453
+ const value = await signCookie(s.clientSecret, "state", { state, verifier, returnTo, exp: Math.floor(Date.now() / 1e3) + STATE_TTL_S });
454
+ return redirect(url.toString(), [setCookie(STATE_COOKIE, value, s.secure, STATE_TTL_S)]);
455
+ }
456
+ /**
457
+ * Ambasada ID sends the person back here: checks the state, exchanges the code (with the PKCE
458
+ * verifier and the client secret), reads their profile from the Ambasada API, sets the session cookie
459
+ * and redirects to `returnTo`. A refused consent goes back to `returnTo` signed out.
460
+ */
461
+ async callback(req) {
462
+ const s = this.settings(req);
463
+ const params = new URL(req.url).searchParams;
464
+ const clear = setCookie(STATE_COOKIE, "", s.secure, 0);
465
+ const saved = await verifyCookie(s.clientSecret, "state", readOurCookie(req, STATE_COOKIE));
466
+ if (!saved || !params.get("state") || params.get("state") !== saved.state) {
467
+ throw new AmbasadaError("UNAUTHORIZED", "sign-in expired or was started elsewhere: try again", 400);
468
+ }
469
+ const returnTo = safeReturnTo(saved.returnTo);
470
+ if (params.get("error")) return redirect(returnTo, [clear]);
471
+ const code = params.get("code");
472
+ if (!code) throw new AmbasadaError("INVALID_REQUEST", "no authorization code", 400);
473
+ const token = await s.fetch(`${s.issuer}/oauth/token`, {
474
+ method: "POST",
475
+ headers: {
476
+ "content-type": "application/x-www-form-urlencoded",
477
+ accept: "application/json",
478
+ authorization: `Basic ${btoa(`${encodeURIComponent(s.clientId)}:${encodeURIComponent(s.clientSecret)}`)}`
479
+ },
480
+ body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: s.redirectUri, code_verifier: saved.verifier })
481
+ });
482
+ const tokens = await token.json().catch(() => ({}));
483
+ if (!token.ok || !tokens.access_token) throw new AmbasadaError("UNAUTHORIZED", `Ambasada ID refused the sign-in (${tokens.error ?? token.status})`, 401);
484
+ const res = await s.fetch(`${s.apiUrl}/v1/auth/profile`, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: "application/json" } });
485
+ if (!res.ok) throw new AmbasadaError("UPSTREAM_ERROR", `could not read the Ambasada profile (${res.status})`, 502);
486
+ const user = await res.json();
487
+ const exp = Math.floor(Date.now() / 1e3) + SESSION_TTL_S;
488
+ const session = await signCookie(s.clientSecret, "session", { user, exp });
489
+ return redirect(returnTo, [clear, setCookie(SESSION_COOKIE, session, s.secure, SESSION_TTL_S)]);
490
+ }
491
+ /** Whoever is signed in on this request, or null (also null when sign-in is not set up). */
492
+ async user(req) {
493
+ const secret = this.config().clientSecret;
494
+ if (!this.configured || !secret) return null;
495
+ return (await verifyCookie(secret, "session", readOurCookie(req, SESSION_COOKIE)))?.user ?? null;
496
+ }
497
+ /**
498
+ * Verifies a shell token (Clerk JWT template `ambasada-miniapp`, RS256, 60 s) against
499
+ * `<AMBASADA_AUTH_ISSUER>/.well-known/jwks.json` (cached 1 h): signature, expiry, issuer, and that its `aud`
500
+ * is this app (its id or origin). Returns who it is, or throws `AmbasadaError("UNAUTHORIZED")`.
501
+ */
502
+ async verifyShellToken(token, options = {}) {
503
+ const c = this.config();
504
+ if (!c.issuer) throw new AmbasadaError("CONFIG", "silent sign-in needs AMBASADA_AUTH_ISSUER (injected on ambasada.dev and by `ambasada dev`)");
505
+ const trusted = [c.issuer, c.hubUrl].filter((v) => !!v).map((v) => v.replace(/\/+$/, ""));
506
+ const audiences = [options.audience ?? c.audiences ?? []].flat().filter(Boolean).map((a) => a.replace(/\/+$/, ""));
507
+ if (!audiences.length) throw new AmbasadaError("CONFIG", "silent sign-in needs to know this app: set AMBASADA_APP_ORIGIN or PUBLIC_URL, or pass { audience }");
508
+ const doFetch = c.fetch ?? ((input, init) => fetch(input, init));
509
+ const parts = typeof token === "string" ? token.split(".") : [];
510
+ if (parts.length !== 3) throw shellRefused("not a JWT");
511
+ let header2;
512
+ let claims;
513
+ try {
514
+ header2 = JSON.parse(new TextDecoder().decode(fromB64url(parts[0])));
515
+ claims = JSON.parse(new TextDecoder().decode(fromB64url(parts[1])));
516
+ } catch {
517
+ throw shellRefused("not a JWT");
518
+ }
519
+ if (header2.alg !== "RS256") throw shellRefused(`alg ${String(header2.alg)} (only RS256)`);
520
+ const iss = typeof claims.iss === "string" ? claims.iss.replace(/\/+$/, "") : null;
521
+ if (iss && !trusted.includes(iss)) throw shellRefused(`issuer ${claims.iss}`);
522
+ const issuer = iss ?? trusted[0];
523
+ let keys3 = await jwks(issuer, doFetch);
524
+ let jwk = keys3.find((k) => k.kid === header2.kid) ?? (!header2.kid && keys3.length === 1 ? keys3[0] : void 0);
525
+ if (!jwk) {
526
+ keys3 = await jwks(issuer, doFetch, true);
527
+ jwk = keys3.find((k) => k.kid === header2.kid);
528
+ }
529
+ if (!jwk) throw shellRefused(`unknown key ${String(header2.kid)}`);
530
+ const key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
531
+ const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, fromB64url(parts[2]), enc.encode(`${parts[0]}.${parts[1]}`));
532
+ if (!ok) throw shellRefused("bad signature");
533
+ const now = Math.floor((options.now ?? Date.now()) / 1e3);
534
+ if (typeof claims.exp !== "number" || claims.exp + LEEWAY_S < now) throw shellRefused("expired");
535
+ if (typeof claims.nbf === "number" && claims.nbf - LEEWAY_S > now) throw shellRefused("not valid yet");
536
+ if (typeof claims.iat === "number" && claims.iat - LEEWAY_S > now) throw shellRefused("issued in the future");
537
+ const aud = [claims.aud].flat().filter((a) => typeof a === "string").map((a) => a.replace(/\/+$/, ""));
538
+ if (!aud.some((a) => audiences.includes(a))) throw shellRefused(`it is for ${aud.join(", ") || "no app"}, not this one`);
539
+ if (typeof claims.sub !== "string" || !claims.sub) throw shellRefused("no subject");
540
+ const text = (v) => typeof v === "string" && v ? v : null;
541
+ return { user_id: claims.sub, name: text(claims.name), avatar: text(claims.avatar) ?? text(claims.picture) ?? text(claims.image_url) };
542
+ }
543
+ /**
544
+ * Silent sign-in: `POST /api/auth/shell` with `{ "token": "<auth.getToken() token>" }` (or `Authorization: Bearer`).
545
+ * Verifies it, reads the full profile from the hub (falling back to what the token carries) and sets the same
546
+ * session cookie as `callback()`, so a person signed in inside the app and one signed in on the web look the same.
547
+ * Answers `{ user }`.
548
+ */
549
+ async shell(req) {
550
+ const s = this.settings(req);
551
+ if (req.method !== "POST") throw new AmbasadaError("INVALID_REQUEST", "POST the shell token as JSON { token }", 405);
552
+ let token = /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim();
553
+ if (!token) {
554
+ if (!(req.headers.get("content-type") ?? "").includes("application/json")) throw new AmbasadaError("INVALID_REQUEST", "send the token as JSON { token }", 415);
555
+ const body = await req.json().catch(() => null);
556
+ token = typeof body?.token === "string" ? body.token : void 0;
557
+ }
558
+ if (!token) throw new AmbasadaError("INVALID_REQUEST", "no shell token", 400);
559
+ const audiences = [...this.config().audiences ?? [], s.origin];
560
+ const who = await this.verifyShellToken(token, { audience: audiences });
561
+ let user = { id: who.user_id, name: who.name, avatar_url: who.avatar, builder: false, builder_since: null, member_since: null, pass: null };
562
+ const hub = this.config().hubUrl;
563
+ if (hub) {
564
+ try {
565
+ const res = await s.fetch(`${hub.replace(/\/+$/, "")}/api/apps/profile`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } });
566
+ if (res.ok) {
567
+ const profile = await res.json();
568
+ if (profile.id === who.user_id) user = { ...user, ...profile, id: who.user_id };
569
+ } else await res.body?.cancel();
570
+ } catch {
571
+ }
572
+ }
573
+ const exp = Math.floor(Date.now() / 1e3) + SESSION_TTL_S;
574
+ const session = await signCookie(s.clientSecret, "session", { user, exp });
575
+ const headers = new Headers({ "cache-control": "no-store", "content-type": "application/json" });
576
+ headers.append("set-cookie", setCookie(SESSION_COOKIE, session, s.secure, SESSION_TTL_S));
577
+ return new Response(JSON.stringify({ user }), { status: 200, headers });
578
+ }
579
+ /** Clears the session and redirects to `returnTo` (default "/"). Signs out of this product only. */
580
+ logout(req, options = {}) {
581
+ const url = new URL(req.url);
582
+ const publicUrl = this.config().publicUrl;
583
+ const secure = (publicUrl ? new URL(publicUrl) : url).protocol === "https:";
584
+ const returnTo = safeReturnTo(options.returnTo ?? url.searchParams.get("returnTo"));
585
+ return redirect(returnTo, [setCookie(SESSION_COOKIE, "", secure, 0)]);
586
+ }
587
+ };
588
+
589
+ // src/client.ts
590
+ var DEFAULT_API_URL = "https://api.ambasada.dev";
591
+ function processEnv() {
592
+ const p = globalThis.process;
593
+ return p?.env ?? {};
594
+ }
595
+ var Ambasada = class {
596
+ options;
597
+ keyLoader;
598
+ projectPromise;
599
+ constructor(options = {}) {
600
+ this.options = options;
601
+ }
602
+ env(name) {
603
+ const v = this.options.env?.[name];
604
+ return typeof v === "string" ? v : processEnv()[name];
605
+ }
606
+ get apiUrl() {
607
+ return (this.options.apiUrl ?? this.env("AMBASADA_API_URL") ?? this.env("VILLAGE_API_URL") ?? DEFAULT_API_URL).replace(/\/+$/, "");
608
+ }
609
+ get apiKey() {
610
+ const key = this.options.apiKey ?? (this.env("AMBASADA_API_KEY") ?? this.env("VILLAGE_API_KEY"));
611
+ if (!key) throw new AmbasadaError("CONFIG", "no API key: set AMBASADA_API_KEY (a project key from developer.ambasada.dev, or `ambasada login`) or pass { apiKey }");
612
+ return key;
613
+ }
614
+ /**
615
+ * Sign in with Ambasada (docs/AUTH.md): `login(req)`, `callback(req)`, `user(req)`, `logout(req)`.
616
+ * Credentials come with the deploy to ambasada.dev, and from `ambasada dev` locally.
617
+ */
618
+ auth = new AmbasadaAuth(() => ({
619
+ clientId: this.options.auth?.clientId ?? this.env("AMBASADA_AUTH_CLIENT_ID"),
620
+ clientSecret: this.options.auth?.clientSecret ?? this.env("AMBASADA_AUTH_CLIENT_SECRET"),
621
+ issuer: this.options.auth?.issuer ?? this.env("AMBASADA_AUTH_ISSUER"),
622
+ publicUrl: this.options.auth?.publicUrl ?? this.env("PUBLIC_URL"),
623
+ apiUrl: this.apiUrl,
624
+ // Silent sign-in: a shell token must be for this app, by id or origin.
625
+ audiences: [this.appOrigin, this.options.app?.id ?? this.env("AMBASADA_APP_ID") ?? this.env("AMBASADA_COMPANY")].filter((a) => !!a),
626
+ hubUrl: this.hubUrl,
627
+ fetch: this.options.fetch
628
+ }));
629
+ get appOrigin() {
630
+ const url = this.options.app?.origin ?? this.env("AMBASADA_APP_ORIGIN") ?? this.options.auth?.publicUrl ?? this.env("PUBLIC_URL");
631
+ return url && URL.canParse(url) ? new URL(url).origin : void 0;
632
+ }
633
+ get hubUrl() {
634
+ return this.options.app?.hubUrl ?? this.env("AMBASADA_HUB_URL");
635
+ }
636
+ /**
637
+ * The mini-app platform (docs/MINI-APPS.md §7): `verifyWebhook(req)` for the hub's app events and
638
+ * `notify({ … })` to push to people who turned notifications on. Uses `AMBASADA_APP_SECRET`.
639
+ */
640
+ apps = new AmbasadaApps(() => ({
641
+ appSecret: this.options.app?.secret ?? this.env("AMBASADA_APP_SECRET"),
642
+ hubUrl: this.hubUrl,
643
+ origin: this.appOrigin,
644
+ fetch: this.options.fetch
645
+ }));
646
+ // ── HTTP ──────────────────────────────────────────────────────────────────
647
+ async send(method, path, opts = {}) {
648
+ const url = new URL(this.apiUrl + path);
649
+ for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== void 0) url.searchParams.set(k, String(v));
650
+ const headers = { accept: "application/json" };
651
+ if (opts.auth !== false) headers.authorization = `Bearer ${this.apiKey}`;
652
+ let body = opts.raw;
653
+ if (opts.body !== void 0) {
654
+ body = JSON.stringify(opts.body);
655
+ headers["content-type"] = "application/json";
656
+ }
657
+ for (const [k, v] of Object.entries(opts.headers ?? {})) if (v !== void 0) headers[k.toLowerCase()] = v;
658
+ const doFetch = this.options.fetch ?? fetch;
659
+ const attempt = () => {
660
+ const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 45e3);
661
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
662
+ return doFetch(url, { method, headers, body, signal });
663
+ };
664
+ let res;
665
+ try {
666
+ res = await attempt();
667
+ } catch (error) {
668
+ if (opts.signal?.aborted) throw error;
669
+ if (!opts.retry) throw new AmbasadaError("NETWORK", `${method} ${url.pathname}: ${error.message}`, 0);
670
+ try {
671
+ res = await attempt();
672
+ } catch (again) {
673
+ throw new AmbasadaError("NETWORK", `${method} ${url.pathname}: ${again.message}`, 0);
674
+ }
675
+ }
676
+ const text = await res.text();
677
+ let json = text;
678
+ if (text && (res.headers.get("content-type") ?? "").includes("json")) {
679
+ try {
680
+ json = JSON.parse(text);
681
+ } catch {
682
+ }
683
+ }
684
+ if (!res.ok) throw errorFromResponse(res.status, json, `${method} ${url.pathname} \u2192 ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
685
+ return { status: res.status, headers: res.headers, json };
686
+ }
687
+ async call(method, path, opts) {
688
+ return (await this.send(method, path, opts)).json;
689
+ }
690
+ // ── identity, keys ────────────────────────────────────────────────────────
691
+ /** Your app's id (from options, `AMBASADA_APP_ID`, or the project the API key belongs to). */
692
+ project() {
693
+ const known = this.options.project ?? this.env("AMBASADA_APP_ID") ?? this.env("AMBASADA_COMPANY");
694
+ if (known) return Promise.resolve(known);
695
+ this.projectPromise ??= this.call("GET", "/v1/key").then(
696
+ (k) => k.id,
697
+ (error) => {
698
+ this.projectPromise = void 0;
699
+ throw error;
700
+ }
701
+ );
702
+ return this.projectPromise;
703
+ }
704
+ /** The API's signing keys (`/.well-known/jwks.json`, cached). */
705
+ keys() {
706
+ const given = this.options.keys;
707
+ if (Array.isArray(given)) return Promise.resolve(given);
708
+ if (typeof given === "function") return given();
709
+ this.keyLoader ??= cachedKeys(() => this.call("GET", "/.well-known/jwks.json", { auth: false }).then((r) => r.keys));
710
+ return this.keyLoader();
711
+ }
712
+ // ── storage ───────────────────────────────────────────────────────────────
713
+ files = {
714
+ /**
715
+ * Stores bytes, a Blob, or a URL to copy (`POST /v1/files`). Returns a public URL. For an app's own
716
+ * uploads prefer its bucket, `env.FILES` (docs/STORAGE.md).
717
+ */
718
+ put: (data, options = {}) => {
719
+ const query = { ttl_seconds: options.ttlSeconds };
720
+ if (typeof data === "string") return this.call("POST", "/v1/files", { body: { url: data, content_type: options.contentType }, query });
721
+ const contentType = options.contentType ?? (data instanceof Blob && data.type ? data.type : "application/octet-stream");
722
+ return this.call("POST", "/v1/files", { raw: data, headers: { "content-type": contentType }, query });
723
+ }
724
+ };
725
+ /**
726
+ * Your project's JSON key-value store (`/v1/store`): 1 MB per project (keys + values), free. For more,
727
+ * use the app's own D1, KV and R2 (docs/STORAGE.md).
728
+ */
729
+ store = {
730
+ /** The value, or null when the key is not set. */
731
+ get: async (key) => {
732
+ try {
733
+ return (await this.call("GET", `/v1/store/${encodeURIComponent(key)}`)).value;
734
+ } catch (error) {
735
+ if (error instanceof AmbasadaError && error.status === 404) return null;
736
+ throw error;
737
+ }
738
+ },
739
+ /** Saves any JSON value (fails with INVALID_REQUEST when the store would pass 1 MB). */
740
+ set: (key, value) => {
741
+ if (value === void 0) throw new AmbasadaError("INVALID_REQUEST", "store.set needs a JSON value (use store.delete to remove a key)");
742
+ return this.call("PUT", `/v1/store/${encodeURIComponent(key)}`, { body: value });
743
+ },
744
+ /** `set` under its other name. */
745
+ put: (key, value) => this.store.set(key, value),
746
+ /** True when the key existed. */
747
+ delete: async (key) => (await this.call("DELETE", `/v1/store/${encodeURIComponent(key)}`)).deleted,
748
+ /** Keys (up to 1000, optionally under a prefix) and how much of the 1 MB is used. */
749
+ list: (prefix) => this.call("GET", "/v1/store", { query: { prefix } })
750
+ };
751
+ };
752
+ function cachedKeys(load, ttlMs = 10 * 6e4) {
753
+ let cached;
754
+ return () => {
755
+ if (!cached || Date.now() - cached.at > ttlMs) {
756
+ const keys3 = load();
757
+ const entry = { at: Date.now(), keys: keys3 };
758
+ cached = entry;
759
+ keys3.catch(() => {
760
+ if (cached === entry) cached = void 0;
761
+ });
762
+ }
763
+ return cached.keys;
764
+ };
765
+ }
766
+ function createAmbasada(options = {}) {
767
+ return new Ambasada(options);
768
+ }
769
+
770
+ // src/headers.ts
771
+ function getHeader(headers, name) {
772
+ if (typeof headers.get === "function") return headers.get(name);
773
+ const record = headers;
774
+ const key = Object.keys(record).find((k) => k.toLowerCase() === name);
775
+ const value = key === void 0 ? void 0 : record[key];
776
+ return Array.isArray(value) ? value[0] ?? null : value ?? null;
777
+ }
778
+
779
+ // src/define.ts
780
+ var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
781
+ function defineApp(config) {
782
+ if (typeof config.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(config.id)) throw new Error(`app id "${String(config.id)}" must be a lowercase slug (a-z, 0-9, -)`);
783
+ const m = config.miniapp;
784
+ if (m !== void 0) {
785
+ if (!m || typeof m !== "object") throw new Error(`${config.id}: miniapp must be { entry: "/app" }`);
786
+ if (typeof m.entry !== "string" || !MINIAPP_ENTRY.test(m.entry) || m.entry.split("/").includes("..")) {
787
+ throw new Error(`${config.id}: miniapp.entry must be a path like "/app" or "/" (got ${JSON.stringify(m.entry)})`);
788
+ }
789
+ if (m.icon !== void 0 && (typeof m.icon !== "string" || !m.icon.startsWith("/"))) throw new Error(`${config.id}: miniapp.icon must be a path like "/icon.png"`);
790
+ if (m.accent !== void 0 && !/^#[0-9a-fA-F]{6}$/.test(m.accent)) throw new Error(`${config.id}: miniapp.accent must be a #RRGGBB colour`);
791
+ }
792
+ return config;
793
+ }
794
+
795
+ // src/platform.ts
796
+ var ambasada = new Ambasada();
797
+ var platform_default = ambasada;
798
+ export {
799
+ APP_SIGNATURE_HEADER2 as APP_SIGNATURE_HEADER,
800
+ APP_TIMESTAMP_HEADER2 as APP_TIMESTAMP_HEADER,
801
+ AUTH_PATHS,
802
+ Ambasada,
803
+ AmbasadaApps,
804
+ AmbasadaAuth,
805
+ AmbasadaError,
806
+ DEFAULT_API_URL,
807
+ ERROR_STATUS,
808
+ JWKS_TTL_MS,
809
+ MINIAPP_ENTRY,
810
+ SESSION_TTL_S,
811
+ WebhookInvalidError,
812
+ ambasada,
813
+ appWebhookHeaders2 as appWebhookHeaders,
814
+ createAmbasada,
815
+ platform_default as default,
816
+ defineApp,
817
+ errorFromResponse,
818
+ getHeader,
819
+ isAmbasadaError,
820
+ signAppWebhook2 as signAppWebhook
821
+ };