@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.
- package/README.md +297 -59
- package/dist/cjs/index.d.ts +223 -0
- package/dist/cjs/index.js +269 -0
- package/dist/cjs/jwt.d.ts +54 -0
- package/dist/cjs/jwt.js +80 -0
- package/dist/cjs/origin.d.ts +11 -0
- package/dist/cjs/origin.js +20 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/server.d.ts +145 -0
- package/dist/cjs/server.js +140 -0
- package/dist/index.d.ts +200 -66
- package/dist/index.js +229 -122
- package/dist/jwt.d.ts +3 -2
- package/dist/jwt.js +2 -1
- package/dist/origin.d.ts +11 -0
- package/dist/origin.js +15 -0
- package/dist/server.d.ts +41 -4
- package/dist/server.js +19 -5
- package/package.json +19 -9
- package/test/vectors.json +1 -24
- package/dist/signing.d.ts +0 -19
- package/dist/signing.js +0 -10
package/dist/index.js
CHANGED
|
@@ -1,156 +1,263 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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. */
|
|
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;
|
|
3
11
|
/**
|
|
4
|
-
*
|
|
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.
|
|
12
|
+
* The API refused a request, or it still failed after the retries: a 429, a 5xx,
|
|
13
|
+
* or a timeout or network failure (`status` 0, `code` `timeout` or `network_error`).
|
|
14
14
|
*/
|
|
15
|
-
export { HEADERS, sign } from "./signing.js";
|
|
16
|
-
const MAX_BATCH = 100;
|
|
17
15
|
export class YouGrowError extends Error {
|
|
18
16
|
status;
|
|
19
17
|
body;
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
/** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), `timeout` or `network_error`, or `http_<status>`. */
|
|
19
|
+
code;
|
|
20
|
+
/** For a 400: which fields were wrong, and why. */
|
|
21
|
+
fields;
|
|
22
|
+
/**
|
|
23
|
+
* True for a 429, a 5xx, a timeout or a network failure: trying again later may
|
|
24
|
+
* work, so a queue or trigger should rethrow it for redelivery. Anything else
|
|
25
|
+
* (a 400, 401, 404…) won't succeed as it is.
|
|
26
|
+
*/
|
|
27
|
+
retryable;
|
|
28
|
+
constructor(message, status, body, options) {
|
|
29
|
+
super(message, options);
|
|
22
30
|
this.status = status;
|
|
23
31
|
this.body = body;
|
|
24
32
|
this.name = "YouGrowError";
|
|
33
|
+
const b = asRecord(body);
|
|
34
|
+
this.code = typeof b?.error === "string" ? b.error : `http_${status}`;
|
|
35
|
+
if (Array.isArray(b?.fields))
|
|
36
|
+
this.fields = b.fields;
|
|
37
|
+
this.retryable = status === 0 || status === 429 || status >= 500;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
|
|
41
|
+
export class YouGrowBatchError extends Error {
|
|
42
|
+
result;
|
|
43
|
+
constructor(message, result) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.result = result;
|
|
46
|
+
this.name = "YouGrowBatchError";
|
|
25
47
|
}
|
|
26
48
|
}
|
|
27
49
|
export class YouGrow {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
timer = null;
|
|
50
|
+
users;
|
|
51
|
+
events;
|
|
52
|
+
#origin;
|
|
53
|
+
/** Private (#), so logging the client never prints the credentials. */
|
|
54
|
+
#authorization;
|
|
55
|
+
#timeoutMs;
|
|
56
|
+
#maxRetries;
|
|
57
|
+
#maxRetryWaitMs;
|
|
58
|
+
#fetch;
|
|
38
59
|
constructor(opts) {
|
|
39
60
|
if (!opts.keyId || !opts.secret)
|
|
40
61
|
throw new Error("YouGrow: keyId and secret are required");
|
|
41
|
-
this
|
|
42
|
-
this
|
|
43
|
-
|
|
44
|
-
this
|
|
45
|
-
this
|
|
46
|
-
this
|
|
47
|
-
this
|
|
48
|
-
this
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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;
|
|
62
|
+
this.#origin = originOf(opts.origin);
|
|
63
|
+
if (!isSecureOrigin(this.#origin))
|
|
64
|
+
throw new Error("YouGrow: origin must be https, e.g. https://yougrow.ai");
|
|
65
|
+
this.#authorization = `Basic ${Buffer.from(`${opts.keyId}:${opts.secret}`, "utf8").toString("base64")}`;
|
|
66
|
+
this.#timeoutMs = whole(opts.timeoutMs, 10_000, 1, MAX_TIMER_MS);
|
|
67
|
+
this.#maxRetries = whole(opts.maxRetries, 3, 0, Number.MAX_SAFE_INTEGER);
|
|
68
|
+
this.#maxRetryWaitMs = whole(opts.maxRetryWaitMs, 5_000, 0, MAX_TIMER_MS);
|
|
69
|
+
this.#fetch = opts.fetch ?? globalThis.fetch;
|
|
70
|
+
this.users = {
|
|
71
|
+
update: async (userId, patch) => (await this.#send("PATCH", userPath(userId), JSON.stringify(patch))),
|
|
72
|
+
batch: (items, batchOpts) => this.#patchMany(items, batchOpts),
|
|
73
|
+
get: async (userId) => {
|
|
74
|
+
try {
|
|
75
|
+
return (await this.#send("GET", userPath(userId)));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// Only the API's own "no such user": a 404 from anything else (e.g. a wrong origin) still throws.
|
|
79
|
+
if (err instanceof YouGrowError && err.status === 404 && err.code === "not_found")
|
|
80
|
+
return null;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
delete: async (userId) => {
|
|
85
|
+
await this.#send("DELETE", userPath(userId));
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
this.events = {
|
|
89
|
+
track: async (userId, event, options = {}) => {
|
|
90
|
+
const path = `${userPath(userId)}/events`;
|
|
91
|
+
const { properties, occurredAt } = options;
|
|
92
|
+
// One key for every attempt, so a retry is never recorded twice.
|
|
93
|
+
const body = { event, properties, occurredAt, idempotencyKey: options.idempotencyKey ?? randomUUID() };
|
|
94
|
+
return (await this.#send("POST", path, JSON.stringify(body)));
|
|
95
|
+
},
|
|
96
|
+
};
|
|
86
97
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The connection behind your key: its name, environment and status. A credential
|
|
100
|
+
* check — and a way to catch a key from the wrong environment — before you send.
|
|
101
|
+
*/
|
|
102
|
+
async me() {
|
|
103
|
+
return (await this.#send("GET", "/api/v2/me"));
|
|
90
104
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
async #patchMany(items, opts = {}) {
|
|
106
|
+
if (!Array.isArray(items))
|
|
107
|
+
throw new TypeError("YouGrow: users.batch takes an array of { userId, ...patch }");
|
|
108
|
+
const total = { applied: 0, ignored: 0, failed: 0, results: [] };
|
|
109
|
+
for (const chunk of chunks(items)) {
|
|
110
|
+
if (chunk.bytes > MAX_BODY_BYTES) {
|
|
111
|
+
// One item bigger than any request may be: it can't be valid, so it fails here.
|
|
112
|
+
const userId = asRecord(items[chunk.start])?.userId;
|
|
113
|
+
total.failed += 1;
|
|
114
|
+
total.results.push({ index: chunk.start, userId: typeof userId === "string" ? userId : null, status: "failed", reason: "body_too_large" });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const r = (await this.#send("POST", `${USERS_PATH}/batch`, `{"users":[${chunk.json.join(",")}]}`));
|
|
118
|
+
total.applied += r.applied;
|
|
119
|
+
total.ignored += r.ignored;
|
|
120
|
+
total.failed += r.failed;
|
|
121
|
+
for (const item of r.results)
|
|
122
|
+
total.results.push({ ...item, index: chunk.start + item.index });
|
|
95
123
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
if (total.failed > 0) {
|
|
125
|
+
const summary = failureSummary(total, items.length);
|
|
126
|
+
if (opts.throwOnItemError)
|
|
127
|
+
throw new YouGrowBatchError(`YouGrow ${summary}`, total);
|
|
128
|
+
if (!opts.quiet)
|
|
129
|
+
console.warn(`[@yougrowai/node] ${summary}`);
|
|
102
130
|
}
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
clearTimer() {
|
|
106
|
-
if (this.timer)
|
|
107
|
-
clearTimeout(this.timer);
|
|
108
|
-
this.timer = null;
|
|
131
|
+
return total;
|
|
109
132
|
}
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
/**
|
|
134
|
+
* One request, retried on network errors, timeouts, 429 and 5xx. Resolves
|
|
135
|
+
* with the JSON body (none for DELETE); an error status throws a YouGrowError.
|
|
136
|
+
*/
|
|
137
|
+
async #send(method, path, body) {
|
|
138
|
+
const headers = { authorization: this.#authorization, accept: "application/json" };
|
|
139
|
+
if (body !== undefined)
|
|
140
|
+
headers["content-type"] = "application/json";
|
|
141
|
+
const doFetch = this.#fetch; // called unbound: some fetch implementations refuse another `this`
|
|
112
142
|
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
143
|
let res;
|
|
144
|
+
let text;
|
|
116
145
|
try {
|
|
117
|
-
res = await this.
|
|
118
|
-
|
|
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
|
-
});
|
|
146
|
+
res = await doFetch(`${this.#origin}${path}`, { method, headers, body, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
147
|
+
text = await res.text(); // under the same deadline
|
|
127
148
|
}
|
|
128
149
|
catch (err) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
// Network error or timeout.
|
|
151
|
+
if (attempt >= this.#maxRetries)
|
|
152
|
+
throw transportError(err);
|
|
153
|
+
await sleep(this.#retryWait(attempt));
|
|
132
154
|
continue;
|
|
133
155
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
throw new YouGrowError(`YouGrow ingest failed: ${code}`, res.status, data);
|
|
156
|
+
const data = parseJson(text);
|
|
157
|
+
if (res.ok) {
|
|
158
|
+
if (method !== "DELETE" && !asRecord(data)) {
|
|
159
|
+
throw new YouGrowError(`YouGrow API ${res.status}: the response isn't JSON (is origin right?)`, res.status);
|
|
160
|
+
}
|
|
161
|
+
return data;
|
|
141
162
|
}
|
|
142
|
-
|
|
163
|
+
if ((res.status === 429 || res.status >= 500) && attempt < this.#maxRetries) {
|
|
164
|
+
await sleep(this.#retryWait(attempt, res.headers.get("retry-after")));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
throw apiError(res.status, data);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Retry-After (seconds) if given, else exponential backoff with full jitter; never over maxRetryWaitMs. */
|
|
171
|
+
#retryWait(attempt, retryAfter) {
|
|
172
|
+
const seconds = retryAfter?.trim();
|
|
173
|
+
if (seconds && /^\d+$/.test(seconds))
|
|
174
|
+
return Math.min(Number(seconds) * 1000, this.#maxRetryWaitMs);
|
|
175
|
+
return Math.random() * Math.min(500 * 2 ** attempt, this.#maxRetryWaitMs);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** `/api/v2/users/{userId}`, refusing an id the API can't take before anything is sent. */
|
|
179
|
+
function userPath(userId) {
|
|
180
|
+
const problem = userIdProblem(userId);
|
|
181
|
+
if (problem)
|
|
182
|
+
throw new TypeError(`YouGrow: userId ${problem}`);
|
|
183
|
+
return `${USERS_PATH}/${encodeURIComponent(userId)}`;
|
|
184
|
+
}
|
|
185
|
+
function userIdProblem(userId) {
|
|
186
|
+
if (typeof userId !== "string")
|
|
187
|
+
return "must be a string";
|
|
188
|
+
if (userId.length === 0)
|
|
189
|
+
return "is empty";
|
|
190
|
+
if (userId.length > MAX_USER_ID)
|
|
191
|
+
return `is over ${MAX_USER_ID} characters`;
|
|
192
|
+
// "batch" is a route; "." and ".." vanish from a URL path.
|
|
193
|
+
if (userId === "batch" || userId === "." || userId === "..")
|
|
194
|
+
return `can't be "${userId}"`;
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Consecutive runs of batch items, one request each: at most 100 users and
|
|
199
|
+
* 512 KB of body. A run over the limit is a single item too big to send.
|
|
200
|
+
*/
|
|
201
|
+
function* chunks(items) {
|
|
202
|
+
let chunk = { start: 0, json: [], bytes: BATCH_ENVELOPE_BYTES };
|
|
203
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
204
|
+
const json = JSON.stringify(items[i]) ?? "null";
|
|
205
|
+
const size = Buffer.byteLength(json, "utf8");
|
|
206
|
+
// + 1 for the comma before it.
|
|
207
|
+
if (chunk.json.length === MAX_BATCH || (chunk.json.length > 0 && chunk.bytes + 1 + size > MAX_BODY_BYTES)) {
|
|
208
|
+
yield chunk;
|
|
209
|
+
chunk = { start: i, json: [], bytes: BATCH_ENVELOPE_BYTES };
|
|
143
210
|
}
|
|
211
|
+
chunk.bytes += (chunk.json.length > 0 ? 1 : 0) + size;
|
|
212
|
+
chunk.json.push(json);
|
|
213
|
+
}
|
|
214
|
+
if (chunk.json.length > 0)
|
|
215
|
+
yield chunk;
|
|
216
|
+
}
|
|
217
|
+
/** e.g. "users.batch: 2 of 250 users failed (u3: invalid, u17: invalid)". Ids and reasons only, never the data. */
|
|
218
|
+
function failureSummary(r, count) {
|
|
219
|
+
const failed = r.results.filter((x) => x.status === "failed");
|
|
220
|
+
const shown = failed.slice(0, 3).map((x) => `${x.userId ?? `item ${x.index}`}: ${x.reason}`);
|
|
221
|
+
if (failed.length > 3)
|
|
222
|
+
shown.push(`+${failed.length - 3} more`);
|
|
223
|
+
return `users.batch: ${r.failed} of ${count} users failed${shown.length > 0 ? ` (${shown.join(", ")})` : ""}`;
|
|
224
|
+
}
|
|
225
|
+
/** e.g. "YouGrow API 400 invalid: traits.plan: …". */
|
|
226
|
+
/** A timeout or network failure that outlasted the retries: status 0, retryable, the original error as `cause`. */
|
|
227
|
+
function transportError(err) {
|
|
228
|
+
const code = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError") ? "timeout" : "network_error";
|
|
229
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
230
|
+
return new YouGrowError(`YouGrow API ${code}: ${detail}`, 0, { error: code }, { cause: err });
|
|
231
|
+
}
|
|
232
|
+
function apiError(status, body) {
|
|
233
|
+
const b = asRecord(body);
|
|
234
|
+
const fields = Array.isArray(b?.fields) ? b.fields : [];
|
|
235
|
+
let detail = fields
|
|
236
|
+
.slice(0, 3)
|
|
237
|
+
.map((f) => `${f.path}: ${f.message}`)
|
|
238
|
+
.join("; ");
|
|
239
|
+
if (fields.length > 3)
|
|
240
|
+
detail += `; +${fields.length - 3} more`;
|
|
241
|
+
if (!detail && typeof b?.message === "string")
|
|
242
|
+
detail = b.message;
|
|
243
|
+
const code = typeof b?.error === "string" ? ` ${b.error}` : "";
|
|
244
|
+
return new YouGrowError(`YouGrow API ${status}${code}${detail ? `: ${detail}` : ""}`, status, body);
|
|
245
|
+
}
|
|
246
|
+
function asRecord(v) {
|
|
247
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
248
|
+
}
|
|
249
|
+
function parseJson(text) {
|
|
250
|
+
try {
|
|
251
|
+
return text ? JSON.parse(text) : undefined;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return undefined;
|
|
144
255
|
}
|
|
145
256
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return typeof t === "string" ? t : t.toISOString();
|
|
257
|
+
/** A whole number in [min, max] from an option; unset or not a number means the default. */
|
|
258
|
+
function whole(value, fallback, min, max) {
|
|
259
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), min), max) : fallback;
|
|
150
260
|
}
|
|
151
|
-
|
|
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);
|
|
261
|
+
function sleep(ms) {
|
|
155
262
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
156
263
|
}
|
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
|
-
|
|
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
|
|
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 };
|
package/dist/origin.d.ts
ADDED
|
@@ -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;
|
package/dist/origin.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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 const DEFAULT_ORIGIN = "https://yougrow.ai";
|
|
8
|
+
/** Without trailing slashes. Unset or empty means DEFAULT_ORIGIN. */
|
|
9
|
+
export function originOf(value) {
|
|
10
|
+
return (value || DEFAULT_ORIGIN).replace(/\/+$/, "");
|
|
11
|
+
}
|
|
12
|
+
/** https, or plain http on localhost for local development. */
|
|
13
|
+
export function isSecureOrigin(origin) {
|
|
14
|
+
return /^https:\/\//.test(origin) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
|
15
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -10,13 +10,15 @@ import { type Jwk, type JwtFailure, type RequestDirection, type YouGrowClaims }
|
|
|
10
10
|
* Every such request carries `Authorization: Bearer <JWT>` signed with
|
|
11
11
|
* YouGrow's private key. Your secret is NOT involved — you verify against
|
|
12
12
|
* YouGrow's public keys, so nothing you store can be used to forge YouGrow.
|
|
13
|
-
* Always verify against the RAW body,
|
|
13
|
+
* Always verify against the RAW body (the exact bytes received, as a Buffer or
|
|
14
|
+
* string), before parsing it.
|
|
14
15
|
*
|
|
15
|
-
* const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID
|
|
16
|
+
* const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID!, origin: process.env.YOUGROW_ORIGIN });
|
|
16
17
|
* const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" });
|
|
17
18
|
* if (!v.ok) return res.status(401).end();
|
|
18
19
|
*/
|
|
19
20
|
export type { Jwk, RequestDirection, YouGrowClaims } from "./jwt.js";
|
|
21
|
+
/** YouGrow's default origin: the `iss` of its tokens. */
|
|
20
22
|
export declare const DEFAULT_ISSUER = "https://yougrow.ai";
|
|
21
23
|
type HeaderBag = Headers | Record<string, string | string[] | undefined>;
|
|
22
24
|
export type VerifyResult = {
|
|
@@ -26,10 +28,40 @@ export type VerifyResult = {
|
|
|
26
28
|
ok: false;
|
|
27
29
|
reason: JwtFailure | "keys_unavailable";
|
|
28
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
|
+
}
|
|
29
55
|
export interface VerifierOptions {
|
|
30
56
|
/** Your connection's key id — the token's audience. */
|
|
31
57
|
keyId: string;
|
|
32
|
-
/**
|
|
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). */
|
|
33
65
|
issuer?: string;
|
|
34
66
|
/** Pin the key set instead of fetching it (tests, air-gapped setups). */
|
|
35
67
|
jwks?: {
|
|
@@ -38,9 +70,14 @@ export interface VerifierOptions {
|
|
|
38
70
|
fetch?: typeof fetch;
|
|
39
71
|
}
|
|
40
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
|
+
*/
|
|
41
78
|
verify(input: {
|
|
42
79
|
headers: HeaderBag;
|
|
43
|
-
rawBody: string;
|
|
80
|
+
rawBody: string | Uint8Array;
|
|
44
81
|
direction: RequestDirection;
|
|
45
82
|
nowMs?: number;
|
|
46
83
|
}): Promise<VerifyResult>;
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { tokenFromAuthorization, tokenKid, verifyJwt } from "./jwt.js";
|
|
2
|
-
|
|
2
|
+
import { DEFAULT_ORIGIN, isSecureOrigin, originOf } from "./origin.js";
|
|
3
|
+
/** YouGrow's default origin: the `iss` of its tokens. */
|
|
4
|
+
export const DEFAULT_ISSUER = DEFAULT_ORIGIN;
|
|
3
5
|
const JWKS_PATH = "/.well-known/jwks.json";
|
|
4
6
|
const MIN_CACHE_MS = 60_000;
|
|
5
7
|
const MAX_CACHE_MS = 24 * 3600_000;
|
|
@@ -7,10 +9,17 @@ const DEFAULT_CACHE_MS = 3600_000;
|
|
|
7
9
|
/** An unknown kid refetches the keys at most this often. */
|
|
8
10
|
const REFETCH_COOLDOWN_MS = 60_000;
|
|
9
11
|
function header(h, name) {
|
|
12
|
+
if (!h)
|
|
13
|
+
return null;
|
|
10
14
|
if (typeof h.get === "function")
|
|
11
15
|
return h.get(name);
|
|
12
16
|
const bag = h;
|
|
13
|
-
|
|
17
|
+
let v = bag[name] ?? bag[name.toLowerCase()];
|
|
18
|
+
if (v === undefined) {
|
|
19
|
+
// Plain objects can keep the sender's casing (e.g. API Gateway REST events): match any case.
|
|
20
|
+
const lower = name.toLowerCase();
|
|
21
|
+
v = Object.entries(bag).find(([k, value]) => value !== undefined && k.toLowerCase() === lower)?.[1];
|
|
22
|
+
}
|
|
14
23
|
return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
|
|
15
24
|
}
|
|
16
25
|
function cacheMs(cacheControl) {
|
|
@@ -25,10 +34,12 @@ function cacheMs(cacheControl) {
|
|
|
25
34
|
* change on your side. If a refresh fails it keeps using the keys it has.
|
|
26
35
|
*/
|
|
27
36
|
export function createVerifier(opts) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error("createVerifier: issuer must be https");
|
|
37
|
+
if (opts.origin && opts.issuer && originOf(opts.origin) !== originOf(opts.issuer)) {
|
|
38
|
+
throw new Error("createVerifier: origin and issuer differ (issuer is an alias of origin); pass origin only");
|
|
31
39
|
}
|
|
40
|
+
const issuer = originOf(opts.origin || opts.issuer);
|
|
41
|
+
if (!opts.jwks && !isSecureOrigin(issuer))
|
|
42
|
+
throw new Error("createVerifier: origin must be https");
|
|
32
43
|
if (!opts.keyId)
|
|
33
44
|
throw new Error("createVerifier: keyId is required");
|
|
34
45
|
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
@@ -62,6 +73,9 @@ export function createVerifier(opts) {
|
|
|
62
73
|
}
|
|
63
74
|
return {
|
|
64
75
|
async verify(input) {
|
|
76
|
+
if (typeof input.rawBody !== "string" && !ArrayBuffer.isView(input.rawBody)) {
|
|
77
|
+
throw new TypeError("verify: rawBody must be the raw request body (a string or Buffer), not parsed JSON");
|
|
78
|
+
}
|
|
65
79
|
const token = tokenFromAuthorization(header(input.headers, "authorization"));
|
|
66
80
|
const now = Date.now();
|
|
67
81
|
const sinceFetch = now - lastFetchAt;
|
package/package.json
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yougrowai/node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Keep your users' state in YouGrow lifecycle journeys, and verify the requests YouGrow sends you.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "YouGrow.AI Limited",
|
|
7
|
-
"homepage": "https://
|
|
7
|
+
"homepage": "https://yougrow.ai/developers",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/ygai-jezl/vizzyblmrkt.git",
|
|
11
11
|
"directory": "sdk/node"
|
|
12
12
|
},
|
|
13
|
-
"keywords": ["yougrow", "lifecycle-email", "onboarding", "events", "webhooks", "jwks"],
|
|
13
|
+
"keywords": ["yougrow", "lifecycle-email", "onboarding", "user-state", "events", "webhooks", "jwks"],
|
|
14
14
|
"type": "module",
|
|
15
|
-
"main": "./dist/index.js",
|
|
16
|
-
"types": "./dist/index.d.ts",
|
|
15
|
+
"main": "./dist/cjs/index.js",
|
|
16
|
+
"types": "./dist/cjs/index.d.ts",
|
|
17
17
|
"exports": {
|
|
18
|
-
".": {
|
|
19
|
-
|
|
18
|
+
".": {
|
|
19
|
+
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
20
|
+
"require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" }
|
|
21
|
+
},
|
|
22
|
+
"./server": {
|
|
23
|
+
"import": { "types": "./dist/server.d.ts", "default": "./dist/server.js" },
|
|
24
|
+
"require": { "types": "./dist/cjs/server.d.ts", "default": "./dist/cjs/server.js" }
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"typesVersions": {
|
|
29
|
+
"*": { "server": ["./dist/cjs/server.d.ts"] }
|
|
20
30
|
},
|
|
21
31
|
"files": ["dist", "test/vectors.json", "README.md", "LICENSE"],
|
|
22
32
|
"engines": { "node": ">=18" },
|
|
23
33
|
"sideEffects": false,
|
|
24
34
|
"publishConfig": { "access": "public" },
|
|
25
35
|
"scripts": {
|
|
26
|
-
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
36
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json',JSON.stringify({type:'commonjs'})+'\\n')\"",
|
|
27
37
|
"prepack": "npm run build"
|
|
28
38
|
}
|
|
29
39
|
}
|