@traceten/sdk-node 1.0.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,141 @@
1
+ /**
2
+ * Verification for Traceten's client-facing OUTBOUND webhooks.
3
+ *
4
+ * Traceten signs every webhook delivery so your endpoint can prove the request
5
+ * really came from us and was not tampered with or replayed. {@link verifyWebhook}
6
+ * does the three things a hand-rolled verifier most often gets wrong:
7
+ *
8
+ * 1. Recomputes `hmac-sha256(secret, `${timestamp}.${rawBody}`)` over the RAW
9
+ * body bytes — never a re-serialized object.
10
+ * 2. Compares in **constant time** (`crypto.timingSafeEqual`), length-guarded
11
+ * so a mismatched-length signature can never throw.
12
+ * 3. Rejects deliveries whose timestamp is outside the replay window.
13
+ *
14
+ * On success it returns the parsed, typed {@link WebhookEvent}. On ANY failure
15
+ * it throws {@link WebhookVerificationError} — and it never parses the body
16
+ * before the signature checks out.
17
+ *
18
+ * The constants below are vendored (not imported from `@traceten/shared`) so
19
+ * this SDK stays a self-contained, publishable package. They mirror the frozen
20
+ * signing spec exactly; the known-answer vector in the test suite proves this
21
+ * implementation is byte-identical to Traceten's signer.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { verifyWebhook, WebhookVerificationError } from "@traceten/sdk-node";
26
+ *
27
+ * // Express — note `express.raw`, so `req.body` is the exact bytes we signed.
28
+ * app.post("/traceten-webhooks", express.raw({ type: "application/json" }), (req, res) => {
29
+ * try {
30
+ * const event = verifyWebhook(
31
+ * req.body, // Buffer or string of the RAW body
32
+ * req.header("X-Traceten-Signature") ?? "",
33
+ * req.header("X-Traceten-Signature-Timestamp") ?? "",
34
+ * process.env.TRACETEN_WEBHOOK_SECRET!,
35
+ * );
36
+ * if (event.type === "ai_session.classified") {
37
+ * // event.data is fully typed here
38
+ * }
39
+ * res.status(200).end(); // ack fast; do heavy work on a queue
40
+ * } catch (err) {
41
+ * if (err instanceof WebhookVerificationError) res.status(401).end();
42
+ * else res.status(400).end();
43
+ * }
44
+ * });
45
+ * ```
46
+ */
47
+ import { createHmac, timingSafeEqual } from "node:crypto";
48
+ /** Header carrying the hex HMAC-SHA256 signature. */
49
+ export const WEBHOOK_SIGNATURE_HEADER = "X-Traceten-Signature";
50
+ /** Header carrying the unix-SECONDS timestamp that was signed over. */
51
+ export const WEBHOOK_TIMESTAMP_HEADER = "X-Traceten-Signature-Timestamp";
52
+ /** Default replay tolerance for verification, in seconds (5 minutes). */
53
+ export const WEBHOOK_REPLAY_TOLERANCE_SECONDS = 300;
54
+ /**
55
+ * The exhaustive allow-list of outbound webhook event types (frozen contract).
56
+ * Adding a type is additive-only.
57
+ */
58
+ export const WEBHOOK_EVENT_TYPES = [
59
+ "ai_session.classified",
60
+ "conversion.attributed",
61
+ "webhook.ping",
62
+ ];
63
+ /** Attribution model names carried in `conversion.attributed`. */
64
+ export const ATTRIBUTION_MODELS = ["first_touch", "last_touch", "linear", "time_decay"];
65
+ /**
66
+ * Thrown by {@link verifyWebhook} when a delivery cannot be trusted: a bad or
67
+ * missing signature, a stale/invalid timestamp, or a body that is not the JSON
68
+ * envelope we signed. Never thrown for a valid, fresh delivery.
69
+ */
70
+ export class WebhookVerificationError extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = "WebhookVerificationError";
74
+ }
75
+ }
76
+ /**
77
+ * Verify an outbound Traceten webhook and return its typed envelope.
78
+ *
79
+ * @param rawBody The EXACT raw request body — a `Buffer` (preferred)
80
+ * or the raw UTF-8 string. Never a re-serialized object;
81
+ * re-serializing changes the bytes and the signature
82
+ * will not match.
83
+ * @param signatureHeader The `X-Traceten-Signature` header value.
84
+ * @param timestampHeader The `X-Traceten-Signature-Timestamp` header value
85
+ * (unix seconds).
86
+ * @param secret The endpoint's signing secret (raw UTF-8 string).
87
+ * @param options {@link VerifyWebhookOptions}.
88
+ * @returns The verified, parsed {@link WebhookEvent}.
89
+ * @throws {WebhookVerificationError} on any verification or parse failure.
90
+ * @throws {TypeError} if `secret` is empty or blank — a misconfiguration (e.g.
91
+ * an unset `TRACETEN_WEBHOOK_SECRET`), surfaced loudly rather than silently
92
+ * weakening verification. This is a config bug, not a bad delivery, so it is
93
+ * deliberately NOT a {@link WebhookVerificationError}.
94
+ */
95
+ export function verifyWebhook(rawBody, signatureHeader, timestampHeader, secret, options = {}) {
96
+ const { toleranceSeconds = WEBHOOK_REPLAY_TOLERANCE_SECONDS, nowSeconds } = options;
97
+ if (typeof secret !== "string" || secret.trim().length === 0) {
98
+ throw new TypeError("traceten: a non-empty webhook signing secret is required");
99
+ }
100
+ if (typeof signatureHeader !== "string" || signatureHeader.length === 0) {
101
+ throw new WebhookVerificationError("missing X-Traceten-Signature header");
102
+ }
103
+ if (typeof timestampHeader !== "string" || timestampHeader.length === 0) {
104
+ throw new WebhookVerificationError("missing X-Traceten-Signature-Timestamp header");
105
+ }
106
+ // Replay window. A non-numeric timestamp is always a hard reject.
107
+ const ts = Number(timestampHeader);
108
+ if (!Number.isFinite(ts)) {
109
+ throw new WebhookVerificationError("invalid X-Traceten-Signature-Timestamp header");
110
+ }
111
+ const now = nowSeconds ?? Date.now() / 1000;
112
+ if (Math.abs(now - ts) > toleranceSeconds) {
113
+ throw new WebhookVerificationError("timestamp outside the replay tolerance window");
114
+ }
115
+ // Recompute the signature over the RAW body bytes.
116
+ const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
117
+ const hmac = createHmac("sha256", secret);
118
+ hmac.update(`${timestampHeader}.`, "utf8");
119
+ hmac.update(body);
120
+ const expected = hmac.digest("hex");
121
+ // Constant-time compare. timingSafeEqual throws on length mismatch, so guard
122
+ // first — a length mismatch is a definite reject, not an exception.
123
+ const expectedBuf = Buffer.from(expected, "utf8");
124
+ const providedBuf = Buffer.from(signatureHeader, "utf8");
125
+ if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {
126
+ throw new WebhookVerificationError("signature mismatch");
127
+ }
128
+ // Only now — after the signature is proven — do we parse the body.
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(body.toString("utf8"));
132
+ }
133
+ catch {
134
+ throw new WebhookVerificationError("verified body is not valid JSON");
135
+ }
136
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
137
+ throw new WebhookVerificationError("verified body is not a webhook envelope object");
138
+ }
139
+ return parsed;
140
+ }
141
+ //# sourceMappingURL=webhook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@traceten/sdk-node",
3
+ "version": "1.0.0",
4
+ "description": "Traceten server-side SDK for Node.js — send AI-traffic pageviews and conversions from your backend.",
5
+ "keywords": [
6
+ "traceten",
7
+ "traceten.com",
8
+ "analytics",
9
+ "attribution",
10
+ "ai-traffic",
11
+ "ai-analytics",
12
+ "web-analytics",
13
+ "product-analytics",
14
+ "revenue-attribution",
15
+ "conversion-tracking",
16
+ "funnels",
17
+ "ai-visibility",
18
+ "chatgpt",
19
+ "perplexity",
20
+ "llm-traffic",
21
+ "generative-engine-optimization",
22
+ "aeo",
23
+ "nodejs",
24
+ "typescript"
25
+ ],
26
+ "license": "MIT",
27
+ "homepage": "https://docs.traceten.com/sdks/node",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/traceten/sdk-node.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/traceten/sdk-node/issues"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "sideEffects": false,
39
+ "type": "module",
40
+ "main": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "import": "./dist/index.js",
46
+ "default": "./dist/index.js"
47
+ }
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "LICENSE",
52
+ "CHANGELOG.md"
53
+ ],
54
+ "engines": {
55
+ "node": ">=18"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc --project tsconfig.json",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "typecheck": "tsc --noEmit --project tsconfig.json",
62
+ "lint": "echo 'no linter configured yet'"
63
+ },
64
+ "dependencies": {
65
+ "undici": "^6.21.0"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "^20.0.0",
69
+ "typescript": "^5.7.0",
70
+ "vitest": "^2.0.0"
71
+ }
72
+ }