@robono/linked-apps 0.1.0-preview.2 → 0.1.0-preview.4
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 +20 -0
- package/INTEGRATION.md +87 -45
- package/README.md +9 -8
- package/dist/delivery.d.ts +0 -1
- package/dist/identity.d.ts +13 -0
- package/dist/identity.js +28 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.js +79 -40
- package/dist/stream.d.ts +14 -0
- package/dist/stream.js +84 -0
- package/package.json +1 -1
- package/dist/webhook.d.ts +0 -19
- package/dist/webhook.js +0 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
# 0.1.0-preview.4
|
|
2
|
+
|
|
3
|
+
- Add direct integration through `app` details in SDK configuration. No developer
|
|
4
|
+
account, website registration, issued client ID or manual activation is needed.
|
|
5
|
+
- Use Robono's public service URL by default. Existing `clientId` integrations and
|
|
6
|
+
explicitly selected development servers remain supported.
|
|
7
|
+
- Normalize and bind app details to a stable internal identity. Direct integrations
|
|
8
|
+
use code pairing; user consent, PKCE, access scopes and revocation stay intact.
|
|
9
|
+
- Validate returned identity and preserve pairing/refresh across SDK restarts.
|
|
10
|
+
|
|
11
|
+
# 0.1.0-preview.3
|
|
12
|
+
|
|
13
|
+
- `watch()` now uses an authenticated persistent WebSocket and automatic recovery.
|
|
14
|
+
- Acknowledgements follow successful processing and cursor persistence. Reconnects
|
|
15
|
+
replay missed events; token refresh and account switching are handled explicitly.
|
|
16
|
+
- Remove `configureBackgroundDelivery`, `verifyWebhook` and `pollIntervalMs`.
|
|
17
|
+
No webhook delivery option remains. Use a backend watcher for your own app's push.
|
|
18
|
+
- Global WebSocket is used in supported runtimes; other hosts can inject a factory.
|
|
19
|
+
- Self-service registration and code pairing require no delivery endpoint setup.
|
|
20
|
+
|
|
1
21
|
# 0.1.0-preview.2
|
|
2
22
|
|
|
3
23
|
- Add `beginPairing`, `pollPairing`, and cancellable `waitForPairing`.
|
package/INTEGRATION.md
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# Integrating an independent app
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Install `@robono/linked-apps@0.1.0-preview.4` and supply your app details in SDK
|
|
4
|
+
configuration. No Robono developer account, website registration, API key, issued
|
|
5
|
+
client ID, callback URL or manual activation is needed. The SDK uses Robono's
|
|
6
|
+
published service address by default. This connects to Robono app accounts, not
|
|
7
|
+
the Robono Bridge network API.
|
|
8
|
+
|
|
9
|
+
App details are supplied by your app; Robono does not certify ownership of the name
|
|
10
|
+
or URLs. Use accurate names and public HTTPS website/privacy-policy links. Robono
|
|
11
|
+
creates its internal client identifier automatically. Keep these details stable:
|
|
12
|
+
a changed profile has a different identifier and needs new account connections.
|
|
13
|
+
For development, use a distinct app name and separate secure credential storage.
|
|
14
|
+
Your users still approve access inside Robono. Installing the SDK grants no access.
|
|
6
15
|
|
|
7
16
|
## Link an account with a code
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
use an empty `redirect_uris` list and do not need callback URLs.
|
|
18
|
+
Direct integration requires SDK 0.1.0-preview.4 or newer. Existing integrations
|
|
19
|
+
with registered client IDs remain supported, but new integrations should use `app`.
|
|
12
20
|
|
|
13
21
|
```ts
|
|
14
22
|
import { RobonoLinkedApps, secureTokenStore, nativeCryptoProvider } from '@robono/linked-apps';
|
|
@@ -16,8 +24,12 @@ import * as SecureStore from 'expo-secure-store';
|
|
|
16
24
|
import * as Crypto from 'expo-crypto';
|
|
17
25
|
|
|
18
26
|
const robono = new RobonoLinkedApps({
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
app: {
|
|
28
|
+
name: 'Your App',
|
|
29
|
+
developerName: 'Your Company',
|
|
30
|
+
websiteUrl: 'https://example.com',
|
|
31
|
+
privacyUrl: 'https://example.com/privacy',
|
|
32
|
+
},
|
|
21
33
|
tokenStore: secureTokenStore(SecureStore, 'robono.linked.account.primary'),
|
|
22
34
|
crypto: nativeCryptoProvider({
|
|
23
35
|
getRandomBytes: Crypto.getRandomBytes,
|
|
@@ -58,24 +70,29 @@ storage. Only the app that initiated pairing can exchange the approved request.
|
|
|
58
70
|
Do not call completion repeatedly after receiving tokens. If the successful
|
|
59
71
|
exchange response is lost, start a new pairing; a consumed code cannot be reused.
|
|
60
72
|
|
|
61
|
-
HTTP clients: POST `linked-app-pair` with
|
|
62
|
-
and code_challenge_method.
|
|
63
|
-
|
|
73
|
+
HTTP clients: POST `linked-app-pair` with `app` (name, developerName, websiteUrl,
|
|
74
|
+
privacyUrl), scopes, S256 code_challenge and code_challenge_method. Do not send a
|
|
75
|
+
client_id with app. Save the returned client_id with the pending secret state; no
|
|
76
|
+
separate client-registration call is required. Poll `linked-app-token` using grant_type
|
|
77
|
+
`urn:ietf:params:oauth:grant-type:device_code`, the returned client_id, device_code and
|
|
64
78
|
code_verifier. Wait at least the returned interval (initially five seconds).
|
|
65
79
|
`authorization_pending` means keep waiting; `slow_down` increases the interval
|
|
66
80
|
by five seconds (up to sixty). Stop on `access_denied`, `expired_token`, or
|
|
67
81
|
`invalid_grant`. A 429 requires backoff. Only a successful response contains tokens.
|
|
68
82
|
|
|
69
|
-
The older registered-callback `beginLink` / `completeLink`
|
|
70
|
-
for existing clients.
|
|
83
|
+
The older `clientId` configuration and registered-callback `beginLink` / `completeLink`
|
|
84
|
+
APIs remain supported for existing clients. Do not combine `app` and `clientId`.
|
|
85
|
+
Direct integration uses code pairing and requires no callback handler. The internal
|
|
86
|
+
identifier is public; authorization still depends on PKCE and explicit account approval.
|
|
87
|
+
An app name or client ID is not proof of app ownership.
|
|
71
88
|
|
|
72
89
|
## Display and send
|
|
73
90
|
|
|
74
91
|
```ts
|
|
75
|
-
const snapshot = await
|
|
76
|
-
const page = await
|
|
92
|
+
const snapshot = await robono.conversations();
|
|
93
|
+
const page = await robono.messages(conversationId, { limit: 50 });
|
|
77
94
|
// Generate and persist this ID before sending; use it again on every retry.
|
|
78
|
-
const result = await
|
|
95
|
+
const result = await robono.send({ conversationId, messageKind: 'text',
|
|
79
96
|
clientMessageId: durableOutgoingId, textBody: 'Hello' });
|
|
80
97
|
```
|
|
81
98
|
|
|
@@ -100,10 +117,10 @@ actual listening. Use `delivered` and `read` for their corresponding user states
|
|
|
100
117
|
|
|
101
118
|
```ts
|
|
102
119
|
const controller = new AbortController();
|
|
103
|
-
const watching =
|
|
120
|
+
const watching = robono.watch({
|
|
104
121
|
signal: controller.signal,
|
|
105
122
|
cursorStore: persistedCursors, // keyed by grant ID
|
|
106
|
-
onResync: async () => reconcile(await
|
|
123
|
+
onResync: async () => reconcile(await robono.conversations()),
|
|
107
124
|
onEvents: async events => {
|
|
108
125
|
// Deduplicate message IDs. Fetch only relevant current pages; remove unavailable
|
|
109
126
|
// or deleted content. Do not notify for receipt-only events or your own messages.
|
|
@@ -122,42 +139,67 @@ reported. Walkie-talkie replacement can invalidate an earlier message even if it
|
|
|
122
139
|
was previously downloaded. Read all pages with `has_more` using both returned page
|
|
123
140
|
markers; preserve microsecond timestamps as strings. Do not synthesize timestamps.
|
|
124
141
|
|
|
125
|
-
|
|
142
|
+
The watcher stores its cursor only after successful processing. A callback can run again
|
|
126
143
|
after a crash, so make local writes idempotent. Failed callbacks keep the previous
|
|
127
144
|
cursor. Abort does not advance past unfinished work. Grant changes terminate the old
|
|
128
145
|
watcher; cursors and cached data must never move to a different account.
|
|
129
146
|
|
|
130
|
-
##
|
|
147
|
+
## Live connections and background notifications
|
|
131
148
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
149
|
+
`watch()` opens a TLS WebSocket to `/linked-app-stream`, authenticates with the
|
|
150
|
+
linked account token, and resumes from its saved cursor. The SDK reconnects with
|
|
151
|
+
backoff, including routine server rotation. No webhook is offered.
|
|
138
152
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
Modern browsers, React Native and Node.js 22+ supply WebSocket. Other runtimes may
|
|
154
|
+
inject `webSocket: url => new WebSocketImplementation(url)` in the SDK constructor.
|
|
155
|
+
Do not disable TLS verification. Stop a phone's watcher when backgrounded; resume
|
|
156
|
+
it on foreground. A phone operating system can suspend its sockets.
|
|
157
|
+
|
|
158
|
+
For notifications while your phone app is suspended, your backend can run the same
|
|
159
|
+
watcher and send a content-free hint through your app's APNs/FCM/Expo credentials.
|
|
160
|
+
This uses an outbound connection to Robono, with no URL to register. First verify
|
|
161
|
+
the account/grant using `account()` before routing hints to your own user's devices.
|
|
162
|
+
Disclose server-side account access and protect its credentials. Use one token
|
|
163
|
+
owner to coordinate refreshes; do not let independent phone/server processes rotate
|
|
164
|
+
the same refresh token. Relay through that owner, or pair separately for each host.
|
|
148
165
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
Keep separate cursor stores for independent consumers. Maximum three connections
|
|
167
|
+
per grant. Events are retained for seven days. A longer absence triggers a fresh
|
|
168
|
+
snapshot, not silent event loss. Callbacks must be idempotent. The stream carries
|
|
169
|
+
change IDs, not message content; fetch the authoritative messages via the API.
|
|
170
|
+
Keep companion hints silent by default to avoid duplicating Robono's alerts.
|
|
171
|
+
Background execution and notification timing remain subject to iOS/Android rules.
|
|
153
172
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
173
|
+
### Protocol for clients not using the SDK
|
|
174
|
+
|
|
175
|
+
Connect to `wss://vzoqxavqacydtwypjsrd.supabase.co/functions/v1/linked-app-stream`
|
|
176
|
+
without query parameters. Within ten seconds send this JSON text frame:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{"type":"authenticate","version":1,"access_token":"rla_…","cursor":null}
|
|
180
|
+
```
|
|
159
181
|
|
|
160
|
-
|
|
182
|
+
Use the last successfully processed cursor string, or null for initial sync. Never
|
|
183
|
+
put tokens in URLs or subprotocols. Require `messages:read` permission. The server
|
|
184
|
+
sends `{"type":"ready","grant_id":"…","heartbeat_seconds":15}`; verify the grant.
|
|
185
|
+
A `{"type":"page","page":…}` frame contains the same page as `events.poll`.
|
|
186
|
+
For initial sync or `resync_required`, reconcile conversations and current histories.
|
|
187
|
+
Otherwise apply its events, preserving their order and deduplicating IDs. Save the
|
|
188
|
+
page cursor only after processing succeeds, then send
|
|
189
|
+
`{"type":"ack","cursor":"<saved cursor>"}`. There is at most one outstanding page
|
|
190
|
+
and at most 100 events per page. ACK within 60 seconds or reconnect from the previous
|
|
191
|
+
saved cursor. Respond to `{"type":"ping"}` with `{"type":"pong"}`.
|
|
192
|
+
|
|
193
|
+
On `{"type":"reconnect"}`, reconnect with jitter from your saved cursor. The
|
|
194
|
+
current hosting environment rotates connections after about 85 seconds. The SDK
|
|
195
|
+
handles this. An error frame contains `error` and HTTP-style `status`; stop on
|
|
196
|
+
invalid/revoked credentials or insufficient permission. Back off on temporary
|
|
197
|
+
failures/rate limits. Rotate nearly expired access tokens through the existing
|
|
198
|
+
refresh API, then reconnect. Messages retained during interruptions are replayed.
|
|
199
|
+
`events(cursor)` remains available for a one-time reconciliation; `watch()` uses
|
|
200
|
+
the persistent connection rather than repeatedly polling the HTTP API.
|
|
201
|
+
|
|
202
|
+
Disconnect: call `robono.disconnect()`; clear your local cached account content and stop
|
|
161
203
|
watchers after revocation succeeds. A lost response may require retry/relink UI; a
|
|
162
204
|
user can always revoke from Robono's Linked apps screen. Account deletion, revoked
|
|
163
205
|
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.
|
|
4
|
+
Developer preview. Install the SDK and supply your app details through `app` in its configuration. No developer account, website registration, issued client ID or manual activation is required. Each user approves 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
|
|
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.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,9 +43,9 @@ 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.
|
|
46
|
+
npm install --save-exact @robono/linked-apps@0.1.0-preview.4
|
|
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.
|
|
49
50
|
|
|
50
|
-
The SDK is covered by the included Robono SDK License Agreement. This package contains no client secret or credentials. Robono
|
|
51
|
+
The SDK is covered by the included Robono SDK License Agreement. This package contains no client secret or credentials. The default Robono service URL is built in. Existing registered client IDs remain compatible; new integrations use app details.
|
package/dist/delivery.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** App-supplied display details, not verified ownership or a credential. */
|
|
2
|
+
export interface AppIdentity {
|
|
3
|
+
name: string;
|
|
4
|
+
developerName: string;
|
|
5
|
+
websiteUrl: string;
|
|
6
|
+
privacyUrl: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function normalizeAppIdentity(app: AppIdentity): AppIdentity;
|
|
9
|
+
/** Stable identifier derived from display details; never proof of app ownership. */
|
|
10
|
+
export declare function directAppIdentity(app: AppIdentity, sha256: (bytes: Uint8Array) => Promise<Uint8Array>): Promise<{
|
|
11
|
+
clientId: string;
|
|
12
|
+
profile: AppIdentity;
|
|
13
|
+
}>;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function normalizeAppIdentity(app) {
|
|
2
|
+
if (!app || typeof app !== "object" || Array.isArray(app))
|
|
3
|
+
throw new Error("Provide your app details.");
|
|
4
|
+
const label = (value, max) => {
|
|
5
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value))
|
|
6
|
+
throw new Error("Invalid app details.");
|
|
7
|
+
return value.trim();
|
|
8
|
+
};
|
|
9
|
+
const website = (value) => {
|
|
10
|
+
const url = new URL(label(value, 2048));
|
|
11
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash ||
|
|
12
|
+
!url.hostname.includes(".") || /^(\d+\.){3}\d+$/.test(url.hostname) || url.hostname.includes(":"))
|
|
13
|
+
throw new Error("Use a public HTTPS website and privacy-policy URL.");
|
|
14
|
+
return url.toString();
|
|
15
|
+
};
|
|
16
|
+
return { name: label(app.name, 100), developerName: label(app.developerName, 150),
|
|
17
|
+
websiteUrl: website(app.websiteUrl), privacyUrl: website(app.privacyUrl) };
|
|
18
|
+
}
|
|
19
|
+
/** Stable identifier derived from display details; never proof of app ownership. */
|
|
20
|
+
export async function directAppIdentity(app, sha256) {
|
|
21
|
+
const profile = normalizeAppIdentity(app);
|
|
22
|
+
const bytes = await sha256(new TextEncoder().encode(JSON.stringify([
|
|
23
|
+
"robono-direct-app-v1", profile.name, profile.developerName, profile.websiteUrl, profile.privacyUrl,
|
|
24
|
+
])));
|
|
25
|
+
const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
|
26
|
+
const clientId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
27
|
+
return { clientId, profile };
|
|
28
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type AppIdentity } from "./identity.js";
|
|
2
|
+
export type { AppIdentity } from "./identity.js";
|
|
1
3
|
import { type EventPage, type WatchOptions } from "./delivery.js";
|
|
4
|
+
import { type LinkedSocketFactory } from "./stream.js";
|
|
2
5
|
import type { ConversationPage, Message, MessagePage, Receipt, UploadTicket } from "./types.js";
|
|
3
6
|
export * from "./types.js";
|
|
4
7
|
export * from "./delivery.js";
|
|
5
|
-
export
|
|
8
|
+
export type { LinkedSocket, LinkedSocketFactory } from "./stream.js";
|
|
6
9
|
export type Scope = "account:read" | "messages:read" | "messages:send" | "receipts:write";
|
|
7
10
|
export type Tokens = {
|
|
8
11
|
accessToken: string;
|
|
@@ -73,16 +76,23 @@ export declare class RobonoLinkedApps {
|
|
|
73
76
|
private readonly options;
|
|
74
77
|
private readonly baseUrl;
|
|
75
78
|
private readonly fetcher;
|
|
79
|
+
private readonly app?;
|
|
80
|
+
private identityPending?;
|
|
76
81
|
private pairingPolls;
|
|
77
82
|
private refreshPending;
|
|
78
83
|
constructor(options: {
|
|
79
|
-
functionsUrl
|
|
80
|
-
|
|
84
|
+
functionsUrl?: string;
|
|
85
|
+
/** Direct integration: app details replace website registration. */
|
|
86
|
+
app?: AppIdentity;
|
|
87
|
+
/** Compatibility for existing registered integrations. Use app for new integrations. */
|
|
88
|
+
clientId?: string;
|
|
81
89
|
tokenStore: TokenStore;
|
|
82
90
|
crypto?: CryptoProvider;
|
|
83
91
|
fetch?: typeof fetch;
|
|
84
92
|
timeoutMs?: number;
|
|
93
|
+
webSocket?: LinkedSocketFactory;
|
|
85
94
|
});
|
|
95
|
+
private clientIdentity;
|
|
86
96
|
private post;
|
|
87
97
|
/** Show only userCode. The user approves inside their signed-in Robono app. */
|
|
88
98
|
beginPairing(scopes: Scope[], signal?: AbortSignal): Promise<{
|
|
@@ -150,11 +160,9 @@ export declare class RobonoLinkedApps {
|
|
|
150
160
|
receipts: Receipt[];
|
|
151
161
|
}>;
|
|
152
162
|
events(cursor: string | null, signal?: AbortSignal): Promise<EventPage>;
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
163
|
+
/** Open a live connection, replay missed events, and reconnect automatically.
|
|
164
|
+
* On phones, stop on suspension/logout; a backend may keep watching for push.
|
|
165
|
+
* Checkpoints and acknowledgements happen only after successful callbacks.
|
|
158
166
|
*/
|
|
159
167
|
watch(options: WatchOptions): Promise<void>;
|
|
160
168
|
disconnect(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { directAppIdentity, normalizeAppIdentity } from "./identity.js";
|
|
1
2
|
import { waitForPoll } from "./delivery.js";
|
|
3
|
+
import { consumeStream } from "./stream.js";
|
|
2
4
|
export * from "./types.js";
|
|
3
5
|
export * from "./delivery.js";
|
|
4
|
-
export * from "./webhook.js";
|
|
5
6
|
export class RobonoLinkedAppError extends Error {
|
|
6
7
|
code;
|
|
7
8
|
status;
|
|
@@ -47,11 +48,16 @@ export class RobonoLinkedApps {
|
|
|
47
48
|
options;
|
|
48
49
|
baseUrl;
|
|
49
50
|
fetcher;
|
|
51
|
+
app;
|
|
52
|
+
identityPending;
|
|
50
53
|
pairingPolls = new Map();
|
|
51
54
|
refreshPending = null;
|
|
52
55
|
constructor(options) {
|
|
53
56
|
this.options = options;
|
|
54
|
-
|
|
57
|
+
if (Boolean(options.app) === Boolean(options.clientId))
|
|
58
|
+
throw new Error("Provide app details or an existing clientId, not both.");
|
|
59
|
+
this.app = options.app ? normalizeAppIdentity(options.app) : undefined;
|
|
60
|
+
const url = new URL(options.functionsUrl ?? "https://vzoqxavqacydtwypjsrd.supabase.co/functions/v1");
|
|
55
61
|
if (url.username || url.password || url.search || url.hash ||
|
|
56
62
|
!(url.protocol === "https:" ||
|
|
57
63
|
(url.protocol === "http:" &&
|
|
@@ -61,6 +67,15 @@ export class RobonoLinkedApps {
|
|
|
61
67
|
this.baseUrl = url.toString().replace(/\/$/, "");
|
|
62
68
|
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
63
69
|
}
|
|
70
|
+
clientIdentity() {
|
|
71
|
+
if (this.options.clientId)
|
|
72
|
+
return Promise.resolve(this.options.clientId);
|
|
73
|
+
if (!this.identityPending) {
|
|
74
|
+
const crypto = this.options.crypto ?? webCryptoProvider();
|
|
75
|
+
this.identityPending = directAppIdentity(this.app, bytes => crypto.sha256(bytes)).then(result => result.clientId);
|
|
76
|
+
}
|
|
77
|
+
return this.identityPending;
|
|
78
|
+
}
|
|
64
79
|
async post(endpoint, body, token, signal) {
|
|
65
80
|
const controller = new AbortController();
|
|
66
81
|
const cancel = () => controller.abort();
|
|
@@ -102,23 +117,25 @@ export class RobonoLinkedApps {
|
|
|
102
117
|
const verifier = hex(crypto.randomBytes(32));
|
|
103
118
|
const challenge = base64url(await crypto.sha256(new TextEncoder().encode(verifier)));
|
|
104
119
|
const result = await this.post("linked-app-pair", {
|
|
105
|
-
client_id: this.
|
|
120
|
+
...(this.app ? { app: this.app } : { client_id: await this.clientIdentity() }), scopes,
|
|
106
121
|
code_challenge: challenge, code_challenge_method: "S256",
|
|
107
122
|
}, undefined, signal);
|
|
123
|
+
if (this.app && result.client_id !== await this.clientIdentity())
|
|
124
|
+
throw new RobonoLinkedAppError("invalid_client_identity", 400);
|
|
108
125
|
const interval = Math.max(5, result.interval);
|
|
109
126
|
return {
|
|
110
127
|
userCode: result.user_code,
|
|
111
128
|
verificationUrl: result.verification_uri,
|
|
112
129
|
verificationAppUrl: result.verification_app_uri,
|
|
113
130
|
pending: {
|
|
114
|
-
clientId: this.
|
|
131
|
+
clientId: await this.clientIdentity(), deviceCode: result.device_code, verifier,
|
|
115
132
|
expiresAt: result.expires_at, interval, nextPollAt: Date.now() + interval * 1000,
|
|
116
133
|
},
|
|
117
134
|
};
|
|
118
135
|
}
|
|
119
136
|
/** Returns pending until approval. Concurrent calls share one token exchange. */
|
|
120
137
|
async pollPairing(pending, signal) {
|
|
121
|
-
if (pending.clientId !== this.
|
|
138
|
+
if (pending.clientId !== await this.clientIdentity() ||
|
|
122
139
|
!Number.isFinite(Date.parse(pending.expiresAt)) ||
|
|
123
140
|
Date.parse(pending.expiresAt) <= Date.now()) {
|
|
124
141
|
throw new RobonoLinkedAppError("expired_token", 400);
|
|
@@ -134,7 +151,7 @@ export class RobonoLinkedApps {
|
|
|
134
151
|
pending.nextPollAt = Date.now() + Math.max(5, pending.interval) * 1000;
|
|
135
152
|
try {
|
|
136
153
|
const wire = await this.post("linked-app-token", {
|
|
137
|
-
client_id: this.
|
|
154
|
+
client_id: await this.clientIdentity(),
|
|
138
155
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
139
156
|
device_code: pending.deviceCode, code_verifier: pending.verifier,
|
|
140
157
|
}, undefined, signal);
|
|
@@ -178,6 +195,8 @@ export class RobonoLinkedApps {
|
|
|
178
195
|
throw new RobonoLinkedAppError("cancelled", 400);
|
|
179
196
|
}
|
|
180
197
|
async beginLink(redirectUri, scopes) {
|
|
198
|
+
if (this.app)
|
|
199
|
+
throw new Error("Direct integrations use beginPairing; redirect linking requires an existing registered clientId.");
|
|
181
200
|
if (!scopes.length || scopes.some((scope) => !scopeNames.has(scope))) {
|
|
182
201
|
throw new Error("Select supported permissions.");
|
|
183
202
|
}
|
|
@@ -185,7 +204,7 @@ export class RobonoLinkedApps {
|
|
|
185
204
|
const verifier = hex(crypto.randomBytes(32)), state = hex(crypto.randomBytes(32));
|
|
186
205
|
const challenge = base64url(await crypto.sha256(new TextEncoder().encode(verifier)));
|
|
187
206
|
const result = await this.post("linked-app-authorize", {
|
|
188
|
-
client_id: this.
|
|
207
|
+
client_id: await this.clientIdentity(),
|
|
189
208
|
redirect_uri: redirectUri,
|
|
190
209
|
scopes,
|
|
191
210
|
state,
|
|
@@ -196,7 +215,7 @@ export class RobonoLinkedApps {
|
|
|
196
215
|
authorizationUrl: result.authorization_url,
|
|
197
216
|
requestId: result.request_id,
|
|
198
217
|
pending: {
|
|
199
|
-
clientId: this.
|
|
218
|
+
clientId: await this.clientIdentity(),
|
|
200
219
|
redirectUri,
|
|
201
220
|
verifier,
|
|
202
221
|
state,
|
|
@@ -206,7 +225,7 @@ export class RobonoLinkedApps {
|
|
|
206
225
|
}
|
|
207
226
|
async completeLink(callback, pending) {
|
|
208
227
|
const url = new URL(callback), expected = new URL(pending.redirectUri);
|
|
209
|
-
if (pending.clientId !== this.
|
|
228
|
+
if (pending.clientId !== await this.clientIdentity() ||
|
|
210
229
|
Date.parse(pending.expiresAt) <= Date.now() ||
|
|
211
230
|
url.protocol !== expected.protocol || url.host !== expected.host ||
|
|
212
231
|
url.pathname !== expected.pathname ||
|
|
@@ -225,7 +244,7 @@ export class RobonoLinkedApps {
|
|
|
225
244
|
throw new RobonoLinkedAppError("invalid_callback", 400);
|
|
226
245
|
}
|
|
227
246
|
return this.save(await this.post("linked-app-token", {
|
|
228
|
-
client_id: this.
|
|
247
|
+
client_id: await this.clientIdentity(),
|
|
229
248
|
grant_type: "authorization_code",
|
|
230
249
|
redirect_uri: pending.redirectUri,
|
|
231
250
|
code,
|
|
@@ -253,7 +272,7 @@ export class RobonoLinkedApps {
|
|
|
253
272
|
throw new RobonoLinkedAppError("not_connected", 401);
|
|
254
273
|
try {
|
|
255
274
|
return await this.save(await this.post("linked-app-token", {
|
|
256
|
-
client_id: this.
|
|
275
|
+
client_id: await this.clientIdentity(),
|
|
257
276
|
grant_type: "refresh_token",
|
|
258
277
|
refresh_token: tokens.refreshToken,
|
|
259
278
|
}));
|
|
@@ -355,52 +374,72 @@ export class RobonoLinkedApps {
|
|
|
355
374
|
events(cursor, signal) {
|
|
356
375
|
return this.call("events.poll", { cursor }, signal);
|
|
357
376
|
}
|
|
358
|
-
|
|
359
|
-
|
|
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.
|
|
377
|
+
/** Open a live connection, replay missed events, and reconnect automatically.
|
|
378
|
+
* On phones, stop on suspension/logout; a backend may keep watching for push.
|
|
379
|
+
* Checkpoints and acknowledgements happen only after successful callbacks.
|
|
363
380
|
*/
|
|
364
381
|
async watch(options) {
|
|
365
|
-
const
|
|
366
|
-
if (!
|
|
382
|
+
const initial = await this.options.tokenStore.get();
|
|
383
|
+
if (!initial)
|
|
367
384
|
throw new RobonoLinkedAppError("not_connected", 401);
|
|
368
|
-
const grantId =
|
|
369
|
-
|
|
385
|
+
const grantId = initial.grantId;
|
|
386
|
+
const factory = this.options.webSocket ?? ((url) => new WebSocket(url));
|
|
387
|
+
if (!this.options.webSocket && typeof globalThis.WebSocket !== "function") {
|
|
388
|
+
throw new Error("Provide a WebSocket implementation for this runtime.");
|
|
389
|
+
}
|
|
390
|
+
const streamUrl = this.baseUrl.replace(/^http/, "ws") + "/linked-app-stream";
|
|
391
|
+
const current = async () => {
|
|
392
|
+
const tokens = await this.options.tokenStore.get();
|
|
393
|
+
if (!tokens || tokens.grantId !== grantId)
|
|
394
|
+
throw new RobonoLinkedAppError("not_connected", 401);
|
|
395
|
+
return tokens;
|
|
396
|
+
};
|
|
370
397
|
let failures = 0;
|
|
371
398
|
while (!options.signal.aborted) {
|
|
399
|
+
let sessionToken = "";
|
|
372
400
|
try {
|
|
373
|
-
|
|
374
|
-
if (
|
|
401
|
+
let tokens = await current();
|
|
402
|
+
if (tokens.expiresAt <= Date.now() + 30_000)
|
|
403
|
+
tokens = await this.refresh();
|
|
404
|
+
if (tokens.grantId !== grantId)
|
|
375
405
|
throw new RobonoLinkedAppError("not_connected", 401);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
406
|
+
sessionToken = tokens.accessToken;
|
|
407
|
+
let cursor = await options.cursorStore.get(grantId);
|
|
408
|
+
await consumeStream({ url: streamUrl, factory, tokens, cursor, signal: options.signal,
|
|
409
|
+
onPage: async (page) => {
|
|
410
|
+
await current();
|
|
411
|
+
if (options.signal.aborted)
|
|
412
|
+
return;
|
|
413
|
+
if (cursor === null || page.resync_required)
|
|
414
|
+
await options.onResync();
|
|
415
|
+
else if (page.events.length)
|
|
416
|
+
await options.onEvents(page.events);
|
|
417
|
+
await current();
|
|
418
|
+
if (options.signal.aborted)
|
|
419
|
+
return;
|
|
420
|
+
await options.cursorStore.set(grantId, page.cursor);
|
|
421
|
+
cursor = page.cursor;
|
|
422
|
+
failures = 0;
|
|
423
|
+
},
|
|
424
|
+
});
|
|
384
425
|
if (options.signal.aborted)
|
|
385
426
|
return;
|
|
386
|
-
await options.cursorStore.set(grantId, page.cursor);
|
|
387
|
-
cursor = page.cursor;
|
|
388
|
-
failures = 0;
|
|
389
|
-
if (page.has_more)
|
|
390
|
-
continue;
|
|
391
427
|
}
|
|
392
428
|
catch (error) {
|
|
393
429
|
if (options.signal.aborted)
|
|
394
430
|
return;
|
|
395
|
-
if (error instanceof RobonoLinkedAppError &&
|
|
396
|
-
|
|
431
|
+
if (error instanceof RobonoLinkedAppError && error.code === "invalid_token") {
|
|
432
|
+
const tokens = await current();
|
|
433
|
+
// Another request may have rotated the token used by this socket.
|
|
434
|
+
if (tokens.accessToken !== sessionToken || tokens.expiresAt <= Date.now() + 30_000)
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (error instanceof RobonoLinkedAppError && [400, 401, 403].includes(error.status))
|
|
397
438
|
throw error;
|
|
398
439
|
options.onError?.(error);
|
|
399
440
|
failures++;
|
|
400
441
|
}
|
|
401
|
-
await waitForPoll(failures
|
|
402
|
-
? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5))
|
|
403
|
-
: Math.max(1000, options.pollIntervalMs ?? 1000), options.signal);
|
|
442
|
+
await waitForPoll(failures ? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5)) : 250 + Math.floor(Math.random() * 500), options.signal);
|
|
404
443
|
}
|
|
405
444
|
}
|
|
406
445
|
async disconnect() {
|
package/dist/stream.d.ts
ADDED
|
@@ -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
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
|
-
}
|