@robono/linked-apps 0.1.0-preview.3 → 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 +10 -0
- package/INTEGRATION.md +30 -16
- package/README.md +3 -3
- package/dist/identity.d.ts +13 -0
- package/dist/identity.js +28 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +29 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
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
|
+
|
|
1
11
|
# 0.1.0-preview.3
|
|
2
12
|
|
|
3
13
|
- `watch()` now uses an authenticated persistent WebSocket and automatic recovery.
|
package/INTEGRATION.md
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
# Integrating an independent app
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
9
15
|
|
|
10
16
|
## Link an account with a code
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
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`.
|
|
15
20
|
|
|
16
21
|
```ts
|
|
17
22
|
import { RobonoLinkedApps, secureTokenStore, nativeCryptoProvider } from '@robono/linked-apps';
|
|
@@ -19,8 +24,12 @@ import * as SecureStore from 'expo-secure-store';
|
|
|
19
24
|
import * as Crypto from 'expo-crypto';
|
|
20
25
|
|
|
21
26
|
const robono = new RobonoLinkedApps({
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
app: {
|
|
28
|
+
name: 'Your App',
|
|
29
|
+
developerName: 'Your Company',
|
|
30
|
+
websiteUrl: 'https://example.com',
|
|
31
|
+
privacyUrl: 'https://example.com/privacy',
|
|
32
|
+
},
|
|
24
33
|
tokenStore: secureTokenStore(SecureStore, 'robono.linked.account.primary'),
|
|
25
34
|
crypto: nativeCryptoProvider({
|
|
26
35
|
getRandomBytes: Crypto.getRandomBytes,
|
|
@@ -61,16 +70,21 @@ storage. Only the app that initiated pairing can exchange the approved request.
|
|
|
61
70
|
Do not call completion repeatedly after receiving tokens. If the successful
|
|
62
71
|
exchange response is lost, start a new pairing; a consumed code cannot be reused.
|
|
63
72
|
|
|
64
|
-
HTTP clients: POST `linked-app-pair` with
|
|
65
|
-
and code_challenge_method.
|
|
66
|
-
|
|
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
|
|
67
78
|
code_verifier. Wait at least the returned interval (initially five seconds).
|
|
68
79
|
`authorization_pending` means keep waiting; `slow_down` increases the interval
|
|
69
80
|
by five seconds (up to sixty). Stop on `access_denied`, `expired_token`, or
|
|
70
81
|
`invalid_grant`. A 429 requires backoff. Only a successful response contains tokens.
|
|
71
82
|
|
|
72
|
-
The older registered-callback `beginLink` / `completeLink`
|
|
73
|
-
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.
|
|
74
88
|
|
|
75
89
|
## Display and send
|
|
76
90
|
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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,
|
|
@@ -43,9 +43,9 @@ require relinking because reusing a spent refresh token revokes the grant.
|
|
|
43
43
|
The official distribution channel is npm. Install this exact preview version:
|
|
44
44
|
|
|
45
45
|
```sh
|
|
46
|
-
npm install --save-exact @robono/linked-apps@0.1.0-preview.
|
|
46
|
+
npm install --save-exact @robono/linked-apps@0.1.0-preview.4
|
|
47
47
|
```
|
|
48
48
|
|
|
49
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.
|
|
50
50
|
|
|
51
|
-
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.
|
|
@@ -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,3 +1,5 @@
|
|
|
1
|
+
import { type AppIdentity } from "./identity.js";
|
|
2
|
+
export type { AppIdentity } from "./identity.js";
|
|
1
3
|
import { type EventPage, type WatchOptions } from "./delivery.js";
|
|
2
4
|
import { type LinkedSocketFactory } from "./stream.js";
|
|
3
5
|
import type { ConversationPage, Message, MessagePage, Receipt, UploadTicket } from "./types.js";
|
|
@@ -74,17 +76,23 @@ export declare class RobonoLinkedApps {
|
|
|
74
76
|
private readonly options;
|
|
75
77
|
private readonly baseUrl;
|
|
76
78
|
private readonly fetcher;
|
|
79
|
+
private readonly app?;
|
|
80
|
+
private identityPending?;
|
|
77
81
|
private pairingPolls;
|
|
78
82
|
private refreshPending;
|
|
79
83
|
constructor(options: {
|
|
80
|
-
functionsUrl
|
|
81
|
-
|
|
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;
|
|
82
89
|
tokenStore: TokenStore;
|
|
83
90
|
crypto?: CryptoProvider;
|
|
84
91
|
fetch?: typeof fetch;
|
|
85
92
|
timeoutMs?: number;
|
|
86
93
|
webSocket?: LinkedSocketFactory;
|
|
87
94
|
});
|
|
95
|
+
private clientIdentity;
|
|
88
96
|
private post;
|
|
89
97
|
/** Show only userCode. The user approves inside their signed-in Robono app. */
|
|
90
98
|
beginPairing(scopes: Scope[], signal?: AbortSignal): Promise<{
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { directAppIdentity, normalizeAppIdentity } from "./identity.js";
|
|
1
2
|
import { waitForPoll } from "./delivery.js";
|
|
2
3
|
import { consumeStream } from "./stream.js";
|
|
3
4
|
export * from "./types.js";
|
|
@@ -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
|
}));
|