@ambasada/sdk 0.4.1 → 0.6.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/dist/apps.d.ts +110 -0
- package/dist/auth.d.ts +37 -0
- package/dist/client.d.ts +15 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +510 -183
- package/package.json +1 -1
package/dist/apps.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications for every mini app (docs/MINI-APPS.md §7). The hub tells your app who turned notifications
|
|
3
|
+
* on through a signed webhook; you keep `token ↔ your user` and send with that token:
|
|
4
|
+
*
|
|
5
|
+
* POST /api/ambasada/webhook → const event = await ambasada.apps.verifyWebhook(req) // AppEvent
|
|
6
|
+
* if (event.event === "notifications_enabled") save(event.user, event.token)
|
|
7
|
+
* anywhere → await ambasada.apps.notify({ notification_id, title, body, url, tokens: [token] })
|
|
8
|
+
*
|
|
9
|
+
* The app secret signs both directions: `AMBASADA_APP_SECRET` (the deploy injects it on ambasada.dev; the hub
|
|
10
|
+
* dashboard shows it for apps hosted elsewhere; `ambasada dev` sets a fake one and plays the hub).
|
|
11
|
+
* WebCrypto only; the wire format is in @ambasada/protocol (packages/protocol/src/apps.ts).
|
|
12
|
+
*/
|
|
13
|
+
import type { HeaderSource } from "./webhook.js";
|
|
14
|
+
export declare const APP_SIGNATURE_HEADER = "x-ambasada-signature";
|
|
15
|
+
export declare const APP_TIMESTAMP_HEADER = "x-ambasada-timestamp";
|
|
16
|
+
export type AppEventType = "notifications_enabled" | "notifications_disabled" | "app_added" | "app_removed" | "ping";
|
|
17
|
+
/** What the hub POSTs to your webhook. `token` comes with notifications_enabled (and app_added when notifications are on). */
|
|
18
|
+
export type AppEvent = {
|
|
19
|
+
event: "notifications_enabled";
|
|
20
|
+
app: string;
|
|
21
|
+
token: string;
|
|
22
|
+
user: string;
|
|
23
|
+
at: string;
|
|
24
|
+
} | {
|
|
25
|
+
event: "notifications_disabled";
|
|
26
|
+
app: string;
|
|
27
|
+
user: string;
|
|
28
|
+
token?: string;
|
|
29
|
+
at: string;
|
|
30
|
+
} | {
|
|
31
|
+
event: "app_added";
|
|
32
|
+
app: string;
|
|
33
|
+
user: string;
|
|
34
|
+
token?: string;
|
|
35
|
+
at: string;
|
|
36
|
+
} | {
|
|
37
|
+
event: "app_removed";
|
|
38
|
+
app: string;
|
|
39
|
+
user: string;
|
|
40
|
+
token?: string;
|
|
41
|
+
at: string;
|
|
42
|
+
} | {
|
|
43
|
+
event: "ping";
|
|
44
|
+
app: string;
|
|
45
|
+
at: string;
|
|
46
|
+
};
|
|
47
|
+
export type NotificationCategory = "transactional" | "social" | "marketing";
|
|
48
|
+
export interface AppNotifyInput {
|
|
49
|
+
/** ≤ 128; (person, id) is deduped by the hub for 24 h. */
|
|
50
|
+
notification_id: string;
|
|
51
|
+
/** ≤ 40: the whole headline. */
|
|
52
|
+
title: string;
|
|
53
|
+
/** ≤ 140. */
|
|
54
|
+
body: string;
|
|
55
|
+
/** A path on your app (`/orders/184`) or a URL on its origin. */
|
|
56
|
+
url: string;
|
|
57
|
+
/** Notification tokens from `notifications_enabled`, ≤ 100 per call. */
|
|
58
|
+
tokens?: string[];
|
|
59
|
+
/** Ambasada user ids, for Verified apps with the identity scope. */
|
|
60
|
+
users?: string[];
|
|
61
|
+
/** Default "transactional", the only one Listed apps may send. */
|
|
62
|
+
category?: NotificationCategory;
|
|
63
|
+
/** Replaces an earlier unread push with the same key. */
|
|
64
|
+
collapse_key?: string;
|
|
65
|
+
ttl_seconds?: number;
|
|
66
|
+
}
|
|
67
|
+
export interface AppNotifyResult {
|
|
68
|
+
sent: string[];
|
|
69
|
+
/** Gone (the person turned notifications off or removed the app): forget them. */
|
|
70
|
+
invalid: string[];
|
|
71
|
+
rate_limited: string[];
|
|
72
|
+
}
|
|
73
|
+
/** The `x-ambasada-signature` value for a delivery (`v1=<hex>`): for tests and your own fake hub. */
|
|
74
|
+
export declare const signAppWebhook: (secret: string, timestamp: number, body: string) => Promise<string>;
|
|
75
|
+
/** Timestamp + signature headers for `body`, as the hub sends them. */
|
|
76
|
+
export declare const appWebhookHeaders: (secret: string, body: string, timestamp?: number) => Promise<Record<string, string>>;
|
|
77
|
+
export interface AppsConfig {
|
|
78
|
+
/** `AMBASADA_APP_SECRET`. */
|
|
79
|
+
appSecret?: string;
|
|
80
|
+
/** `AMBASADA_HUB_URL`, else https://hub.ambasada.id. */
|
|
81
|
+
hubUrl?: string;
|
|
82
|
+
/** Your app's origin, for the same-origin `url` rule: `AMBASADA_APP_ORIGIN`, else `PUBLIC_URL`. */
|
|
83
|
+
origin?: string;
|
|
84
|
+
fetch?: typeof fetch;
|
|
85
|
+
}
|
|
86
|
+
/** A fetch `Request`, or the raw body and headers exactly as received. */
|
|
87
|
+
export type WebhookInput = Request | {
|
|
88
|
+
body: string | Uint8Array;
|
|
89
|
+
headers: HeaderSource;
|
|
90
|
+
};
|
|
91
|
+
export declare class AmbasadaApps {
|
|
92
|
+
private readonly config;
|
|
93
|
+
constructor(config: () => AppsConfig);
|
|
94
|
+
/** True when the app has its secret (hosted on ambasada.dev, `ambasada dev`, or set by hand). */
|
|
95
|
+
get configured(): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Verifies a webhook from the hub (signature, 5-minute window) and returns the event, or throws
|
|
98
|
+
* `WebhookInvalidError` (answer it with `error.toResponse()`, a 400). Read nothing from the request before.
|
|
99
|
+
*/
|
|
100
|
+
verifyWebhook(input: WebhookInput, secret?: string, options?: {
|
|
101
|
+
toleranceSeconds?: number;
|
|
102
|
+
now?: number;
|
|
103
|
+
}): Promise<AppEvent>;
|
|
104
|
+
/**
|
|
105
|
+
* Sends a push to the people behind `tokens` (or `users`, for Verified apps with `identity`) through the hub.
|
|
106
|
+
* Checks the limits first (title ≤ 40, body ≤ 140, id ≤ 128, ≤ 100 recipients, `url` on your origin) and
|
|
107
|
+
* throws `VillageError("INVALID_REQUEST")` before sending anything. Tokens in `invalid` are gone: forget them.
|
|
108
|
+
*/
|
|
109
|
+
notify(input: AppNotifyInput): Promise<AppNotifyResult>;
|
|
110
|
+
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* The session is an HttpOnly cookie holding the signed public profile (HMAC-SHA256 under a key
|
|
13
13
|
* derived from the client secret), host-only (`__Host-` on https), so no other *.ambasada.dev product
|
|
14
14
|
* can read, set or forge it. No access token is kept.
|
|
15
|
+
*
|
|
16
|
+
* Silent sign-in inside the Ambasada app (docs/MINI-APPS.md §5): the page asks the shell for a short-lived
|
|
17
|
+
* token (`auth.getToken()` from @ambasada/miniapp) and POSTs it here; the same session cookie comes back:
|
|
18
|
+
*
|
|
19
|
+
* POST /api/auth/shell { token } → return village.auth.shell(req) // { user } + cookie
|
|
20
|
+
* anywhere → const who = await village.auth.verifyShellToken(token) // { user_id, name, avatar }
|
|
15
21
|
*/
|
|
16
22
|
/** The person signed in, as `GET /v1/auth/profile` returns them. */
|
|
17
23
|
export interface AmbasadaUser {
|
|
@@ -32,6 +38,10 @@ export interface AuthConfig {
|
|
|
32
38
|
clientId?: string;
|
|
33
39
|
clientSecret?: string;
|
|
34
40
|
issuer?: string;
|
|
41
|
+
/** Who a shell token must be for (`aud`): your app id and origin. The request's origin is added in `shell()`. */
|
|
42
|
+
audiences?: string[];
|
|
43
|
+
/** The hub, for the full profile behind a shell token (`/api/apps/profile`). */
|
|
44
|
+
hubUrl?: string;
|
|
35
45
|
/** Where `/v1/auth/profile` lives (the village API). */
|
|
36
46
|
apiUrl: string;
|
|
37
47
|
/** Your public origin if the Worker sees another host (behind a proxy). Default: the request's origin. */
|
|
@@ -48,6 +58,7 @@ export declare const AUTH_PATHS: {
|
|
|
48
58
|
readonly login: "/api/auth/login";
|
|
49
59
|
readonly callback: "/api/auth/callback";
|
|
50
60
|
readonly logout: "/api/auth/logout";
|
|
61
|
+
readonly shell: "/api/auth/shell";
|
|
51
62
|
};
|
|
52
63
|
/** How long a sign-in lasts (the cookie holds a profile snapshot; signing in again refreshes it). */
|
|
53
64
|
export declare const SESSION_TTL_S: number;
|
|
@@ -72,6 +83,16 @@ export declare function readCookie(req: WithHeaders, name: string): string | nul
|
|
|
72
83
|
* any control character (browsers drop tabs and newlines, so "/\t/evil" would become "//evil").
|
|
73
84
|
*/
|
|
74
85
|
export declare function safeReturnTo(value: string | null | undefined): string;
|
|
86
|
+
/** Who a verified shell token is for. Name and avatar are what Ambasada ID had when the shell minted it. */
|
|
87
|
+
export interface ShellUser {
|
|
88
|
+
user_id: string;
|
|
89
|
+
name: string | null;
|
|
90
|
+
avatar: string | null;
|
|
91
|
+
}
|
|
92
|
+
/** JWKS cache per issuer: 1 hour, refreshed once early when a token names a key we don't have. */
|
|
93
|
+
export declare const JWKS_TTL_MS = 3600000;
|
|
94
|
+
/** For tests. */
|
|
95
|
+
export declare function clearJwksCache(): void;
|
|
75
96
|
export declare class AmbasadaAuth {
|
|
76
97
|
private readonly config;
|
|
77
98
|
constructor(config: () => AuthConfig);
|
|
@@ -88,6 +109,22 @@ export declare class AmbasadaAuth {
|
|
|
88
109
|
callback(req: Request): Promise<Response>;
|
|
89
110
|
/** Whoever is signed in on this request, or null (also null when sign-in is not set up). */
|
|
90
111
|
user(req: WithHeaders): Promise<AmbasadaUser | null>;
|
|
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 `VillageError("UNAUTHORIZED")`.
|
|
116
|
+
*/
|
|
117
|
+
verifyShellToken(token: string, options?: {
|
|
118
|
+
audience?: string | string[];
|
|
119
|
+
now?: number;
|
|
120
|
+
}): Promise<ShellUser>;
|
|
121
|
+
/**
|
|
122
|
+
* Silent sign-in: `POST /api/auth/shell` with `{ "token": "<auth.getToken() token>" }` (or `Authorization: Bearer`).
|
|
123
|
+
* Verifies it, reads the full profile from the hub (falling back to what the token carries) and sets the same
|
|
124
|
+
* session cookie as `callback()`, so a person signed in inside the app and one signed in on the web look the same.
|
|
125
|
+
* Answers `{ user }`.
|
|
126
|
+
*/
|
|
127
|
+
shell(req: Request): Promise<Response>;
|
|
91
128
|
/** Clears the session and redirects to `returnTo` (default "/"). Signs out of this product only. */
|
|
92
129
|
logout(req: Request, options?: {
|
|
93
130
|
returnTo?: string;
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AmbasadaApps } from "./apps.js";
|
|
1
2
|
import { AmbasadaAuth } from "./auth.js";
|
|
2
3
|
import { type IncomingRequest } from "./receipt.js";
|
|
3
4
|
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";
|
|
@@ -27,6 +28,13 @@ export interface VillageOptions {
|
|
|
27
28
|
issuer?: string;
|
|
28
29
|
publicUrl?: string;
|
|
29
30
|
};
|
|
31
|
+
/** Mini-app platform (notifications). Default: `AMBASADA_APP_SECRET`, `AMBASADA_HUB_URL`, `AMBASADA_APP_ORIGIN` (else `PUBLIC_URL`). */
|
|
32
|
+
app?: {
|
|
33
|
+
secret?: string;
|
|
34
|
+
hubUrl?: string;
|
|
35
|
+
origin?: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
};
|
|
30
38
|
}
|
|
31
39
|
export interface VerifyReceiptOptions {
|
|
32
40
|
/** Pin the offering name (`translate`), so a receipt for another of your offerings is refused. */
|
|
@@ -53,6 +61,13 @@ export declare class Village {
|
|
|
53
61
|
* Credentials come with the deploy to ambasada.dev, and from `ambasada dev` locally.
|
|
54
62
|
*/
|
|
55
63
|
readonly auth: AmbasadaAuth;
|
|
64
|
+
private get appOrigin();
|
|
65
|
+
private get hubUrl();
|
|
66
|
+
/**
|
|
67
|
+
* The mini-app platform (docs/MINI-APPS.md §7): `verifyWebhook(req)` for the hub's app events and
|
|
68
|
+
* `notify({ … })` to push to people who turned notifications on. Uses `AMBASADA_APP_SECRET`.
|
|
69
|
+
*/
|
|
70
|
+
readonly apps: AmbasadaApps;
|
|
56
71
|
private send;
|
|
57
72
|
private call;
|
|
58
73
|
/** Your company id (from options, `AMBASADA_COMPANY`, or the wallet the API key belongs to). */
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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
7
|
* const user = await village.auth.user(req); // Sign in with Ambasada (docs/AUTH.md)
|
|
8
|
+
* await village.apps.notify({ notification_id, title, body, url, tokens }); // mini-app pushes (docs/MINI-APPS.md §7)
|
|
8
9
|
*
|
|
9
10
|
* Works in Node ≥ 22, Bun, Cloudflare Workers (`createAmbasada({ env })`) and Next.js route handlers.
|
|
10
11
|
*/
|
|
@@ -17,8 +18,10 @@ export { verifyWebhookPayload, webhookHeaders, generateWebhookSecret, WEBHOOK_HE
|
|
|
17
18
|
export { RECEIPT_HEADER, readIncoming, verifyCallReceipt, verifyCheckoutReceiptToken } from "./receipt.js";
|
|
18
19
|
export type { IncomingRequest } from "./receipt.js";
|
|
19
20
|
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";
|
|
21
|
+
export { AmbasadaAuth, AUTH_PATHS, JWKS_TTL_MS, SESSION_TTL_S } from "./auth.js";
|
|
22
|
+
export type { AmbasadaUser, AuthConfig, LoginOptions, ShellUser, WithHeaders } from "./auth.js";
|
|
23
|
+
export { AmbasadaApps, APP_SIGNATURE_HEADER, APP_TIMESTAMP_HEADER, appWebhookHeaders, signAppWebhook } from "./apps.js";
|
|
24
|
+
export type { AppEvent, AppEventType, AppNotifyInput, AppNotifyResult, AppsConfig, NotificationCategory, WebhookInput } from "./apps.js";
|
|
22
25
|
export type { CompanyConfig, OfferingDef } from "./define.js";
|
|
23
26
|
/** The default client, configured from the environment at call time. */
|
|
24
27
|
export declare const village: Village;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,315 @@
|
|
|
1
|
+
// ../protocol/src/encoding.ts
|
|
2
|
+
var encoder = new TextEncoder();
|
|
3
|
+
var decoder = new TextDecoder();
|
|
4
|
+
function utf8(text) {
|
|
5
|
+
return encoder.encode(text);
|
|
6
|
+
}
|
|
7
|
+
function fromUtf8(bytes) {
|
|
8
|
+
return decoder.decode(bytes);
|
|
9
|
+
}
|
|
10
|
+
function fromBase64url(text) {
|
|
11
|
+
const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
|
|
12
|
+
const binary = atob(padded);
|
|
13
|
+
const bytes = new Uint8Array(binary.length);
|
|
14
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
15
|
+
return bytes;
|
|
16
|
+
}
|
|
17
|
+
function toBytes(data) {
|
|
18
|
+
if (typeof data === "string") return utf8(data);
|
|
19
|
+
if (data instanceof Uint8Array) return new Uint8Array(data);
|
|
20
|
+
return new Uint8Array(data);
|
|
21
|
+
}
|
|
22
|
+
async function sha256Hex(data) {
|
|
23
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", toBytes(data)));
|
|
24
|
+
let hex2 = "";
|
|
25
|
+
for (const byte of digest) hex2 += byte.toString(16).padStart(2, "0");
|
|
26
|
+
return hex2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ../protocol/src/receipt.ts
|
|
30
|
+
var RECEIPT_HEADER = "x-village-receipt";
|
|
31
|
+
var DEFAULT_ISSUER = "village";
|
|
32
|
+
var ReceiptError = class extends Error {
|
|
33
|
+
constructor(reason, message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
this.name = "ReceiptError";
|
|
37
|
+
}
|
|
38
|
+
reason;
|
|
39
|
+
};
|
|
40
|
+
var ED25519 = { name: "Ed25519" };
|
|
41
|
+
async function verifyReceipt(token, options) {
|
|
42
|
+
const claims = await verifySigned(token, options);
|
|
43
|
+
if (claims.kind !== void 0 || typeof claims.call_id !== "string" || typeof claims.body_sha256 !== "string") {
|
|
44
|
+
throw new ReceiptError("wrong_kind", "not a call receipt");
|
|
45
|
+
}
|
|
46
|
+
if (options.seller !== void 0 && claims.seller !== options.seller) {
|
|
47
|
+
throw new ReceiptError("wrong_seller", `receipt is for seller ${claims.seller}`);
|
|
48
|
+
}
|
|
49
|
+
if (options.offering !== void 0 && claims.offering !== options.offering) {
|
|
50
|
+
throw new ReceiptError("wrong_offering", `receipt is for offering ${claims.offering}`);
|
|
51
|
+
}
|
|
52
|
+
if (options.body !== void 0 && await sha256Hex(options.body) !== claims.body_sha256) {
|
|
53
|
+
throw new ReceiptError("body_mismatch", "request body does not match the receipt");
|
|
54
|
+
}
|
|
55
|
+
return claims;
|
|
56
|
+
}
|
|
57
|
+
async function verifyCheckoutReceipt(token, options) {
|
|
58
|
+
const claims = await verifySigned(token, options);
|
|
59
|
+
if (claims.kind !== "checkout" || typeof claims.session !== "string") throw new ReceiptError("wrong_kind", "not a checkout receipt");
|
|
60
|
+
if (options.seller !== void 0 && claims.seller !== options.seller) {
|
|
61
|
+
throw new ReceiptError("wrong_seller", `receipt is for seller ${claims.seller}`);
|
|
62
|
+
}
|
|
63
|
+
if (options.session !== void 0 && claims.session !== options.session) {
|
|
64
|
+
throw new ReceiptError("wrong_session", `receipt is for checkout ${claims.session}`);
|
|
65
|
+
}
|
|
66
|
+
return claims;
|
|
67
|
+
}
|
|
68
|
+
async function verifySigned(token, options) {
|
|
69
|
+
if (!token) throw new ReceiptError("missing", `missing ${RECEIPT_HEADER}`);
|
|
70
|
+
const parts = token.split(".");
|
|
71
|
+
if (parts.length !== 3) throw new ReceiptError("malformed", "receipt is not a JWT");
|
|
72
|
+
const [headerPart, payloadPart, signaturePart] = parts;
|
|
73
|
+
let header3;
|
|
74
|
+
let claims;
|
|
75
|
+
try {
|
|
76
|
+
header3 = JSON.parse(fromUtf8(fromBase64url(headerPart)));
|
|
77
|
+
claims = JSON.parse(fromUtf8(fromBase64url(payloadPart)));
|
|
78
|
+
} catch {
|
|
79
|
+
throw new ReceiptError("malformed", "receipt is not valid base64url JSON");
|
|
80
|
+
}
|
|
81
|
+
if (header3.alg !== "EdDSA") throw new ReceiptError("malformed", `unsupported alg ${header3.alg}`);
|
|
82
|
+
if (typeof claims !== "object" || claims === null) throw new ReceiptError("malformed", "receipt claims are not an object");
|
|
83
|
+
const keys3 = typeof options.keys === "function" ? await options.keys() : options.keys;
|
|
84
|
+
const jwk = keys3.find((k) => (k.kid ?? k.x.slice(0, 16)) === header3.kid) ?? (keys3.length === 1 && !header3.kid ? keys3[0] : void 0);
|
|
85
|
+
if (!jwk) throw new ReceiptError("unknown_key", `no village key with kid ${header3.kid}`);
|
|
86
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, ED25519, false, ["verify"]);
|
|
87
|
+
const ok = await crypto.subtle.verify(ED25519, key, fromBase64url(signaturePart), utf8(`${headerPart}.${payloadPart}`));
|
|
88
|
+
if (!ok) throw new ReceiptError("bad_signature", "receipt signature is invalid");
|
|
89
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
90
|
+
const skew = options.clockSkewSeconds ?? 30;
|
|
91
|
+
if (typeof claims.exp !== "number" || claims.exp + skew < now) throw new ReceiptError("expired", "receipt expired");
|
|
92
|
+
if (typeof claims.iat === "number" && claims.iat - skew > now) throw new ReceiptError("not_yet_valid", "receipt issued in the future");
|
|
93
|
+
if (claims.iss !== (options.issuer ?? DEFAULT_ISSUER)) throw new ReceiptError("wrong_issuer", `unexpected issuer ${String(claims.iss)}`);
|
|
94
|
+
return claims;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ../protocol/src/webhook.ts
|
|
98
|
+
var WEBHOOK_HEADERS = {
|
|
99
|
+
id: "webhook-id",
|
|
100
|
+
timestamp: "webhook-timestamp",
|
|
101
|
+
signature: "webhook-signature"
|
|
102
|
+
};
|
|
103
|
+
var SECRET_PREFIX = "whsec_";
|
|
104
|
+
var encoder2 = new TextEncoder();
|
|
105
|
+
var WebhookError = class extends Error {
|
|
106
|
+
constructor(reason, message) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.reason = reason;
|
|
109
|
+
this.name = "WebhookError";
|
|
110
|
+
}
|
|
111
|
+
reason;
|
|
112
|
+
};
|
|
113
|
+
function generateWebhookSecret() {
|
|
114
|
+
const bytes = new Uint8Array(24);
|
|
115
|
+
crypto.getRandomValues(bytes);
|
|
116
|
+
return SECRET_PREFIX + toStdBase64(bytes);
|
|
117
|
+
}
|
|
118
|
+
async function hmacKey(secret) {
|
|
119
|
+
const raw = secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret;
|
|
120
|
+
let bytes;
|
|
121
|
+
try {
|
|
122
|
+
bytes = fromStdBase64(raw);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new WebhookError("bad_secret", "webhook secret is not whsec_<base64>");
|
|
125
|
+
}
|
|
126
|
+
return crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
127
|
+
}
|
|
128
|
+
async function signWebhook(secret, id, timestamp, body) {
|
|
129
|
+
const mac2 = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder2.encode(`${id}.${timestamp}.${body}`));
|
|
130
|
+
return `v1,${toStdBase64(new Uint8Array(mac2))}`;
|
|
131
|
+
}
|
|
132
|
+
async function webhookHeaders(secret, id, timestamp, body) {
|
|
133
|
+
return {
|
|
134
|
+
[WEBHOOK_HEADERS.id]: id,
|
|
135
|
+
[WEBHOOK_HEADERS.timestamp]: String(timestamp),
|
|
136
|
+
[WEBHOOK_HEADERS.signature]: await signWebhook(secret, id, timestamp, body)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function header(headers, name) {
|
|
140
|
+
if (headers instanceof Headers) return headers.get(name);
|
|
141
|
+
const value = headers[name] ?? headers[name.toLowerCase()];
|
|
142
|
+
return Array.isArray(value) ? value[0] ?? null : value ?? null;
|
|
143
|
+
}
|
|
144
|
+
async function verifyWebhook(secret, headers, body, { toleranceSeconds = 300, now = Math.floor(Date.now() / 1e3) } = {}) {
|
|
145
|
+
const id = header(headers, WEBHOOK_HEADERS.id);
|
|
146
|
+
const ts = header(headers, WEBHOOK_HEADERS.timestamp);
|
|
147
|
+
const signatures = header(headers, WEBHOOK_HEADERS.signature);
|
|
148
|
+
if (!id || !ts || !signatures) throw new WebhookError("missing_headers", "missing webhook-id, webhook-timestamp or webhook-signature");
|
|
149
|
+
const timestamp = Number(ts);
|
|
150
|
+
if (!/^\d+$/.test(ts) || !Number.isSafeInteger(timestamp)) throw new WebhookError("bad_timestamp", "webhook-timestamp is not unix seconds");
|
|
151
|
+
if (timestamp < now - toleranceSeconds) throw new WebhookError("too_old", "webhook timestamp is too old");
|
|
152
|
+
if (timestamp > now + toleranceSeconds) throw new WebhookError("too_new", "webhook timestamp is in the future");
|
|
153
|
+
const text = typeof body === "string" ? body : fromUtf8(body);
|
|
154
|
+
const expected = (await signWebhook(secret, id, timestamp, text)).slice(3);
|
|
155
|
+
const match = signatures.split(" ").filter((s) => s.startsWith("v1,")).some((s) => constantTimeEqual(s.slice(3), expected));
|
|
156
|
+
if (!match) throw new WebhookError("bad_signature", "no matching webhook signature");
|
|
157
|
+
return JSON.parse(text);
|
|
158
|
+
}
|
|
159
|
+
function constantTimeEqual(a, b) {
|
|
160
|
+
if (a.length !== b.length) return false;
|
|
161
|
+
let diff = 0;
|
|
162
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
163
|
+
return diff === 0;
|
|
164
|
+
}
|
|
165
|
+
function toStdBase64(bytes) {
|
|
166
|
+
let binary = "";
|
|
167
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
168
|
+
return btoa(binary);
|
|
169
|
+
}
|
|
170
|
+
function fromStdBase64(text) {
|
|
171
|
+
const binary = atob(text);
|
|
172
|
+
const bytes = new Uint8Array(binary.length);
|
|
173
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
174
|
+
return bytes;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ../protocol/src/apps.ts
|
|
178
|
+
var APP_SIGNATURE_HEADER = "x-ambasada-signature";
|
|
179
|
+
var APP_TIMESTAMP_HEADER = "x-ambasada-timestamp";
|
|
180
|
+
var APP_WEBHOOK_TOLERANCE_S = 300;
|
|
181
|
+
var DEFAULT_HUB_URL = "https://hub.ambasada.id";
|
|
182
|
+
var APP_NOTIFY_PATH = "/api/apps/notify";
|
|
183
|
+
var AppWebhookError = class extends Error {
|
|
184
|
+
constructor(reason, message) {
|
|
185
|
+
super(message);
|
|
186
|
+
this.reason = reason;
|
|
187
|
+
this.name = "AppWebhookError";
|
|
188
|
+
}
|
|
189
|
+
reason;
|
|
190
|
+
};
|
|
191
|
+
var encoder3 = new TextEncoder();
|
|
192
|
+
var keys = /* @__PURE__ */ new Map();
|
|
193
|
+
function hmacKey2(secret) {
|
|
194
|
+
let key = keys.get(secret);
|
|
195
|
+
if (!key) {
|
|
196
|
+
key = crypto.subtle.importKey("raw", encoder3.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
197
|
+
keys.set(secret, key);
|
|
198
|
+
}
|
|
199
|
+
return key;
|
|
200
|
+
}
|
|
201
|
+
var hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
202
|
+
async function mac(secret, timestamp, body) {
|
|
203
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey2(secret), encoder3.encode(`${timestamp}.${body}`)));
|
|
204
|
+
}
|
|
205
|
+
async function signAppWebhook(secret, timestamp, body) {
|
|
206
|
+
if (!secret) throw new AppWebhookError("bad_secret", "no app secret");
|
|
207
|
+
return `v1=${hex(await mac(secret, String(timestamp), body))}`;
|
|
208
|
+
}
|
|
209
|
+
async function appWebhookHeaders(secret, body, timestamp = Math.floor(Date.now() / 1e3)) {
|
|
210
|
+
return { [APP_TIMESTAMP_HEADER]: String(timestamp), [APP_SIGNATURE_HEADER]: await signAppWebhook(secret, timestamp, body), "content-type": "application/json" };
|
|
211
|
+
}
|
|
212
|
+
function decodeSignature(value) {
|
|
213
|
+
if (/^[0-9a-f]{64}$/i.test(value)) return Uint8Array.from(value.match(/../g), (h) => parseInt(h, 16));
|
|
214
|
+
try {
|
|
215
|
+
const std = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
216
|
+
const bin = atob(std + "===".slice((std.length + 3) % 4));
|
|
217
|
+
return bin.length === 32 ? Uint8Array.from(bin, (c) => c.charCodeAt(0)) : null;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function equal(a, b) {
|
|
223
|
+
if (a.length !== b.length) return false;
|
|
224
|
+
let diff = 0;
|
|
225
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
226
|
+
return diff === 0;
|
|
227
|
+
}
|
|
228
|
+
function header2(headers, name) {
|
|
229
|
+
if (typeof headers.get === "function") return headers.get(name);
|
|
230
|
+
const record = headers;
|
|
231
|
+
const key = Object.keys(record).find((k) => k.toLowerCase() === name);
|
|
232
|
+
const value = key === void 0 ? void 0 : record[key];
|
|
233
|
+
return Array.isArray(value) ? value[0] ?? null : value ?? null;
|
|
234
|
+
}
|
|
235
|
+
async function verifyAppWebhook(secret, headers, body, { toleranceSeconds = APP_WEBHOOK_TOLERANCE_S, now = Math.floor(Date.now() / 1e3) } = {}) {
|
|
236
|
+
if (!secret) throw new AppWebhookError("bad_secret", "no app secret: set AMBASADA_APP_SECRET (injected on ambasada.dev; from the hub dashboard elsewhere)");
|
|
237
|
+
const ts = header2(headers, APP_TIMESTAMP_HEADER);
|
|
238
|
+
const sig = header2(headers, APP_SIGNATURE_HEADER);
|
|
239
|
+
if (!ts || !sig) throw new AppWebhookError("missing_headers", `missing ${APP_TIMESTAMP_HEADER} or ${APP_SIGNATURE_HEADER}`);
|
|
240
|
+
if (!/^\d+$/.test(ts.trim())) throw new AppWebhookError("bad_timestamp", `${APP_TIMESTAMP_HEADER} is not unix seconds`);
|
|
241
|
+
let timestamp = Number(ts.trim());
|
|
242
|
+
if (timestamp > 1e12) timestamp = Math.floor(timestamp / 1e3);
|
|
243
|
+
if (timestamp < now - toleranceSeconds) throw new AppWebhookError("too_old", "webhook timestamp is too old");
|
|
244
|
+
if (timestamp > now + toleranceSeconds) throw new AppWebhookError("too_new", "webhook timestamp is in the future");
|
|
245
|
+
const expected = await mac(secret, ts.trim(), body);
|
|
246
|
+
const candidates = sig.split(/[\s,]+/).map((part) => /^v1=(.+)$/.exec(part.trim())?.[1]).filter((v) => !!v).map(decodeSignature).filter((v) => v !== null);
|
|
247
|
+
if (!candidates.some((c) => equal(c, expected))) throw new AppWebhookError("bad_signature", "webhook signature does not match: check AMBASADA_APP_SECRET");
|
|
248
|
+
let event;
|
|
249
|
+
try {
|
|
250
|
+
event = JSON.parse(body);
|
|
251
|
+
} catch {
|
|
252
|
+
throw new AppWebhookError("bad_body", "webhook body is not JSON");
|
|
253
|
+
}
|
|
254
|
+
if (!event || typeof event !== "object" || typeof event.event !== "string") throw new AppWebhookError("bad_body", 'webhook body has no "event"');
|
|
255
|
+
return event;
|
|
256
|
+
}
|
|
257
|
+
var NOTIFY_LIMITS = { notification_id: 128, title: 40, body: 140, recipients: 100, collapse_key: 128, ttl_seconds: 7 * 24 * 3600 };
|
|
258
|
+
var CATEGORIES = ["transactional", "social", "marketing"];
|
|
259
|
+
function appNotifyBody(input, origin) {
|
|
260
|
+
const fail = (message) => {
|
|
261
|
+
throw new Error(message);
|
|
262
|
+
};
|
|
263
|
+
const id = typeof input.notification_id === "string" ? input.notification_id : "";
|
|
264
|
+
if (!id || id.length > NOTIFY_LIMITS.notification_id) fail(`notification_id must be 1\u2013${NOTIFY_LIMITS.notification_id} characters`);
|
|
265
|
+
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
266
|
+
if (!title || title.length > NOTIFY_LIMITS.title) fail(`title must be 1\u2013${NOTIFY_LIMITS.title} characters (got ${title.length})`);
|
|
267
|
+
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
268
|
+
if (body.length > NOTIFY_LIMITS.body) fail(`body must be at most ${NOTIFY_LIMITS.body} characters (got ${body.length})`);
|
|
269
|
+
const url = typeof input.url === "string" ? input.url.trim() : "";
|
|
270
|
+
if (!url) fail("url is required: a path on your app (/orders/184) or a URL on its origin");
|
|
271
|
+
if (url.startsWith("/")) {
|
|
272
|
+
if (url.startsWith("//") || url.startsWith("/\\") || /[\u0000-\u001f\u007f]/.test(url)) fail(`url ${JSON.stringify(url)} is not a path on your app`);
|
|
273
|
+
} else {
|
|
274
|
+
let parsed = null;
|
|
275
|
+
try {
|
|
276
|
+
parsed = new URL(url);
|
|
277
|
+
} catch {
|
|
278
|
+
fail(`url ${JSON.stringify(url)} is not a URL or a path`);
|
|
279
|
+
}
|
|
280
|
+
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");
|
|
281
|
+
if (parsed.username || parsed.password) fail("url must not carry credentials");
|
|
282
|
+
if (origin && parsed.origin !== new URL(origin).origin) fail(`url must be on your app's origin ${new URL(origin).origin} (got ${parsed.origin})`);
|
|
283
|
+
}
|
|
284
|
+
const tokens = input.tokens ?? [];
|
|
285
|
+
const users = input.users ?? [];
|
|
286
|
+
if (!Array.isArray(tokens) || !Array.isArray(users)) fail("tokens and users must be arrays");
|
|
287
|
+
if (tokens.length && users.length) fail("send tokens or users, not both");
|
|
288
|
+
const recipients = tokens.length ? tokens : users;
|
|
289
|
+
if (!recipients.length) fail("no recipients: pass tokens (from notifications_enabled) or users");
|
|
290
|
+
if (recipients.length > NOTIFY_LIMITS.recipients) fail(`at most ${NOTIFY_LIMITS.recipients} recipients per call (got ${recipients.length})`);
|
|
291
|
+
if (recipients.some((r) => typeof r !== "string" || !r)) fail("recipients must be non-empty strings");
|
|
292
|
+
const category = input.category ?? "transactional";
|
|
293
|
+
if (!CATEGORIES.includes(category)) fail(`category must be one of ${CATEGORIES.join(", ")}`);
|
|
294
|
+
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`);
|
|
295
|
+
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}`);
|
|
296
|
+
return {
|
|
297
|
+
notification_id: id,
|
|
298
|
+
title,
|
|
299
|
+
body,
|
|
300
|
+
url,
|
|
301
|
+
...tokens.length ? { tokens } : { users },
|
|
302
|
+
category,
|
|
303
|
+
...input.collapse_key !== void 0 ? { collapse_key: input.collapse_key } : {},
|
|
304
|
+
...input.ttl_seconds !== void 0 ? { ttl_seconds: input.ttl_seconds } : {}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function appNotifyResult(json) {
|
|
308
|
+
const o = json && typeof json === "object" ? json : {};
|
|
309
|
+
const list = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
310
|
+
return { sent: list(o.sent), invalid: list(o.invalid), rate_limited: list(o.rate_limited) };
|
|
311
|
+
}
|
|
312
|
+
|
|
1
313
|
// ../sdk/src/errors.ts
|
|
2
314
|
var ERROR_STATUS = {
|
|
3
315
|
// EXCHANGE.md §5 vocabulary
|
|
@@ -102,8 +414,78 @@ function errorFromResponse(status, body, fallback) {
|
|
|
102
414
|
return new VillageError(code, fallback, status);
|
|
103
415
|
}
|
|
104
416
|
|
|
417
|
+
// ../sdk/src/apps.ts
|
|
418
|
+
var APP_SIGNATURE_HEADER2 = "x-ambasada-signature";
|
|
419
|
+
var APP_TIMESTAMP_HEADER2 = "x-ambasada-timestamp";
|
|
420
|
+
var signAppWebhook2 = (secret, timestamp, body) => signAppWebhook(secret, timestamp, body);
|
|
421
|
+
var appWebhookHeaders2 = (secret, body, timestamp) => appWebhookHeaders(secret, body, timestamp);
|
|
422
|
+
var AmbasadaApps = class {
|
|
423
|
+
constructor(config) {
|
|
424
|
+
this.config = config;
|
|
425
|
+
}
|
|
426
|
+
config;
|
|
427
|
+
/** True when the app has its secret (hosted on ambasada.dev, `ambasada dev`, or set by hand). */
|
|
428
|
+
get configured() {
|
|
429
|
+
return !!this.config().appSecret;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Verifies a webhook from the hub (signature, 5-minute window) and returns the event, or throws
|
|
433
|
+
* `WebhookInvalidError` (answer it with `error.toResponse()`, a 400). Read nothing from the request before.
|
|
434
|
+
*/
|
|
435
|
+
async verifyWebhook(input, secret, options = {}) {
|
|
436
|
+
const key = secret ?? this.config().appSecret;
|
|
437
|
+
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");
|
|
438
|
+
const isRequest = typeof input.text === "function" && input.headers instanceof Headers;
|
|
439
|
+
const headers = isRequest ? input.headers : input.headers;
|
|
440
|
+
const raw = isRequest ? await input.text() : input.body;
|
|
441
|
+
const body = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
|
442
|
+
try {
|
|
443
|
+
return await verifyAppWebhook(key, headers, body, options);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (error instanceof AppWebhookError) throw new WebhookInvalidError(error.reason, error.message);
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Sends a push to the people behind `tokens` (or `users`, for Verified apps with `identity`) through the hub.
|
|
451
|
+
* Checks the limits first (title ≤ 40, body ≤ 140, id ≤ 128, ≤ 100 recipients, `url` on your origin) and
|
|
452
|
+
* throws `VillageError("INVALID_REQUEST")` before sending anything. Tokens in `invalid` are gone: forget them.
|
|
453
|
+
*/
|
|
454
|
+
async notify(input) {
|
|
455
|
+
const c = this.config();
|
|
456
|
+
if (!c.appSecret) throw new VillageError("CONFIG", "no app secret: set AMBASADA_APP_SECRET (injected on ambasada.dev; `ambasada dev` sets one for the mock hub)");
|
|
457
|
+
let body;
|
|
458
|
+
try {
|
|
459
|
+
body = appNotifyBody(input, c.origin);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
throw new VillageError("INVALID_REQUEST", error.message, 400);
|
|
462
|
+
}
|
|
463
|
+
const url = `${(c.hubUrl ?? DEFAULT_HUB_URL).replace(/\/+$/, "")}${APP_NOTIFY_PATH}`;
|
|
464
|
+
const doFetch = c.fetch ?? ((i, init) => fetch(i, init));
|
|
465
|
+
let res;
|
|
466
|
+
try {
|
|
467
|
+
res = await doFetch(url, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { authorization: `Bearer ${c.appSecret}`, "content-type": "application/json", accept: "application/json" },
|
|
470
|
+
body: JSON.stringify(body),
|
|
471
|
+
signal: AbortSignal.timeout(15e3)
|
|
472
|
+
});
|
|
473
|
+
} catch (error) {
|
|
474
|
+
throw new VillageError("NETWORK", `POST ${url}: ${error.message}`, 0);
|
|
475
|
+
}
|
|
476
|
+
const json = await res.json().catch(() => null);
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
const err = json?.error;
|
|
479
|
+
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";
|
|
480
|
+
const message = typeof err === "string" ? err : err?.message ?? `the hub answered ${res.status}`;
|
|
481
|
+
throw new VillageError(code, `notify: ${message}`, res.status);
|
|
482
|
+
}
|
|
483
|
+
return appNotifyResult(json);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
105
487
|
// ../sdk/src/auth.ts
|
|
106
|
-
var AUTH_PATHS = { login: "/api/auth/login", callback: "/api/auth/callback", logout: "/api/auth/logout" };
|
|
488
|
+
var AUTH_PATHS = { login: "/api/auth/login", callback: "/api/auth/callback", logout: "/api/auth/logout", shell: "/api/auth/shell" };
|
|
107
489
|
var SESSION_TTL_S = 7 * 24 * 3600;
|
|
108
490
|
var STATE_TTL_S = 600;
|
|
109
491
|
var SESSION_COOKIE = "ambasada_session";
|
|
@@ -128,11 +510,11 @@ async function pkce() {
|
|
|
128
510
|
async function pkceChallenge(verifier) {
|
|
129
511
|
return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(verifier))));
|
|
130
512
|
}
|
|
131
|
-
var
|
|
513
|
+
var keys2 = /* @__PURE__ */ new Map();
|
|
132
514
|
function cookieKey(secret, purpose) {
|
|
133
515
|
const id = `${purpose}
|
|
134
516
|
${secret}`;
|
|
135
|
-
let key =
|
|
517
|
+
let key = keys2.get(id);
|
|
136
518
|
if (!key) {
|
|
137
519
|
key = crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]).then(
|
|
138
520
|
(ikm) => crypto.subtle.deriveKey(
|
|
@@ -143,7 +525,7 @@ ${secret}`;
|
|
|
143
525
|
["sign", "verify"]
|
|
144
526
|
)
|
|
145
527
|
);
|
|
146
|
-
|
|
528
|
+
keys2.set(id, key);
|
|
147
529
|
}
|
|
148
530
|
return key;
|
|
149
531
|
}
|
|
@@ -186,6 +568,21 @@ function redirect(location, cookies = []) {
|
|
|
186
568
|
for (const c of cookies) headers.append("set-cookie", c);
|
|
187
569
|
return new Response(null, { status: 303, headers });
|
|
188
570
|
}
|
|
571
|
+
var JWKS_TTL_MS = 36e5;
|
|
572
|
+
var LEEWAY_S = 30;
|
|
573
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
574
|
+
async function jwks(issuer, doFetch, refresh = false) {
|
|
575
|
+
const cached = jwksCache.get(issuer);
|
|
576
|
+
if (cached && !refresh && Date.now() - cached.at < JWKS_TTL_MS) return cached.keys;
|
|
577
|
+
if (cached && refresh && Date.now() - cached.at < 6e4) return cached.keys;
|
|
578
|
+
const res = await doFetch(`${issuer}/.well-known/jwks.json`, { headers: { accept: "application/json" } });
|
|
579
|
+
if (!res.ok) throw new VillageError("UPSTREAM_ERROR", `could not read the Ambasada ID keys (${res.status})`, 502);
|
|
580
|
+
const body = await res.json();
|
|
581
|
+
const keys3 = (body.keys ?? []).filter((k) => k.kty === "RSA" && k.n && k.e);
|
|
582
|
+
jwksCache.set(issuer, { at: Date.now(), keys: keys3 });
|
|
583
|
+
return keys3;
|
|
584
|
+
}
|
|
585
|
+
var shellRefused = (message) => new VillageError("UNAUTHORIZED", `shell token refused: ${message}`, 401);
|
|
189
586
|
var AmbasadaAuth = class {
|
|
190
587
|
constructor(config) {
|
|
191
588
|
this.config = config;
|
|
@@ -274,6 +671,86 @@ var AmbasadaAuth = class {
|
|
|
274
671
|
if (!this.configured || !secret) return null;
|
|
275
672
|
return (await verifyCookie(secret, "session", readOurCookie(req, SESSION_COOKIE)))?.user ?? null;
|
|
276
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* Verifies a shell token (Clerk JWT template `ambasada-miniapp`, RS256, 60 s) against
|
|
676
|
+
* `<AMBASADA_AUTH_ISSUER>/.well-known/jwks.json` (cached 1 h): signature, expiry, issuer, and that its `aud`
|
|
677
|
+
* is this app (its id or origin). Returns who it is, or throws `VillageError("UNAUTHORIZED")`.
|
|
678
|
+
*/
|
|
679
|
+
async verifyShellToken(token, options = {}) {
|
|
680
|
+
const c = this.config();
|
|
681
|
+
if (!c.issuer) throw new VillageError("CONFIG", "silent sign-in needs AMBASADA_AUTH_ISSUER (injected on ambasada.dev and by `ambasada dev`)");
|
|
682
|
+
const issuer = c.issuer.replace(/\/+$/, "");
|
|
683
|
+
const audiences = [options.audience ?? c.audiences ?? []].flat().filter(Boolean).map((a) => a.replace(/\/+$/, ""));
|
|
684
|
+
if (!audiences.length) throw new VillageError("CONFIG", "silent sign-in needs to know this app: set AMBASADA_APP_ORIGIN or PUBLIC_URL, or pass { audience }");
|
|
685
|
+
const doFetch = c.fetch ?? ((input, init) => fetch(input, init));
|
|
686
|
+
const parts = typeof token === "string" ? token.split(".") : [];
|
|
687
|
+
if (parts.length !== 3) throw shellRefused("not a JWT");
|
|
688
|
+
let header3;
|
|
689
|
+
let claims;
|
|
690
|
+
try {
|
|
691
|
+
header3 = JSON.parse(new TextDecoder().decode(fromB64url(parts[0])));
|
|
692
|
+
claims = JSON.parse(new TextDecoder().decode(fromB64url(parts[1])));
|
|
693
|
+
} catch {
|
|
694
|
+
throw shellRefused("not a JWT");
|
|
695
|
+
}
|
|
696
|
+
if (header3.alg !== "RS256") throw shellRefused(`alg ${String(header3.alg)} (only RS256)`);
|
|
697
|
+
let keys3 = await jwks(issuer, doFetch);
|
|
698
|
+
let jwk = keys3.find((k) => k.kid === header3.kid) ?? (!header3.kid && keys3.length === 1 ? keys3[0] : void 0);
|
|
699
|
+
if (!jwk) {
|
|
700
|
+
keys3 = await jwks(issuer, doFetch, true);
|
|
701
|
+
jwk = keys3.find((k) => k.kid === header3.kid);
|
|
702
|
+
}
|
|
703
|
+
if (!jwk) throw shellRefused(`unknown key ${String(header3.kid)}`);
|
|
704
|
+
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"]);
|
|
705
|
+
const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, fromB64url(parts[2]), enc.encode(`${parts[0]}.${parts[1]}`));
|
|
706
|
+
if (!ok) throw shellRefused("bad signature");
|
|
707
|
+
const now = Math.floor((options.now ?? Date.now()) / 1e3);
|
|
708
|
+
if (typeof claims.exp !== "number" || claims.exp + LEEWAY_S < now) throw shellRefused("expired");
|
|
709
|
+
if (typeof claims.nbf === "number" && claims.nbf - LEEWAY_S > now) throw shellRefused("not valid yet");
|
|
710
|
+
if (typeof claims.iat === "number" && claims.iat - LEEWAY_S > now) throw shellRefused("issued in the future");
|
|
711
|
+
if (typeof claims.iss === "string" && claims.iss.replace(/\/+$/, "") !== issuer) throw shellRefused(`issuer ${claims.iss}`);
|
|
712
|
+
const aud = [claims.aud].flat().filter((a) => typeof a === "string").map((a) => a.replace(/\/+$/, ""));
|
|
713
|
+
if (!aud.some((a) => audiences.includes(a))) throw shellRefused(`it is for ${aud.join(", ") || "no app"}, not this one`);
|
|
714
|
+
if (typeof claims.sub !== "string" || !claims.sub) throw shellRefused("no subject");
|
|
715
|
+
const text = (v) => typeof v === "string" && v ? v : null;
|
|
716
|
+
return { user_id: claims.sub, name: text(claims.name), avatar: text(claims.avatar) ?? text(claims.picture) ?? text(claims.image_url) };
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Silent sign-in: `POST /api/auth/shell` with `{ "token": "<auth.getToken() token>" }` (or `Authorization: Bearer`).
|
|
720
|
+
* Verifies it, reads the full profile from the hub (falling back to what the token carries) and sets the same
|
|
721
|
+
* session cookie as `callback()`, so a person signed in inside the app and one signed in on the web look the same.
|
|
722
|
+
* Answers `{ user }`.
|
|
723
|
+
*/
|
|
724
|
+
async shell(req) {
|
|
725
|
+
const s = this.settings(req);
|
|
726
|
+
if (req.method !== "POST") throw new VillageError("INVALID_REQUEST", "POST the shell token as JSON { token }", 405);
|
|
727
|
+
let token = /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim();
|
|
728
|
+
if (!token) {
|
|
729
|
+
if (!(req.headers.get("content-type") ?? "").includes("application/json")) throw new VillageError("INVALID_REQUEST", "send the token as JSON { token }", 415);
|
|
730
|
+
const body = await req.json().catch(() => null);
|
|
731
|
+
token = typeof body?.token === "string" ? body.token : void 0;
|
|
732
|
+
}
|
|
733
|
+
if (!token) throw new VillageError("INVALID_REQUEST", "no shell token", 400);
|
|
734
|
+
const audiences = [...this.config().audiences ?? [], s.origin];
|
|
735
|
+
const who = await this.verifyShellToken(token, { audience: audiences });
|
|
736
|
+
let user = { id: who.user_id, name: who.name, avatar_url: who.avatar, builder: false, builder_since: null, member_since: null, pass: null };
|
|
737
|
+
const hub = this.config().hubUrl;
|
|
738
|
+
if (hub) {
|
|
739
|
+
try {
|
|
740
|
+
const res = await s.fetch(`${hub.replace(/\/+$/, "")}/api/apps/profile`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } });
|
|
741
|
+
if (res.ok) {
|
|
742
|
+
const profile = await res.json();
|
|
743
|
+
if (profile.id === who.user_id) user = { ...user, ...profile, id: who.user_id };
|
|
744
|
+
} else await res.body?.cancel();
|
|
745
|
+
} catch {
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const exp = Math.floor(Date.now() / 1e3) + SESSION_TTL_S;
|
|
749
|
+
const session = await signCookie(s.clientSecret, "session", { user, exp });
|
|
750
|
+
const headers = new Headers({ "cache-control": "no-store", "content-type": "application/json" });
|
|
751
|
+
headers.append("set-cookie", setCookie(SESSION_COOKIE, session, s.secure, SESSION_TTL_S));
|
|
752
|
+
return new Response(JSON.stringify({ user }), { status: 200, headers });
|
|
753
|
+
}
|
|
277
754
|
/** Clears the session and redirects to `returnTo` (default "/"). Signs out of this product only. */
|
|
278
755
|
logout(req, options = {}) {
|
|
279
756
|
const url = new URL(req.url);
|
|
@@ -284,182 +761,6 @@ var AmbasadaAuth = class {
|
|
|
284
761
|
}
|
|
285
762
|
};
|
|
286
763
|
|
|
287
|
-
// ../protocol/src/encoding.ts
|
|
288
|
-
var encoder = new TextEncoder();
|
|
289
|
-
var decoder = new TextDecoder();
|
|
290
|
-
function utf8(text) {
|
|
291
|
-
return encoder.encode(text);
|
|
292
|
-
}
|
|
293
|
-
function fromUtf8(bytes) {
|
|
294
|
-
return decoder.decode(bytes);
|
|
295
|
-
}
|
|
296
|
-
function fromBase64url(text) {
|
|
297
|
-
const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
|
|
298
|
-
const binary = atob(padded);
|
|
299
|
-
const bytes = new Uint8Array(binary.length);
|
|
300
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
301
|
-
return bytes;
|
|
302
|
-
}
|
|
303
|
-
function toBytes(data) {
|
|
304
|
-
if (typeof data === "string") return utf8(data);
|
|
305
|
-
if (data instanceof Uint8Array) return new Uint8Array(data);
|
|
306
|
-
return new Uint8Array(data);
|
|
307
|
-
}
|
|
308
|
-
async function sha256Hex(data) {
|
|
309
|
-
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", toBytes(data)));
|
|
310
|
-
let hex = "";
|
|
311
|
-
for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
|
|
312
|
-
return hex;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ../protocol/src/receipt.ts
|
|
316
|
-
var RECEIPT_HEADER = "x-village-receipt";
|
|
317
|
-
var DEFAULT_ISSUER = "village";
|
|
318
|
-
var ReceiptError = class extends Error {
|
|
319
|
-
constructor(reason, message) {
|
|
320
|
-
super(message);
|
|
321
|
-
this.reason = reason;
|
|
322
|
-
this.name = "ReceiptError";
|
|
323
|
-
}
|
|
324
|
-
reason;
|
|
325
|
-
};
|
|
326
|
-
var ED25519 = { name: "Ed25519" };
|
|
327
|
-
async function verifyReceipt(token, options) {
|
|
328
|
-
const claims = await verifySigned(token, options);
|
|
329
|
-
if (claims.kind !== void 0 || typeof claims.call_id !== "string" || typeof claims.body_sha256 !== "string") {
|
|
330
|
-
throw new ReceiptError("wrong_kind", "not a call receipt");
|
|
331
|
-
}
|
|
332
|
-
if (options.seller !== void 0 && claims.seller !== options.seller) {
|
|
333
|
-
throw new ReceiptError("wrong_seller", `receipt is for seller ${claims.seller}`);
|
|
334
|
-
}
|
|
335
|
-
if (options.offering !== void 0 && claims.offering !== options.offering) {
|
|
336
|
-
throw new ReceiptError("wrong_offering", `receipt is for offering ${claims.offering}`);
|
|
337
|
-
}
|
|
338
|
-
if (options.body !== void 0 && await sha256Hex(options.body) !== claims.body_sha256) {
|
|
339
|
-
throw new ReceiptError("body_mismatch", "request body does not match the receipt");
|
|
340
|
-
}
|
|
341
|
-
return claims;
|
|
342
|
-
}
|
|
343
|
-
async function verifyCheckoutReceipt(token, options) {
|
|
344
|
-
const claims = await verifySigned(token, options);
|
|
345
|
-
if (claims.kind !== "checkout" || typeof claims.session !== "string") throw new ReceiptError("wrong_kind", "not a checkout receipt");
|
|
346
|
-
if (options.seller !== void 0 && claims.seller !== options.seller) {
|
|
347
|
-
throw new ReceiptError("wrong_seller", `receipt is for seller ${claims.seller}`);
|
|
348
|
-
}
|
|
349
|
-
if (options.session !== void 0 && claims.session !== options.session) {
|
|
350
|
-
throw new ReceiptError("wrong_session", `receipt is for checkout ${claims.session}`);
|
|
351
|
-
}
|
|
352
|
-
return claims;
|
|
353
|
-
}
|
|
354
|
-
async function verifySigned(token, options) {
|
|
355
|
-
if (!token) throw new ReceiptError("missing", `missing ${RECEIPT_HEADER}`);
|
|
356
|
-
const parts = token.split(".");
|
|
357
|
-
if (parts.length !== 3) throw new ReceiptError("malformed", "receipt is not a JWT");
|
|
358
|
-
const [headerPart, payloadPart, signaturePart] = parts;
|
|
359
|
-
let header2;
|
|
360
|
-
let claims;
|
|
361
|
-
try {
|
|
362
|
-
header2 = JSON.parse(fromUtf8(fromBase64url(headerPart)));
|
|
363
|
-
claims = JSON.parse(fromUtf8(fromBase64url(payloadPart)));
|
|
364
|
-
} catch {
|
|
365
|
-
throw new ReceiptError("malformed", "receipt is not valid base64url JSON");
|
|
366
|
-
}
|
|
367
|
-
if (header2.alg !== "EdDSA") throw new ReceiptError("malformed", `unsupported alg ${header2.alg}`);
|
|
368
|
-
if (typeof claims !== "object" || claims === null) throw new ReceiptError("malformed", "receipt claims are not an object");
|
|
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);
|
|
371
|
-
if (!jwk) throw new ReceiptError("unknown_key", `no village key with kid ${header2.kid}`);
|
|
372
|
-
const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, ED25519, false, ["verify"]);
|
|
373
|
-
const ok = await crypto.subtle.verify(ED25519, key, fromBase64url(signaturePart), utf8(`${headerPart}.${payloadPart}`));
|
|
374
|
-
if (!ok) throw new ReceiptError("bad_signature", "receipt signature is invalid");
|
|
375
|
-
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
376
|
-
const skew = options.clockSkewSeconds ?? 30;
|
|
377
|
-
if (typeof claims.exp !== "number" || claims.exp + skew < now) throw new ReceiptError("expired", "receipt expired");
|
|
378
|
-
if (typeof claims.iat === "number" && claims.iat - skew > now) throw new ReceiptError("not_yet_valid", "receipt issued in the future");
|
|
379
|
-
if (claims.iss !== (options.issuer ?? DEFAULT_ISSUER)) throw new ReceiptError("wrong_issuer", `unexpected issuer ${String(claims.iss)}`);
|
|
380
|
-
return claims;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ../protocol/src/webhook.ts
|
|
384
|
-
var WEBHOOK_HEADERS = {
|
|
385
|
-
id: "webhook-id",
|
|
386
|
-
timestamp: "webhook-timestamp",
|
|
387
|
-
signature: "webhook-signature"
|
|
388
|
-
};
|
|
389
|
-
var SECRET_PREFIX = "whsec_";
|
|
390
|
-
var encoder2 = new TextEncoder();
|
|
391
|
-
var WebhookError = class extends Error {
|
|
392
|
-
constructor(reason, message) {
|
|
393
|
-
super(message);
|
|
394
|
-
this.reason = reason;
|
|
395
|
-
this.name = "WebhookError";
|
|
396
|
-
}
|
|
397
|
-
reason;
|
|
398
|
-
};
|
|
399
|
-
function generateWebhookSecret() {
|
|
400
|
-
const bytes = new Uint8Array(24);
|
|
401
|
-
crypto.getRandomValues(bytes);
|
|
402
|
-
return SECRET_PREFIX + toStdBase64(bytes);
|
|
403
|
-
}
|
|
404
|
-
async function hmacKey(secret) {
|
|
405
|
-
const raw = secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret;
|
|
406
|
-
let bytes;
|
|
407
|
-
try {
|
|
408
|
-
bytes = fromStdBase64(raw);
|
|
409
|
-
} catch {
|
|
410
|
-
throw new WebhookError("bad_secret", "webhook secret is not whsec_<base64>");
|
|
411
|
-
}
|
|
412
|
-
return crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
413
|
-
}
|
|
414
|
-
async function signWebhook(secret, id, timestamp, body) {
|
|
415
|
-
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder2.encode(`${id}.${timestamp}.${body}`));
|
|
416
|
-
return `v1,${toStdBase64(new Uint8Array(mac))}`;
|
|
417
|
-
}
|
|
418
|
-
async function webhookHeaders(secret, id, timestamp, body) {
|
|
419
|
-
return {
|
|
420
|
-
[WEBHOOK_HEADERS.id]: id,
|
|
421
|
-
[WEBHOOK_HEADERS.timestamp]: String(timestamp),
|
|
422
|
-
[WEBHOOK_HEADERS.signature]: await signWebhook(secret, id, timestamp, body)
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
function header(headers, name) {
|
|
426
|
-
if (headers instanceof Headers) return headers.get(name);
|
|
427
|
-
const value = headers[name] ?? headers[name.toLowerCase()];
|
|
428
|
-
return Array.isArray(value) ? value[0] ?? null : value ?? null;
|
|
429
|
-
}
|
|
430
|
-
async function verifyWebhook(secret, headers, body, { toleranceSeconds = 300, now = Math.floor(Date.now() / 1e3) } = {}) {
|
|
431
|
-
const id = header(headers, WEBHOOK_HEADERS.id);
|
|
432
|
-
const ts = header(headers, WEBHOOK_HEADERS.timestamp);
|
|
433
|
-
const signatures = header(headers, WEBHOOK_HEADERS.signature);
|
|
434
|
-
if (!id || !ts || !signatures) throw new WebhookError("missing_headers", "missing webhook-id, webhook-timestamp or webhook-signature");
|
|
435
|
-
const timestamp = Number(ts);
|
|
436
|
-
if (!/^\d+$/.test(ts) || !Number.isSafeInteger(timestamp)) throw new WebhookError("bad_timestamp", "webhook-timestamp is not unix seconds");
|
|
437
|
-
if (timestamp < now - toleranceSeconds) throw new WebhookError("too_old", "webhook timestamp is too old");
|
|
438
|
-
if (timestamp > now + toleranceSeconds) throw new WebhookError("too_new", "webhook timestamp is in the future");
|
|
439
|
-
const text = typeof body === "string" ? body : fromUtf8(body);
|
|
440
|
-
const expected = (await signWebhook(secret, id, timestamp, text)).slice(3);
|
|
441
|
-
const match = signatures.split(" ").filter((s) => s.startsWith("v1,")).some((s) => constantTimeEqual(s.slice(3), expected));
|
|
442
|
-
if (!match) throw new WebhookError("bad_signature", "no matching webhook signature");
|
|
443
|
-
return JSON.parse(text);
|
|
444
|
-
}
|
|
445
|
-
function constantTimeEqual(a, b) {
|
|
446
|
-
if (a.length !== b.length) return false;
|
|
447
|
-
let diff = 0;
|
|
448
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
449
|
-
return diff === 0;
|
|
450
|
-
}
|
|
451
|
-
function toStdBase64(bytes) {
|
|
452
|
-
let binary = "";
|
|
453
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
454
|
-
return btoa(binary);
|
|
455
|
-
}
|
|
456
|
-
function fromStdBase64(text) {
|
|
457
|
-
const binary = atob(text);
|
|
458
|
-
const bytes = new Uint8Array(binary.length);
|
|
459
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
460
|
-
return bytes;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
764
|
// ../sdk/src/webhook.ts
|
|
464
765
|
function getHeader(headers, name) {
|
|
465
766
|
if (typeof headers.get === "function") return headers.get(name);
|
|
@@ -626,6 +927,26 @@ var Village = class {
|
|
|
626
927
|
issuer: this.options.auth?.issuer ?? this.env("AMBASADA_AUTH_ISSUER"),
|
|
627
928
|
publicUrl: this.options.auth?.publicUrl ?? this.env("PUBLIC_URL"),
|
|
628
929
|
apiUrl: this.apiUrl,
|
|
930
|
+
// Silent sign-in: a shell token must be for this app, by id or origin.
|
|
931
|
+
audiences: [this.appOrigin, this.options.app?.id ?? this.env("AMBASADA_APP_ID") ?? this.env("AMBASADA_COMPANY")].filter((a) => !!a),
|
|
932
|
+
hubUrl: this.hubUrl,
|
|
933
|
+
fetch: this.options.fetch
|
|
934
|
+
}));
|
|
935
|
+
get appOrigin() {
|
|
936
|
+
const url = this.options.app?.origin ?? this.env("AMBASADA_APP_ORIGIN") ?? this.options.auth?.publicUrl ?? this.env("PUBLIC_URL");
|
|
937
|
+
return url && URL.canParse(url) ? new URL(url).origin : void 0;
|
|
938
|
+
}
|
|
939
|
+
get hubUrl() {
|
|
940
|
+
return this.options.app?.hubUrl ?? this.env("AMBASADA_HUB_URL");
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* The mini-app platform (docs/MINI-APPS.md §7): `verifyWebhook(req)` for the hub's app events and
|
|
944
|
+
* `notify({ … })` to push to people who turned notifications on. Uses `AMBASADA_APP_SECRET`.
|
|
945
|
+
*/
|
|
946
|
+
apps = new AmbasadaApps(() => ({
|
|
947
|
+
appSecret: this.options.app?.secret ?? this.env("AMBASADA_APP_SECRET"),
|
|
948
|
+
hubUrl: this.hubUrl,
|
|
949
|
+
origin: this.appOrigin,
|
|
629
950
|
fetch: this.options.fetch
|
|
630
951
|
}));
|
|
631
952
|
// ── HTTP ──────────────────────────────────────────────────────────────────
|
|
@@ -960,10 +1281,10 @@ function cachedKeys(load, ttlMs = 10 * 6e4) {
|
|
|
960
1281
|
let cached;
|
|
961
1282
|
return () => {
|
|
962
1283
|
if (!cached || Date.now() - cached.at > ttlMs) {
|
|
963
|
-
const
|
|
964
|
-
const entry = { at: Date.now(), keys:
|
|
1284
|
+
const keys3 = load();
|
|
1285
|
+
const entry = { at: Date.now(), keys: keys3 };
|
|
965
1286
|
cached = entry;
|
|
966
|
-
|
|
1287
|
+
keys3.catch(() => {
|
|
967
1288
|
if (cached === entry) cached = void 0;
|
|
968
1289
|
});
|
|
969
1290
|
}
|
|
@@ -988,11 +1309,15 @@ function offering(def) {
|
|
|
988
1309
|
var village = new Village();
|
|
989
1310
|
var index_default = village;
|
|
990
1311
|
export {
|
|
1312
|
+
APP_SIGNATURE_HEADER2 as APP_SIGNATURE_HEADER,
|
|
1313
|
+
APP_TIMESTAMP_HEADER2 as APP_TIMESTAMP_HEADER,
|
|
991
1314
|
AUTH_PATHS,
|
|
1315
|
+
AmbasadaApps,
|
|
992
1316
|
AmbasadaAuth,
|
|
993
1317
|
DEFAULT_API_URL,
|
|
994
1318
|
DEFAULT_MODEL,
|
|
995
1319
|
ERROR_STATUS,
|
|
1320
|
+
JWKS_TTL_MS,
|
|
996
1321
|
RECEIPT_HEADER2 as RECEIPT_HEADER,
|
|
997
1322
|
ReceiptInvalidError,
|
|
998
1323
|
SESSION_TTL_S,
|
|
@@ -1000,6 +1325,7 @@ export {
|
|
|
1000
1325
|
VillageError,
|
|
1001
1326
|
WEBHOOK_HEADERS,
|
|
1002
1327
|
WebhookInvalidError,
|
|
1328
|
+
appWebhookHeaders2 as appWebhookHeaders,
|
|
1003
1329
|
createAmbasada,
|
|
1004
1330
|
index_default as default,
|
|
1005
1331
|
defineCompany,
|
|
@@ -1008,6 +1334,7 @@ export {
|
|
|
1008
1334
|
isVillageError,
|
|
1009
1335
|
offering,
|
|
1010
1336
|
readIncoming,
|
|
1337
|
+
signAppWebhook2 as signAppWebhook,
|
|
1011
1338
|
verifyCallReceipt,
|
|
1012
1339
|
verifyCheckoutReceiptToken,
|
|
1013
1340
|
verifyWebhookPayload,
|
package/package.json
CHANGED