@yougrowai/node 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.YouGrow = exports.YouGrowBatchError = exports.YouGrowError = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const origin_js_1 = require("./origin.js");
6
+ const USERS_PATH = "/api/v2/users";
7
+ /** The API's limits: users per batch request, and bytes per request body. */
8
+ const MAX_BATCH = 100;
9
+ const MAX_BODY_BYTES = 512 * 1024;
10
+ const BATCH_ENVELOPE_BYTES = '{"users":[]}'.length;
11
+ const MAX_USER_ID = 256;
12
+ /** The longest delay a Node timer accepts. */
13
+ const MAX_TIMER_MS = 2_147_483_647;
14
+ /**
15
+ * The API refused a request, or it still failed after the retries: a 429, a 5xx,
16
+ * or a timeout or network failure (`status` 0, `code` `timeout` or `network_error`).
17
+ */
18
+ class YouGrowError extends Error {
19
+ status;
20
+ body;
21
+ /** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), `timeout` or `network_error`, or `http_<status>`. */
22
+ code;
23
+ /** For a 400: which fields were wrong, and why. */
24
+ fields;
25
+ /**
26
+ * True for a 429, a 5xx, a timeout or a network failure: trying again later may
27
+ * work, so a queue or trigger should rethrow it for redelivery. Anything else
28
+ * (a 400, 401, 404…) won't succeed as it is.
29
+ */
30
+ retryable;
31
+ constructor(message, status, body, options) {
32
+ super(message, options);
33
+ this.status = status;
34
+ this.body = body;
35
+ this.name = "YouGrowError";
36
+ const b = asRecord(body);
37
+ this.code = typeof b?.error === "string" ? b.error : `http_${status}`;
38
+ if (Array.isArray(b?.fields))
39
+ this.fields = b.fields;
40
+ this.retryable = status === 0 || status === 429 || status >= 500;
41
+ }
42
+ }
43
+ exports.YouGrowError = YouGrowError;
44
+ /** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
45
+ class YouGrowBatchError extends Error {
46
+ result;
47
+ constructor(message, result) {
48
+ super(message);
49
+ this.result = result;
50
+ this.name = "YouGrowBatchError";
51
+ }
52
+ }
53
+ exports.YouGrowBatchError = YouGrowBatchError;
54
+ class YouGrow {
55
+ users;
56
+ events;
57
+ #origin;
58
+ /** Private (#), so logging the client never prints the credentials. */
59
+ #authorization;
60
+ #timeoutMs;
61
+ #maxRetries;
62
+ #maxRetryWaitMs;
63
+ #fetch;
64
+ constructor(opts) {
65
+ if (!opts.keyId || !opts.secret)
66
+ throw new Error("YouGrow: keyId and secret are required");
67
+ this.#origin = (0, origin_js_1.originOf)(opts.origin);
68
+ if (!(0, origin_js_1.isSecureOrigin)(this.#origin))
69
+ throw new Error("YouGrow: origin must be https, e.g. https://yougrow.ai");
70
+ this.#authorization = `Basic ${Buffer.from(`${opts.keyId}:${opts.secret}`, "utf8").toString("base64")}`;
71
+ this.#timeoutMs = whole(opts.timeoutMs, 10_000, 1, MAX_TIMER_MS);
72
+ this.#maxRetries = whole(opts.maxRetries, 3, 0, Number.MAX_SAFE_INTEGER);
73
+ this.#maxRetryWaitMs = whole(opts.maxRetryWaitMs, 5_000, 0, MAX_TIMER_MS);
74
+ this.#fetch = opts.fetch ?? globalThis.fetch;
75
+ this.users = {
76
+ update: async (userId, patch) => (await this.#send("PATCH", userPath(userId), JSON.stringify(patch))),
77
+ batch: (items, batchOpts) => this.#patchMany(items, batchOpts),
78
+ get: async (userId) => {
79
+ try {
80
+ return (await this.#send("GET", userPath(userId)));
81
+ }
82
+ catch (err) {
83
+ // Only the API's own "no such user": a 404 from anything else (e.g. a wrong origin) still throws.
84
+ if (err instanceof YouGrowError && err.status === 404 && err.code === "not_found")
85
+ return null;
86
+ throw err;
87
+ }
88
+ },
89
+ delete: async (userId) => {
90
+ await this.#send("DELETE", userPath(userId));
91
+ },
92
+ };
93
+ this.events = {
94
+ track: async (userId, event, options = {}) => {
95
+ const path = `${userPath(userId)}/events`;
96
+ const { properties, occurredAt } = options;
97
+ // One key for every attempt, so a retry is never recorded twice.
98
+ const body = { event, properties, occurredAt, idempotencyKey: options.idempotencyKey ?? (0, node_crypto_1.randomUUID)() };
99
+ return (await this.#send("POST", path, JSON.stringify(body)));
100
+ },
101
+ };
102
+ }
103
+ /**
104
+ * The connection behind your key: its name, environment and status. A credential
105
+ * check — and a way to catch a key from the wrong environment — before you send.
106
+ */
107
+ async me() {
108
+ return (await this.#send("GET", "/api/v2/me"));
109
+ }
110
+ async #patchMany(items, opts = {}) {
111
+ if (!Array.isArray(items))
112
+ throw new TypeError("YouGrow: users.batch takes an array of { userId, ...patch }");
113
+ const total = { applied: 0, ignored: 0, failed: 0, results: [] };
114
+ for (const chunk of chunks(items)) {
115
+ if (chunk.bytes > MAX_BODY_BYTES) {
116
+ // One item bigger than any request may be: it can't be valid, so it fails here.
117
+ const userId = asRecord(items[chunk.start])?.userId;
118
+ total.failed += 1;
119
+ total.results.push({ index: chunk.start, userId: typeof userId === "string" ? userId : null, status: "failed", reason: "body_too_large" });
120
+ continue;
121
+ }
122
+ const r = (await this.#send("POST", `${USERS_PATH}/batch`, `{"users":[${chunk.json.join(",")}]}`));
123
+ total.applied += r.applied;
124
+ total.ignored += r.ignored;
125
+ total.failed += r.failed;
126
+ for (const item of r.results)
127
+ total.results.push({ ...item, index: chunk.start + item.index });
128
+ }
129
+ if (total.failed > 0) {
130
+ const summary = failureSummary(total, items.length);
131
+ if (opts.throwOnItemError)
132
+ throw new YouGrowBatchError(`YouGrow ${summary}`, total);
133
+ if (!opts.quiet)
134
+ console.warn(`[@yougrowai/node] ${summary}`);
135
+ }
136
+ return total;
137
+ }
138
+ /**
139
+ * One request, retried on network errors, timeouts, 429 and 5xx. Resolves
140
+ * with the JSON body (none for DELETE); an error status throws a YouGrowError.
141
+ */
142
+ async #send(method, path, body) {
143
+ const headers = { authorization: this.#authorization, accept: "application/json" };
144
+ if (body !== undefined)
145
+ headers["content-type"] = "application/json";
146
+ const doFetch = this.#fetch; // called unbound: some fetch implementations refuse another `this`
147
+ for (let attempt = 0;; attempt += 1) {
148
+ let res;
149
+ let text;
150
+ try {
151
+ res = await doFetch(`${this.#origin}${path}`, { method, headers, body, signal: AbortSignal.timeout(this.#timeoutMs) });
152
+ text = await res.text(); // under the same deadline
153
+ }
154
+ catch (err) {
155
+ // Network error or timeout.
156
+ if (attempt >= this.#maxRetries)
157
+ throw transportError(err);
158
+ await sleep(this.#retryWait(attempt));
159
+ continue;
160
+ }
161
+ const data = parseJson(text);
162
+ if (res.ok) {
163
+ if (method !== "DELETE" && !asRecord(data)) {
164
+ throw new YouGrowError(`YouGrow API ${res.status}: the response isn't JSON (is origin right?)`, res.status);
165
+ }
166
+ return data;
167
+ }
168
+ if ((res.status === 429 || res.status >= 500) && attempt < this.#maxRetries) {
169
+ await sleep(this.#retryWait(attempt, res.headers.get("retry-after")));
170
+ continue;
171
+ }
172
+ throw apiError(res.status, data);
173
+ }
174
+ }
175
+ /** Retry-After (seconds) if given, else exponential backoff with full jitter; never over maxRetryWaitMs. */
176
+ #retryWait(attempt, retryAfter) {
177
+ const seconds = retryAfter?.trim();
178
+ if (seconds && /^\d+$/.test(seconds))
179
+ return Math.min(Number(seconds) * 1000, this.#maxRetryWaitMs);
180
+ return Math.random() * Math.min(500 * 2 ** attempt, this.#maxRetryWaitMs);
181
+ }
182
+ }
183
+ exports.YouGrow = YouGrow;
184
+ /** `/api/v2/users/{userId}`, refusing an id the API can't take before anything is sent. */
185
+ function userPath(userId) {
186
+ const problem = userIdProblem(userId);
187
+ if (problem)
188
+ throw new TypeError(`YouGrow: userId ${problem}`);
189
+ return `${USERS_PATH}/${encodeURIComponent(userId)}`;
190
+ }
191
+ function userIdProblem(userId) {
192
+ if (typeof userId !== "string")
193
+ return "must be a string";
194
+ if (userId.length === 0)
195
+ return "is empty";
196
+ if (userId.length > MAX_USER_ID)
197
+ return `is over ${MAX_USER_ID} characters`;
198
+ // "batch" is a route; "." and ".." vanish from a URL path.
199
+ if (userId === "batch" || userId === "." || userId === "..")
200
+ return `can't be "${userId}"`;
201
+ return null;
202
+ }
203
+ /**
204
+ * Consecutive runs of batch items, one request each: at most 100 users and
205
+ * 512 KB of body. A run over the limit is a single item too big to send.
206
+ */
207
+ function* chunks(items) {
208
+ let chunk = { start: 0, json: [], bytes: BATCH_ENVELOPE_BYTES };
209
+ for (let i = 0; i < items.length; i += 1) {
210
+ const json = JSON.stringify(items[i]) ?? "null";
211
+ const size = Buffer.byteLength(json, "utf8");
212
+ // + 1 for the comma before it.
213
+ if (chunk.json.length === MAX_BATCH || (chunk.json.length > 0 && chunk.bytes + 1 + size > MAX_BODY_BYTES)) {
214
+ yield chunk;
215
+ chunk = { start: i, json: [], bytes: BATCH_ENVELOPE_BYTES };
216
+ }
217
+ chunk.bytes += (chunk.json.length > 0 ? 1 : 0) + size;
218
+ chunk.json.push(json);
219
+ }
220
+ if (chunk.json.length > 0)
221
+ yield chunk;
222
+ }
223
+ /** e.g. "users.batch: 2 of 250 users failed (u3: invalid, u17: invalid)". Ids and reasons only, never the data. */
224
+ function failureSummary(r, count) {
225
+ const failed = r.results.filter((x) => x.status === "failed");
226
+ const shown = failed.slice(0, 3).map((x) => `${x.userId ?? `item ${x.index}`}: ${x.reason}`);
227
+ if (failed.length > 3)
228
+ shown.push(`+${failed.length - 3} more`);
229
+ return `users.batch: ${r.failed} of ${count} users failed${shown.length > 0 ? ` (${shown.join(", ")})` : ""}`;
230
+ }
231
+ /** e.g. "YouGrow API 400 invalid: traits.plan: …". */
232
+ /** A timeout or network failure that outlasted the retries: status 0, retryable, the original error as `cause`. */
233
+ function transportError(err) {
234
+ const code = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError") ? "timeout" : "network_error";
235
+ const detail = err instanceof Error ? err.message : String(err);
236
+ return new YouGrowError(`YouGrow API ${code}: ${detail}`, 0, { error: code }, { cause: err });
237
+ }
238
+ function apiError(status, body) {
239
+ const b = asRecord(body);
240
+ const fields = Array.isArray(b?.fields) ? b.fields : [];
241
+ let detail = fields
242
+ .slice(0, 3)
243
+ .map((f) => `${f.path}: ${f.message}`)
244
+ .join("; ");
245
+ if (fields.length > 3)
246
+ detail += `; +${fields.length - 3} more`;
247
+ if (!detail && typeof b?.message === "string")
248
+ detail = b.message;
249
+ const code = typeof b?.error === "string" ? ` ${b.error}` : "";
250
+ return new YouGrowError(`YouGrow API ${status}${code}${detail ? `: ${detail}` : ""}`, status, body);
251
+ }
252
+ function asRecord(v) {
253
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
254
+ }
255
+ function parseJson(text) {
256
+ try {
257
+ return text ? JSON.parse(text) : undefined;
258
+ }
259
+ catch {
260
+ return undefined;
261
+ }
262
+ }
263
+ /** A whole number in [min, max] from an option; unset or not a number means the default. */
264
+ function whole(value, fallback, min, max) {
265
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), min), max) : fallback;
266
+ }
267
+ function sleep(ms) {
268
+ return new Promise((resolve) => setTimeout(resolve, ms));
269
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Verifies the token YouGrow puts on every request it sends your server
3
+ * (context pulls, webhooks):
4
+ *
5
+ * Authorization: Bearer <JWT> alg ES256, kid = one of YouGrow's published keys
6
+ * iss YouGrow's origin (its keys are at `${iss}/.well-known/jwks.json`)
7
+ * aud your connection's key id
8
+ * dir "context" | "webhook"
9
+ * iat / exp at most 5 minutes apart
10
+ * jti unique per request
11
+ * body_sha256 base64url SHA-256 of the exact raw body (the bytes as received)
12
+ *
13
+ * Signature first, then every claim. Pinned by test/vectors.json.
14
+ */
15
+ export type RequestDirection = "context" | "webhook";
16
+ export interface Jwk {
17
+ kty: string;
18
+ crv?: string;
19
+ x?: string;
20
+ y?: string;
21
+ kid?: string;
22
+ alg?: string;
23
+ use?: string;
24
+ }
25
+ export type JwtFailure = "missing_token" | "malformed" | "unsupported_alg" | "unknown_key" | "bad_signature" | "wrong_issuer" | "wrong_audience" | "wrong_direction" | "expired" | "not_yet_valid" | "body_mismatch";
26
+ export interface YouGrowClaims {
27
+ iss: string;
28
+ aud: string;
29
+ iat: number;
30
+ exp: number;
31
+ jti: string;
32
+ dir: RequestDirection;
33
+ body_sha256: string;
34
+ }
35
+ export type JwtResult = {
36
+ ok: true;
37
+ claims: YouGrowClaims;
38
+ } | {
39
+ ok: false;
40
+ reason: JwtFailure;
41
+ };
42
+ export declare function tokenFromAuthorization(value: string | null | undefined): string | null;
43
+ /** The kid in a token's header, without trusting anything else in it. */
44
+ export declare function tokenKid(token: string): string | null;
45
+ export declare function verifyJwt(input: {
46
+ token: string | null;
47
+ keys: Jwk[];
48
+ issuer: string;
49
+ audience: string;
50
+ direction: RequestDirection;
51
+ /** The bytes as received; a string is hashed as UTF-8. */
52
+ rawBody: string | Uint8Array;
53
+ nowMs?: number;
54
+ }): JwtResult;
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tokenFromAuthorization = tokenFromAuthorization;
4
+ exports.tokenKid = tokenKid;
5
+ exports.verifyJwt = verifyJwt;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const MAX_LIFETIME_SEC = 300;
8
+ const LEEWAY_SEC = 60;
9
+ function decodeJson(part) {
10
+ try {
11
+ const v = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
12
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function tokenFromAuthorization(value) {
19
+ const m = /^Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\s*$/.exec(value ?? "");
20
+ return m ? m[1] : null;
21
+ }
22
+ /** The kid in a token's header, without trusting anything else in it. */
23
+ function tokenKid(token) {
24
+ const header = decodeJson(token.split(".")[0] ?? "");
25
+ return header && typeof header.kid === "string" ? header.kid : null;
26
+ }
27
+ function verifyJwt(input) {
28
+ if (!input.token)
29
+ return { ok: false, reason: "missing_token" };
30
+ const parts = input.token.split(".");
31
+ if (parts.length !== 3)
32
+ return { ok: false, reason: "malformed" };
33
+ const [h, p, s] = parts;
34
+ const header = decodeJson(h);
35
+ if (!header)
36
+ return { ok: false, reason: "malformed" };
37
+ // Pin the algorithm: never let the token choose (no "none", no HMAC).
38
+ if (header.alg !== "ES256")
39
+ return { ok: false, reason: "unsupported_alg" };
40
+ const jwk = input.keys.find((k) => k.kid === header.kid && k.kty === "EC" && k.crv === "P-256");
41
+ if (!jwk?.x || !jwk.y)
42
+ return { ok: false, reason: "unknown_key" };
43
+ const signature = Buffer.from(s, "base64url");
44
+ let valid = false;
45
+ try {
46
+ const key = (0, node_crypto_1.createPublicKey)({ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, format: "jwk" });
47
+ valid =
48
+ signature.length === 64 &&
49
+ (0, node_crypto_1.verify)("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, signature);
50
+ }
51
+ catch {
52
+ valid = false;
53
+ }
54
+ if (!valid)
55
+ return { ok: false, reason: "bad_signature" };
56
+ const c = decodeJson(p);
57
+ if (!c)
58
+ return { ok: false, reason: "malformed" };
59
+ if (c.iss !== input.issuer)
60
+ return { ok: false, reason: "wrong_issuer" };
61
+ if (c.aud !== input.audience)
62
+ return { ok: false, reason: "wrong_audience" };
63
+ if (c.dir !== input.direction)
64
+ return { ok: false, reason: "wrong_direction" };
65
+ if (typeof c.iat !== "number" || typeof c.exp !== "number" || typeof c.jti !== "string") {
66
+ return { ok: false, reason: "malformed" };
67
+ }
68
+ if (c.exp - c.iat > MAX_LIFETIME_SEC)
69
+ return { ok: false, reason: "malformed" };
70
+ const nowSec = Math.floor((input.nowMs ?? Date.now()) / 1000);
71
+ if (nowSec > c.exp + LEEWAY_SEC)
72
+ return { ok: false, reason: "expired" };
73
+ if (nowSec < c.iat - LEEWAY_SEC)
74
+ return { ok: false, reason: "not_yet_valid" };
75
+ const bytes = typeof input.rawBody === "string" ? Buffer.from(input.rawBody, "utf8") : input.rawBody;
76
+ const hash = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("base64url");
77
+ if (c.body_sha256 !== hash)
78
+ return { ok: false, reason: "body_mismatch" };
79
+ return { ok: true, claims: c };
80
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * YouGrow's origin, shared by the client (its requests go to `${origin}/api/v2/…`)
3
+ * and the verifier (tokens carry `iss: origin`; keys are at
4
+ * `${origin}/.well-known/jwks.json`). Pass the same value to both, e.g. from
5
+ * YOUGROW_ORIGIN, when you're connected to another YouGrow instance.
6
+ */
7
+ export declare const DEFAULT_ORIGIN = "https://yougrow.ai";
8
+ /** Without trailing slashes. Unset or empty means DEFAULT_ORIGIN. */
9
+ export declare function originOf(value: string | undefined): string;
10
+ /** https, or plain http on localhost for local development. */
11
+ export declare function isSecureOrigin(origin: string): boolean;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * YouGrow's origin, shared by the client (its requests go to `${origin}/api/v2/…`)
4
+ * and the verifier (tokens carry `iss: origin`; keys are at
5
+ * `${origin}/.well-known/jwks.json`). Pass the same value to both, e.g. from
6
+ * YOUGROW_ORIGIN, when you're connected to another YouGrow instance.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.DEFAULT_ORIGIN = void 0;
10
+ exports.originOf = originOf;
11
+ exports.isSecureOrigin = isSecureOrigin;
12
+ exports.DEFAULT_ORIGIN = "https://yougrow.ai";
13
+ /** Without trailing slashes. Unset or empty means DEFAULT_ORIGIN. */
14
+ function originOf(value) {
15
+ return (value || exports.DEFAULT_ORIGIN).replace(/\/+$/, "");
16
+ }
17
+ /** https, or plain http on localhost for local development. */
18
+ function isSecureOrigin(origin) {
19
+ return /^https:\/\//.test(origin) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
20
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,145 @@
1
+ import { type Jwk, type JwtFailure, type RequestDirection, type YouGrowClaims } from "./jwt.js";
2
+ /**
3
+ * Helpers for the two endpoints YouGrow calls on YOUR server:
4
+ *
5
+ * - the context endpoint (direction "context"): before an email, YouGrow asks
6
+ * for one user's onboarding steps, facts and insight sentences;
7
+ * - the webhook endpoint (direction "webhook"): YouGrow tells you about
8
+ * preference changes, e.g. an unsubscribe.
9
+ *
10
+ * Every such request carries `Authorization: Bearer <JWT>` signed with
11
+ * YouGrow's private key. Your secret is NOT involved — you verify against
12
+ * YouGrow's public keys, so nothing you store can be used to forge YouGrow.
13
+ * Always verify against the RAW body (the exact bytes received, as a Buffer or
14
+ * string), before parsing it.
15
+ *
16
+ * const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID!, origin: process.env.YOUGROW_ORIGIN });
17
+ * const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" });
18
+ * if (!v.ok) return res.status(401).end();
19
+ */
20
+ export type { Jwk, RequestDirection, YouGrowClaims } from "./jwt.js";
21
+ /** YouGrow's default origin: the `iss` of its tokens. */
22
+ export declare const DEFAULT_ISSUER = "https://yougrow.ai";
23
+ type HeaderBag = Headers | Record<string, string | string[] | undefined>;
24
+ export type VerifyResult = {
25
+ ok: true;
26
+ claims: YouGrowClaims;
27
+ } | {
28
+ ok: false;
29
+ reason: JwtFailure | "keys_unavailable";
30
+ };
31
+ /** What YouGrow POSTs to your webhook endpoint: one of these, by `type`. Reply 2xx to any type you don't handle. */
32
+ export type WebhookEvent = WebhookEnvelope<"email_preferences.updated",
33
+ /** The person unsubscribed from one of YouGrow's emails: from one category, or all of them. */
34
+ {
35
+ userId: string;
36
+ category: string;
37
+ subscribed: false;
38
+ scope: "all" | "category";
39
+ source: string;
40
+ }>
41
+ /** YouGrow stopped emailing the person: their address hard-bounced, or they reported an email as spam. */
42
+ | WebhookEnvelope<"email.suppressed", {
43
+ userId: string;
44
+ reason: "hard_bounce" | "complaint";
45
+ }>
46
+ /** The Test webhook button. */
47
+ | WebhookEnvelope<"connection.test", Record<string, never>>;
48
+ export interface WebhookEnvelope<T extends string, D> {
49
+ /** Unique per webhook, and the same on every retry: drop ones you've seen. */
50
+ id: string;
51
+ type: T;
52
+ createdAt: string;
53
+ data: D;
54
+ }
55
+ export interface VerifierOptions {
56
+ /** Your connection's key id — the token's audience. */
57
+ keyId: string;
58
+ /**
59
+ * YouGrow's origin: the same value as the client's `origin`, e.g. from
60
+ * YOUGROW_ORIGIN. Defaults to https://yougrow.ai. Tokens must carry it as
61
+ * `iss`, and the keys are fetched from `${origin}/.well-known/jwks.json`.
62
+ */
63
+ origin?: string;
64
+ /** Alias of `origin` (its 0.1 name). */
65
+ issuer?: string;
66
+ /** Pin the key set instead of fetching it (tests, air-gapped setups). */
67
+ jwks?: {
68
+ keys: Jwk[];
69
+ };
70
+ fetch?: typeof fetch;
71
+ }
72
+ export interface Verifier {
73
+ /**
74
+ * Check one request. `rawBody` is the exact body received — a string, or the
75
+ * bytes (e.g. a Buffer) — never re-serialised JSON. Plain header objects
76
+ * match in any case; Fetch `Headers` already do.
77
+ */
78
+ verify(input: {
79
+ headers: HeaderBag;
80
+ rawBody: string | Uint8Array;
81
+ direction: RequestDirection;
82
+ nowMs?: number;
83
+ }): Promise<VerifyResult>;
84
+ }
85
+ /**
86
+ * A verifier for one connection. It fetches YouGrow's public keys once, caches
87
+ * them as long as their Cache-Control allows, and refetches early (rate-limited)
88
+ * when a token names a key it hasn't seen — so YouGrow's key rotations need no
89
+ * change on your side. If a refresh fails it keeps using the keys it has.
90
+ */
91
+ export declare function createVerifier(opts: VerifierOptions): Verifier;
92
+ export interface ContextStep {
93
+ id: string;
94
+ label: string;
95
+ done: boolean;
96
+ doneAt?: string | null;
97
+ url?: string | null;
98
+ blocked?: string | null;
99
+ }
100
+ export interface ContextFact {
101
+ id: string;
102
+ label: string;
103
+ value: string | number | boolean;
104
+ unit?: string | null;
105
+ display?: string | null;
106
+ source?: string | null;
107
+ observedAt?: string | null;
108
+ }
109
+ export interface ContextInsight {
110
+ id: string;
111
+ /** A complete, TRUE sentence — the only place numbers about the user appear. */
112
+ sentence: string;
113
+ factIds?: string[];
114
+ weight?: number;
115
+ supportsStep?: string | null;
116
+ }
117
+ export interface ContextInput {
118
+ asOf?: Date | string;
119
+ steps?: ContextStep[];
120
+ nextStep?: {
121
+ id: string;
122
+ label: string;
123
+ url?: string | null;
124
+ } | null;
125
+ facts?: ContextFact[];
126
+ insights?: ContextInsight[];
127
+ consent?: {
128
+ basis: "consent" | "soft_opt_in" | "corporate_subscriber" | "none";
129
+ categories?: Record<string, boolean>;
130
+ } | null;
131
+ /** Don't email this user yet (e.g. data still loading). */
132
+ hold?: {
133
+ until?: string | null;
134
+ reason: string;
135
+ } | null;
136
+ /** Stop all lifecycle email for this user (e.g. staff, pending deletion). */
137
+ exit?: {
138
+ reason: string;
139
+ } | null;
140
+ }
141
+ /**
142
+ * Build a context response body. Throws on values YouGrow would reject, so
143
+ * mistakes show up in your logs rather than as a failed context pull.
144
+ */
145
+ export declare function contextResponse(input: ContextInput): string;