@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,140 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_ISSUER = void 0;
4
+ exports.createVerifier = createVerifier;
5
+ exports.contextResponse = contextResponse;
6
+ const jwt_js_1 = require("./jwt.js");
7
+ const origin_js_1 = require("./origin.js");
8
+ /** YouGrow's default origin: the `iss` of its tokens. */
9
+ exports.DEFAULT_ISSUER = origin_js_1.DEFAULT_ORIGIN;
10
+ const JWKS_PATH = "/.well-known/jwks.json";
11
+ const MIN_CACHE_MS = 60_000;
12
+ const MAX_CACHE_MS = 24 * 3600_000;
13
+ const DEFAULT_CACHE_MS = 3600_000;
14
+ /** An unknown kid refetches the keys at most this often. */
15
+ const REFETCH_COOLDOWN_MS = 60_000;
16
+ function header(h, name) {
17
+ if (!h)
18
+ return null;
19
+ if (typeof h.get === "function")
20
+ return h.get(name);
21
+ const bag = h;
22
+ let v = bag[name] ?? bag[name.toLowerCase()];
23
+ if (v === undefined) {
24
+ // Plain objects can keep the sender's casing (e.g. API Gateway REST events): match any case.
25
+ const lower = name.toLowerCase();
26
+ v = Object.entries(bag).find(([k, value]) => value !== undefined && k.toLowerCase() === lower)?.[1];
27
+ }
28
+ return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
29
+ }
30
+ function cacheMs(cacheControl) {
31
+ const m = /max-age=(\d+)/.exec(cacheControl ?? "");
32
+ const ms = m ? Number(m[1]) * 1000 : DEFAULT_CACHE_MS;
33
+ return Math.min(MAX_CACHE_MS, Math.max(MIN_CACHE_MS, ms));
34
+ }
35
+ /**
36
+ * A verifier for one connection. It fetches YouGrow's public keys once, caches
37
+ * them as long as their Cache-Control allows, and refetches early (rate-limited)
38
+ * when a token names a key it hasn't seen — so YouGrow's key rotations need no
39
+ * change on your side. If a refresh fails it keeps using the keys it has.
40
+ */
41
+ function createVerifier(opts) {
42
+ if (opts.origin && opts.issuer && (0, origin_js_1.originOf)(opts.origin) !== (0, origin_js_1.originOf)(opts.issuer)) {
43
+ throw new Error("createVerifier: origin and issuer differ (issuer is an alias of origin); pass origin only");
44
+ }
45
+ const issuer = (0, origin_js_1.originOf)(opts.origin || opts.issuer);
46
+ if (!opts.jwks && !(0, origin_js_1.isSecureOrigin)(issuer))
47
+ throw new Error("createVerifier: origin must be https");
48
+ if (!opts.keyId)
49
+ throw new Error("createVerifier: keyId is required");
50
+ const doFetch = opts.fetch ?? globalThis.fetch;
51
+ let keys = opts.jwks?.keys ?? null;
52
+ let expiresAt = opts.jwks ? Number.POSITIVE_INFINITY : 0;
53
+ let lastFetchAt = Number.NEGATIVE_INFINITY;
54
+ let inflight = null;
55
+ async function refresh(now) {
56
+ if (opts.jwks)
57
+ return;
58
+ inflight ??= (async () => {
59
+ lastFetchAt = now;
60
+ try {
61
+ const res = await doFetch(`${issuer}${JWKS_PATH}`, { signal: AbortSignal.timeout(5000), redirect: "error" });
62
+ if (!res.ok)
63
+ return;
64
+ const body = (await res.json());
65
+ if (!Array.isArray(body.keys))
66
+ return;
67
+ keys = body.keys;
68
+ expiresAt = Date.now() + cacheMs(res.headers.get("cache-control"));
69
+ }
70
+ catch {
71
+ // Keep the keys we have (if any); the next request tries again.
72
+ }
73
+ finally {
74
+ inflight = null;
75
+ }
76
+ })();
77
+ await inflight;
78
+ }
79
+ return {
80
+ async verify(input) {
81
+ if (typeof input.rawBody !== "string" && !ArrayBuffer.isView(input.rawBody)) {
82
+ throw new TypeError("verify: rawBody must be the raw request body (a string or Buffer), not parsed JSON");
83
+ }
84
+ const token = (0, jwt_js_1.tokenFromAuthorization)(header(input.headers, "authorization"));
85
+ const now = Date.now();
86
+ const sinceFetch = now - lastFetchAt;
87
+ if (token && (keys ? now >= expiresAt && sinceFetch >= REFETCH_COOLDOWN_MS : sinceFetch >= 5000)) {
88
+ await refresh(now);
89
+ }
90
+ const kid = token ? (0, jwt_js_1.tokenKid)(token) : null;
91
+ if (token && kid && keys && !keys.some((k) => k.kid === kid) && Date.now() - lastFetchAt >= REFETCH_COOLDOWN_MS) {
92
+ await refresh(now);
93
+ }
94
+ if (token && !keys)
95
+ return { ok: false, reason: "keys_unavailable" };
96
+ return (0, jwt_js_1.verifyJwt)({
97
+ token,
98
+ keys: keys ?? [],
99
+ issuer,
100
+ audience: opts.keyId,
101
+ direction: input.direction,
102
+ rawBody: input.rawBody,
103
+ nowMs: input.nowMs,
104
+ });
105
+ },
106
+ };
107
+ }
108
+ /**
109
+ * Build a context response body. Throws on values YouGrow would reject, so
110
+ * mistakes show up in your logs rather than as a failed context pull.
111
+ */
112
+ function contextResponse(input) {
113
+ const steps = input.steps ?? [];
114
+ const facts = input.facts ?? [];
115
+ const insights = input.insights ?? [];
116
+ if (steps.length > 20)
117
+ throw new Error("contextResponse: at most 20 steps");
118
+ if (facts.length > 50)
119
+ throw new Error("contextResponse: at most 50 facts");
120
+ if (insights.length > 20)
121
+ throw new Error("contextResponse: at most 20 insights");
122
+ for (const i of insights) {
123
+ if (i.sentence.length > 300)
124
+ throw new Error(`contextResponse: insight ${i.id} is over 300 characters`);
125
+ }
126
+ const asOf = input.asOf === undefined ? new Date() : input.asOf;
127
+ const body = JSON.stringify({
128
+ asOf: typeof asOf === "string" ? asOf : asOf.toISOString(),
129
+ steps,
130
+ nextStep: input.nextStep ?? null,
131
+ facts,
132
+ insights,
133
+ ...(input.consent ? { consent: input.consent } : {}),
134
+ ...(input.hold ? { hold: input.hold } : {}),
135
+ ...(input.exit ? { exit: input.exit } : {}),
136
+ });
137
+ if (Buffer.byteLength(body, "utf8") > 64 * 1024)
138
+ throw new Error("contextResponse: over 64 KB");
139
+ return body;
140
+ }
package/dist/index.d.ts CHANGED
@@ -1,89 +1,223 @@
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
+ /** `ignoredFields`: profile fields whose value was invalid, so left as they were; the rest applied. */
86
+ {
87
+ applied: true;
88
+ user: UserState;
89
+ ignoredFields?: FieldError[];
90
+ } | {
91
+ applied: false;
92
+ reason: SkipReason;
93
+ storedUpdatedAt?: string | null;
94
+ user?: UserState;
95
+ };
96
+ /** What was wrong with one field, e.g. `{ path: "traits.plan", message: "…" }`. */
97
+ export interface FieldError {
98
+ path: string;
99
+ message: string;
100
+ }
101
+ /** One user in `users.batch`: their id plus a patch. */
102
+ export interface BatchItem extends UserPatch {
103
+ userId: string;
104
+ }
105
+ export interface BatchResponse {
106
+ applied: number;
107
+ ignored: number;
108
+ failed: number;
109
+ /**
110
+ * The ignored and failed items, and applied ones whose invalid profile fields were
111
+ * left as they were (reason `fields_ignored`). `index` is the item's position in your array.
112
+ */
113
+ results: Array<{
114
+ index: number;
115
+ userId: string | null;
116
+ status: "applied" | "ignored" | "failed";
117
+ reason: string;
118
+ fields?: FieldError[];
119
+ }>;
120
+ }
121
+ /** The connection behind your key (`GET /api/v2/me`). */
122
+ export interface MeResponse {
123
+ connection: {
124
+ id: string;
125
+ name: string;
126
+ environment: "staging" | "production" | null;
127
+ status: "active" | "paused" | "revoked";
128
+ };
129
+ keyId: string;
130
+ /** A new secret was issued in the last 24 hours; the previous one works until then. */
131
+ rotating: boolean;
132
+ }
133
+ export interface EventResult {
134
+ recorded: boolean;
135
+ duplicate: boolean;
136
+ }
16
137
  export interface YouGrowOptions {
138
+ /** Your connection's key id (`ygk_…`). */
17
139
  keyId: string;
140
+ /** Its secret (`ygs_…`). Server-side only. */
18
141
  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. */
142
+ /**
143
+ * YouGrow's origin, e.g. from YOUGROW_ORIGIN. Defaults to https://yougrow.ai;
144
+ * set it when you're connected to another YouGrow instance (staging, self-hosted).
145
+ */
146
+ origin?: string;
147
+ /** Abandon a request after this long (default 10000); it's retried like a network error. */
148
+ timeoutMs?: number;
149
+ /** Retries per request for network errors, timeouts, 429 and 5xx (default 3). */
26
150
  maxRetries?: number;
151
+ /** Longest wait between retries (default 5000), even when Retry-After asks for more. */
152
+ maxRetryWaitMs?: number;
27
153
  /** Custom fetch (tests, proxies). */
28
154
  fetch?: typeof fetch;
29
- /** Called when a background flush fails. */
30
- onError?: (err: Error) => void;
31
155
  }
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;
156
+ export interface BatchOptions {
157
+ /** Don't log the console.warn line about failed items. */
158
+ quiet?: boolean;
159
+ /** Throw a YouGrowBatchError, carrying the result, if any item failed. Every item is still sent. */
160
+ throwOnItemError?: boolean;
41
161
  }
42
- export interface TrackInput {
43
- userId: string;
44
- event: string;
162
+ export interface TrackOptions {
45
163
  properties?: Record<string, unknown>;
46
- traits?: Record<string, TraitValue>;
47
- timestamp?: Date | string;
48
- messageId?: string;
49
- }
50
- export interface IngestResult {
51
- accepted: number;
52
- duplicates: number;
53
- rejected: Array<{
54
- index: number;
55
- messageId: string | null;
56
- reason: string;
57
- }>;
164
+ /** When it happened (ISO 8601 with a zone). */
165
+ occurredAt?: string;
166
+ /** The same key is recorded once. Defaults to a random key per call, so the SDK's own retries never count twice. */
167
+ idempotencyKey?: string;
168
+ }
169
+ export interface UsersApi {
170
+ /** Merge a patch into one user's state; creates the user if YouGrow hasn't seen them. */
171
+ update(userId: string, patch: UserPatch): Promise<PatchResponse>;
172
+ /**
173
+ * Patch any number of users, 100 per request, one request after another. One
174
+ * bad item never fails the rest: the result lists the ignored and failed ones,
175
+ * and failures are logged in one console.warn line (see BatchOptions).
176
+ */
177
+ batch(items: readonly BatchItem[], opts?: BatchOptions): Promise<BatchResponse>;
178
+ /** The user's state, journeys and opt-outs, or null if YouGrow doesn't know them. */
179
+ get(userId: string): Promise<UserView | null>;
180
+ /** Erase the user and their history. Safe to repeat, and for users YouGrow never saw. */
181
+ delete(userId: string): Promise<void>;
58
182
  }
183
+ export interface EventsApi {
184
+ /** Record a milestone, e.g. "report.exported". Optional: journeys run on state. */
185
+ track(userId: string, event: string, opts?: TrackOptions): Promise<EventResult>;
186
+ }
187
+ /**
188
+ * The API refused a request, or it still failed after the retries: a 429, a 5xx,
189
+ * or a timeout or network failure (`status` 0, `code` `timeout` or `network_error`).
190
+ */
59
191
  export declare class YouGrowError extends Error {
60
192
  readonly status: number;
61
193
  readonly body?: unknown | undefined;
62
- constructor(message: string, status: number, body?: unknown | undefined);
194
+ /** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), `timeout` or `network_error`, or `http_<status>`. */
195
+ readonly code: string;
196
+ /** For a 400: which fields were wrong, and why. */
197
+ readonly fields?: FieldError[];
198
+ /**
199
+ * True for a 429, a 5xx, a timeout or a network failure: trying again later may
200
+ * work, so a queue or trigger should rethrow it for redelivery. Anything else
201
+ * (a 400, 401, 404…) won't succeed as it is.
202
+ */
203
+ readonly retryable: boolean;
204
+ constructor(message: string, status: number, body?: unknown | undefined, options?: {
205
+ cause?: unknown;
206
+ });
207
+ }
208
+ /** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
209
+ export declare class YouGrowBatchError extends Error {
210
+ readonly result: BatchResponse;
211
+ constructor(message: string, result: BatchResponse);
63
212
  }
64
213
  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;
214
+ #private;
215
+ readonly users: UsersApi;
216
+ readonly events: EventsApi;
75
217
  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;
218
+ /**
219
+ * The connection behind your key: its name, environment and status. A credential
220
+ * check — and a way to catch a key from the wrong environment — before you send.
221
+ */
222
+ me(): Promise<MeResponse>;
89
223
  }