@yougrowai/node 0.1.0 → 0.3.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/index.d.ts CHANGED
@@ -1,89 +1,189 @@
1
1
  /**
2
- * @yougrowai/node — send your product's user events to YouGrow lifecycle journeys.
2
+ * @yougrowai/node — keep your users' state in YouGrow lifecycle journeys (API v2).
3
3
  *
4
4
  * const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! });
5
- * yg.identify({ userId: user.id, traits: { email: user.email }, consent: { basis: "soft_opt_in" } });
6
- * yg.track({ userId: user.id, event: "user.signed_up" });
7
- * await yg.flush();
5
+ * await yg.users.update(user.id, { email: user.email, signedUpAt: user.createdAt.toISOString(), consent: "soft_opt_in" });
8
6
  *
9
- * Server-side only (the secret signs every request). Messages are batched (≤100
10
- * per request) and retried with backoff on network errors, 429 and 5xx. Every
11
- * message carries a messageId, so a retried or duplicated send is harmless.
7
+ * Server-side only: the secret authenticates every request (HTTP Basic). Each
8
+ * method resolves once its request is done (a batch's, one per 100 users), with
9
+ * nothing queued or sent in the background, so it's safe in serverless
10
+ * functions. A request is abandoned after `timeoutMs` and retried with capped,
11
+ * jittered backoff on network errors, timeouts, 429 and 5xx; any other error
12
+ * status throws a YouGrowError straight away.
12
13
  */
13
- export { HEADERS, sign, type Direction } from "./signing.js";
14
14
  export type ConsentBasis = "consent" | "soft_opt_in" | "corporate_subscriber" | "none";
15
- export type TraitValue = string | number | boolean | null;
15
+ /**
16
+ * Any subset of a user's state, as a JSON Merge Patch (RFC 7396): fields sent
17
+ * replace YouGrow's, fields left out stay, `null` clears, and `steps`, `facts`
18
+ * and `traits` merge key by key. Timestamps are ISO 8601 with a zone, e.g.
19
+ * `new Date().toISOString()`.
20
+ */
21
+ export interface UserPatch {
22
+ email?: string | null;
23
+ firstName?: string | null;
24
+ lastName?: string | null;
25
+ /** An IANA time zone, e.g. "Europe/London". */
26
+ timezone?: string | null;
27
+ /** A BCP 47 locale, e.g. "en-GB". */
28
+ locale?: string | null;
29
+ /** When the account was created. Starts sign-up journeys while inside their window. */
30
+ signedUpAt?: string;
31
+ /** The legal basis for marketing email. */
32
+ consent?: ConsentBasis | null;
33
+ /** false = the person opted out of lifecycle email in your product. */
34
+ subscribed?: boolean;
35
+ /** Never email this person, in any journey (staff, test accounts, invited teammates). */
36
+ excluded?: {
37
+ reason: string;
38
+ } | null;
39
+ /** Onboarding step id → when it was done (null: not done). */
40
+ steps?: Record<string, string | null>;
41
+ /** Fact id → its latest value (null removes it). */
42
+ facts?: Record<string, number | string | boolean | null>;
43
+ /** Anything else journeys branch on (null removes a key). */
44
+ traits?: Record<string, string | number | boolean | null>;
45
+ /** When you read this state. A write older than the stored one is ignored. */
46
+ updatedAt?: string;
47
+ }
48
+ /** A user's state as YouGrow holds it. */
49
+ export interface UserState {
50
+ userId: string;
51
+ email: string | null;
52
+ firstName: string | null;
53
+ lastName: string | null;
54
+ timezone: string | null;
55
+ locale: string | null;
56
+ signedUpAt: string | null;
57
+ consent: ConsentBasis | null;
58
+ subscribed: boolean;
59
+ excluded: {
60
+ reason: string;
61
+ } | null;
62
+ steps: Record<string, string>;
63
+ facts: Record<string, string | number | boolean>;
64
+ traits: Record<string, string | number | boolean>;
65
+ updatedAt: string | null;
66
+ }
67
+ /** `users.get` adds what YouGrow decided: journeys and opt-outs. */
68
+ export interface UserView extends UserState {
69
+ enrolments: Array<{
70
+ journeyId: string;
71
+ status: string;
72
+ mode: string;
73
+ enrolledAt: string;
74
+ }>;
75
+ /** Unsubscribes made in YouGrow's emails. They hold until the person lifts them; the API can't. */
76
+ optOuts: Array<{
77
+ scope: "all" | "category";
78
+ category: string | null;
79
+ at: string | null;
80
+ }>;
81
+ }
82
+ /** Why a write was ignored: older than the stored state, or than the user's deletion. */
83
+ export type SkipReason = "stale_write" | "deleted_later";
84
+ export type PatchResponse = {
85
+ applied: true;
86
+ user: UserState;
87
+ } | {
88
+ applied: false;
89
+ reason: SkipReason;
90
+ storedUpdatedAt?: string | null;
91
+ user?: UserState;
92
+ };
93
+ /** What was wrong with one field, e.g. `{ path: "traits.plan", message: "…" }`. */
94
+ export interface FieldError {
95
+ path: string;
96
+ message: string;
97
+ }
98
+ /** One user in `users.batch`: their id plus a patch. */
99
+ export interface BatchItem extends UserPatch {
100
+ userId: string;
101
+ }
102
+ export interface BatchResponse {
103
+ applied: number;
104
+ ignored: number;
105
+ failed: number;
106
+ /** Only the ignored and failed items; applied ones are counted. `index` is the item's position in your array. */
107
+ results: Array<{
108
+ index: number;
109
+ userId: string | null;
110
+ status: "ignored" | "failed";
111
+ reason: string;
112
+ fields?: FieldError[];
113
+ }>;
114
+ }
115
+ export interface EventResult {
116
+ recorded: boolean;
117
+ duplicate: boolean;
118
+ }
16
119
  export interface YouGrowOptions {
120
+ /** Your connection's key id (`ygk_…`). */
17
121
  keyId: string;
122
+ /** Its secret (`ygs_…`). Server-side only. */
18
123
  secret: string;
19
- /** Ingest URL. Defaults to https://yougrow.ai/api/v1/events. */
20
- endpoint?: string;
21
- /** Flush automatically once this many messages are queued (max 100). */
22
- flushAt?: number;
23
- /** Flush automatically this long after the first queued message. 0 = manual only. */
24
- flushIntervalMs?: number;
25
- /** Retries per batch for network errors, 429 and 5xx. */
124
+ /**
125
+ * YouGrow's origin, e.g. from YOUGROW_ORIGIN. Defaults to https://yougrow.ai;
126
+ * set it when you're connected to another YouGrow instance (staging, self-hosted).
127
+ */
128
+ origin?: string;
129
+ /** Abandon a request after this long (default 10000); it's retried like a network error. */
130
+ timeoutMs?: number;
131
+ /** Retries per request for network errors, timeouts, 429 and 5xx (default 3). */
26
132
  maxRetries?: number;
133
+ /** Longest wait between retries (default 5000), even when Retry-After asks for more. */
134
+ maxRetryWaitMs?: number;
27
135
  /** Custom fetch (tests, proxies). */
28
136
  fetch?: typeof fetch;
29
- /** Called when a background flush fails. */
30
- onError?: (err: Error) => void;
31
137
  }
32
- export interface IdentifyInput {
33
- userId: string;
34
- traits?: Record<string, TraitValue>;
35
- consent?: {
36
- basis: ConsentBasis;
37
- source?: string;
38
- };
39
- timestamp?: Date | string;
40
- messageId?: string;
138
+ export interface BatchOptions {
139
+ /** Don't log the console.warn line about failed items. */
140
+ quiet?: boolean;
141
+ /** Throw a YouGrowBatchError, carrying the result, if any item failed. Every item is still sent. */
142
+ throwOnItemError?: boolean;
41
143
  }
42
- export interface TrackInput {
43
- userId: string;
44
- event: string;
144
+ export interface TrackOptions {
45
145
  properties?: Record<string, unknown>;
46
- traits?: Record<string, TraitValue>;
47
- timestamp?: Date | string;
48
- messageId?: string;
146
+ /** When it happened (ISO 8601 with a zone). */
147
+ occurredAt?: string;
148
+ /** The same key is recorded once. Defaults to a random key per call, so the SDK's own retries never count twice. */
149
+ idempotencyKey?: string;
49
150
  }
50
- export interface IngestResult {
51
- accepted: number;
52
- duplicates: number;
53
- rejected: Array<{
54
- index: number;
55
- messageId: string | null;
56
- reason: string;
57
- }>;
151
+ export interface UsersApi {
152
+ /** Merge a patch into one user's state; creates the user if YouGrow hasn't seen them. */
153
+ update(userId: string, patch: UserPatch): Promise<PatchResponse>;
154
+ /**
155
+ * Patch any number of users, 100 per request, one request after another. One
156
+ * bad item never fails the rest: the result lists the ignored and failed ones,
157
+ * and failures are logged in one console.warn line (see BatchOptions).
158
+ */
159
+ batch(items: readonly BatchItem[], opts?: BatchOptions): Promise<BatchResponse>;
160
+ /** The user's state, journeys and opt-outs, or null if YouGrow doesn't know them. */
161
+ get(userId: string): Promise<UserView | null>;
162
+ /** Erase the user and their history. Safe to repeat, and for users YouGrow never saw. */
163
+ delete(userId: string): Promise<void>;
164
+ }
165
+ export interface EventsApi {
166
+ /** Record a milestone, e.g. "report.exported". Optional: journeys run on state. */
167
+ track(userId: string, event: string, opts?: TrackOptions): Promise<EventResult>;
58
168
  }
169
+ /** The API refused a request, or still failed (429, 5xx) after the retries. */
59
170
  export declare class YouGrowError extends Error {
60
171
  readonly status: number;
61
172
  readonly body?: unknown | undefined;
173
+ /** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), or `http_<status>` when the body has none. */
174
+ readonly code: string;
175
+ /** For a 400: which fields were wrong, and why. */
176
+ readonly fields?: FieldError[];
62
177
  constructor(message: string, status: number, body?: unknown | undefined);
63
178
  }
179
+ /** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
180
+ export declare class YouGrowBatchError extends Error {
181
+ readonly result: BatchResponse;
182
+ constructor(message: string, result: BatchResponse);
183
+ }
64
184
  export declare class YouGrow {
65
- private readonly keyId;
66
- private readonly secret;
67
- private readonly endpoint;
68
- private readonly flushAt;
69
- private readonly flushIntervalMs;
70
- private readonly maxRetries;
71
- private readonly fetchImpl;
72
- private readonly onError?;
73
- private queue;
74
- private timer;
185
+ #private;
186
+ readonly users: UsersApi;
187
+ readonly events: EventsApi;
75
188
  constructor(opts: YouGrowOptions);
76
- /** Who the user is: email, name, timezone, plan… plus the consent basis. Returns the messageId. */
77
- identify(input: IdentifyInput): string;
78
- /** Something the user did. Returns the messageId. */
79
- track(input: TrackInput): string;
80
- /** Shorthand for the reserved `onboarding.step_completed` event. */
81
- stepCompleted(userId: string, step: string, timestamp?: Date | string): string;
82
- /** Send everything queued. Resolves with one result per request. */
83
- flush(): Promise<IngestResult[]>;
84
- /** Flush and stop the background timer (call on shutdown). */
85
- close(): Promise<IngestResult[]>;
86
- private enqueue;
87
- private clearTimer;
88
- private send;
89
189
  }
package/dist/index.js CHANGED
@@ -1,156 +1,240 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { HEADERS, sign } from "./signing.js";
3
- /**
4
- * @yougrowai/node — send your product's user events to YouGrow lifecycle journeys.
5
- *
6
- * const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! });
7
- * yg.identify({ userId: user.id, traits: { email: user.email }, consent: { basis: "soft_opt_in" } });
8
- * yg.track({ userId: user.id, event: "user.signed_up" });
9
- * await yg.flush();
10
- *
11
- * Server-side only (the secret signs every request). Messages are batched (≤100
12
- * per request) and retried with backoff on network errors, 429 and 5xx. Every
13
- * message carries a messageId, so a retried or duplicated send is harmless.
14
- */
15
- export { HEADERS, sign } from "./signing.js";
2
+ import { isSecureOrigin, originOf } from "./origin.js";
3
+ const USERS_PATH = "/api/v2/users";
4
+ /** The API's limits: users per batch request, and bytes per request body. */
16
5
  const MAX_BATCH = 100;
6
+ const MAX_BODY_BYTES = 512 * 1024;
7
+ const BATCH_ENVELOPE_BYTES = '{"users":[]}'.length;
8
+ const MAX_USER_ID = 256;
9
+ /** The longest delay a Node timer accepts. */
10
+ const MAX_TIMER_MS = 2_147_483_647;
11
+ /** The API refused a request, or still failed (429, 5xx) after the retries. */
17
12
  export class YouGrowError extends Error {
18
13
  status;
19
14
  body;
15
+ /** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), or `http_<status>` when the body has none. */
16
+ code;
17
+ /** For a 400: which fields were wrong, and why. */
18
+ fields;
20
19
  constructor(message, status, body) {
21
20
  super(message);
22
21
  this.status = status;
23
22
  this.body = body;
24
23
  this.name = "YouGrowError";
24
+ const b = asRecord(body);
25
+ this.code = typeof b?.error === "string" ? b.error : `http_${status}`;
26
+ if (Array.isArray(b?.fields))
27
+ this.fields = b.fields;
28
+ }
29
+ }
30
+ /** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
31
+ export class YouGrowBatchError extends Error {
32
+ result;
33
+ constructor(message, result) {
34
+ super(message);
35
+ this.result = result;
36
+ this.name = "YouGrowBatchError";
25
37
  }
26
38
  }
27
39
  export class YouGrow {
28
- keyId;
29
- secret;
30
- endpoint;
31
- flushAt;
32
- flushIntervalMs;
33
- maxRetries;
34
- fetchImpl;
35
- onError;
36
- queue = [];
37
- timer = null;
40
+ users;
41
+ events;
42
+ #origin;
43
+ /** Private (#), so logging the client never prints the credentials. */
44
+ #authorization;
45
+ #timeoutMs;
46
+ #maxRetries;
47
+ #maxRetryWaitMs;
48
+ #fetch;
38
49
  constructor(opts) {
39
50
  if (!opts.keyId || !opts.secret)
40
51
  throw new Error("YouGrow: keyId and secret are required");
41
- this.keyId = opts.keyId;
42
- this.secret = opts.secret;
43
- this.endpoint = opts.endpoint ?? "https://yougrow.ai/api/v1/events";
44
- this.flushAt = Math.min(Math.max(opts.flushAt ?? 20, 1), MAX_BATCH);
45
- this.flushIntervalMs = opts.flushIntervalMs ?? 5000;
46
- this.maxRetries = opts.maxRetries ?? 3;
47
- this.fetchImpl = opts.fetch ?? fetch;
48
- this.onError = opts.onError;
49
- }
50
- /** Who the user is: email, name, timezone, plan… plus the consent basis. Returns the messageId. */
51
- identify(input) {
52
- return this.enqueue({
53
- type: "identify",
54
- messageId: input.messageId ?? randomUUID(),
55
- userId: input.userId,
56
- timestamp: iso(input.timestamp),
57
- traits: input.traits ?? {},
58
- ...(input.consent ? { consent: input.consent } : {}),
59
- });
60
- }
61
- /** Something the user did. Returns the messageId. */
62
- track(input) {
63
- return this.enqueue({
64
- type: "track",
65
- messageId: input.messageId ?? randomUUID(),
66
- userId: input.userId,
67
- timestamp: iso(input.timestamp),
68
- event: input.event,
69
- properties: input.properties ?? {},
70
- ...(input.traits ? { traits: input.traits } : {}),
71
- });
72
- }
73
- /** Shorthand for the reserved `onboarding.step_completed` event. */
74
- stepCompleted(userId, step, timestamp) {
75
- return this.track({ userId, event: "onboarding.step_completed", properties: { step }, timestamp });
76
- }
77
- /** Send everything queued. Resolves with one result per request. */
78
- async flush() {
79
- this.clearTimer();
80
- const results = [];
81
- while (this.queue.length > 0) {
82
- const batch = this.queue.splice(0, MAX_BATCH);
83
- results.push(await this.send(batch));
84
- }
85
- return results;
86
- }
87
- /** Flush and stop the background timer (call on shutdown). */
88
- async close() {
89
- return this.flush();
52
+ this.#origin = originOf(opts.origin);
53
+ if (!isSecureOrigin(this.#origin))
54
+ throw new Error("YouGrow: origin must be https, e.g. https://yougrow.ai");
55
+ this.#authorization = `Basic ${Buffer.from(`${opts.keyId}:${opts.secret}`, "utf8").toString("base64")}`;
56
+ this.#timeoutMs = whole(opts.timeoutMs, 10_000, 1, MAX_TIMER_MS);
57
+ this.#maxRetries = whole(opts.maxRetries, 3, 0, Number.MAX_SAFE_INTEGER);
58
+ this.#maxRetryWaitMs = whole(opts.maxRetryWaitMs, 5_000, 0, MAX_TIMER_MS);
59
+ this.#fetch = opts.fetch ?? globalThis.fetch;
60
+ this.users = {
61
+ update: async (userId, patch) => (await this.#send("PATCH", userPath(userId), JSON.stringify(patch))),
62
+ batch: (items, batchOpts) => this.#patchMany(items, batchOpts),
63
+ get: async (userId) => {
64
+ try {
65
+ return (await this.#send("GET", userPath(userId)));
66
+ }
67
+ catch (err) {
68
+ // Only the API's own "no such user": a 404 from anything else (e.g. a wrong origin) still throws.
69
+ if (err instanceof YouGrowError && err.status === 404 && err.code === "not_found")
70
+ return null;
71
+ throw err;
72
+ }
73
+ },
74
+ delete: async (userId) => {
75
+ await this.#send("DELETE", userPath(userId));
76
+ },
77
+ };
78
+ this.events = {
79
+ track: async (userId, event, options = {}) => {
80
+ const path = `${userPath(userId)}/events`;
81
+ const { properties, occurredAt } = options;
82
+ // One key for every attempt, so a retry is never recorded twice.
83
+ const body = { event, properties, occurredAt, idempotencyKey: options.idempotencyKey ?? randomUUID() };
84
+ return (await this.#send("POST", path, JSON.stringify(body)));
85
+ },
86
+ };
90
87
  }
91
- enqueue(msg) {
92
- this.queue.push(msg);
93
- if (this.queue.length >= this.flushAt) {
94
- void this.flush().catch((err) => this.onError?.(err));
88
+ async #patchMany(items, opts = {}) {
89
+ if (!Array.isArray(items))
90
+ throw new TypeError("YouGrow: users.batch takes an array of { userId, ...patch }");
91
+ const total = { applied: 0, ignored: 0, failed: 0, results: [] };
92
+ for (const chunk of chunks(items)) {
93
+ if (chunk.bytes > MAX_BODY_BYTES) {
94
+ // One item bigger than any request may be: it can't be valid, so it fails here.
95
+ const userId = asRecord(items[chunk.start])?.userId;
96
+ total.failed += 1;
97
+ total.results.push({ index: chunk.start, userId: typeof userId === "string" ? userId : null, status: "failed", reason: "body_too_large" });
98
+ continue;
99
+ }
100
+ const r = (await this.#send("POST", `${USERS_PATH}/batch`, `{"users":[${chunk.json.join(",")}]}`));
101
+ total.applied += r.applied;
102
+ total.ignored += r.ignored;
103
+ total.failed += r.failed;
104
+ for (const item of r.results)
105
+ total.results.push({ ...item, index: chunk.start + item.index });
95
106
  }
96
- else if (this.flushIntervalMs > 0 && !this.timer) {
97
- this.timer = setTimeout(() => {
98
- this.timer = null;
99
- void this.flush().catch((err) => this.onError?.(err));
100
- }, this.flushIntervalMs);
101
- this.timer.unref?.();
107
+ if (total.failed > 0) {
108
+ const summary = failureSummary(total, items.length);
109
+ if (opts.throwOnItemError)
110
+ throw new YouGrowBatchError(`YouGrow ${summary}`, total);
111
+ if (!opts.quiet)
112
+ console.warn(`[@yougrowai/node] ${summary}`);
102
113
  }
103
- return msg.messageId;
114
+ return total;
104
115
  }
105
- clearTimer() {
106
- if (this.timer)
107
- clearTimeout(this.timer);
108
- this.timer = null;
109
- }
110
- async send(batch) {
111
- const body = JSON.stringify({ batch });
116
+ /**
117
+ * One request, retried on network errors, timeouts, 429 and 5xx. Resolves
118
+ * with the JSON body (none for DELETE); an error status throws a YouGrowError.
119
+ */
120
+ async #send(method, path, body) {
121
+ const headers = { authorization: this.#authorization, accept: "application/json" };
122
+ if (body !== undefined)
123
+ headers["content-type"] = "application/json";
124
+ const doFetch = this.#fetch; // called unbound: some fetch implementations refuse another `this`
112
125
  for (let attempt = 0;; attempt += 1) {
113
- // Sign every attempt afresh: the timestamp must stay inside the 5-minute window.
114
- const ts = Math.floor(Date.now() / 1000);
115
126
  let res;
127
+ let text;
116
128
  try {
117
- res = await this.fetchImpl(this.endpoint, {
118
- method: "POST",
119
- headers: {
120
- "content-type": "application/json",
121
- [HEADERS.keyId]: this.keyId,
122
- [HEADERS.timestamp]: String(ts),
123
- [HEADERS.signature]: sign(this.secret, "events", ts, body),
124
- },
125
- body,
126
- });
129
+ res = await doFetch(`${this.#origin}${path}`, { method, headers, body, signal: AbortSignal.timeout(this.#timeoutMs) });
130
+ text = await res.text(); // under the same deadline
127
131
  }
128
132
  catch (err) {
129
- if (attempt >= this.maxRetries)
133
+ // Network error or timeout.
134
+ if (attempt >= this.#maxRetries)
130
135
  throw err;
131
- await backoff(attempt);
136
+ await sleep(this.#retryWait(attempt));
132
137
  continue;
133
138
  }
134
- if (res.status === 202 || res.ok)
135
- return (await res.json());
136
- const retryable = res.status === 429 || res.status >= 500;
137
- if (!retryable || attempt >= this.maxRetries) {
138
- const data = await res.json().catch(() => undefined);
139
- const code = data?.error ?? `http_${res.status}`;
140
- throw new YouGrowError(`YouGrow ingest failed: ${code}`, res.status, data);
139
+ const data = parseJson(text);
140
+ if (res.ok) {
141
+ if (method !== "DELETE" && !asRecord(data)) {
142
+ throw new YouGrowError(`YouGrow API ${res.status}: the response isn't JSON (is origin right?)`, res.status);
143
+ }
144
+ return data;
141
145
  }
142
- await backoff(attempt, res.headers.get("retry-after"));
146
+ if ((res.status === 429 || res.status >= 500) && attempt < this.#maxRetries) {
147
+ await sleep(this.#retryWait(attempt, res.headers.get("retry-after")));
148
+ continue;
149
+ }
150
+ throw apiError(res.status, data);
143
151
  }
144
152
  }
153
+ /** Retry-After (seconds) if given, else exponential backoff with full jitter; never over maxRetryWaitMs. */
154
+ #retryWait(attempt, retryAfter) {
155
+ const seconds = retryAfter?.trim();
156
+ if (seconds && /^\d+$/.test(seconds))
157
+ return Math.min(Number(seconds) * 1000, this.#maxRetryWaitMs);
158
+ return Math.random() * Math.min(500 * 2 ** attempt, this.#maxRetryWaitMs);
159
+ }
160
+ }
161
+ /** `/api/v2/users/{userId}`, refusing an id the API can't take before anything is sent. */
162
+ function userPath(userId) {
163
+ const problem = userIdProblem(userId);
164
+ if (problem)
165
+ throw new TypeError(`YouGrow: userId ${problem}`);
166
+ return `${USERS_PATH}/${encodeURIComponent(userId)}`;
167
+ }
168
+ function userIdProblem(userId) {
169
+ if (typeof userId !== "string")
170
+ return "must be a string";
171
+ if (userId.length === 0)
172
+ return "is empty";
173
+ if (userId.length > MAX_USER_ID)
174
+ return `is over ${MAX_USER_ID} characters`;
175
+ // "batch" is a route; "." and ".." vanish from a URL path.
176
+ if (userId === "batch" || userId === "." || userId === "..")
177
+ return `can't be "${userId}"`;
178
+ return null;
179
+ }
180
+ /**
181
+ * Consecutive runs of batch items, one request each: at most 100 users and
182
+ * 512 KB of body. A run over the limit is a single item too big to send.
183
+ */
184
+ function* chunks(items) {
185
+ let chunk = { start: 0, json: [], bytes: BATCH_ENVELOPE_BYTES };
186
+ for (let i = 0; i < items.length; i += 1) {
187
+ const json = JSON.stringify(items[i]) ?? "null";
188
+ const size = Buffer.byteLength(json, "utf8");
189
+ // + 1 for the comma before it.
190
+ if (chunk.json.length === MAX_BATCH || (chunk.json.length > 0 && chunk.bytes + 1 + size > MAX_BODY_BYTES)) {
191
+ yield chunk;
192
+ chunk = { start: i, json: [], bytes: BATCH_ENVELOPE_BYTES };
193
+ }
194
+ chunk.bytes += (chunk.json.length > 0 ? 1 : 0) + size;
195
+ chunk.json.push(json);
196
+ }
197
+ if (chunk.json.length > 0)
198
+ yield chunk;
199
+ }
200
+ /** e.g. "users.batch: 2 of 250 users failed (u3: invalid, u17: invalid)". Ids and reasons only, never the data. */
201
+ function failureSummary(r, count) {
202
+ const failed = r.results.filter((x) => x.status === "failed");
203
+ const shown = failed.slice(0, 3).map((x) => `${x.userId ?? `item ${x.index}`}: ${x.reason}`);
204
+ if (failed.length > 3)
205
+ shown.push(`+${failed.length - 3} more`);
206
+ return `users.batch: ${r.failed} of ${count} users failed${shown.length > 0 ? ` (${shown.join(", ")})` : ""}`;
207
+ }
208
+ /** e.g. "YouGrow API 400 invalid: traits.plan: …". */
209
+ function apiError(status, body) {
210
+ const b = asRecord(body);
211
+ const fields = Array.isArray(b?.fields) ? b.fields : [];
212
+ let detail = fields
213
+ .slice(0, 3)
214
+ .map((f) => `${f.path}: ${f.message}`)
215
+ .join("; ");
216
+ if (fields.length > 3)
217
+ detail += `; +${fields.length - 3} more`;
218
+ if (!detail && typeof b?.message === "string")
219
+ detail = b.message;
220
+ const code = typeof b?.error === "string" ? ` ${b.error}` : "";
221
+ return new YouGrowError(`YouGrow API ${status}${code}${detail ? `: ${detail}` : ""}`, status, body);
222
+ }
223
+ function asRecord(v) {
224
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
225
+ }
226
+ function parseJson(text) {
227
+ try {
228
+ return text ? JSON.parse(text) : undefined;
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
145
233
  }
146
- function iso(t) {
147
- if (t === undefined)
148
- return new Date().toISOString();
149
- return typeof t === "string" ? t : t.toISOString();
234
+ /** A whole number in [min, max] from an option; unset or not a number means the default. */
235
+ function whole(value, fallback, min, max) {
236
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), min), max) : fallback;
150
237
  }
151
- /** Exponential backoff with full jitter; honours Retry-After (seconds). */
152
- function backoff(attempt, retryAfter) {
153
- const hinted = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : 0;
154
- const ms = hinted || Math.random() * Math.min(500 * 2 ** attempt, 10_000);
238
+ function sleep(ms) {
155
239
  return new Promise((resolve) => setTimeout(resolve, ms));
156
240
  }
package/dist/jwt.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * dir "context" | "webhook"
9
9
  * iat / exp at most 5 minutes apart
10
10
  * jti unique per request
11
- * body_sha256 base64url SHA-256 of the exact raw body
11
+ * body_sha256 base64url SHA-256 of the exact raw body (the bytes as received)
12
12
  *
13
13
  * Signature first, then every claim. Pinned by test/vectors.json.
14
14
  */
@@ -48,6 +48,7 @@ export declare function verifyJwt(input: {
48
48
  issuer: string;
49
49
  audience: string;
50
50
  direction: RequestDirection;
51
- rawBody: string;
51
+ /** The bytes as received; a string is hashed as UTF-8. */
52
+ rawBody: string | Uint8Array;
52
53
  nowMs?: number;
53
54
  }): JwtResult;
package/dist/jwt.js CHANGED
@@ -67,7 +67,8 @@ export function verifyJwt(input) {
67
67
  return { ok: false, reason: "expired" };
68
68
  if (nowSec < c.iat - LEEWAY_SEC)
69
69
  return { ok: false, reason: "not_yet_valid" };
70
- const hash = createHash("sha256").update(input.rawBody, "utf8").digest("base64url");
70
+ const bytes = typeof input.rawBody === "string" ? Buffer.from(input.rawBody, "utf8") : input.rawBody;
71
+ const hash = createHash("sha256").update(bytes).digest("base64url");
71
72
  if (c.body_sha256 !== hash)
72
73
  return { ok: false, reason: "body_mismatch" };
73
74
  return { ok: true, claims: c };