@robono/linked-apps 0.1.0-preview.2 → 0.1.0-preview.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ # 0.1.0-preview.3
2
+
3
+ - `watch()` now uses an authenticated persistent WebSocket and automatic recovery.
4
+ - Acknowledgements follow successful processing and cursor persistence. Reconnects
5
+ replay missed events; token refresh and account switching are handled explicitly.
6
+ - Remove `configureBackgroundDelivery`, `verifyWebhook` and `pollIntervalMs`.
7
+ No webhook delivery option remains. Use a backend watcher for your own app's push.
8
+ - Global WebSocket is used in supported runtimes; other hosts can inject a factory.
9
+ - Self-service registration and code pairing require no delivery endpoint setup.
10
+
1
11
  # 0.1.0-preview.2
2
12
 
3
13
  - Add `beginPairing`, `pollPairing`, and cancellable `waitForPairing`.
package/INTEGRATION.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # Integrating an independent app
2
2
 
3
- Use a separate registration for development and production. Robono supplies its
4
- functions URL, client ID and reviewed redirect URI/scopes. Client IDs are public.
5
- A backend webhook signing secret is confidential and must never enter a mobile bundle.
3
+ Register at https://www.robono.com/linked-apps/manage. Developer signup, email
4
+ verification and two-factor authentication are self-service. Registration returns an
5
+ active client ID and functions URL immediately. No Bridge organization, subscription,
6
+ API key or manual activation is required. Use separate development and production
7
+ registrations. Client IDs are public; each user must still approve account access.
8
+ Live updates use an authenticated connection opened by your SDK. No incoming endpoint or delivery configuration is required.
6
9
 
7
10
  ## Link an account with a code
8
11
 
9
- Pairing support requires SDK 0.1.0-preview.2 or newer. The currently registered
12
+ Use SDK 0.1.0-preview.3 or newer for persistent connections. The currently registered
10
13
  client ID is public; users never need a developer account. Pairing-only clients
11
14
  use an empty `redirect_uris` list and do not need callback URLs.
12
15
 
@@ -72,10 +75,10 @@ for existing clients. New code pairing requires no callback handler.
72
75
  ## Display and send
73
76
 
74
77
  ```ts
75
- const snapshot = await ot.conversations();
76
- const page = await ot.messages(conversationId, { limit: 50 });
78
+ const snapshot = await robono.conversations();
79
+ const page = await robono.messages(conversationId, { limit: 50 });
77
80
  // Generate and persist this ID before sending; use it again on every retry.
78
- const result = await ot.send({ conversationId, messageKind: 'text',
81
+ const result = await robono.send({ conversationId, messageKind: 'text',
79
82
  clientMessageId: durableOutgoingId, textBody: 'Hello' });
80
83
  ```
81
84
 
@@ -100,10 +103,10 @@ actual listening. Use `delivered` and `read` for their corresponding user states
100
103
 
101
104
  ```ts
102
105
  const controller = new AbortController();
103
- const watching = ot.watch({
106
+ const watching = robono.watch({
104
107
  signal: controller.signal,
105
108
  cursorStore: persistedCursors, // keyed by grant ID
106
- onResync: async () => reconcile(await ot.conversations()),
109
+ onResync: async () => reconcile(await robono.conversations()),
107
110
  onEvents: async events => {
108
111
  // Deduplicate message IDs. Fetch only relevant current pages; remove unavailable
109
112
  // or deleted content. Do not notify for receipt-only events or your own messages.
@@ -122,42 +125,67 @@ reported. Walkie-talkie replacement can invalidate an earlier message even if it
122
125
  was previously downloaded. Read all pages with `has_more` using both returned page
123
126
  markers; preserve microsecond timestamps as strings. Do not synthesize timestamps.
124
127
 
125
- Polling stores its cursor only after successful processing. A callback can run again
128
+ The watcher stores its cursor only after successful processing. A callback can run again
126
129
  after a crash, so make local writes idempotent. Failed callbacks keep the previous
127
130
  cursor. Abort does not advance past unfinished work. Grant changes terminate the old
128
131
  watcher; cursors and cached data must never move to a different account.
129
132
 
130
- ## Background notices
133
+ ## Live connections and background notifications
131
134
 
132
- 1. Configure a reviewed HTTPS backend webhook with Robono.
133
- 2. Your backend associates a grant with an authenticated account in your own service.
134
- Verify it by calling `account.get` using that account's linked token; never trust
135
- a client-supplied grant ID to route another user's notifications.
136
- 3. Call `configureBackgroundDelivery(true)` after the mapping is ready.
137
- 4. Verify the **raw** webhook body before parsing/queuing it:
135
+ `watch()` opens a TLS WebSocket to `/linked-app-stream`, authenticates with the
136
+ linked account token, and resumes from its saved cursor. The SDK reconnects with
137
+ backoff, including routine server rotation. No webhook is offered.
138
138
 
139
- ```ts
140
- import { verifyWebhook } from '@robono/linked-apps';
141
- const hint = await verifyWebhook({ body: rawBody,
142
- timestamp: request.headers.get('x-robono-timestamp')!,
143
- signature: request.headers.get('x-robono-signature')!,
144
- secret: backendOnlySigningKey });
145
- // Check expected client_id; atomically deduplicate hint.id and enqueue the hint.
146
- // Return 2xx only after the queue write commits.
147
- ```
139
+ Modern browsers, React Native and Node.js 22+ supply WebSocket. Other runtimes may
140
+ inject `webSocket: url => new WebSocketImplementation(url)` in the SDK constructor.
141
+ Do not disable TLS verification. Stop a phone's watcher when backgrounded; resume
142
+ it on foreground. A phone operating system can suspend its sockets.
143
+
144
+ For notifications while your phone app is suspended, your backend can run the same
145
+ watcher and send a content-free hint through your app's APNs/FCM/Expo credentials.
146
+ This uses an outbound connection to Robono, with no URL to register. First verify
147
+ the account/grant using `account()` before routing hints to your own user's devices.
148
+ Disclose server-side account access and protect its credentials. Use one token
149
+ owner to coordinate refreshes; do not let independent phone/server processes rotate
150
+ the same refresh token. Relay through that owner, or pair separately for each host.
148
151
 
149
- The signature covers `timestamp + '.' + rawBody` with HMAC-SHA256, accepted within
150
- five minutes. Delivery IDs remain stable across retries; attempt timestamps change.
151
- Reject replayed IDs after verification, check client identity, limit request size and
152
- retain dedup IDs for at least seven days. Invalid signatures receive no processing.
152
+ Keep separate cursor stores for independent consumers. Maximum three connections
153
+ per grant. Events are retained for seven days. A longer absence triggers a fresh
154
+ snapshot, not silent event loss. Callbacks must be idempotent. The stream carries
155
+ change IDs, not message content; fetch the authoritative messages via the API.
156
+ Keep companion hints silent by default to avoid duplicating Robono's alerts.
157
+ Background execution and notification timing remain subject to iOS/Android rules.
153
158
 
154
- 5. Send a content-free sync hint through **your app's** APNs/FCM/Expo credentials.
155
- 6. On wake/resume, call `events(cursor)` or restart `watch`; do not trust a webhook's
156
- cursor as proof you already applied earlier events. Never overwrite the local
157
- cursor with the pushed cursor. Keep Robono notifications as the default alert and
158
- companion hints silent to avoid duplicate sounds.
159
+ ### Protocol for clients not using the SDK
160
+
161
+ Connect to `wss://vzoqxavqacydtwypjsrd.supabase.co/functions/v1/linked-app-stream`
162
+ without query parameters. Within ten seconds send this JSON text frame:
163
+
164
+ ```json
165
+ {"type":"authenticate","version":1,"access_token":"rla_…","cursor":null}
166
+ ```
159
167
 
160
- Disconnect: call `ot.disconnect()`; clear your local cached account content and stop
168
+ Use the last successfully processed cursor string, or null for initial sync. Never
169
+ put tokens in URLs or subprotocols. Require `messages:read` permission. The server
170
+ sends `{"type":"ready","grant_id":"…","heartbeat_seconds":15}`; verify the grant.
171
+ A `{"type":"page","page":…}` frame contains the same page as `events.poll`.
172
+ For initial sync or `resync_required`, reconcile conversations and current histories.
173
+ Otherwise apply its events, preserving their order and deduplicating IDs. Save the
174
+ page cursor only after processing succeeds, then send
175
+ `{"type":"ack","cursor":"<saved cursor>"}`. There is at most one outstanding page
176
+ and at most 100 events per page. ACK within 60 seconds or reconnect from the previous
177
+ saved cursor. Respond to `{"type":"ping"}` with `{"type":"pong"}`.
178
+
179
+ On `{"type":"reconnect"}`, reconnect with jitter from your saved cursor. The
180
+ current hosting environment rotates connections after about 85 seconds. The SDK
181
+ handles this. An error frame contains `error` and HTTP-style `status`; stop on
182
+ invalid/revoked credentials or insufficient permission. Back off on temporary
183
+ failures/rate limits. Rotate nearly expired access tokens through the existing
184
+ refresh API, then reconnect. Messages retained during interruptions are replayed.
185
+ `events(cursor)` remains available for a one-time reconciliation; `watch()` uses
186
+ the persistent connection rather than repeatedly polling the HTTP API.
187
+
188
+ Disconnect: call `robono.disconnect()`; clear your local cached account content and stop
161
189
  watchers after revocation succeeds. A lost response may require retry/relink UI; a
162
190
  user can always revoke from Robono's Linked apps screen. Account deletion, revoked
163
191
  permissions or `invalid_token` must not trigger repeated unauthorized background work.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @robono/linked-apps
2
2
 
3
3
  Robono's user-authorized messaging API client, for independent applications.
4
- Developer preview. Requires a reviewed app registration and an activated Robono environment.
4
+ Developer preview. Register your app at https://www.robono.com/linked-apps/manage to receive an active client ID. Each user must approve their own Robono account connection.
5
5
  SDK installation alone does not enable account access.
6
6
 
7
7
  Supports PKCE account linking, rotating credentials, typed conversations/messages,
8
8
  text/voice/file sending, signed media upload/download, receipts, resumable change
9
- watching, backend delivery subscription, webhook verification and revocation.
9
+ watching over authenticated WebSockets and revocation.
10
10
  No dependency on Matrix, TalkOpen, Loop, Expo or a specific UI framework.
11
11
 
12
12
  Build/test from the repository:
@@ -28,10 +28,11 @@ Registered callback linking remains available for existing clients.
28
28
 
29
29
  The watcher is explicit and cancellable. Stop on background/logout; resume on a push
30
30
  hint or foreground. It checkpoints only after successful consumer callbacks, so
31
- callbacks must tolerate redelivery. Backends verify webhook signatures and deduplicate
32
- stable delivery IDs before pushing through their own app credentials. Never ship a
33
- webhook signing key in mobile code. Hints contain no message text and should not
34
- produce sounds by themselves.
31
+ callbacks must tolerate redelivery. The SDK opens the connection, authenticates without
32
+ putting tokens in URLs, and reconnects from the last saved cursor. No webhook setup
33
+ or delivery endpoint is supported. For background notifications, a developer backend
34
+ can run the watcher and use its own app's push credentials. Phone sockets may be
35
+ suspended by the operating system. See the integration guide for secure token ownership.
35
36
 
36
37
  Send retries must retain the original UUID. Refresh requests are serialized within
37
38
  one instance; coordinate across processes yourself. A lost refresh response can
@@ -42,7 +43,7 @@ require relinking because reusing a spent refresh token revokes the grant.
42
43
  The official distribution channel is npm. Install this exact preview version:
43
44
 
44
45
  ```sh
45
- npm install --save-exact @robono/linked-apps@0.1.0-preview.2
46
+ npm install --save-exact @robono/linked-apps@0.1.0-preview.3
46
47
  ```
47
48
 
48
49
  Preview releases use the `preview` tag. Pin the version and commit your lockfile; review release notes and test before updating. The Robono website does not distribute SDK archives.
@@ -24,7 +24,6 @@ export interface WatchOptions {
24
24
  /** Reconcile the conversation list and currently open message pages. */
25
25
  onResync(): Promise<void>;
26
26
  onError?(error: unknown): void;
27
- pollIntervalMs?: number;
28
27
  }
29
28
  export declare function waitForPoll(ms: number, signal: AbortSignal): Promise<void>;
30
29
  /** Dependency injection keeps the SDK usable in Expo and bare native apps. */
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type EventPage, type WatchOptions } from "./delivery.js";
2
+ import { type LinkedSocketFactory } from "./stream.js";
2
3
  import type { ConversationPage, Message, MessagePage, Receipt, UploadTicket } from "./types.js";
3
4
  export * from "./types.js";
4
5
  export * from "./delivery.js";
5
- export * from "./webhook.js";
6
+ export type { LinkedSocket, LinkedSocketFactory } from "./stream.js";
6
7
  export type Scope = "account:read" | "messages:read" | "messages:send" | "receipts:write";
7
8
  export type Tokens = {
8
9
  accessToken: string;
@@ -82,6 +83,7 @@ export declare class RobonoLinkedApps {
82
83
  crypto?: CryptoProvider;
83
84
  fetch?: typeof fetch;
84
85
  timeoutMs?: number;
86
+ webSocket?: LinkedSocketFactory;
85
87
  });
86
88
  private post;
87
89
  /** Show only userCode. The user approves inside their signed-in Robono app. */
@@ -150,11 +152,9 @@ export declare class RobonoLinkedApps {
150
152
  receipts: Receipt[];
151
153
  }>;
152
154
  events(cursor: string | null, signal?: AbortSignal): Promise<EventPage>;
153
- configureBackgroundDelivery(enabled: boolean): Promise<{
154
- enabled: boolean;
155
- }>;
156
- /** Run while active; stop on background, resume on foreground or a push hint.
157
- * Callbacks must tolerate redelivery. Cursor advances only after success.
155
+ /** Open a live connection, replay missed events, and reconnect automatically.
156
+ * On phones, stop on suspension/logout; a backend may keep watching for push.
157
+ * Checkpoints and acknowledgements happen only after successful callbacks.
158
158
  */
159
159
  watch(options: WatchOptions): Promise<void>;
160
160
  disconnect(): Promise<void>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { waitForPoll } from "./delivery.js";
2
+ import { consumeStream } from "./stream.js";
2
3
  export * from "./types.js";
3
4
  export * from "./delivery.js";
4
- export * from "./webhook.js";
5
5
  export class RobonoLinkedAppError extends Error {
6
6
  code;
7
7
  status;
@@ -355,52 +355,72 @@ export class RobonoLinkedApps {
355
355
  events(cursor, signal) {
356
356
  return this.call("events.poll", { cursor }, signal);
357
357
  }
358
- configureBackgroundDelivery(enabled) {
359
- return this.call("delivery.configure", { enabled });
360
- }
361
- /** Run while active; stop on background, resume on foreground or a push hint.
362
- * Callbacks must tolerate redelivery. Cursor advances only after success.
358
+ /** Open a live connection, replay missed events, and reconnect automatically.
359
+ * On phones, stop on suspension/logout; a backend may keep watching for push.
360
+ * Checkpoints and acknowledgements happen only after successful callbacks.
363
361
  */
364
362
  async watch(options) {
365
- const tokens = await this.options.tokenStore.get();
366
- if (!tokens)
363
+ const initial = await this.options.tokenStore.get();
364
+ if (!initial)
367
365
  throw new RobonoLinkedAppError("not_connected", 401);
368
- const grantId = tokens.grantId;
369
- let cursor = await options.cursorStore.get(grantId);
366
+ const grantId = initial.grantId;
367
+ const factory = this.options.webSocket ?? ((url) => new WebSocket(url));
368
+ if (!this.options.webSocket && typeof globalThis.WebSocket !== "function") {
369
+ throw new Error("Provide a WebSocket implementation for this runtime.");
370
+ }
371
+ const streamUrl = this.baseUrl.replace(/^http/, "ws") + "/linked-app-stream";
372
+ const current = async () => {
373
+ const tokens = await this.options.tokenStore.get();
374
+ if (!tokens || tokens.grantId !== grantId)
375
+ throw new RobonoLinkedAppError("not_connected", 401);
376
+ return tokens;
377
+ };
370
378
  let failures = 0;
371
379
  while (!options.signal.aborted) {
380
+ let sessionToken = "";
372
381
  try {
373
- const current = await this.options.tokenStore.get();
374
- if (!current || current.grantId !== grantId) {
382
+ let tokens = await current();
383
+ if (tokens.expiresAt <= Date.now() + 30_000)
384
+ tokens = await this.refresh();
385
+ if (tokens.grantId !== grantId)
375
386
  throw new RobonoLinkedAppError("not_connected", 401);
376
- }
377
- const page = await this.events(cursor, options.signal);
378
- if (options.signal.aborted)
379
- return;
380
- if (cursor === null || page.resync_required)
381
- await options.onResync();
382
- else if (page.events.length)
383
- await options.onEvents(page.events);
387
+ sessionToken = tokens.accessToken;
388
+ let cursor = await options.cursorStore.get(grantId);
389
+ await consumeStream({ url: streamUrl, factory, tokens, cursor, signal: options.signal,
390
+ onPage: async (page) => {
391
+ await current();
392
+ if (options.signal.aborted)
393
+ return;
394
+ if (cursor === null || page.resync_required)
395
+ await options.onResync();
396
+ else if (page.events.length)
397
+ await options.onEvents(page.events);
398
+ await current();
399
+ if (options.signal.aborted)
400
+ return;
401
+ await options.cursorStore.set(grantId, page.cursor);
402
+ cursor = page.cursor;
403
+ failures = 0;
404
+ },
405
+ });
384
406
  if (options.signal.aborted)
385
407
  return;
386
- await options.cursorStore.set(grantId, page.cursor);
387
- cursor = page.cursor;
388
- failures = 0;
389
- if (page.has_more)
390
- continue;
391
408
  }
392
409
  catch (error) {
393
410
  if (options.signal.aborted)
394
411
  return;
395
- if (error instanceof RobonoLinkedAppError &&
396
- [400, 401, 403].includes(error.status))
412
+ if (error instanceof RobonoLinkedAppError && error.code === "invalid_token") {
413
+ const tokens = await current();
414
+ // Another request may have rotated the token used by this socket.
415
+ if (tokens.accessToken !== sessionToken || tokens.expiresAt <= Date.now() + 30_000)
416
+ continue;
417
+ }
418
+ if (error instanceof RobonoLinkedAppError && [400, 401, 403].includes(error.status))
397
419
  throw error;
398
420
  options.onError?.(error);
399
421
  failures++;
400
422
  }
401
- await waitForPoll(failures
402
- ? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5))
403
- : Math.max(1000, options.pollIntervalMs ?? 1000), options.signal);
423
+ await waitForPoll(failures ? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5)) : 250 + Math.floor(Math.random() * 500), options.signal);
404
424
  }
405
425
  }
406
426
  async disconnect() {
@@ -0,0 +1,14 @@
1
+ import { type Tokens } from "./index.js";
2
+ import type { EventPage } from "./delivery.js";
3
+ /** Supported by browser, React Native and modern Node WebSocket implementations. */
4
+ export type LinkedSocket = Pick<WebSocket, "readyState" | "onopen" | "onmessage" | "onerror" | "onclose" | "send" | "close">;
5
+ export type LinkedSocketFactory = (url: string) => LinkedSocket;
6
+ /** One connection; the caller owns replay/checkpoints and reconnect backoff. */
7
+ export declare function consumeStream(input: {
8
+ url: string;
9
+ factory: LinkedSocketFactory;
10
+ tokens: Tokens;
11
+ cursor: string | null;
12
+ signal: AbortSignal;
13
+ onPage(page: EventPage): Promise<void>;
14
+ }): Promise<void>;
package/dist/stream.js ADDED
@@ -0,0 +1,84 @@
1
+ import { RobonoLinkedAppError } from "./index.js";
2
+ /** One connection; the caller owns replay/checkpoints and reconnect backoff. */
3
+ export async function consumeStream(input) {
4
+ if (input.signal.aborted)
5
+ return;
6
+ const socket = input.factory(input.url);
7
+ let ended = false, ready = false, processing = false, chain = Promise.resolve();
8
+ let settle;
9
+ const completion = new Promise((resolve, reject) => { settle = (error) => error ? reject(error) : resolve(); });
10
+ let watchdog;
11
+ const finish = (error) => {
12
+ if (ended)
13
+ return;
14
+ ended = true;
15
+ clearTimeout(watchdog);
16
+ input.signal.removeEventListener("abort", abort);
17
+ try {
18
+ socket.close(1000);
19
+ }
20
+ catch { /* Already disconnected. */ }
21
+ // No overlapping callbacks when the next connection resumes.
22
+ void chain.then(() => settle(error), (callbackError) => settle(callbackError));
23
+ };
24
+ const touch = () => {
25
+ clearTimeout(watchdog);
26
+ watchdog = setTimeout(() => finish(new RobonoLinkedAppError("stream_timeout", 503)), 40_000);
27
+ };
28
+ const abort = () => finish();
29
+ input.signal.addEventListener("abort", abort, { once: true });
30
+ touch();
31
+ socket.onopen = () => {
32
+ if (ended || input.signal.aborted)
33
+ return finish();
34
+ socket.send(JSON.stringify({ type: "authenticate", version: 1, access_token: input.tokens.accessToken, cursor: input.cursor }));
35
+ };
36
+ socket.onmessage = (event) => {
37
+ if (ended)
38
+ return;
39
+ try {
40
+ if (typeof event.data !== "string" || event.data.length > 256_000)
41
+ throw new Error();
42
+ const frame = JSON.parse(event.data);
43
+ touch();
44
+ if (frame.type === "error")
45
+ return finish(new RobonoLinkedAppError(typeof frame.error === "string" ? frame.error : "stream_error", Number(frame.status) || 503));
46
+ if (frame.type === "reconnect")
47
+ return finish();
48
+ if (frame.type === "ready") {
49
+ if (ready || frame.grant_id !== input.tokens.grantId)
50
+ throw new Error();
51
+ ready = true;
52
+ return;
53
+ }
54
+ if (!ready)
55
+ throw new Error();
56
+ if (frame.type === "ping") {
57
+ socket.send(JSON.stringify({ type: "pong" }));
58
+ return;
59
+ }
60
+ const page = frame.page;
61
+ if (frame.type !== "page" || processing || !page || !Array.isArray(page.events) || page.events.length > 100 ||
62
+ typeof page.cursor !== "string" || !/^(0|[1-9][0-9]{0,17})$/.test(page.cursor) || typeof page.has_more !== "boolean")
63
+ throw new Error();
64
+ processing = true;
65
+ chain = chain.then(async () => {
66
+ if (input.signal.aborted)
67
+ return;
68
+ await input.onPage(page);
69
+ processing = false;
70
+ if (!ended && !input.signal.aborted && socket.readyState === 1)
71
+ socket.send(JSON.stringify({ type: "ack", cursor: page.cursor }));
72
+ });
73
+ void chain.catch(finish);
74
+ }
75
+ catch {
76
+ finish(new RobonoLinkedAppError("invalid_stream", 400));
77
+ }
78
+ };
79
+ socket.onerror = () => finish(new RobonoLinkedAppError("stream_unavailable", 503));
80
+ socket.onclose = (event) => finish(event.code === 1000 ? undefined : new RobonoLinkedAppError("stream_disconnected", 503));
81
+ if (input.signal.aborted)
82
+ abort();
83
+ return completion;
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robono/linked-apps",
3
- "version": "0.1.0-preview.2",
3
+ "version": "0.1.0-preview.3",
4
4
  "private": false,
5
5
  "description": "User-authorized companion-app access to Robono (development preview)",
6
6
  "type": "module",
package/dist/webhook.d.ts DELETED
@@ -1,19 +0,0 @@
1
- export type DeliveryHint = {
2
- version: 1;
3
- id: string;
4
- type: "sync_available";
5
- client_id: string;
6
- grant_id: string;
7
- cursor: string;
8
- };
9
- /** SERVER ONLY. Verify the exact raw request body, before parsing or using it.
10
- * Keep signing keys on your backend. Deduplicate id after durably queuing the hint.
11
- */
12
- export declare function verifyWebhook(input: {
13
- body: string;
14
- timestamp: string;
15
- signature: string;
16
- secret: string;
17
- nowMs?: number;
18
- crypto?: Crypto;
19
- }): Promise<DeliveryHint>;
package/dist/webhook.js DELETED
@@ -1,22 +0,0 @@
1
- /** SERVER ONLY. Verify the exact raw request body, before parsing or using it.
2
- * Keep signing keys on your backend. Deduplicate id after durably queuing the hint.
3
- */
4
- export async function verifyWebhook(input) {
5
- const engine = input.crypto ?? globalThis.crypto;
6
- const timestamp = Number(input.timestamp);
7
- if (input.secret.length < 32 || !/^\d{10}$/.test(input.timestamp) ||
8
- Math.abs((input.nowMs ?? Date.now()) / 1000 - timestamp) > 300 ||
9
- !/^v1=[a-f0-9]{64}$/.test(input.signature) || input.body.length > 8192)
10
- throw new Error("Invalid webhook");
11
- const key = await engine.subtle.importKey("raw", new TextEncoder().encode(input.secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
12
- const bytes = Uint8Array.from(input.signature.slice(3).match(/../g), (part) => parseInt(part, 16));
13
- if (!await engine.subtle.verify("HMAC", key, bytes, new TextEncoder().encode(`${input.timestamp}.${input.body}`)))
14
- throw new Error("Invalid webhook");
15
- const data = JSON.parse(input.body);
16
- if (data.version !== 1 || data.type !== "sync_available" ||
17
- typeof data.id !== "string" ||
18
- typeof data.client_id !== "string" || typeof data.grant_id !== "string" ||
19
- typeof data.cursor !== "string" || !/^\d+$/.test(data.cursor))
20
- throw new Error("Invalid webhook");
21
- return data;
22
- }