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

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,11 @@
1
+ # 0.1.0-preview.2
2
+
3
+ - Add `beginPairing`, `pollPairing`, and cancellable `waitForPairing`.
4
+ - Codes expire after five minutes and require owner approval in Robono.
5
+ - Bind exchange to the originating client and PKCE verifier, enforce polling backoff,
6
+ and serialize concurrent polls. Existing callback and messaging APIs remain supported.
7
+ - Pairing uses the existing Robono login. No extra SMS check or callback URL required.
8
+
1
9
  # Changelog
2
10
 
3
11
  ## 0.1.0-preview.1
package/INTEGRATION.md CHANGED
@@ -4,16 +4,19 @@ Use a separate registration for development and production. Robono supplies its
4
4
  functions URL, client ID and reviewed redirect URI/scopes. Client IDs are public.
5
5
  A backend webhook signing secret is confidential and must never enter a mobile bundle.
6
6
 
7
- ## Link an account
7
+ ## Link an account with a code
8
+
9
+ Pairing support requires SDK 0.1.0-preview.2 or newer. The currently registered
10
+ client ID is public; users never need a developer account. Pairing-only clients
11
+ use an empty `redirect_uris` list and do not need callback URLs.
8
12
 
9
13
  ```ts
10
14
  import { RobonoLinkedApps, secureTokenStore, nativeCryptoProvider } from '@robono/linked-apps';
11
15
  import * as SecureStore from 'expo-secure-store';
12
16
  import * as Crypto from 'expo-crypto';
13
- import { Linking } from 'react-native';
14
17
 
15
- const ot = new RobonoLinkedApps({
16
- functionsUrl: config.robonoFunctionsUrl,
18
+ const robono = new RobonoLinkedApps({
19
+ functionsUrl: 'https://vzoqxavqacydtwypjsrd.supabase.co/functions/v1',
17
20
  clientId: config.robonoClientId,
18
21
  tokenStore: secureTokenStore(SecureStore, 'robono.linked.account.primary'),
19
22
  crypto: nativeCryptoProvider({
@@ -21,31 +24,50 @@ const ot = new RobonoLinkedApps({
21
24
  digest: (_, bytes) => Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(bytes)),
22
25
  }),
23
26
  });
24
- const link = await ot.beginLink('com.example.app://robono/callback',
27
+ const pairing = await robono.beginPairing(
25
28
  ['account:read', 'messages:read', 'messages:send', 'receipts:write']);
26
- // Keep the verifier/state in Keychain/Keystore across the app switch.
27
- await SecureStore.setItemAsync('robono.pending-link', JSON.stringify(link.pending));
28
- await Linking.openURL(link.authorizationUrl);
29
- // In your callback handler, after matching the registered route:
30
- const pending = JSON.parse((await SecureStore.getItemAsync('robono.pending-link'))!);
31
- await ot.completeLink(callbackUrl, pending);
32
- await SecureStore.deleteItemAsync('robono.pending-link');
29
+ // Show pairing.userCode with a five-minute expiry indication.
30
+ // Tell the user: Robono > Linked apps > Connect an app > enter this code.
31
+ // Optional: open pairing.verificationAppUrl to take them to Robono.
32
+ await SecureStore.setItemAsync('robono.pending-pair', JSON.stringify(pairing.pending));
33
+ const controller = new AbortController();
34
+ // Abort when this screen closes, the account changes, or the app backgrounds.
35
+ const tokens = await robono.waitForPairing(pairing.pending, controller.signal);
36
+ await SecureStore.deleteItemAsync('robono.pending-pair');
37
+ // Connected. Tokens are already saved by the configured TokenStore.
33
38
  ```
34
39
 
35
- The native adapters are dependency-injected: bare React Native can supply equivalent
36
- Keychain/Keystore and cryptography implementations. Never use Math.random or ordinary
37
- AsyncStorage for credentials/verifiers. Choose platform key accessibility according
38
- to your app's background needs; never opt into backup/sync of account secrets.
39
-
40
- Register the callback on **both iOS and Android**. Prefer an owned HTTPS universal/app
41
- link where possible. Private native schemes must be unique reverse-domain names;
42
- PKCE protects code redemption from interception. Never accept arbitrary callback
43
- hosts, skip state verification, or copy a Robono phone session into your app.
44
-
45
- Only one SDK instance owns a connected account's refresh sequence. Multiple runtimes
46
- or processes must coordinate access to secure storage. A lost refresh response or
47
- crash before the rotated pair is persisted may require relinking; retrying a spent
48
- refresh token revokes the grant. Do not automatically repeat token exchanges.
40
+ If backgrounded while the user opens Robono, cancel polling, preserve pending
41
+ state securely, then resume `waitForPairing` on foreground if it has not expired.
42
+ Use one poller per pairing and one SDK instance per connected account. You can
43
+ call `pollPairing` directly; it returns `pending` or `connected` and updates the
44
+ pending state's interval and next-poll time. Persist those updates if resuming
45
+ across process restarts. Network failures are surfaced: offer a retry with
46
+ backoff while the code remains valid. Never spin on errors.
47
+
48
+ Robono authenticates the owner using its existing phone session. A signed-out
49
+ user signs in through Robono's normal login first. There is no extra SMS check
50
+ for account linking. The code does not itself authorize access: the user must
51
+ review the app, account, and permissions and choose Allow connection in Robono.
52
+ Never request the user's Robono login code, password, or phone-session token.
53
+
54
+ The visible code expires after five minutes. The device code and PKCE verifier
55
+ are secrets; never show, log, put them in URLs, or send them to analytics. Native
56
+ clients use Keychain/Keystore; browser/server hosts must supply equivalent secure
57
+ storage. Only the app that initiated pairing can exchange the approved request.
58
+ Do not call completion repeatedly after receiving tokens. If the successful
59
+ exchange response is lost, start a new pairing; a consumed code cannot be reused.
60
+
61
+ HTTP clients: POST `linked-app-pair` with client_id, scopes, S256 code_challenge
62
+ and code_challenge_method. Poll `linked-app-token` using grant_type
63
+ `urn:ietf:params:oauth:grant-type:device_code`, client_id, device_code and
64
+ code_verifier. Wait at least the returned interval (initially five seconds).
65
+ `authorization_pending` means keep waiting; `slow_down` increases the interval
66
+ by five seconds (up to sixty). Stop on `access_denied`, `expired_token`, or
67
+ `invalid_grant`. A 429 requires backoff. Only a successful response contains tokens.
68
+
69
+ The older registered-callback `beginLink` / `completeLink` APIs remain supported
70
+ for existing clients. New code pairing requires no callback handler.
49
71
 
50
72
  ## Display and send
51
73
 
package/README.md CHANGED
@@ -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. Securely persist pending PKCE verifier/state before
21
- opening Robono; validate the callback using `completeLink`.
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) ·
@@ -40,7 +42,7 @@ require relinking because reusing a spent refresh token revokes the grant.
40
42
  The official distribution channel is npm. Install this exact preview version:
41
43
 
42
44
  ```sh
43
- npm install --save-exact @robono/linked-apps@0.1.0-preview.1
45
+ npm install --save-exact @robono/linked-apps@0.1.0-preview.2
44
46
  ```
45
47
 
46
48
  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/index.d.ts CHANGED
@@ -28,6 +28,21 @@ export interface PendingLink {
28
28
  state: string;
29
29
  expiresAt: string;
30
30
  }
31
+ /** Secret pending state: keep in secure storage, never display or log it. */
32
+ export interface PendingPairing {
33
+ clientId: string;
34
+ deviceCode: string;
35
+ verifier: string;
36
+ expiresAt: string;
37
+ interval: number;
38
+ nextPollAt: number;
39
+ }
40
+ export type PairingResult = {
41
+ status: "pending";
42
+ } | {
43
+ status: "connected";
44
+ tokens: Tokens;
45
+ };
31
46
  export interface SendMessage {
32
47
  conversationId: string;
33
48
  messageKind: "text" | "voice" | "image" | "video" | "document";
@@ -58,6 +73,7 @@ export declare class RobonoLinkedApps {
58
73
  private readonly options;
59
74
  private readonly baseUrl;
60
75
  private readonly fetcher;
76
+ private pairingPolls;
61
77
  private refreshPending;
62
78
  constructor(options: {
63
79
  functionsUrl: string;
@@ -68,6 +84,24 @@ export declare class RobonoLinkedApps {
68
84
  timeoutMs?: number;
69
85
  });
70
86
  private post;
87
+ /** Show only userCode. The user approves inside their signed-in Robono app. */
88
+ beginPairing(scopes: Scope[], signal?: AbortSignal): Promise<{
89
+ userCode: string;
90
+ verificationUrl: string;
91
+ verificationAppUrl: string;
92
+ pending: {
93
+ clientId: string;
94
+ deviceCode: string;
95
+ verifier: string;
96
+ expiresAt: string;
97
+ interval: number;
98
+ nextPollAt: number;
99
+ };
100
+ }>;
101
+ /** Returns pending until approval. Concurrent calls share one token exchange. */
102
+ pollPairing(pending: PendingPairing, signal?: AbortSignal): Promise<PairingResult>;
103
+ /** Cancel when leaving the pairing screen. Never polls in the background implicitly. */
104
+ waitForPairing(pending: PendingPairing, signal: AbortSignal): Promise<Tokens>;
71
105
  beginLink(redirectUri: string, scopes: Scope[]): Promise<{
72
106
  authorizationUrl: string;
73
107
  requestId: string;
package/dist/index.js CHANGED
@@ -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.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robono/linked-apps",
3
- "version": "0.1.0-preview.1",
3
+ "version": "0.1.0-preview.2",
4
4
  "private": false,
5
5
  "description": "User-authorized companion-app access to Robono (development preview)",
6
6
  "type": "module",