@withone/connect 0.13.1 → 0.14.0
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/README.md +81 -55
- package/dist/server/index.cjs.js +152 -28
- package/dist/server/index.d.ts +14 -2
- package/dist/server/index.esm.js +151 -29
- package/dist/server/types.d.ts +42 -5
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +105 -94
- package/src/server/index.ts +212 -38
- package/src/server/types.ts +45 -5
package/dist/server/index.esm.js
CHANGED
|
@@ -151,6 +151,12 @@ function tokenScopes(accessToken) {
|
|
|
151
151
|
|
|
152
152
|
/** One catalog action for a platform. */
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Everything One knows about one action: the guide a caller reads before
|
|
156
|
+
* running it (`knowledge`, Markdown), the shape of its input, and the
|
|
157
|
+
* method and path the SDK runs it with.
|
|
158
|
+
*/
|
|
159
|
+
|
|
154
160
|
/**
|
|
155
161
|
* - `not_connected`: nothing is stored for this user.
|
|
156
162
|
* - `reconnect_required` (key mode): One will not act for this user.
|
|
@@ -466,15 +472,53 @@ function createTokenCredential(tokenStore, postToken) {
|
|
|
466
472
|
* const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
|
|
467
473
|
* // in the callback route
|
|
468
474
|
* const result = await oneConnect.completeAuthorization({ userId, url, getCookie });
|
|
469
|
-
* // afterwards
|
|
475
|
+
* // afterwards, the same way the One CLI works: find the action, read
|
|
476
|
+
* // its knowledge, run it
|
|
470
477
|
* const rows = await oneConnect.listConnections(userId);
|
|
471
|
-
* const
|
|
478
|
+
* const [action] = await oneConnect.searchActions(userId, "stripe", "create an invoice");
|
|
479
|
+
* const guide = await oneConnect.getActionKnowledge(userId, action._id);
|
|
480
|
+
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId: action._id, body });
|
|
472
481
|
*
|
|
473
482
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
474
483
|
* See `./key` and `./token` for what each mode stores and sends.
|
|
475
484
|
*/
|
|
476
485
|
const CATALOG_PAGE_SIZE = 100;
|
|
477
486
|
const CATALOG_MAX_PAGES = 20;
|
|
487
|
+
const SEARCH_DEFAULT_LIMIT = 5;
|
|
488
|
+
const isPlainObject = value => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Blob) && !(value instanceof FormData);
|
|
489
|
+
|
|
490
|
+
/** Fills a path's `{{placeholders}}`. A placeholder with no value is an
|
|
491
|
+
* error the caller can read, rather than a request One cannot route. */
|
|
492
|
+
function fillPath(path, params = {}) {
|
|
493
|
+
return path.replace(/\{\{([^}]+)\}\}/g, (_match, name) => {
|
|
494
|
+
const key = name.trim();
|
|
495
|
+
const value = params[key];
|
|
496
|
+
if (value === undefined || value === null || value === "") throw new OneConnectError("request_failed", `The action's path needs a value for {{${key}}}; pass it in pathParams.`);
|
|
497
|
+
return encodeURIComponent(String(value));
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `application/x-www-form-urlencoded` with nested objects and arrays in
|
|
502
|
+
* bracket notation, as Stripe and other form providers read it. */
|
|
503
|
+
function formEncode(value) {
|
|
504
|
+
const out = new URLSearchParams();
|
|
505
|
+
const walk = (prefix, v) => {
|
|
506
|
+
if (v === undefined || v === null) return;
|
|
507
|
+
if (Array.isArray(v)) v.forEach((item, i) => walk(`${prefix}[${typeof item === "object" ? i : ""}]`, item));else if (typeof v === "object") for (const [k, inner] of Object.entries(v)) walk(prefix ? `${prefix}[${k}]` : k, inner);else out.append(prefix, String(v));
|
|
508
|
+
};
|
|
509
|
+
walk("", value);
|
|
510
|
+
return out.toString();
|
|
511
|
+
}
|
|
512
|
+
const toKnowledge = row => ({
|
|
513
|
+
_id: String(row._id ?? ""),
|
|
514
|
+
title: String(row.title ?? ""),
|
|
515
|
+
method: String(row.method ?? "").toUpperCase(),
|
|
516
|
+
path: String(row.path ?? ""),
|
|
517
|
+
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
|
518
|
+
knowledge: String(row.knowledge ?? ""),
|
|
519
|
+
ioSchema: row.ioSchema,
|
|
520
|
+
platform: row.connectionPlatform ? String(row.connectionPlatform) : undefined
|
|
521
|
+
});
|
|
478
522
|
|
|
479
523
|
/** What an app does with One in either mode. */
|
|
480
524
|
|
|
@@ -649,29 +693,34 @@ function createOneConnect(config) {
|
|
|
649
693
|
const body = await response.json();
|
|
650
694
|
return body.rows ?? [];
|
|
651
695
|
};
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Turns a catalog refusal into the right error. A One API that does not
|
|
699
|
+
* take the credential on its catalog answers the same bare 403 as a
|
|
700
|
+
* consent that is gone; asking what the grant reaches tells them apart.
|
|
701
|
+
*/
|
|
702
|
+
const catalogRefusal = async (userId, what, response) => {
|
|
703
|
+
const refused = credential.refusal(response.status, await response.text());
|
|
704
|
+
if (refused?.code === "reconnect_required") {
|
|
705
|
+
if (await consentStands(userId, refused)) return new OneConnectError("request_failed", `One's ${what} refused the ${keyCredential ? "connect key" : "access token"} (HTTP 403) although the user's consent stands: this One API does not accept it on the ${what}.`, response.status);
|
|
706
|
+
return refused;
|
|
707
|
+
}
|
|
708
|
+
if (refused) return refused;
|
|
709
|
+
return new OneConnectError("request_failed", `One refused the ${what} request (HTTP ${response.status}).`, response.status);
|
|
710
|
+
};
|
|
711
|
+
const slim = rows => rows.map(row => ({
|
|
712
|
+
_id: String(row._id ?? row.systemId ?? ""),
|
|
713
|
+
title: String(row.title ?? ""),
|
|
714
|
+
method: String(row.method ?? "").toUpperCase(),
|
|
715
|
+
path: String(row.path ?? ""),
|
|
716
|
+
...(Array.isArray(row.tags) ? {
|
|
717
|
+
tags: row.tags.map(String)
|
|
718
|
+
} : {})
|
|
719
|
+
}));
|
|
652
720
|
const listActions = async (userId, platform) => {
|
|
653
721
|
const pageUrl = page => `/knowledge?connectionPlatform=${encodeURIComponent(platform)}&limit=${CATALOG_PAGE_SIZE}&page=${page}`;
|
|
654
|
-
const slim = rows => rows.map(row => ({
|
|
655
|
-
_id: String(row._id ?? ""),
|
|
656
|
-
title: String(row.title ?? ""),
|
|
657
|
-
method: String(row.method ?? ""),
|
|
658
|
-
path: String(row.path ?? "")
|
|
659
|
-
}));
|
|
660
722
|
const first = await oneFetch(userId, pageUrl(1));
|
|
661
|
-
if (!first.ok)
|
|
662
|
-
const refused = credential.refusal(first.status, await first.text());
|
|
663
|
-
if (refused?.code === "reconnect_required") {
|
|
664
|
-
// The same bare 403 is what a One API answers when its catalog
|
|
665
|
-
// does not take the connect key. If the consent stands, that is
|
|
666
|
-
// what happened, and asking the user to reconnect would not help.
|
|
667
|
-
if (await consentStands(userId, refused)) {
|
|
668
|
-
throw new OneConnectError("request_failed", "One's action catalog refused the connect key (HTTP 403) although the user's consent stands: this One API does not accept the connect key on the catalog.", first.status);
|
|
669
|
-
}
|
|
670
|
-
throw refused;
|
|
671
|
-
}
|
|
672
|
-
if (refused) throw refused;
|
|
673
|
-
throw new OneConnectError("request_failed", `One refused the catalog request (HTTP ${first.status}).`, first.status);
|
|
674
|
-
}
|
|
723
|
+
if (!first.ok) throw await catalogRefusal(userId, "action catalog", first);
|
|
675
724
|
const page1 = await first.json();
|
|
676
725
|
const pages = Math.min(Math.max(page1.pages ?? 1, 1), CATALOG_MAX_PAGES);
|
|
677
726
|
const rest = await Promise.all(Array.from({
|
|
@@ -679,19 +728,90 @@ function createOneConnect(config) {
|
|
|
679
728
|
}, (_, index) => oneFetch(userId, pageUrl(index + 2)).then(response => response.ok ? response.json() : null).catch(() => null)));
|
|
680
729
|
return slim([page1, ...rest].flatMap(page => page?.rows ?? []));
|
|
681
730
|
};
|
|
731
|
+
|
|
732
|
+
/** The actions that fit a request in words, best first: One's search,
|
|
733
|
+
* the one the One CLI's `actions search` uses. */
|
|
734
|
+
const searchActions = async (userId, platform, query, options = {}) => {
|
|
735
|
+
const params = new URLSearchParams({
|
|
736
|
+
query,
|
|
737
|
+
limit: String(options.limit ?? SEARCH_DEFAULT_LIMIT),
|
|
738
|
+
[options.mode === "knowledge" ? "knowledgeAgent" : "executeAgent"]: "true"
|
|
739
|
+
});
|
|
740
|
+
const response = await oneFetch(userId, `/available-actions/search/${encodeURIComponent(platform)}?${params}`);
|
|
741
|
+
if (!response.ok) throw await catalogRefusal(userId, "action search", response);
|
|
742
|
+
const body = await response.json();
|
|
743
|
+
return slim(Array.isArray(body) ? body : body.rows ?? []);
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
/** One action's knowledge, cached for the life of the client: an
|
|
747
|
+
* action's guide does not change between two calls. */
|
|
748
|
+
const knowledgeCache = new Map();
|
|
749
|
+
const getActionKnowledge = (userId, actionId) => {
|
|
750
|
+
const cached = knowledgeCache.get(actionId);
|
|
751
|
+
if (cached) return cached;
|
|
752
|
+
const loading = (async () => {
|
|
753
|
+
const response = await oneFetch(userId, `/knowledge?_id=${encodeURIComponent(actionId)}`);
|
|
754
|
+
if (!response.ok) throw await catalogRefusal(userId, "action knowledge", response);
|
|
755
|
+
const body = await response.json();
|
|
756
|
+
const row = body.rows?.[0];
|
|
757
|
+
if (!row) throw new OneConnectError("request_failed", `One has no action with the id ${actionId}.`, 404);
|
|
758
|
+
return toKnowledge(row);
|
|
759
|
+
})();
|
|
760
|
+
knowledgeCache.set(actionId, loading);
|
|
761
|
+
loading.catch(() => knowledgeCache.delete(actionId));
|
|
762
|
+
return loading;
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Runs one action through One the way the One CLI's `actions execute`
|
|
767
|
+
* does: the method and path come from the action itself, the path's
|
|
768
|
+
* placeholders are filled, an action One serves gets the connection key
|
|
769
|
+
* in its body, and the body is encoded as the provider reads it.
|
|
770
|
+
*/
|
|
682
771
|
const runAction = async (userId, input) => {
|
|
683
|
-
|
|
684
|
-
|
|
772
|
+
// The caller may hand over method and path from the catalog; otherwise
|
|
773
|
+
// the action says, and its tags say whether it is one One serves.
|
|
774
|
+
const action = input.method && input.path ? {
|
|
775
|
+
method: input.method,
|
|
776
|
+
path: input.path,
|
|
777
|
+
tags: []
|
|
778
|
+
} : await getActionKnowledge(userId, input.actionId);
|
|
779
|
+
const method = action.method.toUpperCase();
|
|
780
|
+
const path = fillPath(action.path, input.pathParams);
|
|
781
|
+
const search = new URLSearchParams();
|
|
782
|
+
for (const [key, value] of Object.entries(input.query ?? {})) for (const item of Array.isArray(value) ? value : [value]) search.append(key, item);
|
|
783
|
+
const query = search.size ? `?${search}` : "";
|
|
685
784
|
const headers = {
|
|
785
|
+
...input.headers,
|
|
686
786
|
"x-one-connection-key": input.connectionKey,
|
|
687
787
|
"x-one-action-id": input.actionId
|
|
688
788
|
};
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
789
|
+
const takesBody = method !== "GET" && method !== "HEAD";
|
|
790
|
+
let payload = input.body;
|
|
791
|
+
// An action One serves itself reads the connection key from its body.
|
|
792
|
+
if (takesBody && action.tags.includes("custom") && (payload === undefined || isPlainObject(payload))) payload = {
|
|
793
|
+
...(payload ?? {}),
|
|
794
|
+
connectionKey: input.connectionKey
|
|
795
|
+
};
|
|
796
|
+
let body;
|
|
797
|
+
if (takesBody && payload !== undefined) {
|
|
798
|
+
const encoding = input.encoding ?? "json";
|
|
799
|
+
if (encoding === "form") {
|
|
800
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
801
|
+
body = formEncode(payload);
|
|
802
|
+
} else if (encoding === "multipart") {
|
|
803
|
+
const form = new FormData();
|
|
804
|
+
for (const [key, value] of Object.entries(isPlainObject(payload) ? payload : {})) form.append(key, value instanceof Blob ? value : typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
805
|
+
body = form; // fetch sets the multipart boundary itself
|
|
806
|
+
} else {
|
|
807
|
+
headers["Content-Type"] = "application/json";
|
|
808
|
+
body = JSON.stringify(payload);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const response = await oneFetch(userId, `/passthrough${path}${query}`, {
|
|
692
812
|
method,
|
|
693
813
|
headers,
|
|
694
|
-
body
|
|
814
|
+
body
|
|
695
815
|
});
|
|
696
816
|
const text = await response.text();
|
|
697
817
|
if (!response.ok) {
|
|
@@ -735,6 +855,8 @@ function createOneConnect(config) {
|
|
|
735
855
|
disconnect: credential.disconnect,
|
|
736
856
|
listConnections,
|
|
737
857
|
listActions,
|
|
858
|
+
searchActions,
|
|
859
|
+
getActionKnowledge,
|
|
738
860
|
runAction,
|
|
739
861
|
fetch: oneFetch
|
|
740
862
|
};
|
|
@@ -756,4 +878,4 @@ function createOneConnect(config) {
|
|
|
756
878
|
};
|
|
757
879
|
}
|
|
758
880
|
|
|
759
|
-
export { OneConnectError, createOneConnect, encodeUserReference, parseUserReference, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
|
|
881
|
+
export { OneConnectError, createOneConnect, encodeUserReference, fillPath, formEncode, parseUserReference, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
|
package/dist/server/types.d.ts
CHANGED
|
@@ -207,17 +207,54 @@ export interface PlatformAction {
|
|
|
207
207
|
title: string;
|
|
208
208
|
method: string;
|
|
209
209
|
path: string;
|
|
210
|
+
/** One's tags for the action. "custom" marks an action One itself
|
|
211
|
+
* serves, which takes the connection key in its body. */
|
|
212
|
+
tags?: string[];
|
|
210
213
|
}
|
|
214
|
+
export interface SearchActionsOptions {
|
|
215
|
+
/** How many candidates to return. Five when omitted. */
|
|
216
|
+
limit?: number;
|
|
217
|
+
/** What the search ranks for: actions to run now (the default), or
|
|
218
|
+
* actions to write code and flows against. */
|
|
219
|
+
mode?: "execute" | "knowledge";
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Everything One knows about one action: the guide a caller reads before
|
|
223
|
+
* running it (`knowledge`, Markdown), the shape of its input, and the
|
|
224
|
+
* method and path the SDK runs it with.
|
|
225
|
+
*/
|
|
226
|
+
export interface ActionKnowledge extends PlatformAction {
|
|
227
|
+
tags: string[];
|
|
228
|
+
/** The action's documentation: what it does, every field it takes,
|
|
229
|
+
* what it answers. Markdown. */
|
|
230
|
+
knowledge: string;
|
|
231
|
+
/** The input and output shape, when One has one. */
|
|
232
|
+
ioSchema?: unknown;
|
|
233
|
+
/** The platform the action belongs to. */
|
|
234
|
+
platform?: string;
|
|
235
|
+
}
|
|
236
|
+
export type ActionBodyEncoding = "json" | "form" | "multipart";
|
|
211
237
|
export interface RunActionInput {
|
|
212
238
|
/** From `listConnections`. */
|
|
213
239
|
connectionKey: string;
|
|
214
|
-
/** From `listActions`. */
|
|
240
|
+
/** From `searchActions`, `listActions` or `getActionKnowledge`. */
|
|
215
241
|
actionId: string;
|
|
216
|
-
method
|
|
217
|
-
|
|
218
|
-
path
|
|
242
|
+
/** The action's method. Looked up from `actionId` when omitted. */
|
|
243
|
+
method?: string;
|
|
244
|
+
/** The action's path, appended to /v1/passthrough. Looked up from
|
|
245
|
+
* `actionId` when omitted. */
|
|
246
|
+
path?: string;
|
|
247
|
+
/** Values for the path's `{{placeholders}}`, such as `{ calendarId: "primary" }`. */
|
|
248
|
+
pathParams?: Record<string, string | number | boolean>;
|
|
219
249
|
body?: unknown;
|
|
220
|
-
query?: Record<string, string>;
|
|
250
|
+
query?: Record<string, string | string[]>;
|
|
251
|
+
/** Extra request headers an action's knowledge asks for, such as a
|
|
252
|
+
* provider's version header. */
|
|
253
|
+
headers?: Record<string, string>;
|
|
254
|
+
/** How `body` is sent. JSON when omitted; "form" for providers that
|
|
255
|
+
* take `application/x-www-form-urlencoded` (nested fields in bracket
|
|
256
|
+
* notation); "multipart" for file-style uploads. */
|
|
257
|
+
encoding?: ActionBodyEncoding;
|
|
221
258
|
}
|
|
222
259
|
export interface RunActionResult {
|
|
223
260
|
status: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withone/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "One Connect for your app: the button your users press, the two backend routes as one import, and a server client that calls One with the grant. Users keep their connections in One; your app holds only what they granted.",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: one-connect
|
|
3
|
-
description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes,
|
|
3
|
+
description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, the connect key, and calling One with the grant.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# One Connect
|
|
7
7
|
|
|
8
8
|
You are adding One Connect to this application. Its users will grant the app
|
|
9
9
|
scoped, revocable access to their own tools. The package does the OAuth work;
|
|
10
|
-
you wire
|
|
11
|
-
made with the grant.
|
|
10
|
+
you wire three things: a button, two routes, and the calls made with the grant.
|
|
12
11
|
|
|
13
12
|
```
|
|
14
13
|
Browser Your backend One
|
|
15
14
|
<ConnectButton> ------> GET /api/one/authorize ---302---> hosted page: sign in, pick tools, set access
|
|
16
15
|
GET /api/one/callback <--302---- ?code&state
|
|
17
|
-
|
|
16
|
+
saves one id for the user, redirects home
|
|
18
17
|
<------ onSuccess fires
|
|
19
18
|
Later: oneConnect.runAction(userId, ...) -> One, grant enforced
|
|
20
|
-
Daily: oneConnect.refreshIfExpiring(userId, ...) for every connected user
|
|
21
19
|
```
|
|
22
20
|
|
|
23
|
-
Use **
|
|
24
|
-
|
|
21
|
+
Use **key mode**, described in sections 1 to 7: the app holds one connect
|
|
22
|
+
key and saves one id per user, and nothing is refreshed. Token mode
|
|
23
|
+
(section 9) is the other way to hold the grant; use it only when the human
|
|
24
|
+
asks for it, or when the app already passes a `tokenStore`.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The client secret and the connect key stay on the server.
|
|
27
27
|
|
|
28
28
|
## 1 - Ask the human for these
|
|
29
29
|
|
|
@@ -33,17 +33,19 @@ They create the app in the One dashboard: Developers -> Connect -> New app.
|
|
|
33
33
|
|---|---|
|
|
34
34
|
| `ONE_CLIENT_ID` | From the app. |
|
|
35
35
|
| `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
|
|
36
|
+
| `ONE_CONNECT_KEY` | On the app's page: Credentials -> Connect keys -> Create key, with the dashboard on Production. Shown once. |
|
|
36
37
|
| Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
|
|
37
38
|
| `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
|
|
38
39
|
|
|
39
|
-
Never ask the human to paste the secret into the chat.
|
|
40
|
-
environment variable to set and read it from there.
|
|
40
|
+
Never ask the human to paste the secret or the connect key into the chat.
|
|
41
|
+
Tell them which environment variable to set and read it from there.
|
|
41
42
|
|
|
42
43
|
## 2 - Environment (server only)
|
|
43
44
|
|
|
44
45
|
```bash
|
|
45
46
|
ONE_CLIENT_ID=...
|
|
46
47
|
ONE_CLIENT_SECRET=one_secret_...
|
|
48
|
+
ONE_CONNECT_KEY=sk_live_...
|
|
47
49
|
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
|
|
48
50
|
ONE_PERMISSION_SET=... # optional
|
|
49
51
|
```
|
|
@@ -63,19 +65,23 @@ export const oneConnect = createOneConnect({
|
|
|
63
65
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
64
66
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
65
67
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
connectKey: process.env.ONE_CONNECT_KEY!,
|
|
69
|
+
userStore: {
|
|
70
|
+
saveUser: (userId, reference) => /* save the string on the app's user row */,
|
|
71
|
+
loadUser: (userId) => /* read it; null when never connected */,
|
|
72
|
+
clearUser: (userId) => /* set it to null */,
|
|
70
73
|
},
|
|
71
74
|
});
|
|
72
75
|
```
|
|
73
76
|
|
|
74
77
|
Use the app's own user id as the key and the database it already has.
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
`reference` is one short string: the user's permanent One id and the space
|
|
80
|
+
they granted from. One text column on the user row is enough. Store it as
|
|
81
|
+
given and hand it back unchanged; do not parse or rebuild it. It is an
|
|
82
|
+
identifier, not a secret, so it needs no encryption and no lock. The
|
|
83
|
+
package writes it in the callback and reads it on every call; the app
|
|
84
|
+
never passes it anywhere.
|
|
79
85
|
|
|
80
86
|
## 4 - The two routes
|
|
81
87
|
|
|
@@ -120,59 +126,46 @@ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
|
|
|
120
126
|
`<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
|
|
121
127
|
A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
|
|
122
128
|
|
|
123
|
-
## 6 -
|
|
124
|
-
|
|
125
|
-
Tokens expire. Two things keep them fresh, and only the first is automatic.
|
|
126
|
-
|
|
127
|
-
| | Who does it | When |
|
|
128
|
-
|---|---|---|
|
|
129
|
-
| Refresh before a call | the package, on its own | whenever the app calls One and the token is about to expire |
|
|
130
|
-
| Refresh for users who have not called in a while | **the app**, with a daily job | once a day, for every connected user |
|
|
131
|
-
|
|
132
|
-
You must add the daily job. Without it, a user who stays away for 30 days
|
|
133
|
-
has to connect again: the refresh token lives 30 days, and only a live
|
|
134
|
-
refresh token can renew the pair.
|
|
135
|
-
|
|
136
|
-
```ts
|
|
137
|
-
// a scheduled job, once a day
|
|
138
|
-
for (const userId of /* every user with stored tokens */) {
|
|
139
|
-
await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
It renews both tokens when either is within 3 days of expiring, and does
|
|
144
|
-
nothing otherwise. The access token lives as long as the app's Token lifetime
|
|
145
|
-
says (30 days unless changed under Advanced when creating the app); keep the
|
|
146
|
-
window shorter than that, or every run refreshes. Use the scheduler the app
|
|
147
|
-
already has (a cron route, a queue, a worker).
|
|
129
|
+
## 6 - Calling One with the grant
|
|
148
130
|
|
|
149
|
-
|
|
131
|
+
The same four steps the One CLI takes: find the action, read its knowledge,
|
|
132
|
+
run it. Always read the knowledge before running an action for the first
|
|
133
|
+
time; it names the required fields, the encoding and any header.
|
|
150
134
|
|
|
151
135
|
```ts
|
|
152
|
-
const connections = await oneConnect.listConnections(userId);
|
|
153
|
-
const
|
|
136
|
+
const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
|
|
137
|
+
const [action] = await oneConnect.searchActions(userId, "stripe", "create an invoice"); // best first, 5 by default
|
|
138
|
+
const guide = await oneConnect.getActionKnowledge(userId, action._id); // { knowledge (Markdown), ioSchema, method, path, tags }
|
|
154
139
|
|
|
155
140
|
const reply = await oneConnect.runAction(userId, {
|
|
156
141
|
connectionKey: connection.key,
|
|
157
142
|
actionId: action._id,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
143
|
+
body: payload, // what the guide asks for
|
|
144
|
+
pathParams: { calendarId: "primary" }, // values for {{placeholders}} in the path
|
|
145
|
+
query: { limit: "10" },
|
|
146
|
+
encoding: "json", // or "form" / "multipart", as the guide says
|
|
147
|
+
headers: {}, // only when the guide names one
|
|
161
148
|
});
|
|
162
|
-
// { status, ok, data }
|
|
149
|
+
// { status, ok, blockedByGrant, data }
|
|
163
150
|
```
|
|
164
151
|
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
`runAction` takes the method and path from the action, fills the path's
|
|
153
|
+
placeholders, puts the connection key in the body of an action One serves
|
|
154
|
+
itself (tag `custom`), and encodes the body as asked. `listActions(userId,
|
|
155
|
+
platform)` lists a whole catalog when search is not enough.
|
|
156
|
+
|
|
157
|
+
Do not set any auth header. The package adds the connect key and the
|
|
158
|
+
user's id to every call it makes.
|
|
167
159
|
|
|
168
|
-
A `403` reply means the call is outside what
|
|
160
|
+
A `403` reply with `blockedByGrant: true` means the call is outside what
|
|
161
|
+
the user granted. Do not retry it.
|
|
169
162
|
|
|
170
163
|
Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
|
|
171
164
|
|
|
172
165
|
| `code` | Meaning | Do |
|
|
173
166
|
|---|---|---|
|
|
174
167
|
| `not_connected` | Nothing is stored for this user. | Show the Connect button. |
|
|
175
|
-
| `
|
|
168
|
+
| `reconnect_required` | One will not act for this user: they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
|
|
176
169
|
| `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
|
|
177
170
|
|
|
178
171
|
```ts
|
|
@@ -183,7 +176,7 @@ try {
|
|
|
183
176
|
} catch (error) {
|
|
184
177
|
if (
|
|
185
178
|
error instanceof OneConnectError &&
|
|
186
|
-
["not_connected", "
|
|
179
|
+
["not_connected", "reconnect_required"].includes(error.code)
|
|
187
180
|
) {
|
|
188
181
|
// show the Connect button again
|
|
189
182
|
} else {
|
|
@@ -192,31 +185,38 @@ try {
|
|
|
192
185
|
}
|
|
193
186
|
```
|
|
194
187
|
|
|
195
|
-
|
|
188
|
+
`isConnected(userId)` says the user has connected before. One confirms the
|
|
189
|
+
consent on each call, so a user who revoked is found by the next call
|
|
190
|
+
throwing `reconnect_required`.
|
|
196
191
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
-
|
|
200
|
-
|
|
192
|
+
## 7 - Rules
|
|
193
|
+
|
|
194
|
+
- Never put the client secret or the connect key in browser code, logs,
|
|
195
|
+
error reports, source files or prompts. Environment variables only.
|
|
196
|
+
- Connect keys work in Production only; the human creates the key with
|
|
197
|
+
the dashboard on Production.
|
|
198
|
+
- Store the per-user string as given.
|
|
201
199
|
- The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
|
|
202
200
|
- Do not build a completion page; the callback redirect is the completion.
|
|
203
201
|
- Do not write OAuth steps or One request headers by hand; use the package.
|
|
204
202
|
- Do not switch an existing app from one mode to the other unless asked.
|
|
205
203
|
|
|
206
|
-
##
|
|
204
|
+
## 8 - Done when
|
|
207
205
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
1. The button leads to One's page; after signing in and authorizing, the user
|
|
207
|
+
lands back in the app and `onSuccess` fires.
|
|
208
|
+
2. One string is saved for the user.
|
|
209
|
+
3. `listConnections` returns only the granted connections.
|
|
210
|
+
4. `runAction` works for an action inside the grant and returns `403` with
|
|
211
|
+
`blockedByGrant: true` for one outside it.
|
|
212
|
+
5. After the user revokes the app in their One dashboard, the next call
|
|
213
|
+
fails with `reconnect_required` and the app asks them to connect again.
|
|
211
214
|
|
|
212
|
-
|
|
213
|
-
actions it runs.
|
|
214
|
-
- It works in Production only. The human creates the key with the dashboard
|
|
215
|
-
on Production.
|
|
215
|
+
## 9 - Token mode (only when asked)
|
|
216
216
|
|
|
217
|
-
The
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
The other way to hold the grant: the app stores an access token and a
|
|
218
|
+
refresh token per user, as a standard OAuth client. The routes, the button
|
|
219
|
+
and the calls are the same. No `ONE_CONNECT_KEY` is needed.
|
|
220
220
|
|
|
221
221
|
```ts
|
|
222
222
|
export const oneConnect = createOneConnect({
|
|
@@ -224,33 +224,44 @@ export const oneConnect = createOneConnect({
|
|
|
224
224
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
225
225
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
226
226
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
clearUser: (userId) => /* set it to null */,
|
|
227
|
+
tokenStore: {
|
|
228
|
+
saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
|
|
229
|
+
loadTokens: (userId) => /* read; null when never connected */,
|
|
230
|
+
clearTokens: (userId) => /* delete */,
|
|
232
231
|
},
|
|
233
232
|
});
|
|
234
233
|
```
|
|
235
234
|
|
|
236
|
-
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
-
|
|
244
|
-
|
|
235
|
+
- Store tokens encrypted, keyed by the app's user.
|
|
236
|
+
- If the app runs more than one server process or a background worker, also
|
|
237
|
+
add `withLock: (userId, run) => ...`, which runs `run()` while holding a
|
|
238
|
+
per-user lock all processes share (for example a Postgres advisory lock).
|
|
239
|
+
- When the user revokes access, the next refresh throws `refresh_failed`
|
|
240
|
+
(not `reconnect_required`) and the tokens are cleared. Ask them to connect
|
|
241
|
+
again.
|
|
242
|
+
- `blockedByGrant` stays `false` in token mode; treat any `403` as outside
|
|
243
|
+
the grant.
|
|
245
244
|
|
|
246
|
-
|
|
245
|
+
Tokens expire, and two things keep them fresh. Only the first is automatic:
|
|
247
246
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
247
|
+
| | Who does it | When |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| Refresh before a call | the package, on its own | whenever the app calls One and the token is about to expire |
|
|
250
|
+
| Refresh for users who have not called in a while | **the app**, with a daily job | once a day, for every connected user |
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// a scheduled job, once a day
|
|
254
|
+
for (const userId of /* every user with stored tokens */) {
|
|
255
|
+
await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Without the job, a user who stays away for 30 days has to connect again:
|
|
260
|
+
the refresh token lives 30 days, and only a live refresh token can renew
|
|
261
|
+
the pair. The access token lives as long as the app's Token lifetime says
|
|
262
|
+
(30 days unless changed under Advanced when creating the app); keep the
|
|
263
|
+
window shorter than that, or every run refreshes. In token mode, "done"
|
|
264
|
+
also means the daily job exists and runs for every connected user.
|
|
265
|
+
|
|
266
|
+
The mode is whichever credential `createOneConnect` is given: `connectKey`
|
|
267
|
+
and `userStore` for key mode, `tokenStore` for token mode.
|