@yougrowai/node 0.1.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/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +156 -0
- package/dist/jwt.d.ts +53 -0
- package/dist/jwt.js +74 -0
- package/dist/server.d.ts +108 -0
- package/dist/server.js +121 -0
- package/dist/signing.d.ts +19 -0
- package/dist/signing.js +10 -0
- package/package.json +29 -0
- package/test/vectors.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YouGrow.AI Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @yougrowai/node
|
|
2
|
+
|
|
3
|
+
Connect your product to YouGrow lifecycle journeys. Your server sends YouGrow
|
|
4
|
+
what your users do (sign-ups, onboarding steps). YouGrow then asks your server
|
|
5
|
+
for fresh context just before it emails someone.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @yougrowai/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 18 or later, no dependencies. Server-side only: your secret must never
|
|
12
|
+
reach a browser or app bundle.
|
|
13
|
+
|
|
14
|
+
> Status: 0.x. The wire protocol is stable; the SDK's API may still change
|
|
15
|
+
> before 1.0. The same signing works from any language; see "Without the SDK"
|
|
16
|
+
> below.
|
|
17
|
+
|
|
18
|
+
## Send events
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { YouGrow } from "@yougrowai/node";
|
|
22
|
+
|
|
23
|
+
const yg = new YouGrow({
|
|
24
|
+
keyId: process.env.YOUGROW_KEY_ID!, // from Products → your connection
|
|
25
|
+
secret: process.env.YOUGROW_SECRET!, // shown once; keep it server-side
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// On sign-up
|
|
29
|
+
yg.identify({
|
|
30
|
+
userId: user.id,
|
|
31
|
+
traits: { email: user.email, firstName: user.firstName, timezone: "Europe/London", plan: "free" },
|
|
32
|
+
consent: { basis: "soft_opt_in" }, // consent | soft_opt_in | corporate_subscriber | none
|
|
33
|
+
});
|
|
34
|
+
yg.track({ userId: user.id, event: "user.signed_up" });
|
|
35
|
+
|
|
36
|
+
// As onboarding progresses
|
|
37
|
+
yg.stepCompleted(user.id, "create_brand");
|
|
38
|
+
|
|
39
|
+
await yg.flush(); // or let it flush automatically (every 20 messages / 5 s)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Messages are batched (at most 100 per request) and retried with backoff on
|
|
43
|
+
network errors, 429 and 5xx. Every message has a `messageId`, so a retry is
|
|
44
|
+
never double-counted.
|
|
45
|
+
|
|
46
|
+
### Reserved events
|
|
47
|
+
|
|
48
|
+
| Event | Meaning |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `user.signed_up` | Starts sign-up journeys |
|
|
51
|
+
| `onboarding.step_completed` | `properties.step` is the step id from your catalog |
|
|
52
|
+
| `onboarding.completed` | Every onboarding step is done |
|
|
53
|
+
| `email_preferences.updated` | `properties.category` + `properties.subscribed` |
|
|
54
|
+
| `user.deleted` | Erase this user and their history |
|
|
55
|
+
|
|
56
|
+
Other event names (lower-case, dotted, e.g. `report.exported`) are recorded as
|
|
57
|
+
milestones.
|
|
58
|
+
|
|
59
|
+
## Answer context requests
|
|
60
|
+
|
|
61
|
+
Before an email, YouGrow sends your context endpoint a `POST` about one user.
|
|
62
|
+
It carries `Authorization: Bearer <JWT>`, signed with **YouGrow's** private key.
|
|
63
|
+
You verify it against YouGrow's published public keys. Your secret isn't
|
|
64
|
+
involved, so nothing you store can be used to forge a request from YouGrow.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createVerifier, contextResponse } from "@yougrowai/node/server";
|
|
68
|
+
|
|
69
|
+
// Once, at startup. keyId is the token's audience: tokens for other connections fail.
|
|
70
|
+
const verifier = createVerifier({
|
|
71
|
+
keyId: process.env.YOUGROW_KEY_ID!,
|
|
72
|
+
// issuer: "https://<dev origin>" // staging only; defaults to https://yougrow.ai
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
app.post("/yougrow/context", express.raw({ type: "application/json" }), async (req, res) => {
|
|
76
|
+
const rawBody = req.body.toString("utf8");
|
|
77
|
+
const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" });
|
|
78
|
+
if (!v.ok) return res.status(401).end();
|
|
79
|
+
|
|
80
|
+
const { userId } = JSON.parse(rawBody);
|
|
81
|
+
const u = await loadOnboardingState(userId);
|
|
82
|
+
res.type("json").send(
|
|
83
|
+
contextResponse({
|
|
84
|
+
steps: u.steps, // [{ id, label, done, url }]
|
|
85
|
+
nextStep: u.nextStep,
|
|
86
|
+
facts: [{ id: "sov", label: "Share of voice", value: 12, unit: "%" }],
|
|
87
|
+
insights: [{ id: "sov", sentence: "Across 4 AI engines you appear in 12% of answers.", factIds: ["sov"] }],
|
|
88
|
+
// exit: { reason: "staff" } stops lifecycle email for this user
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The verifier fetches `https://yougrow.ai/.well-known/jwks.json` once. It caches
|
|
95
|
+
the keys for as long as their `Cache-Control` allows, and refetches early when
|
|
96
|
+
a token names a key it hasn't seen. That means YouGrow can rotate its keys
|
|
97
|
+
without any change on your side.
|
|
98
|
+
|
|
99
|
+
Numbers about the user appear only in your facts and insight sentences. YouGrow
|
|
100
|
+
never invents them.
|
|
101
|
+
|
|
102
|
+
## Webhooks
|
|
103
|
+
|
|
104
|
+
YouGrow tells your webhook endpoint about preference changes, e.g. an
|
|
105
|
+
unsubscribe from onboarding tips. Verify it with
|
|
106
|
+
`verifier.verify({ …, direction: "webhook" })`. Webhook ids (`jti` in the
|
|
107
|
+
token, `id` in the body) are unique, so you can drop duplicates.
|
|
108
|
+
|
|
109
|
+
## Without the SDK
|
|
110
|
+
|
|
111
|
+
**Events you send** are signed with your secret, over the exact request body:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
X-YouGrow-Key-Id: <key id>
|
|
115
|
+
X-YouGrow-Timestamp: <unix seconds>
|
|
116
|
+
X-YouGrow-Signature: v1=<hex HMAC-SHA256(secret, "events:<timestamp>.<raw body>")>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Requests more than five minutes out are refused.
|
|
120
|
+
|
|
121
|
+
**Requests YouGrow sends you** carry a JWT. Use any JWT library, then check:
|
|
122
|
+
|
|
123
|
+
1. `alg` is `ES256` (reject anything else, including `none`). Verify the
|
|
124
|
+
signature with the key from `<issuer>/.well-known/jwks.json` whose `kid`
|
|
125
|
+
matches. Cache that file per its `Cache-Control`.
|
|
126
|
+
2. `iss` is `https://yougrow.ai`, and `aud` is your key id.
|
|
127
|
+
3. `dir` is `context` or `webhook`, matching the endpoint that received it.
|
|
128
|
+
4. `exp` hasn't passed, allowing about 60 s of clock skew. `exp − iat` is at
|
|
129
|
+
most 300.
|
|
130
|
+
5. `body_sha256` is the base64url SHA-256 of the raw body you received.
|
|
131
|
+
|
|
132
|
+
`test/vectors.json` (included in this package) holds reference signatures and
|
|
133
|
+
tokens for checking your own implementation.
|
|
134
|
+
|
|
135
|
+
## Credentials
|
|
136
|
+
|
|
137
|
+
Create a connection in YouGrow (**Products → Connect a product**) to get a key
|
|
138
|
+
id and a secret. Store them in your server's environment or secret manager as
|
|
139
|
+
`YOUGROW_KEY_ID` and `YOUGROW_SECRET`. Use a separate connection, with its own
|
|
140
|
+
key and secret, for each environment (e.g. staging and production).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @yougrowai/node — send your product's user events to YouGrow lifecycle journeys.
|
|
3
|
+
*
|
|
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();
|
|
8
|
+
*
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
export { HEADERS, sign, type Direction } from "./signing.js";
|
|
14
|
+
export type ConsentBasis = "consent" | "soft_opt_in" | "corporate_subscriber" | "none";
|
|
15
|
+
export type TraitValue = string | number | boolean | null;
|
|
16
|
+
export interface YouGrowOptions {
|
|
17
|
+
keyId: string;
|
|
18
|
+
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. */
|
|
26
|
+
maxRetries?: number;
|
|
27
|
+
/** Custom fetch (tests, proxies). */
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
/** Called when a background flush fails. */
|
|
30
|
+
onError?: (err: Error) => void;
|
|
31
|
+
}
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
export interface TrackInput {
|
|
43
|
+
userId: string;
|
|
44
|
+
event: string;
|
|
45
|
+
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
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
export declare class YouGrowError extends Error {
|
|
60
|
+
readonly status: number;
|
|
61
|
+
readonly body?: unknown | undefined;
|
|
62
|
+
constructor(message: string, status: number, body?: unknown | undefined);
|
|
63
|
+
}
|
|
64
|
+
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;
|
|
75
|
+
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
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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";
|
|
16
|
+
const MAX_BATCH = 100;
|
|
17
|
+
export class YouGrowError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
body;
|
|
20
|
+
constructor(message, status, body) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.body = body;
|
|
24
|
+
this.name = "YouGrowError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class YouGrow {
|
|
28
|
+
keyId;
|
|
29
|
+
secret;
|
|
30
|
+
endpoint;
|
|
31
|
+
flushAt;
|
|
32
|
+
flushIntervalMs;
|
|
33
|
+
maxRetries;
|
|
34
|
+
fetchImpl;
|
|
35
|
+
onError;
|
|
36
|
+
queue = [];
|
|
37
|
+
timer = null;
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
if (!opts.keyId || !opts.secret)
|
|
40
|
+
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();
|
|
90
|
+
}
|
|
91
|
+
enqueue(msg) {
|
|
92
|
+
this.queue.push(msg);
|
|
93
|
+
if (this.queue.length >= this.flushAt) {
|
|
94
|
+
void this.flush().catch((err) => this.onError?.(err));
|
|
95
|
+
}
|
|
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?.();
|
|
102
|
+
}
|
|
103
|
+
return msg.messageId;
|
|
104
|
+
}
|
|
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 });
|
|
112
|
+
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
|
+
let res;
|
|
116
|
+
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
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (attempt >= this.maxRetries)
|
|
130
|
+
throw err;
|
|
131
|
+
await backoff(attempt);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
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);
|
|
141
|
+
}
|
|
142
|
+
await backoff(attempt, res.headers.get("retry-after"));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function iso(t) {
|
|
147
|
+
if (t === undefined)
|
|
148
|
+
return new Date().toISOString();
|
|
149
|
+
return typeof t === "string" ? t : t.toISOString();
|
|
150
|
+
}
|
|
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);
|
|
155
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
156
|
+
}
|
package/dist/jwt.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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
|
|
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
|
+
rawBody: string;
|
|
52
|
+
nowMs?: number;
|
|
53
|
+
}): JwtResult;
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
2
|
+
const MAX_LIFETIME_SEC = 300;
|
|
3
|
+
const LEEWAY_SEC = 60;
|
|
4
|
+
function decodeJson(part) {
|
|
5
|
+
try {
|
|
6
|
+
const v = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
7
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function tokenFromAuthorization(value) {
|
|
14
|
+
const m = /^Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\s*$/.exec(value ?? "");
|
|
15
|
+
return m ? m[1] : null;
|
|
16
|
+
}
|
|
17
|
+
/** The kid in a token's header, without trusting anything else in it. */
|
|
18
|
+
export function tokenKid(token) {
|
|
19
|
+
const header = decodeJson(token.split(".")[0] ?? "");
|
|
20
|
+
return header && typeof header.kid === "string" ? header.kid : null;
|
|
21
|
+
}
|
|
22
|
+
export function verifyJwt(input) {
|
|
23
|
+
if (!input.token)
|
|
24
|
+
return { ok: false, reason: "missing_token" };
|
|
25
|
+
const parts = input.token.split(".");
|
|
26
|
+
if (parts.length !== 3)
|
|
27
|
+
return { ok: false, reason: "malformed" };
|
|
28
|
+
const [h, p, s] = parts;
|
|
29
|
+
const header = decodeJson(h);
|
|
30
|
+
if (!header)
|
|
31
|
+
return { ok: false, reason: "malformed" };
|
|
32
|
+
// Pin the algorithm: never let the token choose (no "none", no HMAC).
|
|
33
|
+
if (header.alg !== "ES256")
|
|
34
|
+
return { ok: false, reason: "unsupported_alg" };
|
|
35
|
+
const jwk = input.keys.find((k) => k.kid === header.kid && k.kty === "EC" && k.crv === "P-256");
|
|
36
|
+
if (!jwk?.x || !jwk.y)
|
|
37
|
+
return { ok: false, reason: "unknown_key" };
|
|
38
|
+
const signature = Buffer.from(s, "base64url");
|
|
39
|
+
let valid = false;
|
|
40
|
+
try {
|
|
41
|
+
const key = createPublicKey({ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, format: "jwk" });
|
|
42
|
+
valid =
|
|
43
|
+
signature.length === 64 &&
|
|
44
|
+
cryptoVerify("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, signature);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
valid = false;
|
|
48
|
+
}
|
|
49
|
+
if (!valid)
|
|
50
|
+
return { ok: false, reason: "bad_signature" };
|
|
51
|
+
const c = decodeJson(p);
|
|
52
|
+
if (!c)
|
|
53
|
+
return { ok: false, reason: "malformed" };
|
|
54
|
+
if (c.iss !== input.issuer)
|
|
55
|
+
return { ok: false, reason: "wrong_issuer" };
|
|
56
|
+
if (c.aud !== input.audience)
|
|
57
|
+
return { ok: false, reason: "wrong_audience" };
|
|
58
|
+
if (c.dir !== input.direction)
|
|
59
|
+
return { ok: false, reason: "wrong_direction" };
|
|
60
|
+
if (typeof c.iat !== "number" || typeof c.exp !== "number" || typeof c.jti !== "string") {
|
|
61
|
+
return { ok: false, reason: "malformed" };
|
|
62
|
+
}
|
|
63
|
+
if (c.exp - c.iat > MAX_LIFETIME_SEC)
|
|
64
|
+
return { ok: false, reason: "malformed" };
|
|
65
|
+
const nowSec = Math.floor((input.nowMs ?? Date.now()) / 1000);
|
|
66
|
+
if (nowSec > c.exp + LEEWAY_SEC)
|
|
67
|
+
return { ok: false, reason: "expired" };
|
|
68
|
+
if (nowSec < c.iat - LEEWAY_SEC)
|
|
69
|
+
return { ok: false, reason: "not_yet_valid" };
|
|
70
|
+
const hash = createHash("sha256").update(input.rawBody, "utf8").digest("base64url");
|
|
71
|
+
if (c.body_sha256 !== hash)
|
|
72
|
+
return { ok: false, reason: "body_mismatch" };
|
|
73
|
+
return { ok: true, claims: c };
|
|
74
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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, before parsing it.
|
|
14
|
+
*
|
|
15
|
+
* const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID! });
|
|
16
|
+
* const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" });
|
|
17
|
+
* if (!v.ok) return res.status(401).end();
|
|
18
|
+
*/
|
|
19
|
+
export type { Jwk, RequestDirection, YouGrowClaims } from "./jwt.js";
|
|
20
|
+
export declare const DEFAULT_ISSUER = "https://yougrow.ai";
|
|
21
|
+
type HeaderBag = Headers | Record<string, string | string[] | undefined>;
|
|
22
|
+
export type VerifyResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
claims: YouGrowClaims;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
reason: JwtFailure | "keys_unavailable";
|
|
28
|
+
};
|
|
29
|
+
export interface VerifierOptions {
|
|
30
|
+
/** Your connection's key id — the token's audience. */
|
|
31
|
+
keyId: string;
|
|
32
|
+
/** YouGrow's origin. Defaults to https://yougrow.ai; use the dev origin for staging. */
|
|
33
|
+
issuer?: string;
|
|
34
|
+
/** Pin the key set instead of fetching it (tests, air-gapped setups). */
|
|
35
|
+
jwks?: {
|
|
36
|
+
keys: Jwk[];
|
|
37
|
+
};
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
export interface Verifier {
|
|
41
|
+
verify(input: {
|
|
42
|
+
headers: HeaderBag;
|
|
43
|
+
rawBody: string;
|
|
44
|
+
direction: RequestDirection;
|
|
45
|
+
nowMs?: number;
|
|
46
|
+
}): Promise<VerifyResult>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A verifier for one connection. It fetches YouGrow's public keys once, caches
|
|
50
|
+
* them as long as their Cache-Control allows, and refetches early (rate-limited)
|
|
51
|
+
* when a token names a key it hasn't seen — so YouGrow's key rotations need no
|
|
52
|
+
* change on your side. If a refresh fails it keeps using the keys it has.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createVerifier(opts: VerifierOptions): Verifier;
|
|
55
|
+
export interface ContextStep {
|
|
56
|
+
id: string;
|
|
57
|
+
label: string;
|
|
58
|
+
done: boolean;
|
|
59
|
+
doneAt?: string | null;
|
|
60
|
+
url?: string | null;
|
|
61
|
+
blocked?: string | null;
|
|
62
|
+
}
|
|
63
|
+
export interface ContextFact {
|
|
64
|
+
id: string;
|
|
65
|
+
label: string;
|
|
66
|
+
value: string | number | boolean;
|
|
67
|
+
unit?: string | null;
|
|
68
|
+
display?: string | null;
|
|
69
|
+
source?: string | null;
|
|
70
|
+
observedAt?: string | null;
|
|
71
|
+
}
|
|
72
|
+
export interface ContextInsight {
|
|
73
|
+
id: string;
|
|
74
|
+
/** A complete, TRUE sentence — the only place numbers about the user appear. */
|
|
75
|
+
sentence: string;
|
|
76
|
+
factIds?: string[];
|
|
77
|
+
weight?: number;
|
|
78
|
+
supportsStep?: string | null;
|
|
79
|
+
}
|
|
80
|
+
export interface ContextInput {
|
|
81
|
+
asOf?: Date | string;
|
|
82
|
+
steps?: ContextStep[];
|
|
83
|
+
nextStep?: {
|
|
84
|
+
id: string;
|
|
85
|
+
label: string;
|
|
86
|
+
url?: string | null;
|
|
87
|
+
} | null;
|
|
88
|
+
facts?: ContextFact[];
|
|
89
|
+
insights?: ContextInsight[];
|
|
90
|
+
consent?: {
|
|
91
|
+
basis: "consent" | "soft_opt_in" | "corporate_subscriber" | "none";
|
|
92
|
+
categories?: Record<string, boolean>;
|
|
93
|
+
} | null;
|
|
94
|
+
/** Don't email this user yet (e.g. data still loading). */
|
|
95
|
+
hold?: {
|
|
96
|
+
until?: string | null;
|
|
97
|
+
reason: string;
|
|
98
|
+
} | null;
|
|
99
|
+
/** Stop all lifecycle email for this user (e.g. staff, pending deletion). */
|
|
100
|
+
exit?: {
|
|
101
|
+
reason: string;
|
|
102
|
+
} | null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build a context response body. Throws on values YouGrow would reject, so
|
|
106
|
+
* mistakes show up in your logs rather than as a failed context pull.
|
|
107
|
+
*/
|
|
108
|
+
export declare function contextResponse(input: ContextInput): string;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { tokenFromAuthorization, tokenKid, verifyJwt } from "./jwt.js";
|
|
2
|
+
export const DEFAULT_ISSUER = "https://yougrow.ai";
|
|
3
|
+
const JWKS_PATH = "/.well-known/jwks.json";
|
|
4
|
+
const MIN_CACHE_MS = 60_000;
|
|
5
|
+
const MAX_CACHE_MS = 24 * 3600_000;
|
|
6
|
+
const DEFAULT_CACHE_MS = 3600_000;
|
|
7
|
+
/** An unknown kid refetches the keys at most this often. */
|
|
8
|
+
const REFETCH_COOLDOWN_MS = 60_000;
|
|
9
|
+
function header(h, name) {
|
|
10
|
+
if (typeof h.get === "function")
|
|
11
|
+
return h.get(name);
|
|
12
|
+
const bag = h;
|
|
13
|
+
const v = bag[name] ?? bag[name.toLowerCase()];
|
|
14
|
+
return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
|
|
15
|
+
}
|
|
16
|
+
function cacheMs(cacheControl) {
|
|
17
|
+
const m = /max-age=(\d+)/.exec(cacheControl ?? "");
|
|
18
|
+
const ms = m ? Number(m[1]) * 1000 : DEFAULT_CACHE_MS;
|
|
19
|
+
return Math.min(MAX_CACHE_MS, Math.max(MIN_CACHE_MS, ms));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A verifier for one connection. It fetches YouGrow's public keys once, caches
|
|
23
|
+
* them as long as their Cache-Control allows, and refetches early (rate-limited)
|
|
24
|
+
* when a token names a key it hasn't seen — so YouGrow's key rotations need no
|
|
25
|
+
* change on your side. If a refresh fails it keeps using the keys it has.
|
|
26
|
+
*/
|
|
27
|
+
export function createVerifier(opts) {
|
|
28
|
+
const issuer = (opts.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
|
|
29
|
+
if (!opts.jwks && !/^https:\/\//.test(issuer) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(issuer)) {
|
|
30
|
+
throw new Error("createVerifier: issuer must be https");
|
|
31
|
+
}
|
|
32
|
+
if (!opts.keyId)
|
|
33
|
+
throw new Error("createVerifier: keyId is required");
|
|
34
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
35
|
+
let keys = opts.jwks?.keys ?? null;
|
|
36
|
+
let expiresAt = opts.jwks ? Number.POSITIVE_INFINITY : 0;
|
|
37
|
+
let lastFetchAt = Number.NEGATIVE_INFINITY;
|
|
38
|
+
let inflight = null;
|
|
39
|
+
async function refresh(now) {
|
|
40
|
+
if (opts.jwks)
|
|
41
|
+
return;
|
|
42
|
+
inflight ??= (async () => {
|
|
43
|
+
lastFetchAt = now;
|
|
44
|
+
try {
|
|
45
|
+
const res = await doFetch(`${issuer}${JWKS_PATH}`, { signal: AbortSignal.timeout(5000), redirect: "error" });
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
return;
|
|
48
|
+
const body = (await res.json());
|
|
49
|
+
if (!Array.isArray(body.keys))
|
|
50
|
+
return;
|
|
51
|
+
keys = body.keys;
|
|
52
|
+
expiresAt = Date.now() + cacheMs(res.headers.get("cache-control"));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Keep the keys we have (if any); the next request tries again.
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
inflight = null;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
await inflight;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
async verify(input) {
|
|
65
|
+
const token = tokenFromAuthorization(header(input.headers, "authorization"));
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const sinceFetch = now - lastFetchAt;
|
|
68
|
+
if (token && (keys ? now >= expiresAt && sinceFetch >= REFETCH_COOLDOWN_MS : sinceFetch >= 5000)) {
|
|
69
|
+
await refresh(now);
|
|
70
|
+
}
|
|
71
|
+
const kid = token ? tokenKid(token) : null;
|
|
72
|
+
if (token && kid && keys && !keys.some((k) => k.kid === kid) && Date.now() - lastFetchAt >= REFETCH_COOLDOWN_MS) {
|
|
73
|
+
await refresh(now);
|
|
74
|
+
}
|
|
75
|
+
if (token && !keys)
|
|
76
|
+
return { ok: false, reason: "keys_unavailable" };
|
|
77
|
+
return verifyJwt({
|
|
78
|
+
token,
|
|
79
|
+
keys: keys ?? [],
|
|
80
|
+
issuer,
|
|
81
|
+
audience: opts.keyId,
|
|
82
|
+
direction: input.direction,
|
|
83
|
+
rawBody: input.rawBody,
|
|
84
|
+
nowMs: input.nowMs,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build a context response body. Throws on values YouGrow would reject, so
|
|
91
|
+
* mistakes show up in your logs rather than as a failed context pull.
|
|
92
|
+
*/
|
|
93
|
+
export function contextResponse(input) {
|
|
94
|
+
const steps = input.steps ?? [];
|
|
95
|
+
const facts = input.facts ?? [];
|
|
96
|
+
const insights = input.insights ?? [];
|
|
97
|
+
if (steps.length > 20)
|
|
98
|
+
throw new Error("contextResponse: at most 20 steps");
|
|
99
|
+
if (facts.length > 50)
|
|
100
|
+
throw new Error("contextResponse: at most 50 facts");
|
|
101
|
+
if (insights.length > 20)
|
|
102
|
+
throw new Error("contextResponse: at most 20 insights");
|
|
103
|
+
for (const i of insights) {
|
|
104
|
+
if (i.sentence.length > 300)
|
|
105
|
+
throw new Error(`contextResponse: insight ${i.id} is over 300 characters`);
|
|
106
|
+
}
|
|
107
|
+
const asOf = input.asOf === undefined ? new Date() : input.asOf;
|
|
108
|
+
const body = JSON.stringify({
|
|
109
|
+
asOf: typeof asOf === "string" ? asOf : asOf.toISOString(),
|
|
110
|
+
steps,
|
|
111
|
+
nextStep: input.nextStep ?? null,
|
|
112
|
+
facts,
|
|
113
|
+
insights,
|
|
114
|
+
...(input.consent ? { consent: input.consent } : {}),
|
|
115
|
+
...(input.hold ? { hold: input.hold } : {}),
|
|
116
|
+
...(input.exit ? { exit: input.exit } : {}),
|
|
117
|
+
});
|
|
118
|
+
if (Buffer.byteLength(body, "utf8") > 64 * 1024)
|
|
119
|
+
throw new Error("contextResponse: over 64 KB");
|
|
120
|
+
return body;
|
|
121
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request signing for events your product sends to YouGrow:
|
|
3
|
+
*
|
|
4
|
+
* X-YouGrow-Signature: v1=<hex HMAC-SHA256(secret, `events:${timestamp}.${rawBody}`)>
|
|
5
|
+
*
|
|
6
|
+
* Timestamps are unix seconds; requests more than five minutes off are refused.
|
|
7
|
+
* Pinned by test/vectors.json.
|
|
8
|
+
*
|
|
9
|
+
* Requests YouGrow sends YOU (context pulls, webhooks) are not signed with your
|
|
10
|
+
* secret: they carry a JWT signed with YouGrow's own key. Verify those with
|
|
11
|
+
* `createVerifier` from "@yougrowai/node/server".
|
|
12
|
+
*/
|
|
13
|
+
export type Direction = "events";
|
|
14
|
+
export declare const HEADERS: {
|
|
15
|
+
readonly keyId: "x-yougrow-key-id";
|
|
16
|
+
readonly timestamp: "x-yougrow-timestamp";
|
|
17
|
+
readonly signature: "x-yougrow-signature";
|
|
18
|
+
};
|
|
19
|
+
export declare function sign(secret: string, direction: Direction, timestampSec: number, rawBody: string): string;
|
package/dist/signing.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
export const HEADERS = {
|
|
3
|
+
keyId: "x-yougrow-key-id",
|
|
4
|
+
timestamp: "x-yougrow-timestamp",
|
|
5
|
+
signature: "x-yougrow-signature",
|
|
6
|
+
};
|
|
7
|
+
export function sign(secret, direction, timestampSec, rawBody) {
|
|
8
|
+
const mac = createHmac("sha256", secret).update(`${direction}:${timestampSec}.${rawBody}`).digest("hex");
|
|
9
|
+
return `v1=${mac}`;
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yougrowai/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send your product's user events to YouGrow lifecycle journeys, and answer its signed context and webhook requests.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "YouGrow.AI Limited",
|
|
7
|
+
"homepage": "https://github.com/ygai-jezl/vizzyblmrkt/tree/main/sdk/node#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ygai-jezl/vizzyblmrkt.git",
|
|
11
|
+
"directory": "sdk/node"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["yougrow", "lifecycle-email", "onboarding", "events", "webhooks", "jwks"],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
19
|
+
"./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" }
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist", "test/vectors.json", "README.md", "LICENSE"],
|
|
22
|
+
"engines": { "node": ">=18" },
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"publishConfig": { "access": "public" },
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
27
|
+
"prepack": "npm run build"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Signing vectors for the YouGrow product-connection protocol. 'vectors': product -> platform events, signature = 'v1=' + hex(HMAC-SHA256(secret, 'events:' + timestamp + '.' + body)), generated with openssl. 'outbound': platform -> product ES256 JWTs (context pulls, webhooks), signed once with a throwaway P-256 key whose private half was discarded; only the public JWK is kept. Both the server and the SDK test suites must accept every outbound token at nowMs and reject the listed tamperings.",
|
|
3
|
+
"vectors": [
|
|
4
|
+
{
|
|
5
|
+
"secret": "ygs_test_secret_one",
|
|
6
|
+
"direction": "events",
|
|
7
|
+
"timestamp": 1758455000,
|
|
8
|
+
"body": "{\"batch\":[{\"type\":\"identify\",\"messageId\":\"m1\",\"userId\":\"u1\",\"timestamp\":\"2026-09-21T10:00:00Z\",\"traits\":{\"email\":\"alex@acme.test\"}}]}",
|
|
9
|
+
"signature": "v1=d4bdf726960d10a135b3493f4074b3d3d3773a23ec49955d6efb720bcd2ef914"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"secret": "ygs_test_secret_two",
|
|
13
|
+
"direction": "events",
|
|
14
|
+
"timestamp": 1758455123,
|
|
15
|
+
"body": "{\"batch\":[{\"type\":\"track\",\"messageId\":\"m2\",\"userId\":\"ü-用户\",\"timestamp\":\"2026-09-21T10:00:00+01:00\",\"event\":\"user.signed_up\"}]}",
|
|
16
|
+
"signature": "v1=fadcda4cd874e1ad19b1f7059cca902b54afdfe9032c10dcfdffa75cc450b317"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"secret": "ygs_test_secret_one",
|
|
20
|
+
"direction": "events",
|
|
21
|
+
"timestamp": 1758455000,
|
|
22
|
+
"body": "",
|
|
23
|
+
"signature": "v1=aae9033d246c64b2e62a630910f59e35531cfb71d5b1a4da90a1002323979eae"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"outbound": {
|
|
27
|
+
"issuer": "https://yougrow.ai",
|
|
28
|
+
"audience": "ygk_vectorsvectorsvectors0",
|
|
29
|
+
"nowMs": 1758455060000,
|
|
30
|
+
"jwks": {
|
|
31
|
+
"keys": [
|
|
32
|
+
{
|
|
33
|
+
"kty": "EC",
|
|
34
|
+
"crv": "P-256",
|
|
35
|
+
"x": "-QAJ7DPGd56eA18mz_P75umV_83b9gcmDJeh1CRhlZc",
|
|
36
|
+
"y": "N8BGeFW7xO2eAGQKykRfbc2j-M-F-uz52LgY2zmkpoY",
|
|
37
|
+
"kid": "5xivU6qt3oe7kdhSXWNXkkRwxoB3J-N4Mn1OGMN2KH4",
|
|
38
|
+
"alg": "ES256",
|
|
39
|
+
"use": "sig"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"tokens": [
|
|
44
|
+
{
|
|
45
|
+
"direction": "context",
|
|
46
|
+
"body": "{\"userId\":\"u1\",\"purpose\":\"test\",\"requestId\":\"r1\"}",
|
|
47
|
+
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjV4aXZVNnF0M29lN2tkaFNYV05Ya2tSd3hvQjNKLU40TW4xT0dNTjJLSDQifQ.eyJpc3MiOiJodHRwczovL3lvdWdyb3cuYWkiLCJhdWQiOiJ5Z2tfdmVjdG9yc3ZlY3RvcnN2ZWN0b3JzMCIsImlhdCI6MTc1ODQ1NTAwMCwiZXhwIjoxNzU4NDU1MzAwLCJqdGkiOiJyMSIsImRpciI6ImNvbnRleHQiLCJib2R5X3NoYTI1NiI6InZpMzRxQ0FaMGhHSFNrZ2lhbzZxUGlDYW5RdWJjZFhDX1FraU40RHpfM0EifQ.bvJgcsxbVfjbdGOqwVpVUmQy4Pcchh0lErXTIRFbQYRVFi2imw_SzEzyFvVEPRVILl1mFYnL4fQNyCMkjW_gTg"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"direction": "webhook",
|
|
51
|
+
"body": "{\"id\":\"wh_1\",\"type\":\"email_preferences.updated\",\"createdAt\":\"2026-09-21T10:00:00Z\",\"data\":{\"userId\":\"u1\",\"category\":\"onboarding\",\"subscribed\":false}}",
|
|
52
|
+
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjV4aXZVNnF0M29lN2tkaFNYV05Ya2tSd3hvQjNKLU40TW4xT0dNTjJLSDQifQ.eyJpc3MiOiJodHRwczovL3lvdWdyb3cuYWkiLCJhdWQiOiJ5Z2tfdmVjdG9yc3ZlY3RvcnN2ZWN0b3JzMCIsImlhdCI6MTc1ODQ1NTAwMCwiZXhwIjoxNzU4NDU1MzAwLCJqdGkiOiJ3aF8xIiwiZGlyIjoid2ViaG9vayIsImJvZHlfc2hhMjU2IjoiNjdCLWRsN1hXQTRmRkZyU3VLSkNsZGViWjJQZUJlUEZUbTlDdE5UR1pjWSJ9.DmVqL1DHK8kzNpeHmgJ3pUIQPLBkWoIVkSze88eEe2uYaapz2igRBGA6fXVPLALLi1j1vrJgUOtC9F7AHxAuRw"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"der": {
|
|
56
|
+
"message": "kms-der-vector",
|
|
57
|
+
"der": "MEYCIQDWzM6yGiXD+ByzGupa6bgWJSjRcyPZKgs9iLoIaIVs8QIhAO4ufgQ9yvfciUelapmJ4N6eM7HWIDmAsYwn3DjRKE3X",
|
|
58
|
+
"publicJwk": {
|
|
59
|
+
"kty": "EC",
|
|
60
|
+
"crv": "P-256",
|
|
61
|
+
"x": "-QAJ7DPGd56eA18mz_P75umV_83b9gcmDJeh1CRhlZc",
|
|
62
|
+
"y": "N8BGeFW7xO2eAGQKykRfbc2j-M-F-uz52LgY2zmkpoY",
|
|
63
|
+
"kid": "5xivU6qt3oe7kdhSXWNXkkRwxoB3J-N4Mn1OGMN2KH4",
|
|
64
|
+
"alg": "ES256",
|
|
65
|
+
"use": "sig"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|