@robono/linked-apps 0.1.0-preview.1 → 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 +18 -0
- package/INTEGRATION.md +111 -61
- package/README.md +12 -9
- package/dist/delivery.d.ts +0 -1
- package/dist/index.d.ts +40 -6
- package/dist/index.js +135 -30
- 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,21 @@
|
|
|
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
|
+
|
|
11
|
+
# 0.1.0-preview.2
|
|
12
|
+
|
|
13
|
+
- Add `beginPairing`, `pollPairing`, and cancellable `waitForPairing`.
|
|
14
|
+
- Codes expire after five minutes and require owner approval in Robono.
|
|
15
|
+
- Bind exchange to the originating client and PKCE verifier, enforce polling backoff,
|
|
16
|
+
and serialize concurrent polls. Existing callback and messaging APIs remain supported.
|
|
17
|
+
- Pairing uses the existing Robono login. No extra SMS check or callback URL required.
|
|
18
|
+
|
|
1
19
|
# Changelog
|
|
2
20
|
|
|
3
21
|
## 0.1.0-preview.1
|
package/INTEGRATION.md
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
# Integrating an independent app
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
## Link an account
|
|
10
|
+
## Link an account with a code
|
|
11
|
+
|
|
12
|
+
Use SDK 0.1.0-preview.3 or newer for persistent connections. The currently registered
|
|
13
|
+
client ID is public; users never need a developer account. Pairing-only clients
|
|
14
|
+
use an empty `redirect_uris` list and do not need callback URLs.
|
|
8
15
|
|
|
9
16
|
```ts
|
|
10
17
|
import { RobonoLinkedApps, secureTokenStore, nativeCryptoProvider } from '@robono/linked-apps';
|
|
11
18
|
import * as SecureStore from 'expo-secure-store';
|
|
12
19
|
import * as Crypto from 'expo-crypto';
|
|
13
|
-
import { Linking } from 'react-native';
|
|
14
20
|
|
|
15
|
-
const
|
|
16
|
-
functionsUrl:
|
|
21
|
+
const robono = new RobonoLinkedApps({
|
|
22
|
+
functionsUrl: 'https://vzoqxavqacydtwypjsrd.supabase.co/functions/v1',
|
|
17
23
|
clientId: config.robonoClientId,
|
|
18
24
|
tokenStore: secureTokenStore(SecureStore, 'robono.linked.account.primary'),
|
|
19
25
|
crypto: nativeCryptoProvider({
|
|
@@ -21,39 +27,58 @@ const ot = new RobonoLinkedApps({
|
|
|
21
27
|
digest: (_, bytes) => Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(bytes)),
|
|
22
28
|
}),
|
|
23
29
|
});
|
|
24
|
-
const
|
|
30
|
+
const pairing = await robono.beginPairing(
|
|
25
31
|
['account:read', 'messages:read', 'messages:send', 'receipts:write']);
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
await
|
|
32
|
+
// Show pairing.userCode with a five-minute expiry indication.
|
|
33
|
+
// Tell the user: Robono > Linked apps > Connect an app > enter this code.
|
|
34
|
+
// Optional: open pairing.verificationAppUrl to take them to Robono.
|
|
35
|
+
await SecureStore.setItemAsync('robono.pending-pair', JSON.stringify(pairing.pending));
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
// Abort when this screen closes, the account changes, or the app backgrounds.
|
|
38
|
+
const tokens = await robono.waitForPairing(pairing.pending, controller.signal);
|
|
39
|
+
await SecureStore.deleteItemAsync('robono.pending-pair');
|
|
40
|
+
// Connected. Tokens are already saved by the configured TokenStore.
|
|
33
41
|
```
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
If backgrounded while the user opens Robono, cancel polling, preserve pending
|
|
44
|
+
state securely, then resume `waitForPairing` on foreground if it has not expired.
|
|
45
|
+
Use one poller per pairing and one SDK instance per connected account. You can
|
|
46
|
+
call `pollPairing` directly; it returns `pending` or `connected` and updates the
|
|
47
|
+
pending state's interval and next-poll time. Persist those updates if resuming
|
|
48
|
+
across process restarts. Network failures are surfaced: offer a retry with
|
|
49
|
+
backoff while the code remains valid. Never spin on errors.
|
|
50
|
+
|
|
51
|
+
Robono authenticates the owner using its existing phone session. A signed-out
|
|
52
|
+
user signs in through Robono's normal login first. There is no extra SMS check
|
|
53
|
+
for account linking. The code does not itself authorize access: the user must
|
|
54
|
+
review the app, account, and permissions and choose Allow connection in Robono.
|
|
55
|
+
Never request the user's Robono login code, password, or phone-session token.
|
|
56
|
+
|
|
57
|
+
The visible code expires after five minutes. The device code and PKCE verifier
|
|
58
|
+
are secrets; never show, log, put them in URLs, or send them to analytics. Native
|
|
59
|
+
clients use Keychain/Keystore; browser/server hosts must supply equivalent secure
|
|
60
|
+
storage. Only the app that initiated pairing can exchange the approved request.
|
|
61
|
+
Do not call completion repeatedly after receiving tokens. If the successful
|
|
62
|
+
exchange response is lost, start a new pairing; a consumed code cannot be reused.
|
|
63
|
+
|
|
64
|
+
HTTP clients: POST `linked-app-pair` with client_id, scopes, S256 code_challenge
|
|
65
|
+
and code_challenge_method. Poll `linked-app-token` using grant_type
|
|
66
|
+
`urn:ietf:params:oauth:grant-type:device_code`, client_id, device_code and
|
|
67
|
+
code_verifier. Wait at least the returned interval (initially five seconds).
|
|
68
|
+
`authorization_pending` means keep waiting; `slow_down` increases the interval
|
|
69
|
+
by five seconds (up to sixty). Stop on `access_denied`, `expired_token`, or
|
|
70
|
+
`invalid_grant`. A 429 requires backoff. Only a successful response contains tokens.
|
|
71
|
+
|
|
72
|
+
The older registered-callback `beginLink` / `completeLink` APIs remain supported
|
|
73
|
+
for existing clients. New code pairing requires no callback handler.
|
|
49
74
|
|
|
50
75
|
## Display and send
|
|
51
76
|
|
|
52
77
|
```ts
|
|
53
|
-
const snapshot = await
|
|
54
|
-
const page = await
|
|
78
|
+
const snapshot = await robono.conversations();
|
|
79
|
+
const page = await robono.messages(conversationId, { limit: 50 });
|
|
55
80
|
// Generate and persist this ID before sending; use it again on every retry.
|
|
56
|
-
const result = await
|
|
81
|
+
const result = await robono.send({ conversationId, messageKind: 'text',
|
|
57
82
|
clientMessageId: durableOutgoingId, textBody: 'Hello' });
|
|
58
83
|
```
|
|
59
84
|
|
|
@@ -78,10 +103,10 @@ actual listening. Use `delivered` and `read` for their corresponding user states
|
|
|
78
103
|
|
|
79
104
|
```ts
|
|
80
105
|
const controller = new AbortController();
|
|
81
|
-
const watching =
|
|
106
|
+
const watching = robono.watch({
|
|
82
107
|
signal: controller.signal,
|
|
83
108
|
cursorStore: persistedCursors, // keyed by grant ID
|
|
84
|
-
onResync: async () => reconcile(await
|
|
109
|
+
onResync: async () => reconcile(await robono.conversations()),
|
|
85
110
|
onEvents: async events => {
|
|
86
111
|
// Deduplicate message IDs. Fetch only relevant current pages; remove unavailable
|
|
87
112
|
// or deleted content. Do not notify for receipt-only events or your own messages.
|
|
@@ -100,42 +125,67 @@ reported. Walkie-talkie replacement can invalidate an earlier message even if it
|
|
|
100
125
|
was previously downloaded. Read all pages with `has_more` using both returned page
|
|
101
126
|
markers; preserve microsecond timestamps as strings. Do not synthesize timestamps.
|
|
102
127
|
|
|
103
|
-
|
|
128
|
+
The watcher stores its cursor only after successful processing. A callback can run again
|
|
104
129
|
after a crash, so make local writes idempotent. Failed callbacks keep the previous
|
|
105
130
|
cursor. Abort does not advance past unfinished work. Grant changes terminate the old
|
|
106
131
|
watcher; cursors and cached data must never move to a different account.
|
|
107
132
|
|
|
108
|
-
##
|
|
133
|
+
## Live connections and background notifications
|
|
109
134
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
a client-supplied grant ID to route another user's notifications.
|
|
114
|
-
3. Call `configureBackgroundDelivery(true)` after the mapping is ready.
|
|
115
|
-
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.
|
|
116
138
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
151
|
+
|
|
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.
|
|
126
158
|
|
|
127
|
-
|
|
128
|
-
five minutes. Delivery IDs remain stable across retries; attempt timestamps change.
|
|
129
|
-
Reject replayed IDs after verification, check client identity, limit request size and
|
|
130
|
-
retain dedup IDs for at least seven days. Invalid signatures receive no processing.
|
|
159
|
+
### Protocol for clients not using the SDK
|
|
131
160
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
```
|
|
137
167
|
|
|
138
|
-
|
|
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
|
|
139
189
|
watchers after revocation succeeds. A lost response may require retry/relink UI; a
|
|
140
190
|
user can always revoke from Robono's Linked apps screen. Account deletion, revoked
|
|
141
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.
|
|
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
|
|
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:
|
|
@@ -17,8 +17,10 @@ npm --prefix packages/linked-apps test
|
|
|
17
17
|
|
|
18
18
|
Use one `RobonoLinkedApps` instance per connected account. Inject a Keychain/Keystore
|
|
19
19
|
`TokenStore` and native `CryptoProvider`; adapters are exported. Browser/server
|
|
20
|
-
runtimes may use WebCrypto.
|
|
21
|
-
|
|
20
|
+
runtimes may use WebCrypto. Use `beginPairing` to obtain a code, show it to the user, then call
|
|
21
|
+
`waitForPairing` to finish after approval in Robono. Store pending state securely.
|
|
22
|
+
Robono reuses its normal login; account linking adds no SMS verification.
|
|
23
|
+
Registered callback linking remains available for existing clients.
|
|
22
24
|
|
|
23
25
|
[Integration guide](./INTEGRATION.md) ·
|
|
24
26
|
[API reference](https://robono.com/api-reference-viewer.html?spec=linked-apps) ·
|
|
@@ -26,10 +28,11 @@ opening Robono; validate the callback using `completeLink`.
|
|
|
26
28
|
|
|
27
29
|
The watcher is explicit and cancellable. Stop on background/logout; resume on a push
|
|
28
30
|
hint or foreground. It checkpoints only after successful consumer callbacks, so
|
|
29
|
-
callbacks must tolerate redelivery.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
33
36
|
|
|
34
37
|
Send retries must retain the original UUID. Refresh requests are serialized within
|
|
35
38
|
one instance; coordinate across processes yourself. A lost refresh response can
|
|
@@ -40,7 +43,7 @@ require relinking because reusing a spent refresh token revokes the grant.
|
|
|
40
43
|
The official distribution channel is npm. Install this exact preview version:
|
|
41
44
|
|
|
42
45
|
```sh
|
|
43
|
-
npm install --save-exact @robono/linked-apps@0.1.0-preview.
|
|
46
|
+
npm install --save-exact @robono/linked-apps@0.1.0-preview.3
|
|
44
47
|
```
|
|
45
48
|
|
|
46
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.
|
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. */
|
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
|
|
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;
|
|
@@ -28,6 +29,21 @@ export interface PendingLink {
|
|
|
28
29
|
state: string;
|
|
29
30
|
expiresAt: string;
|
|
30
31
|
}
|
|
32
|
+
/** Secret pending state: keep in secure storage, never display or log it. */
|
|
33
|
+
export interface PendingPairing {
|
|
34
|
+
clientId: string;
|
|
35
|
+
deviceCode: string;
|
|
36
|
+
verifier: string;
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
interval: number;
|
|
39
|
+
nextPollAt: number;
|
|
40
|
+
}
|
|
41
|
+
export type PairingResult = {
|
|
42
|
+
status: "pending";
|
|
43
|
+
} | {
|
|
44
|
+
status: "connected";
|
|
45
|
+
tokens: Tokens;
|
|
46
|
+
};
|
|
31
47
|
export interface SendMessage {
|
|
32
48
|
conversationId: string;
|
|
33
49
|
messageKind: "text" | "voice" | "image" | "video" | "document";
|
|
@@ -58,6 +74,7 @@ export declare class RobonoLinkedApps {
|
|
|
58
74
|
private readonly options;
|
|
59
75
|
private readonly baseUrl;
|
|
60
76
|
private readonly fetcher;
|
|
77
|
+
private pairingPolls;
|
|
61
78
|
private refreshPending;
|
|
62
79
|
constructor(options: {
|
|
63
80
|
functionsUrl: string;
|
|
@@ -66,8 +83,27 @@ export declare class RobonoLinkedApps {
|
|
|
66
83
|
crypto?: CryptoProvider;
|
|
67
84
|
fetch?: typeof fetch;
|
|
68
85
|
timeoutMs?: number;
|
|
86
|
+
webSocket?: LinkedSocketFactory;
|
|
69
87
|
});
|
|
70
88
|
private post;
|
|
89
|
+
/** Show only userCode. The user approves inside their signed-in Robono app. */
|
|
90
|
+
beginPairing(scopes: Scope[], signal?: AbortSignal): Promise<{
|
|
91
|
+
userCode: string;
|
|
92
|
+
verificationUrl: string;
|
|
93
|
+
verificationAppUrl: string;
|
|
94
|
+
pending: {
|
|
95
|
+
clientId: string;
|
|
96
|
+
deviceCode: string;
|
|
97
|
+
verifier: string;
|
|
98
|
+
expiresAt: string;
|
|
99
|
+
interval: number;
|
|
100
|
+
nextPollAt: number;
|
|
101
|
+
};
|
|
102
|
+
}>;
|
|
103
|
+
/** Returns pending until approval. Concurrent calls share one token exchange. */
|
|
104
|
+
pollPairing(pending: PendingPairing, signal?: AbortSignal): Promise<PairingResult>;
|
|
105
|
+
/** Cancel when leaving the pairing screen. Never polls in the background implicitly. */
|
|
106
|
+
waitForPairing(pending: PendingPairing, signal: AbortSignal): Promise<Tokens>;
|
|
71
107
|
beginLink(redirectUri: string, scopes: Scope[]): Promise<{
|
|
72
108
|
authorizationUrl: string;
|
|
73
109
|
requestId: string;
|
|
@@ -116,11 +152,9 @@ export declare class RobonoLinkedApps {
|
|
|
116
152
|
receipts: Receipt[];
|
|
117
153
|
}>;
|
|
118
154
|
events(cursor: string | null, signal?: AbortSignal): Promise<EventPage>;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
/** Run while active; stop on background, resume on foreground or a push hint.
|
|
123
|
-
* 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.
|
|
124
158
|
*/
|
|
125
159
|
watch(options: WatchOptions): Promise<void>;
|
|
126
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;
|
|
@@ -47,6 +47,7 @@ export class RobonoLinkedApps {
|
|
|
47
47
|
options;
|
|
48
48
|
baseUrl;
|
|
49
49
|
fetcher;
|
|
50
|
+
pairingPolls = new Map();
|
|
50
51
|
refreshPending = null;
|
|
51
52
|
constructor(options) {
|
|
52
53
|
this.options = options;
|
|
@@ -92,6 +93,90 @@ export class RobonoLinkedApps {
|
|
|
92
93
|
signal?.removeEventListener("abort", cancel);
|
|
93
94
|
}
|
|
94
95
|
}
|
|
96
|
+
/** Show only userCode. The user approves inside their signed-in Robono app. */
|
|
97
|
+
async beginPairing(scopes, signal) {
|
|
98
|
+
if (!scopes.length || scopes.some((scope) => !scopeNames.has(scope))) {
|
|
99
|
+
throw new Error("Select supported permissions.");
|
|
100
|
+
}
|
|
101
|
+
const crypto = this.options.crypto ?? webCryptoProvider();
|
|
102
|
+
const verifier = hex(crypto.randomBytes(32));
|
|
103
|
+
const challenge = base64url(await crypto.sha256(new TextEncoder().encode(verifier)));
|
|
104
|
+
const result = await this.post("linked-app-pair", {
|
|
105
|
+
client_id: this.options.clientId, scopes,
|
|
106
|
+
code_challenge: challenge, code_challenge_method: "S256",
|
|
107
|
+
}, undefined, signal);
|
|
108
|
+
const interval = Math.max(5, result.interval);
|
|
109
|
+
return {
|
|
110
|
+
userCode: result.user_code,
|
|
111
|
+
verificationUrl: result.verification_uri,
|
|
112
|
+
verificationAppUrl: result.verification_app_uri,
|
|
113
|
+
pending: {
|
|
114
|
+
clientId: this.options.clientId, deviceCode: result.device_code, verifier,
|
|
115
|
+
expiresAt: result.expires_at, interval, nextPollAt: Date.now() + interval * 1000,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Returns pending until approval. Concurrent calls share one token exchange. */
|
|
120
|
+
async pollPairing(pending, signal) {
|
|
121
|
+
if (pending.clientId !== this.options.clientId ||
|
|
122
|
+
!Number.isFinite(Date.parse(pending.expiresAt)) ||
|
|
123
|
+
Date.parse(pending.expiresAt) <= Date.now()) {
|
|
124
|
+
throw new RobonoLinkedAppError("expired_token", 400);
|
|
125
|
+
}
|
|
126
|
+
if (signal?.aborted)
|
|
127
|
+
throw new RobonoLinkedAppError("cancelled", 400);
|
|
128
|
+
const existing = this.pairingPolls.get(pending.deviceCode);
|
|
129
|
+
if (existing)
|
|
130
|
+
return existing;
|
|
131
|
+
if (Date.now() < pending.nextPollAt)
|
|
132
|
+
return { status: "pending" };
|
|
133
|
+
const operation = (async () => {
|
|
134
|
+
pending.nextPollAt = Date.now() + Math.max(5, pending.interval) * 1000;
|
|
135
|
+
try {
|
|
136
|
+
const wire = await this.post("linked-app-token", {
|
|
137
|
+
client_id: this.options.clientId,
|
|
138
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
139
|
+
device_code: pending.deviceCode, code_verifier: pending.verifier,
|
|
140
|
+
}, undefined, signal);
|
|
141
|
+
const tokens = await this.save(wire);
|
|
142
|
+
return { status: "connected", tokens };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof RobonoLinkedAppError &&
|
|
146
|
+
["authorization_pending", "slow_down"].includes(error.code)) {
|
|
147
|
+
if (error.code === "slow_down")
|
|
148
|
+
pending.interval = Math.min(60, pending.interval + 5);
|
|
149
|
+
return { status: "pending" };
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
pending.nextPollAt = Date.now() + Math.max(5, pending.interval) * 1000;
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
this.pairingPolls.set(pending.deviceCode, operation);
|
|
158
|
+
try {
|
|
159
|
+
return await operation;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
this.pairingPolls.delete(pending.deviceCode);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Cancel when leaving the pairing screen. Never polls in the background implicitly. */
|
|
166
|
+
async waitForPairing(pending, signal) {
|
|
167
|
+
while (!signal.aborted) {
|
|
168
|
+
if (Date.parse(pending.expiresAt) <= Date.now()) {
|
|
169
|
+
throw new RobonoLinkedAppError("expired_token", 400);
|
|
170
|
+
}
|
|
171
|
+
await waitForPoll(Math.max(0, Math.min(pending.nextPollAt, Date.parse(pending.expiresAt)) - Date.now()), signal);
|
|
172
|
+
if (signal.aborted)
|
|
173
|
+
break;
|
|
174
|
+
const result = await this.pollPairing(pending, signal);
|
|
175
|
+
if (result.status === "connected")
|
|
176
|
+
return result.tokens;
|
|
177
|
+
}
|
|
178
|
+
throw new RobonoLinkedAppError("cancelled", 400);
|
|
179
|
+
}
|
|
95
180
|
async beginLink(redirectUri, scopes) {
|
|
96
181
|
if (!scopes.length || scopes.some((scope) => !scopeNames.has(scope))) {
|
|
97
182
|
throw new Error("Select supported permissions.");
|
|
@@ -270,52 +355,72 @@ export class RobonoLinkedApps {
|
|
|
270
355
|
events(cursor, signal) {
|
|
271
356
|
return this.call("events.poll", { cursor }, signal);
|
|
272
357
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
/** Run while active; stop on background, resume on foreground or a push hint.
|
|
277
|
-
* 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.
|
|
278
361
|
*/
|
|
279
362
|
async watch(options) {
|
|
280
|
-
const
|
|
281
|
-
if (!
|
|
363
|
+
const initial = await this.options.tokenStore.get();
|
|
364
|
+
if (!initial)
|
|
282
365
|
throw new RobonoLinkedAppError("not_connected", 401);
|
|
283
|
-
const grantId =
|
|
284
|
-
|
|
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
|
+
};
|
|
285
378
|
let failures = 0;
|
|
286
379
|
while (!options.signal.aborted) {
|
|
380
|
+
let sessionToken = "";
|
|
287
381
|
try {
|
|
288
|
-
|
|
289
|
-
if (
|
|
382
|
+
let tokens = await current();
|
|
383
|
+
if (tokens.expiresAt <= Date.now() + 30_000)
|
|
384
|
+
tokens = await this.refresh();
|
|
385
|
+
if (tokens.grantId !== grantId)
|
|
290
386
|
throw new RobonoLinkedAppError("not_connected", 401);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
});
|
|
299
406
|
if (options.signal.aborted)
|
|
300
407
|
return;
|
|
301
|
-
await options.cursorStore.set(grantId, page.cursor);
|
|
302
|
-
cursor = page.cursor;
|
|
303
|
-
failures = 0;
|
|
304
|
-
if (page.has_more)
|
|
305
|
-
continue;
|
|
306
408
|
}
|
|
307
409
|
catch (error) {
|
|
308
410
|
if (options.signal.aborted)
|
|
309
411
|
return;
|
|
310
|
-
if (error instanceof RobonoLinkedAppError &&
|
|
311
|
-
|
|
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))
|
|
312
419
|
throw error;
|
|
313
420
|
options.onError?.(error);
|
|
314
421
|
failures++;
|
|
315
422
|
}
|
|
316
|
-
await waitForPoll(failures
|
|
317
|
-
? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5))
|
|
318
|
-
: 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);
|
|
319
424
|
}
|
|
320
425
|
}
|
|
321
426
|
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
|
-
}
|