@ambasada/sdk 0.8.10 → 0.8.12

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/dist/auth.d.ts CHANGED
@@ -40,7 +40,7 @@ export interface AuthConfig {
40
40
  issuer?: string;
41
41
  /** Who a shell token must be for (`aud`): your app id and origin. The request's origin is added in `shell()`. */
42
42
  audiences?: string[];
43
- /** The hub, for the full profile behind a shell token (`/api/apps/profile`). */
43
+ /** The hub (default https://hub.ambasada.id): signs shell tokens, and has the full profile behind one (`/api/apps/profile`). */
44
44
  hubUrl?: string;
45
45
  /** Where `/v1/auth/profile` lives (the Ambasada API). */
46
46
  apiUrl: string;
@@ -110,9 +110,11 @@ export declare class AmbasadaAuth {
110
110
  /** Whoever is signed in on this request, or null (also null when sign-in is not set up). */
111
111
  user(req: WithHeaders): Promise<AmbasadaUser | null>;
112
112
  /**
113
- * Verifies a shell token (Clerk JWT template `ambasada-miniapp`, RS256, 60 s) against
114
- * `<AMBASADA_AUTH_ISSUER>/.well-known/jwks.json` (cached 1 h): signature, expiry, issuer, and that its `aud`
115
- * is this app (its id or origin). Returns who it is, or throws `AmbasadaError("UNAUTHORIZED")`.
113
+ * Verifies a shell token (RS256, 60 s) from the hub (`<AMBASADA_HUB_URL>/.well-known/jwks.json`, default
114
+ * https://hub.ambasada.id) or Ambasada ID (`<AMBASADA_AUTH_ISSUER>/.well-known/jwks.json`, the Clerk JWT template
115
+ * `ambasada-miniapp`), keys cached 1 h: signature, expiry, issuer, and that its `aud` is this app (its origin or
116
+ * id). Returns who it is, or throws `AmbasadaError("UNAUTHORIZED")`; `NOT_CONFIGURED` (503) while the issuer
117
+ * publishes no keys.
116
118
  */
117
119
  verifyShellToken(token: string, options?: {
118
120
  audience?: string | string[];
package/dist/define.d.ts CHANGED
@@ -31,6 +31,19 @@ export interface MiniAppConfig {
31
31
  scopes?: ("identity" | "notifications")[];
32
32
  visibility?: "public" | "builders" | "unlisted" | `event:${string}`;
33
33
  }
34
+ /**
35
+ * How the app is listed, whether or not it has a mini app (dry run 7 nit d: with the mini app off there was nowhere
36
+ * for these). The mini app's own `age_rating`, `kind` and `tags` win; these are its defaults, and `miniapp disable`
37
+ * keeps them here.
38
+ */
39
+ export interface ListingConfig {
40
+ /** Who it is for: "4+", "9+", "12+" or "17+" (the mini app's default "4+"). */
41
+ age_rating?: MiniAppConfig["age_rating"];
42
+ /** What it is: "product", "event", "tool" or "assistant" (the mini app's default "tool"). */
43
+ kind?: MiniAppConfig["kind"];
44
+ /** Up to 5 search words. */
45
+ tags?: string[];
46
+ }
34
47
  export interface AppConfig {
35
48
  /** The app's slug (lowercase): it lives at https://<id>.ambasada.dev. */
36
49
  id: string;
@@ -46,6 +59,8 @@ export interface AppConfig {
46
59
  };
47
60
  /** The mini app inside the Ambasada app, at a path of this one. Off without it. */
48
61
  miniapp?: MiniAppConfig;
62
+ /** age_rating, kind and tags, for an app with or without a mini app (the mini app's own win). */
63
+ listing?: ListingConfig;
49
64
  /** Add-ons to load besides the ones found in node_modules (package names, e.g. "@ambasada/village"). */
50
65
  addons?: string[];
51
66
  }
package/dist/errors.d.ts CHANGED
@@ -47,6 +47,8 @@ export declare const ERROR_STATUS: {
47
47
  readonly CAP_TOOLS_RATE: 429;
48
48
  readonly NETWORK: 0;
49
49
  readonly CONFIG: 0;
50
+ /** A platform feature this app relies on is not switched on yet (silent sign-in before the hub has keys). */
51
+ readonly NOT_CONFIGURED: 503;
50
52
  readonly RECEIPT_INVALID: 402;
51
53
  readonly WEBHOOK_INVALID: 400;
52
54
  };
@@ -63,6 +65,8 @@ export interface ErrorDetails {
63
65
  upstream_status?: number;
64
66
  /** APPROVAL_REQUIRED: retry with `{ approval: approvalId }` once the team approved it. */
65
67
  approval_id?: string;
68
+ /** CAP_TOOLS_RATE, TOOL_UNAVAILABLE, a busy seller: seconds until it is worth trying again. */
69
+ retry_after_s?: number;
66
70
  [key: string]: unknown;
67
71
  }
68
72
  export declare class AmbasadaError extends Error {
@@ -76,6 +80,8 @@ export declare class AmbasadaError extends Error {
76
80
  get callId(): string | undefined;
77
81
  get refunded(): boolean;
78
82
  get approvalId(): string | undefined;
83
+ /** Seconds until it is worth trying again (CAP_TOOLS_RATE, TOOL_UNAVAILABLE, a busy seller), when the error says. */
84
+ get retryAfterS(): number | undefined;
79
85
  /** The wire shape, e.g. to pass an error on from your own endpoint. */
80
86
  toJSON(): {
81
87
  error: {
@@ -83,7 +89,7 @@ export declare class AmbasadaError extends Error {
83
89
  message: string;
84
90
  } & ErrorDetails;
85
91
  };
86
- /** A `Response` with the wire shape and status (402 for receipt problems). */
92
+ /** A `Response` with the wire shape and status (402 for receipt problems), and `Retry-After` when it has one. */
87
93
  toResponse(): Response;
88
94
  }
89
95
  export type WebhookFailure = "missing_headers" | "bad_timestamp" | "too_old" | "too_new" | "bad_signature" | "bad_secret" | "bad_body";
@@ -11,7 +11,7 @@ export { getHeader } from "./headers.js";
11
11
  export type { HeaderSource } from "./headers.js";
12
12
  export { defineApp } from "./define.js";
13
13
  export { MINIAPP_AGE_RATINGS, MINIAPP_ENTRY, MINIAPP_KINDS, supportUrl } from "./define.js";
14
- export type { AppConfig, MiniAppConfig } from "./define.js";
14
+ export type { AppConfig, ListingConfig, MiniAppConfig } from "./define.js";
15
15
  export { AmbasadaAuth, AUTH_PATHS, JWKS_TTL_MS, SESSION_TTL_S } from "./auth.js";
16
16
  export type { AmbasadaUser, AuthConfig, LoginOptions, ShellUser, WithHeaders } from "./auth.js";
17
17
  export { AmbasadaApps, APP_SIGNATURE_HEADER, APP_TIMESTAMP_HEADER, appWebhookHeaders, signAppWebhook } from "./apps.js";
package/dist/platform.js CHANGED
@@ -180,6 +180,8 @@ var ERROR_STATUS = {
180
180
  // SDK-side
181
181
  NETWORK: 0,
182
182
  CONFIG: 0,
183
+ /** A platform feature this app relies on is not switched on yet (silent sign-in before the hub has keys). */
184
+ NOT_CONFIGURED: 503,
183
185
  RECEIPT_INVALID: 402,
184
186
  WEBHOOK_INVALID: 400
185
187
  };
@@ -211,13 +213,19 @@ var AmbasadaError = class _AmbasadaError extends Error {
211
213
  get approvalId() {
212
214
  return this.details.approval_id;
213
215
  }
216
+ /** Seconds until it is worth trying again (CAP_TOOLS_RATE, TOOL_UNAVAILABLE, a busy seller), when the error says. */
217
+ get retryAfterS() {
218
+ const s = this.details.retry_after_s;
219
+ return typeof s === "number" && Number.isFinite(s) ? s : void 0;
220
+ }
214
221
  /** The wire shape, e.g. to pass an error on from your own endpoint. */
215
222
  toJSON() {
216
223
  return { error: { code: this.code, message: this.message, ...this.details } };
217
224
  }
218
- /** A `Response` with the wire shape and status (402 for receipt problems). */
225
+ /** A `Response` with the wire shape and status (402 for receipt problems), and `Retry-After` when it has one. */
219
226
  toResponse() {
220
- return Response.json(this.toJSON(), { status: this.status || 500 });
227
+ const retry = this.details.retry_after_s;
228
+ return Response.json(this.toJSON(), { status: this.status || 500, ...typeof retry === "number" ? { headers: { "retry-after": String(Math.ceil(retry)) } } : {} });
221
229
  }
222
230
  };
223
231
  var WebhookInvalidError = class extends AmbasadaError {
@@ -403,13 +411,17 @@ async function jwks(issuer, doFetch, refresh = false) {
403
411
  if (cached && !refresh && Date.now() - cached.at < JWKS_TTL_MS) return cached.keys;
404
412
  if (cached && refresh && Date.now() - cached.at < 6e4) return cached.keys;
405
413
  const res = await doFetch(`${issuer}/.well-known/jwks.json`, { headers: { accept: "application/json" } });
406
- if (!res.ok) throw new AmbasadaError("UPSTREAM_ERROR", `could not read the Ambasada ID keys (${res.status})`, 502);
407
- const body = await res.json();
414
+ if (res.status === 404) throw notConfigured(`${issuer} publishes no signing keys yet`);
415
+ if (!res.ok) throw new AmbasadaError("UPSTREAM_ERROR", `could not read the signing keys of ${issuer} (${res.status})`, 502);
416
+ const body = await res.json().catch(() => ({}));
408
417
  const keys3 = (body.keys ?? []).filter((k) => k.kty === "RSA" && k.n && k.e);
418
+ if (!keys3.length) throw notConfigured(`${issuer} publishes no RS256 signing keys yet`);
409
419
  jwksCache.set(issuer, { at: Date.now(), keys: keys3 });
410
420
  return keys3;
411
421
  }
422
+ var hubOf = (c) => (c.hubUrl || DEFAULT_HUB_URL).replace(/\/+$/, "");
412
423
  var shellRefused = (message) => new AmbasadaError("UNAUTHORIZED", `shell token refused: ${message}`, 401);
424
+ var notConfigured = (message) => new AmbasadaError("NOT_CONFIGURED", `silent sign-in is not configured: ${message}`, 503);
413
425
  var AmbasadaAuth = class {
414
426
  constructor(config) {
415
427
  this.config = config;
@@ -499,14 +511,15 @@ var AmbasadaAuth = class {
499
511
  return (await verifyCookie(secret, "session", readOurCookie(req, SESSION_COOKIE)))?.user ?? null;
500
512
  }
501
513
  /**
502
- * Verifies a shell token (Clerk JWT template `ambasada-miniapp`, RS256, 60 s) against
503
- * `<AMBASADA_AUTH_ISSUER>/.well-known/jwks.json` (cached 1 h): signature, expiry, issuer, and that its `aud`
504
- * is this app (its id or origin). Returns who it is, or throws `AmbasadaError("UNAUTHORIZED")`.
514
+ * Verifies a shell token (RS256, 60 s) from the hub (`<AMBASADA_HUB_URL>/.well-known/jwks.json`, default
515
+ * https://hub.ambasada.id) or Ambasada ID (`<AMBASADA_AUTH_ISSUER>/.well-known/jwks.json`, the Clerk JWT template
516
+ * `ambasada-miniapp`), keys cached 1 h: signature, expiry, issuer, and that its `aud` is this app (its origin or
517
+ * id). Returns who it is, or throws `AmbasadaError("UNAUTHORIZED")`; `NOT_CONFIGURED` (503) while the issuer
518
+ * publishes no keys.
505
519
  */
506
520
  async verifyShellToken(token, options = {}) {
507
521
  const c = this.config();
508
- if (!c.issuer) throw new AmbasadaError("CONFIG", "silent sign-in needs AMBASADA_AUTH_ISSUER (injected on ambasada.dev and by `ambasada dev`)");
509
- const trusted = [c.issuer, c.hubUrl].filter((v) => !!v).map((v) => v.replace(/\/+$/, ""));
522
+ const trusted = [...new Set([c.issuer, hubOf(c)].filter((v) => !!v).map((v) => v.replace(/\/+$/, "")))];
510
523
  const audiences = [options.audience ?? c.audiences ?? []].flat().filter(Boolean).map((a) => a.replace(/\/+$/, ""));
511
524
  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 }");
512
525
  const doFetch = c.fetch ?? ((input, init) => fetch(input, init));
@@ -551,6 +564,7 @@ var AmbasadaAuth = class {
551
564
  * Answers `{ user }`.
552
565
  */
553
566
  async shell(req) {
567
+ if (!this.configured) throw notConfigured("this app has no Sign in with Ambasada credentials (AMBASADA_AUTH_*: on ambasada.dev after `ambasada deploy`, and under `ambasada dev`)");
554
568
  const s = this.settings(req);
555
569
  if (req.method !== "POST") throw new AmbasadaError("INVALID_REQUEST", "POST the shell token as JSON { token }", 405);
556
570
  let token = /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim();
@@ -563,16 +577,13 @@ var AmbasadaAuth = class {
563
577
  const audiences = [...this.config().audiences ?? [], s.origin];
564
578
  const who = await this.verifyShellToken(token, { audience: audiences });
565
579
  let user = { id: who.user_id, name: who.name, avatar_url: who.avatar, builder: false, builder_since: null, member_since: null, pass: null };
566
- const hub = this.config().hubUrl;
567
- if (hub) {
568
- try {
569
- const res = await s.fetch(`${hub.replace(/\/+$/, "")}/api/apps/profile`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } });
570
- if (res.ok) {
571
- const profile = await res.json();
572
- if (profile.id === who.user_id) user = { ...user, ...profile, id: who.user_id };
573
- } else await res.body?.cancel();
574
- } catch {
575
- }
580
+ try {
581
+ const res = await s.fetch(`${hubOf(this.config())}/api/apps/profile`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } });
582
+ if (res.ok) {
583
+ const profile = await res.json();
584
+ if (profile.id === who.user_id) user = { ...user, ...profile, id: who.user_id };
585
+ } else await res.body?.cancel();
586
+ } catch {
576
587
  }
577
588
  const exp = Math.floor(Date.now() / 1e3) + SESSION_TTL_S;
578
589
  const session = await signCookie(s.clientSecret, "session", { user, exp });
@@ -592,6 +603,7 @@ var AmbasadaAuth = class {
592
603
 
593
604
  // src/client.ts
594
605
  var DEFAULT_API_URL = "https://api.ambasada.dev";
606
+ var originOf = (url) => url && URL.canParse(url) ? new URL(url).origin : void 0;
595
607
  var isBinding = (v) => typeof v === "object" && v !== null && typeof v.fetch === "function";
596
608
  function processEnv() {
597
609
  const p = globalThis.process;
@@ -642,14 +654,13 @@ var Ambasada = class {
642
654
  issuer: this.options.auth?.issuer ?? this.env("AMBASADA_AUTH_ISSUER"),
643
655
  publicUrl: this.options.auth?.publicUrl ?? this.env("PUBLIC_URL"),
644
656
  apiUrl: this.apiUrl,
645
- // Silent sign-in: a shell token must be for this app, by id or origin.
646
- audiences: [this.appOrigin, this.options.app?.id ?? this.env("AMBASADA_APP_ID") ?? this.env("AMBASADA_COMPANY")].filter((a) => !!a),
657
+ // Silent sign-in: a shell token must be for this app, by origin (the deploy's and PUBLIC_URL) or id.
658
+ audiences: [...new Set([this.appOrigin, originOf(this.env("PUBLIC_URL")), this.options.app?.id, this.env("AMBASADA_APP_ID"), this.env("AMBASADA_COMPANY")].filter((a) => !!a))],
647
659
  hubUrl: this.hubUrl,
648
660
  fetch: this.fetcher
649
661
  }));
650
662
  get appOrigin() {
651
- const url = this.options.app?.origin ?? this.env("AMBASADA_APP_ORIGIN") ?? this.options.auth?.publicUrl ?? this.env("PUBLIC_URL");
652
- return url && URL.canParse(url) ? new URL(url).origin : void 0;
663
+ return originOf(this.options.app?.origin ?? this.env("AMBASADA_APP_ORIGIN") ?? this.options.auth?.publicUrl ?? this.env("PUBLIC_URL"));
653
664
  }
654
665
  get hubUrl() {
655
666
  return this.options.app?.hubUrl ?? this.env("AMBASADA_HUB_URL");
@@ -817,6 +828,11 @@ function supportUrl(value) {
817
828
  return void 0;
818
829
  }
819
830
  var MINIAPP_ENTRY = /^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?$/;
831
+ function checkListing(id, key, l) {
832
+ if (l.age_rating !== void 0 && !MINIAPP_AGE_RATINGS.includes(l.age_rating)) throw new Error(`${id}: ${key}.age_rating must be one of ${MINIAPP_AGE_RATINGS.join(", ")} (got ${JSON.stringify(l.age_rating)})`);
833
+ if (l.kind !== void 0 && !MINIAPP_KINDS.includes(l.kind)) throw new Error(`${id}: ${key}.kind must be one of ${MINIAPP_KINDS.join(", ")} (got ${JSON.stringify(l.kind)})`);
834
+ if (l.tags !== void 0 && (!Array.isArray(l.tags) || l.tags.length > 5 || l.tags.some((t) => typeof t !== "string"))) throw new Error(`${id}: ${key}.tags must be up to 5 strings`);
835
+ }
820
836
  function defineApp(config) {
821
837
  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, -)`);
822
838
  const m = config.miniapp;
@@ -827,10 +843,12 @@ function defineApp(config) {
827
843
  }
828
844
  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"`);
829
845
  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`);
830
- if (m.age_rating !== void 0 && !MINIAPP_AGE_RATINGS.includes(m.age_rating)) throw new Error(`${config.id}: miniapp.age_rating must be one of ${MINIAPP_AGE_RATINGS.join(", ")} (got ${JSON.stringify(m.age_rating)})`);
831
- if (m.kind !== void 0 && !MINIAPP_KINDS.includes(m.kind)) throw new Error(`${config.id}: miniapp.kind must be one of ${MINIAPP_KINDS.join(", ")} (got ${JSON.stringify(m.kind)})`);
846
+ checkListing(config.id, "miniapp", m);
832
847
  if (m.support !== void 0 && !supportUrl(m.support)) throw new Error(`${config.id}: miniapp.support must be an email or an https URL (got ${JSON.stringify(m.support)})`);
833
- if (m.tags !== void 0 && (!Array.isArray(m.tags) || m.tags.length > 5 || m.tags.some((t) => typeof t !== "string"))) throw new Error(`${config.id}: miniapp.tags must be up to 5 strings`);
848
+ }
849
+ if (config.listing !== void 0) {
850
+ if (!config.listing || typeof config.listing !== "object") throw new Error(`${config.id}: listing must be { age_rating, kind, tags }`);
851
+ checkListing(config.id, "listing", config.listing);
834
852
  }
835
853
  if (config.support !== void 0 && !supportUrl(config.support)) throw new Error(`${config.id}: support must be an email or an https URL (got ${JSON.stringify(config.support)})`);
836
854
  return config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambasada/sdk",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "The Ambasada platform SDK: Sign in with Ambasada, mini-app notifications, the project store, files.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "node": ">=22"
28
28
  },
29
29
  "dependencies": {
30
- "@ambasada/village": "^0.8.10"
30
+ "@ambasada/village": "^0.8.12"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.20.5",