@robono/linked-apps 0.1.0-preview.1

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 ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0-preview.1
4
+
5
+ First distributable developer preview: user approval with PKCE, scoped messaging and media, receipts, secure native adapters, rotating credentials, resumable synchronization, verified background hints, and revocation. Requires an approved Robono registration and an activated environment.
package/INTEGRATION.md ADDED
@@ -0,0 +1,141 @@
1
+ # Integrating an independent app
2
+
3
+ Use a separate registration for development and production. Robono supplies its
4
+ functions URL, client ID and reviewed redirect URI/scopes. Client IDs are public.
5
+ A backend webhook signing secret is confidential and must never enter a mobile bundle.
6
+
7
+ ## Link an account
8
+
9
+ ```ts
10
+ import { RobonoLinkedApps, secureTokenStore, nativeCryptoProvider } from '@robono/linked-apps';
11
+ import * as SecureStore from 'expo-secure-store';
12
+ import * as Crypto from 'expo-crypto';
13
+ import { Linking } from 'react-native';
14
+
15
+ const ot = new RobonoLinkedApps({
16
+ functionsUrl: config.robonoFunctionsUrl,
17
+ clientId: config.robonoClientId,
18
+ tokenStore: secureTokenStore(SecureStore, 'robono.linked.account.primary'),
19
+ crypto: nativeCryptoProvider({
20
+ getRandomBytes: Crypto.getRandomBytes,
21
+ digest: (_, bytes) => Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(bytes)),
22
+ }),
23
+ });
24
+ const link = await ot.beginLink('com.example.app://robono/callback',
25
+ ['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');
33
+ ```
34
+
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.
49
+
50
+ ## Display and send
51
+
52
+ ```ts
53
+ const snapshot = await ot.conversations();
54
+ const page = await ot.messages(conversationId, { limit: 50 });
55
+ // Generate and persist this ID before sending; use it again on every retry.
56
+ const result = await ot.send({ conversationId, messageKind: 'text',
57
+ clientMessageId: durableOutgoingId, textBody: 'Hello' });
58
+ ```
59
+
60
+ Show the allowed tools from each network's capabilities and connection status. The
61
+ server remains authoritative: never infer a successful delivery from a successful
62
+ send acknowledgement. Do not reveal another user's block. The API does not create
63
+ connections, bypass parental permissions, unblock contacts or change network rules.
64
+
65
+ For voice, retain the original recording and its measured duration, request an upload
66
+ ticket, upload the bytes, then send `messageKind:'voice'` with `mediaObjectId:ticket.media.id`.
67
+ Use `uploadBytes(ticket,bytes,mimeType)` or the equivalent native file streaming PUT to
68
+ `ticket.signedUrl`. Send **no account Authorization header** to that URL. If a send
69
+ times out, query/retry with the same message ID; do not record/upload/send a new copy
70
+ just because confirmation was lost. The existing server checks enforce format, size,
71
+ ownership, conversation membership and voice restrictions.
72
+
73
+ Download only when needed. A playback URL has an expiry, is sensitive, and may become
74
+ unavailable according to network retention. Only call `receipt(id,'heard')` after
75
+ actual listening. Use `delivered` and `read` for their corresponding user states.
76
+
77
+ ## Synchronize
78
+
79
+ ```ts
80
+ const controller = new AbortController();
81
+ const watching = ot.watch({
82
+ signal: controller.signal,
83
+ cursorStore: persistedCursors, // keyed by grant ID
84
+ onResync: async () => reconcile(await ot.conversations()),
85
+ onEvents: async events => {
86
+ // Deduplicate message IDs. Fetch only relevant current pages; remove unavailable
87
+ // or deleted content. Do not notify for receipt-only events or your own messages.
88
+ await applyAuthoritativeChanges(events);
89
+ },
90
+ onError: reportTransientSyncFailure,
91
+ });
92
+ // Stop on background/logout/account change. Resume on foreground or a push hint.
93
+ controller.abort();
94
+ await watching;
95
+ ```
96
+
97
+ `onResync` must reconcile active conversations and any visible message pages. Clear
98
+ stale local histories when `history_cleared_at` advances or a deleted conversation is
99
+ reported. Walkie-talkie replacement can invalidate an earlier message even if its ID
100
+ was previously downloaded. Read all pages with `has_more` using both returned page
101
+ markers; preserve microsecond timestamps as strings. Do not synthesize timestamps.
102
+
103
+ Polling stores its cursor only after successful processing. A callback can run again
104
+ after a crash, so make local writes idempotent. Failed callbacks keep the previous
105
+ cursor. Abort does not advance past unfinished work. Grant changes terminate the old
106
+ watcher; cursors and cached data must never move to a different account.
107
+
108
+ ## Background notices
109
+
110
+ 1. Configure a reviewed HTTPS backend webhook with Robono.
111
+ 2. Your backend associates a grant with an authenticated account in your own service.
112
+ Verify it by calling `account.get` using that account's linked token; never trust
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:
116
+
117
+ ```ts
118
+ import { verifyWebhook } from '@robono/linked-apps';
119
+ const hint = await verifyWebhook({ body: rawBody,
120
+ timestamp: request.headers.get('x-robono-timestamp')!,
121
+ signature: request.headers.get('x-robono-signature')!,
122
+ secret: backendOnlySigningKey });
123
+ // Check expected client_id; atomically deduplicate hint.id and enqueue the hint.
124
+ // Return 2xx only after the queue write commits.
125
+ ```
126
+
127
+ The signature covers `timestamp + '.' + rawBody` with HMAC-SHA256, accepted within
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.
131
+
132
+ 5. Send a content-free sync hint through **your app's** APNs/FCM/Expo credentials.
133
+ 6. On wake/resume, call `events(cursor)` or restart `watch`; do not trust a webhook's
134
+ cursor as proof you already applied earlier events. Never overwrite the local
135
+ cursor with the pushed cursor. Keep Robono notifications as the default alert and
136
+ companion hints silent to avoid duplicate sounds.
137
+
138
+ Disconnect: call `ot.disconnect()`; clear your local cached account content and stop
139
+ watchers after revocation succeeds. A lost response may require retry/relink UI; a
140
+ user can always revoke from Robono's Linked apps screen. Account deletion, revoked
141
+ permissions or `invalid_token` must not trigger repeated unauthorized background work.
package/LICENSE ADDED
@@ -0,0 +1,130 @@
1
+ ROBONO SDK LICENSE AGREEMENT
2
+ Version 1.0, July 20, 2026
3
+
4
+ Copyright (c) 2026 Add to Loop LLC. All rights reserved.
5
+
6
+ This Robono SDK License Agreement (the "Agreement") is between Add to Loop LLC,
7
+ operating the Robono service ("Robono"), and the person or entity that downloads,
8
+ installs, copies, or uses the Software ("You"). By downloading, installing,
9
+ copying, or using the Software, You accept this Agreement. If You do not accept
10
+ it, do not use the Software.
11
+
12
+ 1. Definitions
13
+
14
+ "Software" means this Robono software development kit, its documentation, and
15
+ any updates Robono provides under this Agreement.
16
+
17
+ "Robono Service" means the messaging interoperability, routing, policy,
18
+ conversion, delivery, and related services operated or expressly authorized by
19
+ Robono.
20
+
21
+ "Application" means a product or service owned or controlled by You that is
22
+ authorized to connect to the Robono Service.
23
+
24
+ 2. Limited License
25
+
26
+ Subject to this Agreement, Robono grants You a limited, non-exclusive,
27
+ non-transferable, non-sublicensable, revocable license to:
28
+
29
+ (a) install, copy, and use the Software internally to develop, test, and
30
+ operate an Application that communicates with the Robono Service; and
31
+
32
+ (b) reproduce and distribute the portions of the Software that are necessarily
33
+ included in Your Application, solely in compiled, minified, bundled, or object
34
+ code form and solely as needed for that Application to communicate with the
35
+ Robono Service.
36
+
37
+ No right is granted to distribute the Software as a standalone product or SDK.
38
+
39
+ 3. Restrictions
40
+
41
+ You may not, and may not permit another person to:
42
+
43
+ (a) sell, rent, lease, sublicense, publish, or redistribute the Software as a
44
+ standalone product, library, SDK, or service;
45
+
46
+ (b) use the Software or a modified or derivative version of it to develop,
47
+ provide, or support a product or service that competes with the Robono Service;
48
+
49
+ (c) use the Software to bypass Robono, avoid applicable usage controls or fees,
50
+ or access the Robono Service without authorization;
51
+
52
+ (d) remove or alter copyright, trademark, attribution, or proprietary notices;
53
+
54
+ (e) reverse engineer, decompile, or disassemble the Software except to the
55
+ limited extent applicable law expressly prohibits this restriction; or
56
+
57
+ (f) use the Software in violation of applicable law, the rights of another
58
+ person, or the terms governing access to the Robono Service.
59
+
60
+ 4. Ownership and Reserved Rights
61
+
62
+ The Software is licensed, not sold. Robono and its licensors retain all right,
63
+ title, and interest in the Software and all related intellectual property.
64
+ Except for the limited rights expressly granted in Section 2, all rights are
65
+ reserved.
66
+
67
+ No patent license is granted except the minimum license, if any, necessarily
68
+ required to exercise the rights expressly granted in Section 2, and that
69
+ limited patent license applies only while the Software is used with an
70
+ authorized Robono Service.
71
+
72
+ 5. Service Access
73
+
74
+ This Agreement does not itself grant an account, API credentials, service
75
+ availability, support, or a right to access the Robono Service. Service access
76
+ may be governed by additional terms, plans, limits, and policies. Robono may
77
+ suspend or terminate access to protect users, systems, legal compliance, or the
78
+ integrity of the Robono Service.
79
+
80
+ 6. Updates and Support
81
+
82
+ Robono is not required to provide maintenance, updates, support, or continued
83
+ availability of any version of the Software unless separately agreed in
84
+ writing.
85
+
86
+ 7. Term and Termination
87
+
88
+ This Agreement continues until terminated. Your rights terminate automatically
89
+ if You breach this Agreement. On termination, You must stop using the Software
90
+ and delete copies under Your control, except for copies necessarily contained
91
+ in previously distributed Applications. Sections 3, 4, 8, 9, 10, and 11
92
+ survive termination.
93
+
94
+ 8. Disclaimer of Warranties
95
+
96
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED "AS IS" AND
97
+ "AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, EXPRESS, IMPLIED, OR
98
+ STATUTORY, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
99
+ PURPOSE, TITLE, AND NON-INFRINGEMENT.
100
+
101
+ 9. Limitation of Liability
102
+
103
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, ROBONO WILL NOT BE LIABLE FOR INDIRECT,
104
+ INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOSS
105
+ OF DATA, REVENUE, PROFITS, GOODWILL, OR BUSINESS OPPORTUNITY, ARISING FROM OR
106
+ RELATING TO THE SOFTWARE. ROBONO'S TOTAL LIABILITY RELATING TO THE SOFTWARE
107
+ WILL NOT EXCEED THE GREATER OF ONE HUNDRED U.S. DOLLARS (US $100) OR THE AMOUNT
108
+ YOU PAID ROBONO FOR THE SOFTWARE DURING THE TWELVE MONTHS BEFORE THE EVENT
109
+ GIVING RISE TO THE CLAIM. THESE LIMITATIONS APPLY TO THE MAXIMUM EXTENT
110
+ PERMITTED BY LAW.
111
+
112
+ 10. Governing Law
113
+
114
+ This Agreement is governed by the laws of the State of Florida, excluding its
115
+ conflict-of-laws rules. The state and federal courts located in Palm Beach
116
+ County, Florida have exclusive jurisdiction, and each party consents to their
117
+ jurisdiction and venue.
118
+
119
+ 11. General
120
+
121
+ You may not assign this Agreement without Robono's prior written consent.
122
+ Robono may assign this Agreement in connection with a reorganization, transfer
123
+ of intellectual property, financing, merger, acquisition, or sale of all or
124
+ substantially all of the relevant assets. If a provision is unenforceable, it
125
+ will be enforced to the maximum extent permitted and the remaining provisions
126
+ will remain effective. A waiver must be in writing. This Agreement is the
127
+ entire agreement concerning the Software unless You and Robono sign a separate
128
+ written agreement that expressly replaces it.
129
+
130
+ Questions about this license may be submitted at https://robono.com/contact.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @robono/linked-apps
2
+
3
+ Robono's user-authorized messaging API client, for independent applications.
4
+ Developer preview. Requires a reviewed app registration and an activated Robono environment.
5
+ SDK installation alone does not enable account access.
6
+
7
+ Supports PKCE account linking, rotating credentials, typed conversations/messages,
8
+ text/voice/file sending, signed media upload/download, receipts, resumable change
9
+ watching, backend delivery subscription, webhook verification and revocation.
10
+ No dependency on Matrix, TalkOpen, Loop, Expo or a specific UI framework.
11
+
12
+ Build/test from the repository:
13
+
14
+ ```
15
+ npm --prefix packages/linked-apps test
16
+ ```
17
+
18
+ Use one `RobonoLinkedApps` instance per connected account. Inject a Keychain/Keystore
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`.
22
+
23
+ [Integration guide](./INTEGRATION.md) ·
24
+ [API reference](https://robono.com/api-reference-viewer.html?spec=linked-apps) ·
25
+ [Developer documentation](https://robono.com/linked-apps)
26
+
27
+ The watcher is explicit and cancellable. Stop on background/logout; resume on a push
28
+ hint or foreground. It checkpoints only after successful consumer callbacks, so
29
+ callbacks must tolerate redelivery. Backends verify webhook signatures and deduplicate
30
+ stable delivery IDs before pushing through their own app credentials. Never ship a
31
+ webhook signing key in mobile code. Hints contain no message text and should not
32
+ produce sounds by themselves.
33
+
34
+ Send retries must retain the original UUID. Refresh requests are serialized within
35
+ one instance; coordinate across processes yourself. A lost refresh response can
36
+ require relinking because reusing a spent refresh token revokes the grant.
37
+
38
+ ## Preview distribution
39
+
40
+ The official distribution channel is npm. Install this exact preview version:
41
+
42
+ ```sh
43
+ npm install --save-exact @robono/linked-apps@0.1.0-preview.1
44
+ ```
45
+
46
+ 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.
47
+
48
+ 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.
@@ -0,0 +1,39 @@
1
+ import type { CryptoProvider, TokenStore } from "./index.js";
2
+ export interface LinkedEvent {
3
+ id: string;
4
+ type: "messages.changed" | "receipts.changed" | "conversations.changed";
5
+ conversation_id: string | null;
6
+ message_id: string | null;
7
+ created_at: string;
8
+ }
9
+ export interface EventPage {
10
+ events: LinkedEvent[];
11
+ cursor: string;
12
+ has_more: boolean;
13
+ resync_required?: boolean;
14
+ }
15
+ export interface CursorStore {
16
+ /** Key by grant ID; never share a cursor between accounts or grants. */
17
+ get(grantId: string): Promise<string | null>;
18
+ set(grantId: string, cursor: string): Promise<void>;
19
+ }
20
+ export interface WatchOptions {
21
+ signal: AbortSignal;
22
+ cursorStore: CursorStore;
23
+ onEvents(events: LinkedEvent[]): Promise<void>;
24
+ /** Reconcile the conversation list and currently open message pages. */
25
+ onResync(): Promise<void>;
26
+ onError?(error: unknown): void;
27
+ pollIntervalMs?: number;
28
+ }
29
+ export declare function waitForPoll(ms: number, signal: AbortSignal): Promise<void>;
30
+ /** Dependency injection keeps the SDK usable in Expo and bare native apps. */
31
+ export declare function secureTokenStore(storage: {
32
+ getItemAsync(key: string): Promise<string | null>;
33
+ setItemAsync(key: string, value: string): Promise<void>;
34
+ deleteItemAsync(key: string): Promise<void>;
35
+ }, key: string): TokenStore;
36
+ export declare function nativeCryptoProvider(native: {
37
+ getRandomBytes(length: number): Uint8Array;
38
+ digest(algorithm: "SHA-256", bytes: Uint8Array): Promise<ArrayBuffer>;
39
+ }): CryptoProvider;
@@ -0,0 +1,39 @@
1
+ export async function waitForPoll(ms, signal) {
2
+ if (signal.aborted)
3
+ return;
4
+ await new Promise((resolve) => {
5
+ const done = () => {
6
+ clearTimeout(timer);
7
+ signal.removeEventListener("abort", done);
8
+ resolve();
9
+ };
10
+ const timer = setTimeout(done, ms);
11
+ signal.addEventListener("abort", done, { once: true });
12
+ if (signal.aborted)
13
+ done();
14
+ });
15
+ }
16
+ /** Dependency injection keeps the SDK usable in Expo and bare native apps. */
17
+ export function secureTokenStore(storage, key) {
18
+ if (!/^[A-Za-z0-9._-]{1,120}$/.test(key)) {
19
+ throw new Error("Use a unique, stable secure-storage key for each account.");
20
+ }
21
+ return {
22
+ async get() {
23
+ const value = await storage.getItemAsync(key);
24
+ return value ? JSON.parse(value) : null;
25
+ },
26
+ async set(tokens) {
27
+ if (tokens)
28
+ await storage.setItemAsync(key, JSON.stringify(tokens));
29
+ else
30
+ await storage.deleteItemAsync(key);
31
+ },
32
+ };
33
+ }
34
+ export function nativeCryptoProvider(native) {
35
+ return {
36
+ randomBytes: (length) => native.getRandomBytes(length),
37
+ sha256: async (bytes) => new Uint8Array(await native.digest("SHA-256", bytes)),
38
+ };
39
+ }
@@ -0,0 +1,127 @@
1
+ import { type EventPage, type WatchOptions } from "./delivery.js";
2
+ import type { ConversationPage, Message, MessagePage, Receipt, UploadTicket } from "./types.js";
3
+ export * from "./types.js";
4
+ export * from "./delivery.js";
5
+ export * from "./webhook.js";
6
+ export type Scope = "account:read" | "messages:read" | "messages:send" | "receipts:write";
7
+ export type Tokens = {
8
+ accessToken: string;
9
+ refreshToken: string;
10
+ expiresAt: number;
11
+ refreshExpiresAt: string;
12
+ grantId: string;
13
+ scopes: Scope[];
14
+ };
15
+ export interface TokenStore {
16
+ /** Use the device Keychain/Keystore. Never AsyncStorage, URLs or logs. */
17
+ get(): Promise<Tokens | null>;
18
+ set(tokens: Tokens | null): Promise<void>;
19
+ }
20
+ export interface CryptoProvider {
21
+ randomBytes(length: number): Uint8Array;
22
+ sha256(bytes: Uint8Array): Promise<Uint8Array>;
23
+ }
24
+ export interface PendingLink {
25
+ clientId: string;
26
+ redirectUri: string;
27
+ verifier: string;
28
+ state: string;
29
+ expiresAt: string;
30
+ }
31
+ export interface SendMessage {
32
+ conversationId: string;
33
+ messageKind: "text" | "voice" | "image" | "video" | "document";
34
+ /** Persist this UUID with the outgoing message. Reuse it for every retry. */
35
+ clientMessageId: string;
36
+ textBody?: string;
37
+ mediaObjectId?: string;
38
+ replyToMessageId?: string;
39
+ }
40
+ export interface MediaUpload {
41
+ conversationId: string;
42
+ mediaKind: "voice" | "image" | "video" | "document";
43
+ mimeType: string;
44
+ sizeBytes: number;
45
+ fileName?: string;
46
+ durationMs?: number;
47
+ waveform?: number[];
48
+ }
49
+ export type ApiRecord = Record<string, unknown>;
50
+ export declare class RobonoLinkedAppError extends Error {
51
+ code: string;
52
+ status: number;
53
+ constructor(code: string, status: number);
54
+ }
55
+ export declare function webCryptoProvider(): CryptoProvider;
56
+ /** One instance per connected account. Watching starts only when explicitly requested. */
57
+ export declare class RobonoLinkedApps {
58
+ private readonly options;
59
+ private readonly baseUrl;
60
+ private readonly fetcher;
61
+ private refreshPending;
62
+ constructor(options: {
63
+ functionsUrl: string;
64
+ clientId: string;
65
+ tokenStore: TokenStore;
66
+ crypto?: CryptoProvider;
67
+ fetch?: typeof fetch;
68
+ timeoutMs?: number;
69
+ });
70
+ private post;
71
+ beginLink(redirectUri: string, scopes: Scope[]): Promise<{
72
+ authorizationUrl: string;
73
+ requestId: string;
74
+ pending: {
75
+ clientId: string;
76
+ redirectUri: string;
77
+ verifier: string;
78
+ state: string;
79
+ expiresAt: string;
80
+ };
81
+ }>;
82
+ completeLink(callback: string, pending: PendingLink): Promise<Tokens>;
83
+ private save;
84
+ refresh(): Promise<Tokens>;
85
+ private call;
86
+ account(): Promise<{
87
+ account: {
88
+ id: string;
89
+ display_name: string | null;
90
+ phone: string;
91
+ };
92
+ grant_id: string;
93
+ scopes: Scope[];
94
+ }>;
95
+ conversations(): Promise<ConversationPage>;
96
+ messages(conversationId: string, page?: {
97
+ before?: string;
98
+ beforeId?: string;
99
+ limit?: number;
100
+ }): Promise<MessagePage>;
101
+ message(conversationId: string, messageId: string): Promise<{
102
+ message: Message;
103
+ messages: Message[];
104
+ }>;
105
+ send(input: SendMessage): Promise<{
106
+ message: Message;
107
+ }>;
108
+ createUpload(input: MediaUpload): Promise<UploadTicket>;
109
+ /** Upload exactly the bytes described in the ticket. No Robono bearer is sent to storage. */
110
+ uploadBytes(ticket: UploadTicket, bytes: Uint8Array, mimeType: string, signal?: AbortSignal): Promise<void>;
111
+ download(messageId: string): Promise<{
112
+ signedUrl: string;
113
+ expiresIn: number;
114
+ }>;
115
+ receipt(messageId: string, state: "delivered" | "read" | "heard"): Promise<{
116
+ receipts: Receipt[];
117
+ }>;
118
+ events(cursor: string | null, signal?: AbortSignal): Promise<EventPage>;
119
+ configureBackgroundDelivery(enabled: boolean): Promise<{
120
+ enabled: boolean;
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.
124
+ */
125
+ watch(options: WatchOptions): Promise<void>;
126
+ disconnect(): Promise<void>;
127
+ }
package/dist/index.js ADDED
@@ -0,0 +1,325 @@
1
+ import { waitForPoll } from "./delivery.js";
2
+ export * from "./types.js";
3
+ export * from "./delivery.js";
4
+ export * from "./webhook.js";
5
+ export class RobonoLinkedAppError extends Error {
6
+ code;
7
+ status;
8
+ constructor(code, status) {
9
+ super(`Robono request failed (${code})`);
10
+ this.code = code;
11
+ this.status = status;
12
+ }
13
+ }
14
+ const scopeNames = new Set([
15
+ "account:read",
16
+ "messages:read",
17
+ "messages:send",
18
+ "receipts:write",
19
+ ]);
20
+ const hex = (bytes) => Array.from(bytes, (x) => x.toString(16).padStart(2, "0")).join("");
21
+ function base64url(bytes) {
22
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
23
+ let result = "", bits = 0, value = 0;
24
+ for (const byte of bytes) {
25
+ value = (value << 8) | byte;
26
+ bits += 8;
27
+ while (bits >= 6) {
28
+ bits -= 6;
29
+ result += alphabet[(value >>> bits) & 63];
30
+ }
31
+ }
32
+ if (bits)
33
+ result += alphabet[(value << (6 - bits)) & 63];
34
+ return result;
35
+ }
36
+ export function webCryptoProvider() {
37
+ if (!globalThis.crypto?.subtle) {
38
+ throw new Error("A secure CryptoProvider is required on this platform.");
39
+ }
40
+ return {
41
+ randomBytes: (length) => globalThis.crypto.getRandomValues(new Uint8Array(length)),
42
+ sha256: async (bytes) => new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(bytes))),
43
+ };
44
+ }
45
+ /** One instance per connected account. Watching starts only when explicitly requested. */
46
+ export class RobonoLinkedApps {
47
+ options;
48
+ baseUrl;
49
+ fetcher;
50
+ refreshPending = null;
51
+ constructor(options) {
52
+ this.options = options;
53
+ const url = new URL(options.functionsUrl);
54
+ if (url.username || url.password || url.search || url.hash ||
55
+ !(url.protocol === "https:" ||
56
+ (url.protocol === "http:" &&
57
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) {
58
+ throw new Error("Use an HTTPS Robono functions URL (HTTP is allowed only for local tests).");
59
+ }
60
+ this.baseUrl = url.toString().replace(/\/$/, "");
61
+ this.fetcher = options.fetch ?? globalThis.fetch;
62
+ }
63
+ async post(endpoint, body, token, signal) {
64
+ const controller = new AbortController();
65
+ const cancel = () => controller.abort();
66
+ signal?.addEventListener("abort", cancel, { once: true });
67
+ if (signal?.aborted)
68
+ controller.abort();
69
+ const timeout = setTimeout(cancel, this.options.timeoutMs ?? 30_000);
70
+ try {
71
+ const response = await this.fetcher(`${this.baseUrl}/${endpoint}`, {
72
+ method: "POST",
73
+ redirect: "error",
74
+ signal: controller.signal,
75
+ headers: {
76
+ "content-type": "application/json",
77
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+ const data = await response.json();
82
+ if (!response.ok) {
83
+ const code = typeof data.error === "string"
84
+ ? data.error
85
+ : data.error?.code ?? "request_failed";
86
+ throw new RobonoLinkedAppError(code, response.status);
87
+ }
88
+ return data;
89
+ }
90
+ finally {
91
+ clearTimeout(timeout);
92
+ signal?.removeEventListener("abort", cancel);
93
+ }
94
+ }
95
+ async beginLink(redirectUri, scopes) {
96
+ if (!scopes.length || scopes.some((scope) => !scopeNames.has(scope))) {
97
+ throw new Error("Select supported permissions.");
98
+ }
99
+ const crypto = this.options.crypto ?? webCryptoProvider();
100
+ const verifier = hex(crypto.randomBytes(32)), state = hex(crypto.randomBytes(32));
101
+ const challenge = base64url(await crypto.sha256(new TextEncoder().encode(verifier)));
102
+ const result = await this.post("linked-app-authorize", {
103
+ client_id: this.options.clientId,
104
+ redirect_uri: redirectUri,
105
+ scopes,
106
+ state,
107
+ code_challenge: challenge,
108
+ code_challenge_method: "S256",
109
+ });
110
+ return {
111
+ authorizationUrl: result.authorization_url,
112
+ requestId: result.request_id,
113
+ pending: {
114
+ clientId: this.options.clientId,
115
+ redirectUri,
116
+ verifier,
117
+ state,
118
+ expiresAt: result.expires_at,
119
+ },
120
+ };
121
+ }
122
+ async completeLink(callback, pending) {
123
+ const url = new URL(callback), expected = new URL(pending.redirectUri);
124
+ if (pending.clientId !== this.options.clientId ||
125
+ Date.parse(pending.expiresAt) <= Date.now() ||
126
+ url.protocol !== expected.protocol || url.host !== expected.host ||
127
+ url.pathname !== expected.pathname ||
128
+ url.username || url.password || url.hash ||
129
+ url.searchParams.getAll("state").length !== 1 ||
130
+ url.searchParams.get("state") !== pending.state ||
131
+ [...expected.searchParams].some(([k, v]) => url.searchParams.get(k) !== v)) {
132
+ throw new RobonoLinkedAppError("invalid_callback", 400);
133
+ }
134
+ if (url.searchParams.has("error")) {
135
+ throw new RobonoLinkedAppError("access_denied", 400);
136
+ }
137
+ const code = url.searchParams.get("code");
138
+ if (url.searchParams.getAll("code").length !== 1 || !code ||
139
+ !/^rlc_[a-f0-9]{64}$/.test(code)) {
140
+ throw new RobonoLinkedAppError("invalid_callback", 400);
141
+ }
142
+ return this.save(await this.post("linked-app-token", {
143
+ client_id: this.options.clientId,
144
+ grant_type: "authorization_code",
145
+ redirect_uri: pending.redirectUri,
146
+ code,
147
+ code_verifier: pending.verifier,
148
+ }));
149
+ }
150
+ async save(wire) {
151
+ const tokens = {
152
+ accessToken: wire.access_token,
153
+ refreshToken: wire.refresh_token,
154
+ expiresAt: Date.now() + wire.expires_in * 1000,
155
+ refreshExpiresAt: wire.refresh_expires_at,
156
+ grantId: wire.grant_id,
157
+ scopes: wire.scope.split(" "),
158
+ };
159
+ await this.options.tokenStore.set(tokens);
160
+ return tokens;
161
+ }
162
+ async refresh() {
163
+ if (this.refreshPending)
164
+ return this.refreshPending;
165
+ this.refreshPending = (async () => {
166
+ const tokens = await this.options.tokenStore.get();
167
+ if (!tokens)
168
+ throw new RobonoLinkedAppError("not_connected", 401);
169
+ try {
170
+ return await this.save(await this.post("linked-app-token", {
171
+ client_id: this.options.clientId,
172
+ grant_type: "refresh_token",
173
+ refresh_token: tokens.refreshToken,
174
+ }));
175
+ }
176
+ catch (error) {
177
+ if (error instanceof RobonoLinkedAppError &&
178
+ error.code === "invalid_grant")
179
+ await this.options.tokenStore.set(null);
180
+ throw error;
181
+ }
182
+ })();
183
+ try {
184
+ return await this.refreshPending;
185
+ }
186
+ finally {
187
+ this.refreshPending = null;
188
+ }
189
+ }
190
+ async call(operation, input = {}, signal) {
191
+ let tokens = await this.options.tokenStore.get();
192
+ if (!tokens)
193
+ throw new RobonoLinkedAppError("not_connected", 401);
194
+ if (tokens.expiresAt <= Date.now() + 30_000)
195
+ tokens = await this.refresh();
196
+ // No blind retries: a timeout may occur after a successful send. The host
197
+ // retains the original clientMessageId and explicitly retries that message.
198
+ return this.post("linked-app-api", { version: 1, operation, input }, tokens.accessToken, signal);
199
+ }
200
+ account() {
201
+ return this.call("account.get");
202
+ }
203
+ conversations() {
204
+ return this.call("conversations.list");
205
+ }
206
+ messages(conversationId, page = {}) {
207
+ return this.call("messages.list", { conversationId, ...page });
208
+ }
209
+ message(conversationId, messageId) {
210
+ return this.call("messages.get", { conversationId, messageId });
211
+ }
212
+ send(input) {
213
+ if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(input.clientMessageId))
214
+ throw new Error("A stable clientMessageId UUID is required.");
215
+ return this.call("messages.send", {
216
+ ...input,
217
+ inputSource: "external",
218
+ });
219
+ }
220
+ createUpload(input) {
221
+ return this.call("media.upload", input);
222
+ }
223
+ /** Upload exactly the bytes described in the ticket. No Robono bearer is sent to storage. */
224
+ async uploadBytes(ticket, bytes, mimeType, signal) {
225
+ const url = new URL(ticket.signedUrl);
226
+ if (url.protocol !== "https:" ||
227
+ url.origin !== new URL(this.baseUrl).origin ||
228
+ !url.pathname.startsWith("/storage/v1/object/upload/sign/"))
229
+ throw new Error("Invalid upload destination");
230
+ if (ticket.media.size_bytes !== undefined &&
231
+ bytes.byteLength !== ticket.media.size_bytes)
232
+ throw new Error("Upload size does not match ticket");
233
+ if (ticket.media.mime_type && mimeType !== ticket.media.mime_type) {
234
+ throw new Error("Upload type does not match ticket");
235
+ }
236
+ const controller = new AbortController();
237
+ const cancel = () => controller.abort();
238
+ const timer = setTimeout(cancel, 120_000);
239
+ signal?.addEventListener("abort", cancel, { once: true });
240
+ if (signal?.aborted)
241
+ controller.abort();
242
+ try {
243
+ const response = await this.fetcher(url.toString(), {
244
+ method: "PUT",
245
+ redirect: "error",
246
+ signal: controller.signal,
247
+ headers: { "content-type": mimeType, "x-upsert": "false" },
248
+ body: new Uint8Array(bytes),
249
+ });
250
+ await response.body?.cancel();
251
+ if (!response.ok) {
252
+ throw new RobonoLinkedAppError("upload_failed", response.status);
253
+ }
254
+ }
255
+ finally {
256
+ clearTimeout(timer);
257
+ signal?.removeEventListener("abort", cancel);
258
+ }
259
+ }
260
+ download(messageId) {
261
+ return this.call("media.download", { messageId });
262
+ }
263
+ receipt(messageId, state) {
264
+ return this.call("receipts.update", {
265
+ messageId,
266
+ heard: state === "heard",
267
+ deliveredOnly: state === "delivered",
268
+ });
269
+ }
270
+ events(cursor, signal) {
271
+ return this.call("events.poll", { cursor }, signal);
272
+ }
273
+ configureBackgroundDelivery(enabled) {
274
+ return this.call("delivery.configure", { enabled });
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.
278
+ */
279
+ async watch(options) {
280
+ const tokens = await this.options.tokenStore.get();
281
+ if (!tokens)
282
+ throw new RobonoLinkedAppError("not_connected", 401);
283
+ const grantId = tokens.grantId;
284
+ let cursor = await options.cursorStore.get(grantId);
285
+ let failures = 0;
286
+ while (!options.signal.aborted) {
287
+ try {
288
+ const current = await this.options.tokenStore.get();
289
+ if (!current || current.grantId !== grantId) {
290
+ throw new RobonoLinkedAppError("not_connected", 401);
291
+ }
292
+ const page = await this.events(cursor, options.signal);
293
+ if (options.signal.aborted)
294
+ return;
295
+ if (cursor === null || page.resync_required)
296
+ await options.onResync();
297
+ else if (page.events.length)
298
+ await options.onEvents(page.events);
299
+ if (options.signal.aborted)
300
+ return;
301
+ await options.cursorStore.set(grantId, page.cursor);
302
+ cursor = page.cursor;
303
+ failures = 0;
304
+ if (page.has_more)
305
+ continue;
306
+ }
307
+ catch (error) {
308
+ if (options.signal.aborted)
309
+ return;
310
+ if (error instanceof RobonoLinkedAppError &&
311
+ [400, 401, 403].includes(error.status))
312
+ throw error;
313
+ options.onError?.(error);
314
+ failures++;
315
+ }
316
+ await waitForPoll(failures
317
+ ? Math.min(30_000, 1000 * 2 ** Math.min(failures, 5))
318
+ : Math.max(1000, options.pollIntervalMs ?? 1000), options.signal);
319
+ }
320
+ }
321
+ async disconnect() {
322
+ await this.call("connection.revoke");
323
+ await this.options.tokenStore.set(null);
324
+ }
325
+ }
@@ -0,0 +1,120 @@
1
+ export interface Receipt {
2
+ id?: string;
3
+ recipient_user_id?: string | null;
4
+ child_app_connection_id?: string | null;
5
+ delivered_at?: string | null;
6
+ seen_at?: string | null;
7
+ read_at?: string | null;
8
+ heard_at?: string | null;
9
+ }
10
+ export interface Media {
11
+ id: string;
12
+ media_kind?: string;
13
+ mime_type?: string;
14
+ size_bytes?: number;
15
+ file_name?: string | null;
16
+ duration_ms?: number | null;
17
+ width?: number | null;
18
+ height?: number | null;
19
+ waveform?: number[] | null;
20
+ upload_state?: string;
21
+ server_expires_at?: string | null;
22
+ deleted_from_storage_at?: string | null;
23
+ signed_url?: string;
24
+ signed_url_expires_at?: string;
25
+ }
26
+ export interface Message {
27
+ id: string;
28
+ conversation_id: string;
29
+ sender_kind: string;
30
+ sender_user_id?: string | null;
31
+ child_app_connection_id?: string | null;
32
+ client_message_id?: string | null;
33
+ message_kind: "text" | "voice" | "image" | "video" | "document";
34
+ text_body?: string | null;
35
+ media_object_id?: string | null;
36
+ created_at: string;
37
+ active_in_walkie_talkie?: boolean;
38
+ reply_to_message_id?: string | null;
39
+ local_only?: boolean;
40
+ local_send_state?: string;
41
+ media_objects: Media | null;
42
+ message_receipts: Receipt[];
43
+ message_reactions: Array<{
44
+ id: string;
45
+ user_id: string;
46
+ emoji: string;
47
+ created_at: string;
48
+ updated_at: string;
49
+ }>;
50
+ message_speech_artifacts: Array<{
51
+ id: string;
52
+ artifact_kind: string;
53
+ language_code?: string;
54
+ text_body?: string | null;
55
+ media_objects: Media | null;
56
+ }>;
57
+ }
58
+ export interface Conversation {
59
+ id: string;
60
+ mode: "history" | "walkie_talkie";
61
+ title?: string | null;
62
+ updated_at?: string;
63
+ unread_count?: number;
64
+ blocked_by_you: boolean;
65
+ user_preferences: {
66
+ muted_at?: string | null;
67
+ blurred_at?: string | null;
68
+ history_cleared_at?: string | null;
69
+ };
70
+ conversation_members: Array<{
71
+ id: string;
72
+ member_kind: string;
73
+ user_id?: string | null;
74
+ child_app_connection_id?: string | null;
75
+ left_at?: string | null;
76
+ app_users: {
77
+ display_name?: string | null;
78
+ phone_e164?: string;
79
+ avatar_url?: string | null;
80
+ deleted_at?: string | null;
81
+ };
82
+ child_app_connections: {
83
+ child_original_name?: string;
84
+ status?: string;
85
+ disconnected_at?: string | null;
86
+ external_account_deleted_at?: string | null;
87
+ external_avatar_url?: string | null;
88
+ capabilities?: Record<string, unknown>;
89
+ monitoring_disclosure?: unknown;
90
+ child_apps: {
91
+ name?: string;
92
+ };
93
+ } | null;
94
+ }>;
95
+ }
96
+ export interface ConversationPage {
97
+ conversations: Conversation[];
98
+ messages: Message[];
99
+ deletedConversations: Array<{
100
+ conversationId: string;
101
+ historyClearedAt: string | null;
102
+ }>;
103
+ }
104
+ export interface MessagePage {
105
+ messages: Message[];
106
+ has_more: boolean;
107
+ next_before: string | null;
108
+ next_before_id: string | null;
109
+ }
110
+ export interface UploadTicket {
111
+ media: Media;
112
+ signedUrl: string;
113
+ token: string;
114
+ path: string;
115
+ thumbnailUpload: {
116
+ signedUrl: string;
117
+ token: string;
118
+ path: string;
119
+ } | null;
120
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
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>;
@@ -0,0 +1,22 @@
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
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@robono/linked-apps",
3
+ "version": "0.1.0-preview.1",
4
+ "private": false,
5
+ "description": "User-authorized companion-app access to Robono (development preview)",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE",
19
+ "INTEGRATION.md",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "test": "npm run build && node --test test/*.test.mjs",
25
+ "prepack": "npm run build",
26
+ "prepublishOnly": "npm test"
27
+ },
28
+ "license": "SEE LICENSE IN LICENSE",
29
+ "homepage": "https://robono.com/linked-apps",
30
+ "bugs": {
31
+ "url": "https://robono.com/contact",
32
+ "email": "support@robono.com"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "tag": "preview"
37
+ },
38
+ "keywords": [
39
+ "robono",
40
+ "messaging",
41
+ "linked-apps",
42
+ "sdk",
43
+ "typescript"
44
+ ],
45
+ "author": {
46
+ "name": "Add to Loop LLC",
47
+ "email": "support@robono.com",
48
+ "url": "https://robono.com"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/IMLX16/robono-sdks.git",
53
+ "directory": "packages/linked-apps"
54
+ },
55
+ "sideEffects": false,
56
+ "devDependencies": {
57
+ "typescript": "6.0.3"
58
+ }
59
+ }