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.
@@ -0,0 +1,524 @@
1
+ type FetchLike = (input: string, init: {
2
+ method: string;
3
+ headers: Record<string, string>;
4
+ body?: string;
5
+ signal: AbortSignal;
6
+ }) => Promise<{
7
+ status: number;
8
+ headers: {
9
+ get(name: string): string | null;
10
+ };
11
+ text(): Promise<string>;
12
+ }>;
13
+ interface HttpOptions {
14
+ fetchImpl: FetchLike;
15
+ timeoutMs: number;
16
+ userAgent: string;
17
+ }
18
+
19
+ /**
20
+ * Wire types. These mirror the ContextKit API field-for-field: request
21
+ * parameters are snake_case on the wire (the SDK accepts camelCase and
22
+ * translates), response bodies are returned as the API sends them.
23
+ */
24
+ declare const APP_SCOPES: readonly ["location.verify.zone", "location.place.current", "location.place.presence", "location.visits.read", "location.lookup.place", "location.rules.place", "location.rules.zone", "location.places.watch", "location.latest.read", "location.history.read", "location.lookup"];
25
+ type AppScope = (typeof APP_SCOPES)[number];
26
+ declare const SENSITIVE_SCOPES: readonly AppScope[];
27
+ declare function isAppScope(value: string): value is AppScope;
28
+ /** Limits the API enforces; the SDK checks them before sending. */
29
+ declare const MIN_ZONE_RADIUS_M = 100;
30
+ declare const MAX_ZONE_RADIUS_M = 10000;
31
+ declare const MIN_MAX_AGE_S = 60;
32
+ declare const MAX_MAX_AGE_S = 86400;
33
+ declare const DEFAULT_MAX_AGE_S = 900;
34
+ declare const MIN_DWELL_MINUTES = 5;
35
+ declare const MAX_DWELL_MINUTES = 720;
36
+ declare const MIN_EVENT_AGE_S = 30;
37
+ declare const MAX_EVENT_AGE_S = 3600;
38
+ /** Raw body of POST /v1/oauth/token. */
39
+ interface TokenResponse {
40
+ access_token: string;
41
+ token_type: "Bearer";
42
+ expires_in: number;
43
+ refresh_token: string;
44
+ refresh_token_expires_at: string | null;
45
+ scope: string;
46
+ /** Pairwise per-app user id. Present once the API ships it; optional until then. */
47
+ sub?: string;
48
+ }
49
+ /** What the integrator persists per user. Refresh tokens ROTATE: every
50
+ * refresh returns a new one and invalidates the old, so store what
51
+ * `onTokens` hands you or the next refresh fails. */
52
+ interface TokenSet {
53
+ accessToken: string;
54
+ /** Epoch milliseconds when `accessToken` stops working. */
55
+ accessTokenExpiresAt: number;
56
+ refreshToken: string;
57
+ /** ISO timestamp of the grant's horizon, or null for no expiry. */
58
+ refreshTokenExpiresAt: string | null;
59
+ scopes: AppScope[];
60
+ sub: string | null;
61
+ }
62
+ type UnknownReason = "stale" | "no_data" | "boundary";
63
+ interface PlaceRef {
64
+ id: string;
65
+ label: string;
66
+ kind: string | null;
67
+ }
68
+ interface ZoneAnswer {
69
+ state: "inside" | "outside" | "unknown";
70
+ reason: UnknownReason | null;
71
+ asOf: string | null;
72
+ ageSeconds: number | null;
73
+ }
74
+ interface CurrentPlaceAnswer {
75
+ place: PlaceRef | null;
76
+ state: "at_place" | "no_place" | "unknown";
77
+ reason: UnknownReason | null;
78
+ asOf: string | null;
79
+ ageSeconds: number | null;
80
+ }
81
+ interface PresenceAnswer {
82
+ state: "yes" | "no" | "unknown";
83
+ reason: UnknownReason | null;
84
+ asOf: string | null;
85
+ ageSeconds: number | null;
86
+ }
87
+ interface SharedPlaceRef {
88
+ id: string;
89
+ label: string;
90
+ kind: string | null;
91
+ }
92
+ interface SharedPlaceList {
93
+ /** Bumps whenever the set of places shared with this app changes. */
94
+ version: number;
95
+ places: SharedPlaceRef[];
96
+ }
97
+ interface Visit {
98
+ arrival: string;
99
+ /** null while the visit is still open. */
100
+ departure: string | null;
101
+ place: PlaceRef;
102
+ }
103
+ interface VisitsPage {
104
+ visits: Visit[];
105
+ nextCursor: string | null;
106
+ }
107
+ interface PlaceLookup {
108
+ place: PlaceRef | null;
109
+ matched: boolean;
110
+ asOf: string | null;
111
+ deltaSeconds: number | null;
112
+ }
113
+ declare const LOCATION_SOURCES: readonly ["slc", "visit", "precise"];
114
+ type LocationSource = (typeof LOCATION_SOURCES)[number];
115
+ interface LocationPoint {
116
+ id: string;
117
+ /** When the fix was measured. ISO8601 UTC. */
118
+ timestamp: string;
119
+ lat: number;
120
+ lon: number;
121
+ horizontalAccuracy?: number;
122
+ altitude?: number;
123
+ speed?: number;
124
+ course?: number;
125
+ source: LocationSource;
126
+ visitArrival?: string;
127
+ visitDeparture?: string;
128
+ batteryLevel?: number;
129
+ deviceId?: string;
130
+ }
131
+ interface PointsPage {
132
+ points: LocationPoint[];
133
+ nextCursor?: string;
134
+ }
135
+ interface LatestPoint {
136
+ point: LocationPoint;
137
+ ageSeconds: number;
138
+ }
139
+ interface PointAt {
140
+ point: LocationPoint;
141
+ deltaSeconds: number;
142
+ }
143
+ interface DaysWithData {
144
+ /** "YYYY-MM-DD" in the requested timezone. */
145
+ days: string[];
146
+ }
147
+ type RuleType = "enter" | "exit" | "dwell";
148
+ type RuleTarget = {
149
+ place_id: string;
150
+ label: string;
151
+ } | {
152
+ lat: number;
153
+ lon: number;
154
+ radius_m: number;
155
+ label: string;
156
+ };
157
+ interface RuleSummary {
158
+ id: string;
159
+ type: RuleType;
160
+ target: RuleTarget;
161
+ dwell_minutes: number | null;
162
+ max_event_age_s: number | null;
163
+ webhook_url: string;
164
+ disabled_at: string | null;
165
+ created_at: string;
166
+ }
167
+ /** Returned once, at creation. Sign-verify every delivery with it. */
168
+ type CreatedRule = RuleSummary & {
169
+ secret: string;
170
+ };
171
+ declare const CONNECTION_EVENTS: readonly ["places.changed"];
172
+ type ConnectionEventName = (typeof CONNECTION_EVENTS)[number];
173
+ interface SubscriptionSummary {
174
+ id: string;
175
+ events: string[];
176
+ webhook_url: string;
177
+ disabled_at: string | null;
178
+ created_at: string;
179
+ }
180
+ type CreatedSubscription = SubscriptionSummary & {
181
+ secret: string;
182
+ };
183
+ interface RuleWebhookEvent {
184
+ event_id: string;
185
+ rule_id: string;
186
+ grant_id: string;
187
+ /** "place.enter" | "place.exit" | "place.dwell" | "zone.enter" | ... */
188
+ type: string;
189
+ occurred_at: string;
190
+ target: {
191
+ place_id?: string;
192
+ label: string;
193
+ };
194
+ }
195
+ interface ConnectionWebhookEvent {
196
+ event_id: string;
197
+ subscription_id: string;
198
+ grant_id: string;
199
+ type: ConnectionEventName;
200
+ occurred_at: string;
201
+ places_version: number;
202
+ }
203
+ type WebhookEvent = RuleWebhookEvent | ConnectionWebhookEvent;
204
+ declare function isRuleEvent(event: WebhookEvent): event is RuleWebhookEvent;
205
+ declare function isConnectionEvent(event: WebhookEvent): event is ConnectionWebhookEvent;
206
+
207
+ /** The minimum a caller must hold per user. A full TokenSet is accepted. */
208
+ interface UserTokens {
209
+ refreshToken: string;
210
+ accessToken?: string;
211
+ /** Epoch ms. Without it the first call refreshes. */
212
+ accessTokenExpiresAt?: number;
213
+ }
214
+ interface UserClientOptions {
215
+ /**
216
+ * Called every time tokens change (after a refresh). PERSIST THEM: refresh
217
+ * tokens rotate, and the old one is dead the moment this fires. Awaited;
218
+ * a throw here fails the call that triggered the refresh.
219
+ */
220
+ onTokens?: (tokens: TokenSet) => void | Promise<void>;
221
+ }
222
+ interface VerifyZoneParams {
223
+ lat: number;
224
+ lon: number;
225
+ /** 100–10 000. Smaller zones are a triangulation tool, not a question. */
226
+ radiusM: number;
227
+ /** 3–80 chars; shown to the user in their access log. */
228
+ label: string;
229
+ /** How stale a fix may be and still count. 60–86 400, default 900. */
230
+ maxAgeS?: number;
231
+ }
232
+ interface VisitsListParams {
233
+ from?: string;
234
+ to?: string;
235
+ /** 1–500 */
236
+ limit?: number;
237
+ cursor?: string;
238
+ }
239
+ interface RangeParams {
240
+ from: string;
241
+ to: string;
242
+ deviceId?: string;
243
+ source?: LocationSource;
244
+ /** 1–5000 */
245
+ limit?: number;
246
+ cursor?: string;
247
+ }
248
+ interface BaseRuleParams {
249
+ type: RuleType;
250
+ /** 5–720; only for `dwell`. */
251
+ dwellMinutes?: number;
252
+ /** 30–3600; deliveries older than this are abandoned, not retried. */
253
+ maxEventAgeS?: number;
254
+ /** https only. */
255
+ webhookUrl: string;
256
+ }
257
+ interface CreatePlaceRuleParams extends BaseRuleParams {
258
+ placeId: string;
259
+ }
260
+ interface CreateZoneRuleParams extends BaseRuleParams {
261
+ lat: number;
262
+ lon: number;
263
+ radiusM: number;
264
+ label: string;
265
+ }
266
+ /**
267
+ * All calls for one connected user. Handles access-token refresh: refreshes
268
+ * before expiry, retries once on 401, and raises TokenRevokedError when a
269
+ * refresh fails — at which point the user must reconnect.
270
+ */
271
+ declare class UserClient {
272
+ private readonly http;
273
+ private readonly apiBaseUrl;
274
+ private readonly refreshImpl;
275
+ private readonly options;
276
+ private tokens;
277
+ private refreshing;
278
+ constructor(http: HttpOptions, apiBaseUrl: string, refreshImpl: (refreshToken: string) => Promise<TokenSet>, tokens: UserTokens, options: UserClientOptions);
279
+ /** The tokens this client currently holds. */
280
+ currentTokens(): Readonly<UserTokens>;
281
+ readonly answers: {
282
+ /** Is the user inside this circle right now? "unknown" is a value. */
283
+ verifyZone: (params: VerifyZoneParams) => Promise<ZoneAnswer>;
284
+ /** Which of the places shared with this app is the user at, if any? */
285
+ currentPlace: (params?: {
286
+ maxAgeS?: number;
287
+ }) => Promise<CurrentPlaceAnswer>;
288
+ /** The places the user chose to share with this app. Cache by `version`. */
289
+ places: () => Promise<SharedPlaceList>;
290
+ /** Is the user at this shared place right now? */
291
+ presence: (placeId: string, params?: {
292
+ maxAgeS?: number;
293
+ }) => Promise<PresenceAnswer>;
294
+ };
295
+ readonly visits: {
296
+ /** Stays at shared places, newest first. Follow `nextCursor`. */
297
+ list: (params?: VisitsListParams) => Promise<VisitsPage>;
298
+ /** Which shared place was the user at, at this instant? */
299
+ lookupPlace: (params: {
300
+ at: string;
301
+ toleranceS?: number;
302
+ }) => Promise<PlaceLookup>;
303
+ };
304
+ /** Sensitive tier: raw coordinates. Needs the location.*.read / lookup scopes. */
305
+ readonly locations: {
306
+ range: (params: RangeParams) => Promise<PointsPage>;
307
+ /** Which calendar days (in `tz`) have any points. */
308
+ days: (params: {
309
+ from: string;
310
+ to: string;
311
+ tz: string;
312
+ }) => Promise<DaysWithData>;
313
+ latest: () => Promise<LatestPoint>;
314
+ /** The point nearest `at`, within `toleranceS`. 404 if none. */
315
+ at: (params: {
316
+ at: string;
317
+ toleranceS?: number;
318
+ source?: LocationSource;
319
+ }) => Promise<PointAt>;
320
+ };
321
+ readonly rules: {
322
+ /** Fire a webhook when the user enters / exits / dwells at a shared place.
323
+ * The returned `secret` is shown once; keep it to verify deliveries. */
324
+ createPlace: (params: CreatePlaceRuleParams) => Promise<CreatedRule>;
325
+ createZone: (params: CreateZoneRuleParams) => Promise<CreatedRule>;
326
+ /** Every rule this app holds for the user, place and zone alike. */
327
+ list: () => Promise<RuleSummary[]>;
328
+ deletePlace: (ruleId: string) => Promise<void>;
329
+ deleteZone: (ruleId: string) => Promise<void>;
330
+ };
331
+ readonly subscriptions: {
332
+ /** One subscription per grant; registering again replaces it and mints a
333
+ * new secret. */
334
+ register: (params: {
335
+ events: readonly ConnectionEventName[];
336
+ webhookUrl: string;
337
+ }) => Promise<CreatedSubscription>;
338
+ get: () => Promise<SubscriptionSummary | null>;
339
+ remove: (subscriptionId: string) => Promise<void>;
340
+ };
341
+ private call;
342
+ private send;
343
+ private accessToken;
344
+ /** Single-flight: concurrent calls share one refresh, because the second
345
+ * use of a rotated refresh token is treated as replay and kills the grant. */
346
+ private refresh;
347
+ }
348
+
349
+ declare const DEFAULT_API_BASE_URL = "https://api.contextkit.com";
350
+ declare const DEFAULT_AUTHORIZE_BASE_URL = "https://contextkit.com";
351
+ declare const DEFAULT_TIMEOUT_MS = 10000;
352
+ interface ContextKitOptions {
353
+ /** The app's client id (a UUID) from the developer portal. */
354
+ clientId: string;
355
+ /** The app's client secret. Server-side only — never ship this to a browser. */
356
+ clientSecret: string;
357
+ /** Defaults to https://api.contextkit.com */
358
+ apiBaseUrl?: string;
359
+ /** Where users are sent to consent. Defaults to https://contextkit.com */
360
+ authorizeBaseUrl?: string;
361
+ /** Per-request timeout. Defaults to 10s. */
362
+ timeoutMs?: number;
363
+ /** Injectable for tests; defaults to global fetch (Node 20+). */
364
+ fetch?: FetchLike;
365
+ }
366
+ interface AuthorizeUrlParams {
367
+ /** Must be one of the redirect URIs registered for the app. */
368
+ redirectUri: string;
369
+ scopes: readonly AppScope[];
370
+ /** Opaque, unguessable, bound to the user's session. Echoed back on the
371
+ * redirect; refuse the callback if it does not match. */
372
+ state: string;
373
+ /** Keep the verifier in the user's session; the challenge is derived here. */
374
+ codeVerifier: string;
375
+ /** Your own id for this user. Stored on the grant and shown to you in the
376
+ * portal's Users tab. Optional; never shown to the user. */
377
+ externalUserId?: string;
378
+ }
379
+ interface ExchangeCodeParams {
380
+ code: string;
381
+ codeVerifier: string;
382
+ redirectUri: string;
383
+ }
384
+ /**
385
+ * One instance per app. Holds the client credentials and builds per-user
386
+ * handles with `forUser`.
387
+ */
388
+ declare class ContextKit {
389
+ readonly clientId: string;
390
+ readonly apiBaseUrl: string;
391
+ readonly authorizeBaseUrl: string;
392
+ private readonly clientSecret;
393
+ private readonly http;
394
+ constructor(options: ContextKitOptions);
395
+ /** The URL to send the user to. */
396
+ authorizeUrl(params: AuthorizeUrlParams): string;
397
+ /** Callback step: trade the code for tokens. Persist the result per user. */
398
+ exchangeCode(params: ExchangeCodeParams): Promise<TokenSet>;
399
+ /**
400
+ * Refresh explicitly. `forUser` does this for you; call it directly only
401
+ * for a scheduled refresh. Refresh tokens rotate — persist the new set.
402
+ */
403
+ refresh(refreshToken: string): Promise<TokenSet>;
404
+ /** A handle that makes calls as one connected user. */
405
+ forUser(tokens: UserTokens, options?: UserClientOptions): UserClient;
406
+ private token;
407
+ }
408
+
409
+ /** 43-character base64url verifier (32 random bytes), the RFC 7636 minimum. */
410
+ declare function generateCodeVerifier(): string;
411
+ /** S256 challenge for a verifier — the only method the API accepts. */
412
+ declare function codeChallenge(verifier: string): string;
413
+ /** 32 random bytes as base64url; use as the OAuth `state`. */
414
+ declare function generateState(): string;
415
+
416
+ /**
417
+ * ContextKit signs every delivery Stripe-style:
418
+ * X-ContextKit-Signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, `${t}.${body}`)>
419
+ * Retries are re-signed, so a receiver never needs a window wider than
420
+ * realistic clock skew. This must agree byte-for-byte with the API's
421
+ * webhook-delivery.service.ts and the Explorer relay's signature.ts.
422
+ */
423
+ declare const RECOMMENDED_TOLERANCE_S = 60;
424
+ declare const MAX_TOLERANCE_S = 300;
425
+ declare const SIGNATURE_HEADER = "x-contextkit-signature";
426
+ /** Something that remembers event ids it has already accepted. Needed for
427
+ * replay protection across your own retries or a duplicated delivery. */
428
+ interface ReplayGuard {
429
+ /** Return true if this id was already seen; otherwise record it. */
430
+ seen(eventId: string, occurredAtMs: number): boolean | Promise<boolean>;
431
+ }
432
+ interface VerifyWebhookParams {
433
+ /** The EXACT bytes received. Re-serialising a parsed body breaks the HMAC. */
434
+ rawBody: Buffer | string;
435
+ /** The X-ContextKit-Signature header value. */
436
+ signature: string | string[] | undefined;
437
+ /** The secret returned when the rule or subscription was created. */
438
+ secret: string;
439
+ /** Seconds of clock skew to allow. Default 60, max 300. */
440
+ toleranceS?: number;
441
+ replayGuard?: ReplayGuard;
442
+ /** Injectable clock, epoch ms. */
443
+ now?: number;
444
+ }
445
+ /**
446
+ * Verify a delivery and return its parsed event. Throws
447
+ * WebhookVerificationError on any failure — respond 400 and do NOT act.
448
+ */
449
+ declare function verifyWebhook(params: VerifyWebhookParams): Promise<WebhookEvent>;
450
+ /** Build the header for a body — for tests and for simulating deliveries. */
451
+ declare function signWebhook(body: Buffer | string, secret: string, atMs?: number): string;
452
+ declare function parseSignatureHeader(header: string): {
453
+ timestamp: number;
454
+ signatures: string[];
455
+ } | null;
456
+ /**
457
+ * A process-local replay guard. Fine for a single instance; behind more than
458
+ * one replica, back `ReplayGuard` with something shared (Redis SET NX EX).
459
+ */
460
+ declare class InMemoryReplayGuard implements ReplayGuard {
461
+ private readonly ttlMs;
462
+ private readonly ids;
463
+ constructor(ttlMs?: number);
464
+ seen(eventId: string, _occurredAtMs: number): boolean;
465
+ }
466
+
467
+ /**
468
+ * Every failure the SDK raises is a ContextKitError. Subclasses tell the
469
+ * caller what to DO, which is the only reason to distinguish them:
470
+ *
471
+ * TokenRevokedError → the user must reconnect; stop retrying
472
+ * RateLimitedError → wait `retryAfterSeconds`, then retry
473
+ * TimeoutError / NetworkError → transient; retry with backoff
474
+ * ValidationError / ScopeError / NotFoundError → caller bug or stale id; do not retry
475
+ *
476
+ * "unknown" answers are NOT errors. They come back as values.
477
+ */
478
+ declare class ContextKitError extends Error {
479
+ readonly code: string;
480
+ readonly status: number | null;
481
+ readonly body: unknown;
482
+ constructor(message: string, code: string, status?: number | null, body?: unknown);
483
+ }
484
+ /** 400 — the request shape was wrong. `messages` is what the API said. */
485
+ declare class ValidationError extends ContextKitError {
486
+ readonly messages: string[];
487
+ constructor(messages: string[], body: unknown);
488
+ }
489
+ /** 401 that a refresh could not fix, or a refresh that itself failed. The
490
+ * grant is gone (revoked, expired, app deactivated, or the refresh token
491
+ * was replayed). Send the user back through the connect flow. */
492
+ declare class TokenRevokedError extends ContextKitError {
493
+ constructor(message?: string, body?: unknown);
494
+ }
495
+ /** 403 — the grant does not carry the scope this call needs. */
496
+ declare class ScopeError extends ContextKitError {
497
+ constructor(message: string, body: unknown);
498
+ }
499
+ /** 404 — unshared and nonexistent are deliberately the same answer. */
500
+ declare class NotFoundError extends ContextKitError {
501
+ constructor(message: string, body: unknown);
502
+ }
503
+ /** 429 — per-app budget or rate limit. Honour `retryAfterSeconds`. */
504
+ declare class RateLimitedError extends ContextKitError {
505
+ readonly retryAfterSeconds: number | null;
506
+ constructor(message: string, retryAfterSeconds: number | null, body: unknown);
507
+ }
508
+ /** Any other non-2xx. */
509
+ declare class ApiError extends ContextKitError {
510
+ constructor(message: string, status: number, body: unknown);
511
+ }
512
+ declare class TimeoutError extends ContextKitError {
513
+ constructor(url: string, timeoutMs: number);
514
+ }
515
+ declare class NetworkError extends ContextKitError {
516
+ readonly cause: unknown;
517
+ constructor(url: string, cause: unknown);
518
+ }
519
+ /** A webhook delivery that failed signature or freshness checks. */
520
+ declare class WebhookVerificationError extends ContextKitError {
521
+ constructor(detail: string);
522
+ }
523
+
524
+ export { APP_SCOPES, ApiError, type AppScope, type AuthorizeUrlParams, CONNECTION_EVENTS, type ConnectionEventName, type ConnectionWebhookEvent, ContextKit, ContextKitError, type ContextKitOptions, type CreatePlaceRuleParams, type CreateZoneRuleParams, type CreatedRule, type CreatedSubscription, type CurrentPlaceAnswer, DEFAULT_API_BASE_URL, DEFAULT_AUTHORIZE_BASE_URL, DEFAULT_MAX_AGE_S, DEFAULT_TIMEOUT_MS, type DaysWithData, type ExchangeCodeParams, InMemoryReplayGuard, LOCATION_SOURCES, type LatestPoint, type LocationPoint, type LocationSource, 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, type PlaceLookup, type PlaceRef, type PointAt, type PointsPage, type PresenceAnswer, RECOMMENDED_TOLERANCE_S, type RangeParams, RateLimitedError, type ReplayGuard, type RuleSummary, type RuleTarget, type RuleType, type RuleWebhookEvent, SENSITIVE_SCOPES, SIGNATURE_HEADER, ScopeError, type SharedPlaceList, type SharedPlaceRef, type SubscriptionSummary, TimeoutError, type TokenResponse, TokenRevokedError, type TokenSet, type UnknownReason, UserClient, type UserClientOptions, type UserTokens, ValidationError, type VerifyWebhookParams, type VerifyZoneParams, type Visit, type VisitsListParams, type VisitsPage, type WebhookEvent, WebhookVerificationError, type ZoneAnswer, codeChallenge, generateCodeVerifier, generateState, isAppScope, isConnectionEvent, isRuleEvent, parseSignatureHeader, signWebhook, verifyWebhook };