@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 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
- 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.
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
- 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.
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
- functionsUrl: 'https://vzoqxavqacydtwypjsrd.supabase.co/functions/v1',
23
- clientId: config.robonoClientId,
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 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
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` APIs remain supported
73
- for existing clients. New code pairing requires no callback handler.
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. 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.
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.3
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 supplies the registered client ID and environment URL separately.
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
+ }>;
@@ -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: string;
81
- clientId: string;
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
- const url = new URL(options.functionsUrl);
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.options.clientId, scopes,
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.options.clientId, deviceCode: result.device_code, verifier,
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.options.clientId ||
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.options.clientId,
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.options.clientId,
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.options.clientId,
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.options.clientId ||
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.options.clientId,
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.options.clientId,
275
+ client_id: await this.clientIdentity(),
257
276
  grant_type: "refresh_token",
258
277
  refresh_token: tokens.refreshToken,
259
278
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robono/linked-apps",
3
- "version": "0.1.0-preview.3",
3
+ "version": "0.1.0-preview.4",
4
4
  "private": false,
5
5
  "description": "User-authorized companion-app access to Robono (development preview)",
6
6
  "type": "module",