contextkit-sdk 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/dist/index.js ADDED
@@ -0,0 +1,604 @@
1
+ import { randomBytes, createHash, createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ // src/errors.ts
4
+ var ContextKitError = class extends Error {
5
+ code;
6
+ status;
7
+ body;
8
+ constructor(message, code, status = null, body = null) {
9
+ super(message);
10
+ this.name = new.target.name;
11
+ this.code = code;
12
+ this.status = status;
13
+ this.body = body;
14
+ }
15
+ };
16
+ var ValidationError = class extends ContextKitError {
17
+ messages;
18
+ constructor(messages, body) {
19
+ super(messages.join("; ") || "validation failed", "validation", 400, body);
20
+ this.messages = messages;
21
+ }
22
+ };
23
+ var TokenRevokedError = class extends ContextKitError {
24
+ constructor(message = "grant is no longer valid; the user must reconnect", body = null) {
25
+ super(message, "token_revoked", 401, body);
26
+ }
27
+ };
28
+ var ScopeError = class extends ContextKitError {
29
+ constructor(message, body) {
30
+ super(message, "scope", 403, body);
31
+ }
32
+ };
33
+ var NotFoundError = class extends ContextKitError {
34
+ constructor(message, body) {
35
+ super(message, "not_found", 404, body);
36
+ }
37
+ };
38
+ var RateLimitedError = class extends ContextKitError {
39
+ retryAfterSeconds;
40
+ constructor(message, retryAfterSeconds, body) {
41
+ super(message, "rate_limited", 429, body);
42
+ this.retryAfterSeconds = retryAfterSeconds;
43
+ }
44
+ };
45
+ var ApiError = class extends ContextKitError {
46
+ constructor(message, status, body) {
47
+ super(message, "api", status, body);
48
+ }
49
+ };
50
+ var TimeoutError = class extends ContextKitError {
51
+ constructor(url, timeoutMs) {
52
+ super(`request to ${url} timed out after ${timeoutMs}ms`, "timeout");
53
+ }
54
+ };
55
+ var NetworkError = class extends ContextKitError {
56
+ cause;
57
+ constructor(url, cause) {
58
+ super(`request to ${url} failed: ${describe(cause)}`, "network");
59
+ this.cause = cause;
60
+ }
61
+ };
62
+ var WebhookVerificationError = class extends ContextKitError {
63
+ constructor(detail) {
64
+ super(detail, "webhook_verification");
65
+ }
66
+ };
67
+ function describe(err) {
68
+ return err instanceof Error ? err.message : String(err);
69
+ }
70
+
71
+ // src/http.ts
72
+ var UnauthorizedSignal = class extends Error {
73
+ body;
74
+ constructor(body) {
75
+ super("unauthorized");
76
+ this.body = body;
77
+ }
78
+ };
79
+ async function request(opts, req) {
80
+ const url = withQuery(req.url, req.query);
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
83
+ let status;
84
+ let text;
85
+ let headers;
86
+ try {
87
+ const res = await opts.fetchImpl(url, {
88
+ method: req.method,
89
+ headers: {
90
+ "accept": "application/json",
91
+ "user-agent": opts.userAgent,
92
+ ...req.body !== void 0 ? { "content-type": "application/json" } : {},
93
+ ...req.headers ?? {}
94
+ },
95
+ ...req.body !== void 0 ? { body: JSON.stringify(req.body) } : {},
96
+ signal: controller.signal
97
+ });
98
+ status = res.status;
99
+ headers = res.headers;
100
+ text = await res.text();
101
+ } catch (err) {
102
+ if (controller.signal.aborted) throw new TimeoutError(url, opts.timeoutMs);
103
+ throw new NetworkError(url, err);
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ const data = parseJson(text);
108
+ if (status >= 200 && status < 300) return { status, data, headers };
109
+ const message = errorMessage(data, status);
110
+ switch (status) {
111
+ case 400:
112
+ throw new ValidationError(errorMessages(data), data);
113
+ case 401:
114
+ throw new UnauthorizedSignal(data);
115
+ case 403:
116
+ throw new ScopeError(message, data);
117
+ case 404:
118
+ throw new NotFoundError(message, data);
119
+ case 429:
120
+ throw new RateLimitedError(message, retryAfter(headers.get("retry-after")), data);
121
+ default:
122
+ throw new ApiError(message, status, data);
123
+ }
124
+ }
125
+ function withQuery(url, query) {
126
+ if (!query) return url;
127
+ const params = new URLSearchParams();
128
+ for (const [key, value] of Object.entries(query)) {
129
+ if (value !== void 0) params.set(key, String(value));
130
+ }
131
+ const qs = params.toString();
132
+ return qs ? `${url}?${qs}` : url;
133
+ }
134
+ function parseJson(text) {
135
+ if (!text) return null;
136
+ try {
137
+ return JSON.parse(text);
138
+ } catch {
139
+ return text;
140
+ }
141
+ }
142
+ function errorMessages(data) {
143
+ if (data && typeof data === "object" && "message" in data) {
144
+ const m = data.message;
145
+ if (Array.isArray(m)) return m.map(String);
146
+ if (typeof m === "string") return [m];
147
+ }
148
+ return [];
149
+ }
150
+ function errorMessage(data, status) {
151
+ const messages = errorMessages(data);
152
+ return messages.length ? messages.join("; ") : `HTTP ${status}`;
153
+ }
154
+ function retryAfter(header) {
155
+ if (!header) return null;
156
+ const n = Number(header);
157
+ return Number.isFinite(n) && n >= 0 ? n : null;
158
+ }
159
+ function generateCodeVerifier() {
160
+ return base64url(randomBytes(32));
161
+ }
162
+ function codeChallenge(verifier) {
163
+ return base64url(createHash("sha256").update(verifier, "utf8").digest());
164
+ }
165
+ function generateState() {
166
+ return base64url(randomBytes(32));
167
+ }
168
+ function base64url(buf) {
169
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
170
+ }
171
+
172
+ // src/types.ts
173
+ var APP_SCOPES = [
174
+ // standard — answers, not data
175
+ "location.verify.zone",
176
+ "location.place.current",
177
+ "location.place.presence",
178
+ "location.visits.read",
179
+ "location.lookup.place",
180
+ "location.rules.place",
181
+ "location.rules.zone",
182
+ "location.places.watch",
183
+ // sensitive — raw coordinates
184
+ "location.latest.read",
185
+ "location.history.read",
186
+ "location.lookup"
187
+ ];
188
+ var SENSITIVE_SCOPES = [
189
+ "location.latest.read",
190
+ "location.history.read",
191
+ "location.lookup"
192
+ ];
193
+ function isAppScope(value) {
194
+ return APP_SCOPES.includes(value);
195
+ }
196
+ var MIN_ZONE_RADIUS_M = 100;
197
+ var MAX_ZONE_RADIUS_M = 1e4;
198
+ var MIN_MAX_AGE_S = 60;
199
+ var MAX_MAX_AGE_S = 86400;
200
+ var DEFAULT_MAX_AGE_S = 900;
201
+ var MIN_DWELL_MINUTES = 5;
202
+ var MAX_DWELL_MINUTES = 720;
203
+ var MIN_EVENT_AGE_S = 30;
204
+ var MAX_EVENT_AGE_S = 3600;
205
+ var LOCATION_SOURCES = ["slc", "visit", "precise"];
206
+ var CONNECTION_EVENTS = ["places.changed"];
207
+ function isRuleEvent(event) {
208
+ return "rule_id" in event;
209
+ }
210
+ function isConnectionEvent(event) {
211
+ return "subscription_id" in event;
212
+ }
213
+
214
+ // src/user.ts
215
+ var EXPIRY_SKEW_MS = 3e4;
216
+ var UserClient = class {
217
+ constructor(http, apiBaseUrl, refreshImpl, tokens, options) {
218
+ this.http = http;
219
+ this.apiBaseUrl = apiBaseUrl;
220
+ this.refreshImpl = refreshImpl;
221
+ this.options = options;
222
+ if (!tokens.refreshToken) throw new Error("forUser: refreshToken is required");
223
+ this.tokens = { ...tokens };
224
+ }
225
+ http;
226
+ apiBaseUrl;
227
+ refreshImpl;
228
+ options;
229
+ tokens;
230
+ refreshing = null;
231
+ /** The tokens this client currently holds. */
232
+ currentTokens() {
233
+ return this.tokens;
234
+ }
235
+ answers = {
236
+ /** Is the user inside this circle right now? "unknown" is a value. */
237
+ verifyZone: async (params) => {
238
+ assertRange("radiusM", params.radiusM, MIN_ZONE_RADIUS_M, MAX_ZONE_RADIUS_M);
239
+ if (params.maxAgeS !== void 0)
240
+ assertRange("maxAgeS", params.maxAgeS, MIN_MAX_AGE_S, MAX_MAX_AGE_S);
241
+ return this.call({
242
+ method: "POST",
243
+ url: "/v1/answers/verify-zone",
244
+ body: {
245
+ lat: params.lat,
246
+ lon: params.lon,
247
+ radius_m: params.radiusM,
248
+ label: params.label,
249
+ ...params.maxAgeS !== void 0 ? { max_age_s: params.maxAgeS } : {}
250
+ }
251
+ });
252
+ },
253
+ /** Which of the places shared with this app is the user at, if any? */
254
+ currentPlace: (params = {}) => this.call({
255
+ method: "GET",
256
+ url: "/v1/answers/current-place",
257
+ query: { max_age_s: params.maxAgeS }
258
+ }),
259
+ /** The places the user chose to share with this app. Cache by `version`. */
260
+ places: () => this.call({ method: "GET", url: "/v1/answers/places" }),
261
+ /** Is the user at this shared place right now? */
262
+ presence: (placeId, params = {}) => this.call({
263
+ method: "GET",
264
+ url: `/v1/answers/places/${encodeURIComponent(placeId)}/presence`,
265
+ query: { max_age_s: params.maxAgeS }
266
+ })
267
+ };
268
+ visits = {
269
+ /** Stays at shared places, newest first. Follow `nextCursor`. */
270
+ list: (params = {}) => this.call({
271
+ method: "GET",
272
+ url: "/v1/visits",
273
+ query: { from: params.from, to: params.to, limit: params.limit, cursor: params.cursor }
274
+ }),
275
+ /** Which shared place was the user at, at this instant? */
276
+ lookupPlace: (params) => this.call({
277
+ method: "GET",
278
+ url: "/v1/lookup/place",
279
+ query: { at: params.at, tolerance_s: params.toleranceS }
280
+ })
281
+ };
282
+ /** Sensitive tier: raw coordinates. Needs the location.*.read / lookup scopes. */
283
+ locations = {
284
+ range: (params) => this.call({
285
+ method: "GET",
286
+ url: "/v1/locations/timerange",
287
+ query: {
288
+ from: params.from,
289
+ to: params.to,
290
+ device_id: params.deviceId,
291
+ source: params.source,
292
+ limit: params.limit,
293
+ cursor: params.cursor
294
+ }
295
+ }),
296
+ /** Which calendar days (in `tz`) have any points. */
297
+ days: (params) => this.call({
298
+ method: "GET",
299
+ url: "/v1/locations/days",
300
+ query: { from: params.from, to: params.to, tz: params.tz }
301
+ }),
302
+ latest: () => this.call({ method: "GET", url: "/v1/locations/latest" }),
303
+ /** The point nearest `at`, within `toleranceS`. 404 if none. */
304
+ at: (params) => this.call({
305
+ method: "GET",
306
+ url: "/v1/locations/lookup",
307
+ query: { at: params.at, tolerance_s: params.toleranceS, source: params.source }
308
+ })
309
+ };
310
+ rules = {
311
+ /** Fire a webhook when the user enters / exits / dwells at a shared place.
312
+ * The returned `secret` is shown once; keep it to verify deliveries. */
313
+ createPlace: (params) => this.call({
314
+ method: "POST",
315
+ url: "/v1/rules/place",
316
+ body: { place_id: params.placeId, ...ruleBody(params) }
317
+ }),
318
+ createZone: async (params) => {
319
+ assertRange("radiusM", params.radiusM, MIN_ZONE_RADIUS_M, MAX_ZONE_RADIUS_M);
320
+ return this.call({
321
+ method: "POST",
322
+ url: "/v1/rules/zone",
323
+ body: {
324
+ lat: params.lat,
325
+ lon: params.lon,
326
+ radius_m: params.radiusM,
327
+ label: params.label,
328
+ ...ruleBody(params)
329
+ }
330
+ });
331
+ },
332
+ /** Every rule this app holds for the user, place and zone alike. */
333
+ list: () => this.call({ method: "GET", url: "/v1/rules/place" }),
334
+ deletePlace: (ruleId) => this.call({ method: "DELETE", url: `/v1/rules/place/${encodeURIComponent(ruleId)}` }),
335
+ deleteZone: (ruleId) => this.call({ method: "DELETE", url: `/v1/rules/zone/${encodeURIComponent(ruleId)}` })
336
+ };
337
+ subscriptions = {
338
+ /** One subscription per grant; registering again replaces it and mints a
339
+ * new secret. */
340
+ register: (params) => this.call({
341
+ method: "POST",
342
+ url: "/v1/subscriptions",
343
+ body: { events: [...params.events], webhook_url: params.webhookUrl }
344
+ }),
345
+ get: () => this.call({ method: "GET", url: "/v1/subscriptions" }),
346
+ remove: (subscriptionId) => this.call({
347
+ method: "DELETE",
348
+ url: `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`
349
+ })
350
+ };
351
+ // -------------------------------------------------------------------------
352
+ async call(req) {
353
+ let accessToken = await this.accessToken();
354
+ try {
355
+ return await this.send(req, accessToken);
356
+ } catch (err) {
357
+ if (!(err instanceof UnauthorizedSignal)) throw err;
358
+ }
359
+ accessToken = (await this.refresh()).accessToken;
360
+ try {
361
+ return await this.send(req, accessToken);
362
+ } catch (err) {
363
+ if (err instanceof UnauthorizedSignal) throw new TokenRevokedError(void 0, err.body);
364
+ throw err;
365
+ }
366
+ }
367
+ async send(req, accessToken) {
368
+ const res = await request(this.http, {
369
+ ...req,
370
+ url: `${this.apiBaseUrl}${req.url}`,
371
+ headers: { authorization: `Bearer ${accessToken}` }
372
+ });
373
+ return res.data;
374
+ }
375
+ async accessToken() {
376
+ const { accessToken, accessTokenExpiresAt } = this.tokens;
377
+ if (accessToken && accessTokenExpiresAt && accessTokenExpiresAt - EXPIRY_SKEW_MS > Date.now()) {
378
+ return accessToken;
379
+ }
380
+ return (await this.refresh()).accessToken;
381
+ }
382
+ /** Single-flight: concurrent calls share one refresh, because the second
383
+ * use of a rotated refresh token is treated as replay and kills the grant. */
384
+ refresh() {
385
+ if (!this.refreshing) {
386
+ this.refreshing = this.refreshImpl(this.tokens.refreshToken).then(async (next) => {
387
+ this.tokens = {
388
+ refreshToken: next.refreshToken,
389
+ accessToken: next.accessToken,
390
+ accessTokenExpiresAt: next.accessTokenExpiresAt
391
+ };
392
+ if (this.options.onTokens) await this.options.onTokens(next);
393
+ return next;
394
+ }).finally(() => {
395
+ this.refreshing = null;
396
+ });
397
+ }
398
+ return this.refreshing;
399
+ }
400
+ };
401
+ function ruleBody(params) {
402
+ return {
403
+ type: params.type,
404
+ webhook_url: params.webhookUrl,
405
+ ...params.dwellMinutes !== void 0 ? { dwell_minutes: params.dwellMinutes } : {},
406
+ ...params.maxEventAgeS !== void 0 ? { max_event_age_s: params.maxEventAgeS } : {}
407
+ };
408
+ }
409
+ function assertRange(name, value, min, max) {
410
+ if (!Number.isInteger(value) || value < min || value > max) {
411
+ throw new RangeError(`${name} must be an integer between ${min} and ${max}, got ${value}`);
412
+ }
413
+ }
414
+
415
+ // src/client.ts
416
+ var DEFAULT_API_BASE_URL = "https://api.contextkit.com";
417
+ var DEFAULT_AUTHORIZE_BASE_URL = "https://contextkit.com";
418
+ var DEFAULT_TIMEOUT_MS = 1e4;
419
+ var VERSION = "0.1.0" ;
420
+ var ContextKit = class {
421
+ clientId;
422
+ apiBaseUrl;
423
+ authorizeBaseUrl;
424
+ clientSecret;
425
+ http;
426
+ constructor(options) {
427
+ if (!options.clientId) throw new Error("ContextKit: clientId is required");
428
+ if (!options.clientSecret) throw new Error("ContextKit: clientSecret is required");
429
+ this.clientId = options.clientId;
430
+ this.clientSecret = options.clientSecret;
431
+ this.apiBaseUrl = stripSlash(options.apiBaseUrl ?? DEFAULT_API_BASE_URL);
432
+ this.authorizeBaseUrl = stripSlash(options.authorizeBaseUrl ?? DEFAULT_AUTHORIZE_BASE_URL);
433
+ const fetchImpl = options.fetch ?? globalThis.fetch;
434
+ if (!fetchImpl) throw new Error("ContextKit: no fetch available; pass options.fetch");
435
+ this.http = {
436
+ fetchImpl,
437
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
438
+ userAgent: `contextkit-sdk/${VERSION} node/${process.versions.node}`
439
+ };
440
+ }
441
+ /** The URL to send the user to. */
442
+ authorizeUrl(params) {
443
+ if (params.scopes.length === 0) throw new Error("authorizeUrl: at least one scope is required");
444
+ for (const scope of params.scopes) {
445
+ if (!isAppScope(scope)) throw new Error(`authorizeUrl: unknown scope "${String(scope)}"`);
446
+ }
447
+ if (!params.state) throw new Error("authorizeUrl: state is required");
448
+ if (params.codeVerifier.length < 43 || params.codeVerifier.length > 128) {
449
+ throw new Error(
450
+ "authorizeUrl: codeVerifier must be 43\u2013128 characters (see generateCodeVerifier)"
451
+ );
452
+ }
453
+ const url = new URL(`${this.authorizeBaseUrl}/authorize`);
454
+ url.searchParams.set("client_id", this.clientId);
455
+ url.searchParams.set("redirect_uri", params.redirectUri);
456
+ url.searchParams.set("scope", params.scopes.join(" "));
457
+ url.searchParams.set("code_challenge", codeChallenge(params.codeVerifier));
458
+ url.searchParams.set("code_challenge_method", "S256");
459
+ url.searchParams.set("state", params.state);
460
+ if (params.externalUserId) url.searchParams.set("external_user_id", params.externalUserId);
461
+ return url.toString();
462
+ }
463
+ /** Callback step: trade the code for tokens. Persist the result per user. */
464
+ async exchangeCode(params) {
465
+ return this.token({
466
+ grantType: "authorization_code",
467
+ code: params.code,
468
+ codeVerifier: params.codeVerifier,
469
+ redirectUri: params.redirectUri
470
+ });
471
+ }
472
+ /**
473
+ * Refresh explicitly. `forUser` does this for you; call it directly only
474
+ * for a scheduled refresh. Refresh tokens rotate — persist the new set.
475
+ */
476
+ async refresh(refreshToken) {
477
+ return this.token({ grantType: "refresh_token", refreshToken });
478
+ }
479
+ /** A handle that makes calls as one connected user. */
480
+ forUser(tokens, options = {}) {
481
+ return new UserClient(this.http, this.apiBaseUrl, (rt) => this.refresh(rt), tokens, options);
482
+ }
483
+ async token(body) {
484
+ let res;
485
+ try {
486
+ res = await request(this.http, {
487
+ method: "POST",
488
+ url: `${this.apiBaseUrl}/v1/oauth/token`,
489
+ body: { clientId: this.clientId, clientSecret: this.clientSecret, ...body }
490
+ });
491
+ } catch (err) {
492
+ if (err instanceof UnauthorizedSignal) throw new TokenRevokedError(void 0, err.body);
493
+ if (err instanceof ValidationError && body.grantType === "refresh_token") {
494
+ throw new TokenRevokedError(err.message, err.body);
495
+ }
496
+ throw err;
497
+ }
498
+ return toTokenSet(res.data);
499
+ }
500
+ };
501
+ function toTokenSet(raw, now = Date.now()) {
502
+ return {
503
+ accessToken: raw.access_token,
504
+ accessTokenExpiresAt: now + raw.expires_in * 1e3,
505
+ refreshToken: raw.refresh_token,
506
+ refreshTokenExpiresAt: raw.refresh_token_expires_at ?? null,
507
+ scopes: raw.scope.split(" ").filter(isAppScope),
508
+ sub: raw.sub ?? null
509
+ };
510
+ }
511
+ function stripSlash(url) {
512
+ return url.replace(/\/+$/, "");
513
+ }
514
+ var RECOMMENDED_TOLERANCE_S = 60;
515
+ var MAX_TOLERANCE_S = 300;
516
+ var SIGNATURE_HEADER = "x-contextkit-signature";
517
+ async function verifyWebhook(params) {
518
+ const header = Array.isArray(params.signature) ? params.signature[0] : params.signature;
519
+ if (!header) throw new WebhookVerificationError(`missing ${SIGNATURE_HEADER} header`);
520
+ const parsed = parseSignatureHeader(header);
521
+ if (!parsed) throw new WebhookVerificationError("malformed signature header");
522
+ const toleranceS = Math.min(params.toleranceS ?? RECOMMENDED_TOLERANCE_S, MAX_TOLERANCE_S);
523
+ const nowS = Math.floor((params.now ?? Date.now()) / 1e3);
524
+ const skew = nowS - parsed.timestamp;
525
+ if (Math.abs(skew) > toleranceS) {
526
+ throw new WebhookVerificationError(`timestamp ${skew}s outside ${toleranceS}s tolerance`);
527
+ }
528
+ const body = Buffer.isBuffer(params.rawBody) ? params.rawBody : Buffer.from(params.rawBody, "utf8");
529
+ const expected = signPayload(body, parsed.timestamp, params.secret);
530
+ if (!parsed.signatures.some((candidate) => constantTimeEquals(candidate, expected))) {
531
+ throw new WebhookVerificationError("no v1 signature matched \u2014 wrong secret or tampered body");
532
+ }
533
+ const event = parseEvent(body);
534
+ if (params.replayGuard) {
535
+ const occurredAtMs = Date.parse(event.occurred_at);
536
+ if (await params.replayGuard.seen(event.event_id, occurredAtMs)) {
537
+ throw new WebhookVerificationError(`event ${event.event_id} already processed`);
538
+ }
539
+ }
540
+ return event;
541
+ }
542
+ function signWebhook(body, secret, atMs = Date.now()) {
543
+ const t = Math.floor(atMs / 1e3);
544
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8");
545
+ return `t=${t},v1=${signPayload(buf, t, secret)}`;
546
+ }
547
+ function parseSignatureHeader(header) {
548
+ let timestamp = null;
549
+ const signatures = [];
550
+ for (const part of header.split(",")) {
551
+ const eq = part.indexOf("=");
552
+ if (eq === -1) return null;
553
+ const key = part.slice(0, eq).trim();
554
+ const value = part.slice(eq + 1).trim();
555
+ if (key === "t") {
556
+ const n = Number(value);
557
+ if (!Number.isFinite(n)) return null;
558
+ timestamp = n;
559
+ } else if (key === "v1") {
560
+ signatures.push(value.toLowerCase());
561
+ }
562
+ }
563
+ if (timestamp === null || signatures.length === 0) return null;
564
+ return { timestamp, signatures };
565
+ }
566
+ var InMemoryReplayGuard = class {
567
+ constructor(ttlMs = 10 * 6e4) {
568
+ this.ttlMs = ttlMs;
569
+ }
570
+ ttlMs;
571
+ ids = /* @__PURE__ */ new Map();
572
+ seen(eventId, _occurredAtMs) {
573
+ const now = Date.now();
574
+ for (const [id, at] of this.ids) if (now - at > this.ttlMs) this.ids.delete(id);
575
+ if (this.ids.has(eventId)) return true;
576
+ this.ids.set(eventId, now);
577
+ return false;
578
+ }
579
+ };
580
+ function signPayload(body, timestamp, secret) {
581
+ return createHmac("sha256", secret).update(Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), body])).digest("hex");
582
+ }
583
+ function parseEvent(body) {
584
+ let data;
585
+ try {
586
+ data = JSON.parse(body.toString("utf8"));
587
+ } catch {
588
+ throw new WebhookVerificationError("body is not JSON");
589
+ }
590
+ if (!data || typeof data !== "object" || typeof data.event_id !== "string" || typeof data.occurred_at !== "string" || typeof data.type !== "string") {
591
+ throw new WebhookVerificationError("body is not a ContextKit event");
592
+ }
593
+ return data;
594
+ }
595
+ function constantTimeEquals(a, b) {
596
+ const left = Buffer.from(a, "utf8");
597
+ const right = Buffer.from(b, "utf8");
598
+ if (left.length !== right.length) return false;
599
+ return timingSafeEqual(left, right);
600
+ }
601
+
602
+ export { APP_SCOPES, ApiError, CONNECTION_EVENTS, ContextKit, ContextKitError, DEFAULT_API_BASE_URL, DEFAULT_AUTHORIZE_BASE_URL, DEFAULT_MAX_AGE_S, DEFAULT_TIMEOUT_MS, InMemoryReplayGuard, LOCATION_SOURCES, MAX_DWELL_MINUTES, MAX_EVENT_AGE_S, MAX_MAX_AGE_S, MAX_TOLERANCE_S, MAX_ZONE_RADIUS_M, MIN_DWELL_MINUTES, MIN_EVENT_AGE_S, MIN_MAX_AGE_S, MIN_ZONE_RADIUS_M, NetworkError, NotFoundError, RECOMMENDED_TOLERANCE_S, RateLimitedError, SENSITIVE_SCOPES, SIGNATURE_HEADER, ScopeError, TimeoutError, TokenRevokedError, UserClient, ValidationError, WebhookVerificationError, codeChallenge, generateCodeVerifier, generateState, isAppScope, isConnectionEvent, isRuleEvent, parseSignatureHeader, signWebhook, verifyWebhook };
603
+ //# sourceMappingURL=index.js.map
604
+ //# sourceMappingURL=index.js.map