@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 +8 -0
- package/INTEGRATION.md +48 -26
- package/README.md +5 -3
- package/dist/index.d.ts +34 -0
- package/dist/index.js +85 -0
- package/package.json +1 -1
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
|
|
16
|
-
functionsUrl:
|
|
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
|
|
27
|
+
const pairing = await robono.beginPairing(
|
|
25
28
|
['account:read', 'messages:read', 'messages:send', 'receipts:write']);
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
await
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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) ·
|
|
@@ -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.
|
|
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.");
|