@calmlens/sdk 0.11.0 → 0.12.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 +59 -15
- package/cjs/CalmLensConnection-CYtBP-vu.js +516 -0
- package/cjs/CalmLensError-CKwMKV0S.js +362 -0
- package/cjs/CalmLensUtils-Dg3s4loH.js +367 -0
- package/cjs/CalmLensWebhooks-BTPxKvcD.js +201 -0
- package/cjs/{CalmLensTypes-BZ-nxhr_.d.ts → WebhookPayload-Cgb32aSH.d.ts} +5424 -3850
- package/cjs/app.d.ts +43 -0
- package/cjs/app.js +91 -0
- package/cjs/index-BgmWj3KL.d.ts +350 -0
- package/cjs/index.d.ts +3 -382
- package/cjs/index.js +5 -816
- package/cjs/server.d.ts +202 -0
- package/cjs/server.js +371 -0
- package/cjs/src-Dsc8Pn6v.js +45 -0
- package/cjs/testing.d.ts +54 -18
- package/cjs/testing.js +190 -62
- package/esm/CalmLensConnection-wLNy0OIP.js +475 -0
- package/esm/CalmLensError-CihGG26K.js +321 -0
- package/esm/CalmLensUtils-Cx32j2zt.js +260 -0
- package/esm/CalmLensWebhooks-DShmAal4.js +148 -0
- package/esm/{CalmLensTypes-BZ-nxhr_.d.ts → WebhookPayload-Cgb32aSH.d.ts} +5424 -3850
- package/esm/app.d.ts +43 -0
- package/esm/app.js +87 -0
- package/esm/index-BgmWj3KL.d.ts +350 -0
- package/esm/index.d.ts +3 -382
- package/esm/index.js +3 -807
- package/esm/server.d.ts +202 -0
- package/esm/server.js +361 -0
- package/esm/src-CJhrILvM.js +22 -0
- package/esm/testing.d.ts +54 -18
- package/esm/testing.js +140 -12
- package/package.json +23 -1
- package/cjs/CalmLensWebhooks-CH2PaPs0.js +0 -784
- package/esm/CalmLensWebhooks-3Otbubyf.js +0 -593
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { i as invalidArgument, n as CalmLensError, r as SDK_ERROR_CODES } from "./CalmLensError-CihGG26K.js";
|
|
2
|
+
import { n as encodePathSegment } from "./CalmLensUtils-Cx32j2zt.js";
|
|
3
|
+
//#region ../shared/src/JsonUtils.ts
|
|
4
|
+
const stringifyJsonCanonical = (value) => JSON.stringify(value, (_key, nestedValue) => {
|
|
5
|
+
if (!nestedValue || typeof nestedValue !== "object" || Array.isArray(nestedValue)) return nestedValue;
|
|
6
|
+
return Object.fromEntries(Object.keys(nestedValue).sort().map((key) => [key, nestedValue[key]]));
|
|
7
|
+
});
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region ../shared/src/CryptoUtils.ts
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
/** Lowercase hex of `bytes`, two characters per byte. */
|
|
12
|
+
const bytesToHex = (bytes) => Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
13
|
+
/** SHA-256 of the UTF-8 encoding of `value`, as lowercase hex. */
|
|
14
|
+
const sha256Hex = async (value) => bytesToHex(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
15
|
+
/** HMAC-SHA256 of `message` keyed with `secret` (both UTF-8 encoded), as lowercase hex. */
|
|
16
|
+
const hmacSha256Hex = async (secret, message) => {
|
|
17
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
18
|
+
name: "HMAC",
|
|
19
|
+
hash: "SHA-256"
|
|
20
|
+
}, false, ["sign"]);
|
|
21
|
+
return bytesToHex(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Compares two strings without leaking, through timing, where they first differ. Strings of
|
|
25
|
+
* different lengths are unequal immediately (the length itself is not secret here).
|
|
26
|
+
*/
|
|
27
|
+
const timingSafeEqual = (a, b) => {
|
|
28
|
+
if (a.length !== b.length) return false;
|
|
29
|
+
let difference = 0;
|
|
30
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
31
|
+
return difference === 0;
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region ../shared/src/UploadToken.ts
|
|
35
|
+
const UPLOAD_TOKEN_PREFIX = "clut_";
|
|
36
|
+
const UPLOAD_TOKEN_MAX_TTL_SECONDS = 3600;
|
|
37
|
+
const SIGNING_CONTEXT = "calmlens-upload-token:v1:";
|
|
38
|
+
const toBase64Url = (value) => btoa(String.fromCharCode(...new TextEncoder().encode(value))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
39
|
+
/**
|
|
40
|
+
* The part of an API key a token names so the backend can find the key's hash. The same rule as
|
|
41
|
+
* the stored `keyPrefix`: the fixed `clsk_<region>_` part plus four random characters.
|
|
42
|
+
*/
|
|
43
|
+
const uploadTokenKeyPrefix = (apiKey) => apiKey.substring(0, apiKey.startsWith("clsk_") ? 13 : 8);
|
|
44
|
+
/** The signing secret: the SHA-256 of the API key, which the backend stores instead of the key. */
|
|
45
|
+
const signingSecret = (apiKey) => sha256Hex(apiKey);
|
|
46
|
+
/** Signs `claims` with `apiKey` and returns the token. */
|
|
47
|
+
const signUploadToken = async (claims, apiKey) => {
|
|
48
|
+
const body = toBase64Url(JSON.stringify(claims));
|
|
49
|
+
const signature = await hmacSha256Hex(await signingSecret(apiKey), SIGNING_CONTEXT + body);
|
|
50
|
+
return `${UPLOAD_TOKEN_PREFIX}${body}.${signature}`;
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/client/CalmLensUploadTokens.ts
|
|
54
|
+
/**
|
|
55
|
+
* Signs a single-use upload token with `apiKey`, locally. `CalmLensServerClient` and
|
|
56
|
+
* `MockCalmLensServerClient` share it so both check options the same way.
|
|
57
|
+
*/
|
|
58
|
+
const signUploadTokenWithApiKey = async (apiKey, options, { now = Date.now(), nonce = crypto.randomUUID() } = {}) => {
|
|
59
|
+
if (!options || typeof options !== "object") throw invalidArgument("Options are required");
|
|
60
|
+
const { uploadPresetId, externalId, expiresInSeconds = 600 } = options;
|
|
61
|
+
encodePathSegment(uploadPresetId, "Upload preset ID");
|
|
62
|
+
if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 3600) throw invalidArgument(`expiresInSeconds must be a whole number from 1 to ${UPLOAD_TOKEN_MAX_TTL_SECONDS}`);
|
|
63
|
+
if (externalId !== void 0 && (typeof externalId !== "string" || !externalId)) throw invalidArgument("externalId must be a non-empty string");
|
|
64
|
+
const expiresAt = now + expiresInSeconds * 1e3;
|
|
65
|
+
return {
|
|
66
|
+
token: await signUploadToken({
|
|
67
|
+
v: 1,
|
|
68
|
+
u: uploadPresetId,
|
|
69
|
+
k: uploadTokenKeyPrefix(apiKey),
|
|
70
|
+
e: expiresAt,
|
|
71
|
+
n: nonce,
|
|
72
|
+
...externalId === void 0 ? {} : { x: externalId }
|
|
73
|
+
}, apiKey),
|
|
74
|
+
expiresAt
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/client/CalmLensWebhooks.ts
|
|
79
|
+
const WEBHOOK_DELIVERY_HEADER = "x-calmlens-delivery";
|
|
80
|
+
const WEBHOOK_TIMESTAMP_HEADER = "x-calmlens-timestamp";
|
|
81
|
+
const WEBHOOK_SIGNATURE_HEADER = "x-calmlens-signature";
|
|
82
|
+
/** Deliveries whose timestamp is further than this from the receiver's clock are rejected. */
|
|
83
|
+
const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
84
|
+
const readHeader = (headers, name) => {
|
|
85
|
+
if (!headers) return void 0;
|
|
86
|
+
if (typeof headers.get === "function") return headers.get(name) ?? void 0;
|
|
87
|
+
const record = headers;
|
|
88
|
+
const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === name);
|
|
89
|
+
const value = key === void 0 ? void 0 : record[key];
|
|
90
|
+
return Array.isArray(value) ? value.join(",") : value ?? void 0;
|
|
91
|
+
};
|
|
92
|
+
/** The raw body as text. Parsed objects are refused: re-serializing JSON changes the signed bytes. */
|
|
93
|
+
const payloadToText = (payload) => {
|
|
94
|
+
if (typeof payload === "string") return payload;
|
|
95
|
+
if (payload instanceof ArrayBuffer || ArrayBuffer.isView(payload)) {
|
|
96
|
+
const view = ArrayBuffer.isView(payload) ? new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) : new Uint8Array(payload);
|
|
97
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(view);
|
|
98
|
+
}
|
|
99
|
+
throw invalidArgument("Webhook payload must be the raw request body as a string, Uint8Array/Buffer or ArrayBuffer, not parsed JSON");
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Verifies a webhook delivery: `x-calmlens-signature` must contain a `v1=` HMAC-SHA256 of
|
|
103
|
+
* `"<x-calmlens-timestamp>.<raw body>"`, and the timestamp must be within `toleranceSeconds`
|
|
104
|
+
* (default 300) of now. Returns `false` for anything missing, malformed or stale, and throws
|
|
105
|
+
* `sdk/invalid-argument` when `payload` is not a raw body (for example, parsed JSON).
|
|
106
|
+
*/
|
|
107
|
+
async function verifyWebhookSignature(options) {
|
|
108
|
+
const { payload, secret, toleranceSeconds = 300, now, headers } = options;
|
|
109
|
+
const body = payloadToText(payload);
|
|
110
|
+
const signature = options.signature ?? readHeader(headers, "x-calmlens-signature");
|
|
111
|
+
const timestamp = options.timestamp ?? readHeader(headers, "x-calmlens-timestamp");
|
|
112
|
+
if (!secret || !signature || timestamp === null || timestamp === void 0) return false;
|
|
113
|
+
const timestampText = String(timestamp).trim();
|
|
114
|
+
if (!/^\d+$/.test(timestampText)) return false;
|
|
115
|
+
const nowMs = now === void 0 ? Date.now() : now instanceof Date ? now.getTime() : now;
|
|
116
|
+
if (Math.abs(Math.floor(nowMs / 1e3) - Number(timestampText)) > toleranceSeconds) return false;
|
|
117
|
+
const candidates = signature.split(",").map((part) => part.trim()).filter((part) => part.startsWith("v1=")).map((part) => part.slice(3).toLowerCase());
|
|
118
|
+
if (candidates.length === 0) return false;
|
|
119
|
+
const expected = await hmacSha256Hex(secret, `${timestampText}.${body}`);
|
|
120
|
+
let matched = false;
|
|
121
|
+
for (const candidate of candidates) if (timingSafeEqual(expected, candidate)) matched = true;
|
|
122
|
+
return matched;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Verifies a webhook delivery and parses its body. Throws `sdk/invalid-webhook-signature`
|
|
126
|
+
* when the signature or timestamp does not verify, and `sdk/invalid-webhook-payload` when a
|
|
127
|
+
* verified body is not a webhook event. Narrow the result on `type` for typed `data.facts`.
|
|
128
|
+
*
|
|
129
|
+
* Event types and fact keys added to CalmLens after this SDK version are returned as they are,
|
|
130
|
+
* so keep a default branch when you switch on `type`.
|
|
131
|
+
*/
|
|
132
|
+
async function parseWebhookEvent(options) {
|
|
133
|
+
if (!await verifyWebhookSignature(options)) throw new CalmLensError(SDK_ERROR_CODES.INVALID_WEBHOOK_SIGNATURE, { message: "Webhook signature or timestamp did not verify" });
|
|
134
|
+
let event;
|
|
135
|
+
try {
|
|
136
|
+
event = JSON.parse(payloadToText(options.payload));
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
throw new CalmLensError(SDK_ERROR_CODES.INVALID_WEBHOOK_PAYLOAD, {
|
|
139
|
+
message: "Webhook body is not JSON",
|
|
140
|
+
cause
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const candidate = event;
|
|
144
|
+
if (!candidate || typeof candidate !== "object" || typeof candidate.id !== "string" || typeof candidate.type !== "string" || typeof candidate.projectId !== "string" || !candidate.data || typeof candidate.data !== "object") throw new CalmLensError(SDK_ERROR_CODES.INVALID_WEBHOOK_PAYLOAD, { message: "Webhook body is not a CalmLens event" });
|
|
145
|
+
return event;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
export { parseWebhookEvent as a, hmacSha256Hex as c, WEBHOOK_TIMESTAMP_HEADER as i, stringifyJsonCanonical as l, WEBHOOK_SIGNATURE_HEADER as n, verifyWebhookSignature as o, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS as r, signUploadTokenWithApiKey as s, WEBHOOK_DELIVERY_HEADER as t };
|