@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/README.md
CHANGED
|
@@ -28,7 +28,7 @@ You add three things: a button, two backend routes, and the calls you make with
|
|
|
28
28
|
Browser Your server One
|
|
29
29
|
<ConnectButton> ──────► GET /api/one/authorize ──302──► hosted page: sign in, pick tools, set access
|
|
30
30
|
GET /api/one/callback ◄──302── ?code&state
|
|
31
|
-
saves
|
|
31
|
+
saves one id for the user, redirects home
|
|
32
32
|
onSuccess() ◄──────
|
|
33
33
|
later: oneConnect.runAction(userId, …) ──► One, grant enforced
|
|
34
34
|
```
|
|
@@ -45,9 +45,12 @@ Using a coding agent? `npx skills add withoneai/connect` teaches it the whole se
|
|
|
45
45
|
|
|
46
46
|
Dashboard → **Developers → Connect → New app**. Register your callback URL exactly, for example `https://yourapp.com/api/one/callback`. Optionally choose the tools and access levels to ask for; the consent page lists them in the order you add them.
|
|
47
47
|
|
|
48
|
+
Then, on the app's page, under **Credentials → Connect keys**, create a key with your dashboard on Production. It is shown once.
|
|
49
|
+
|
|
48
50
|
```env
|
|
49
51
|
ONE_CLIENT_ID=…
|
|
50
52
|
ONE_CLIENT_SECRET=one_secret_… # server only
|
|
53
|
+
ONE_CONNECT_KEY=sk_live_… # server only
|
|
51
54
|
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
|
|
52
55
|
ONE_PERMISSION_SET=… # optional: the tools you ask for
|
|
53
56
|
```
|
|
@@ -98,7 +101,7 @@ To match your design, set `--one-connect-font` and `--one-connect-radius`, or st
|
|
|
98
101
|
|
|
99
102
|
## 3 · The server client
|
|
100
103
|
|
|
101
|
-
Your server
|
|
104
|
+
Your server holds one **connect key** for the app and saves one id per user who connects. Nothing expires and nothing is refreshed: One checks the user's consent on every call. This is **key mode**, the default.
|
|
102
105
|
|
|
103
106
|
```ts
|
|
104
107
|
// lib/one.ts
|
|
@@ -109,17 +112,16 @@ export const oneConnect = createOneConnect({
|
|
|
109
112
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
110
113
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
111
114
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
connectKey: process.env.ONE_CONNECT_KEY!,
|
|
116
|
+
userStore: {
|
|
117
|
+
saveUser: (userId, reference) => db.users.update(userId, { oneConnect: reference }),
|
|
118
|
+
loadUser: async (userId) => (await db.users.find(userId))?.oneConnect ?? null,
|
|
119
|
+
clearUser: (userId) => db.users.update(userId, { oneConnect: null }),
|
|
116
120
|
},
|
|
117
121
|
});
|
|
118
122
|
```
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
Running more than one server or a background worker? Add `withLock(userId, run)` to your token store, for example a Postgres advisory lock. Two servers refreshing at once would otherwise disconnect the user.
|
|
124
|
+
`reference` is one short string: the user's permanent One id, plus the space they granted from. Save it in one column and hand it back unchanged. It is an identifier, not a secret, and it stays the same if the user disconnects and connects again.
|
|
123
125
|
|
|
124
126
|
## 4 · The two routes
|
|
125
127
|
|
|
@@ -137,63 +139,70 @@ That file serves `/api/one/authorize` and `/api/one/callback`. For Express or pl
|
|
|
137
139
|
|
|
138
140
|
## 5 · Using the grant
|
|
139
141
|
|
|
142
|
+
Work the way the One CLI does: find the action, read its knowledge, run it.
|
|
143
|
+
|
|
140
144
|
```ts
|
|
141
|
-
|
|
142
|
-
const
|
|
145
|
+
// 1. what the user granted, with the connection key per tool
|
|
146
|
+
const connections = await oneConnect.listConnections(userId);
|
|
147
|
+
const stripe = connections.find((c) => c.platform === "stripe");
|
|
143
148
|
|
|
149
|
+
// 2. the actions that fit what you want to do, best first
|
|
150
|
+
const [action] = await oneConnect.searchActions(userId, "stripe", "list invoice items");
|
|
151
|
+
|
|
152
|
+
// 3. the action's guide: what it does, every field it takes, what it answers
|
|
153
|
+
const guide = await oneConnect.getActionKnowledge(userId, action._id);
|
|
154
|
+
console.log(guide.knowledge); // Markdown
|
|
155
|
+
|
|
156
|
+
// 4. run it: the method and path come from the action; you pass the input
|
|
144
157
|
const reply = await oneConnect.runAction(userId, {
|
|
145
|
-
connectionKey:
|
|
146
|
-
actionId:
|
|
147
|
-
|
|
148
|
-
path: actions[0].path,
|
|
158
|
+
connectionKey: stripe.key,
|
|
159
|
+
actionId: action._id,
|
|
160
|
+
body: { limit: 10 },
|
|
149
161
|
});
|
|
150
|
-
// { status, ok, data }
|
|
162
|
+
// { status, ok, blockedByGrant, data }
|
|
151
163
|
```
|
|
152
164
|
|
|
153
|
-
|
|
165
|
+
`runAction` does what the guide asks for: it fills `{{placeholders}}` in the path from `pathParams`, puts the connection key in the body of an action One serves itself, and sends the body the way the provider reads it.
|
|
154
166
|
|
|
155
|
-
|
|
167
|
+
```ts
|
|
168
|
+
await oneConnect.runAction(userId, {
|
|
169
|
+
connectionKey: calendar.key,
|
|
170
|
+
actionId: action._id,
|
|
171
|
+
pathParams: { calendarId: "primary" }, // for a path like /calendars/{{calendarId}}/events
|
|
172
|
+
query: { maxResults: "10" },
|
|
173
|
+
body: { summary: "Call" },
|
|
174
|
+
encoding: "form", // when the guide says x-www-form-urlencoded; "multipart" for uploads
|
|
175
|
+
headers: { "Notion-Version": "2022-06-28" }, // when the guide names a header
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`listActions(userId, platform)` still lists everything a platform can do, and `runAction` still takes `method` and `path` from it if you'd rather pass them yourself.
|
|
180
|
+
|
|
181
|
+
You never set an auth header: the client adds the connect key and the user's id to every call it makes.
|
|
182
|
+
|
|
183
|
+
A `403` with `blockedByGrant: true` means the call is outside what the user granted. Don't retry it.
|
|
156
184
|
|
|
157
185
|
Errors are `OneConnectError` with a `code`:
|
|
158
186
|
|
|
159
187
|
| `code` | What it means | What to do |
|
|
160
188
|
|---|---|---|
|
|
161
189
|
| `not_connected` | Nothing is stored for this user. | Show the Connect button. |
|
|
162
|
-
| `
|
|
190
|
+
| `reconnect_required` | One will not act for this user: they revoked access, or the app is deactivated. Your stored value is kept. | Ask the user to connect again. |
|
|
163
191
|
| `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
|
|
164
192
|
|
|
165
|
-
## 6 ·
|
|
193
|
+
## 6 · Key mode notes
|
|
166
194
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
| Refresh for users who have not called in a while | **you**, with a daily job | once a day, for every connected user |
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
// run once a day
|
|
176
|
-
for (const userId of await db.oneTokens.allUserIds()) {
|
|
177
|
-
await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
|
|
178
|
-
}
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
The job renews both tokens when either is within 3 days of expiring. Without it, a user who stays away for 30 days has to connect again: the refresh token lives 30 days, and only a live refresh token can renew the pair.
|
|
182
|
-
|
|
183
|
-
The access token lives as long as your app's **Token lifetime** says: 30 days unless you changed it under **Advanced** when creating or editing the app. Keep the job's window shorter than that, or every run refreshes.
|
|
195
|
+
- Keep the connect key on the server, in an environment variable. It is bound to your app and does nothing without a user's id.
|
|
196
|
+
- Connect keys work in Production only. Create the key with your dashboard on Production.
|
|
197
|
+
- `isConnected(userId)` says whether the user has connected before. One confirms the consent on each call, so a user who revoked is found by the next call throwing `reconnect_required`.
|
|
198
|
+
- `oneConnect.getConnectUserId(userId)` returns the user's One id (`cu_…`) if you want it for your own records.
|
|
199
|
+
- Lost or leaked a key? Create another on the app's page, move your servers to it, then revoke the old one there.
|
|
184
200
|
|
|
185
201
|
To ask for more tools later, edit your app's tools in the dashboard. Users see only the new ones the next time they connect.
|
|
186
202
|
|
|
187
|
-
## 7 ·
|
|
188
|
-
|
|
189
|
-
A second way to hold a grant: one **connect key** for your app and one permanent id per user, with nothing to refresh. The button, the routes and the calls stay the same.
|
|
203
|
+
## 7 · Token mode
|
|
190
204
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
- `listActions` does not work in key mode yet. Your app has to know the actions it runs.
|
|
194
|
-
- It works in Production only. Create the key with your dashboard on Production.
|
|
195
|
-
|
|
196
|
-
Create the key on your app's page, under **Credentials → Connect key**. It is shown once. Keep it on the server as `ONE_CONNECT_KEY`.
|
|
205
|
+
The other way to hold a grant: your server stores an access token and a refresh token per user, as a standard OAuth client. The button, the routes and the calls stay the same. Use it when you need bearer tokens, or when the app already has them; an app that passes only a `tokenStore` keeps running in token mode with no change.
|
|
197
206
|
|
|
198
207
|
```ts
|
|
199
208
|
export const oneConnect = createOneConnect({
|
|
@@ -201,19 +210,36 @@ export const oneConnect = createOneConnect({
|
|
|
201
210
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
202
211
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
203
212
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
clearUser: (userId) => db.users.update(userId, { oneConnect: null }),
|
|
213
|
+
tokenStore: {
|
|
214
|
+
saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
|
|
215
|
+
loadTokens: (userId) => db.oneTokens.find(userId),
|
|
216
|
+
clearTokens: (userId) => db.oneTokens.delete(userId),
|
|
209
217
|
},
|
|
210
218
|
});
|
|
211
219
|
```
|
|
212
220
|
|
|
213
|
-
-
|
|
214
|
-
-
|
|
215
|
-
-
|
|
216
|
-
-
|
|
221
|
+
- Store the tokens in your database, encrypted, keyed by your user id.
|
|
222
|
+
- Running more than one server or a background worker? Add `withLock(userId, run)` to your token store, for example a Postgres advisory lock. Two servers refreshing at once would otherwise disconnect the user.
|
|
223
|
+
- When a user revokes access, the next refresh throws `refresh_failed` and the tokens are cleared. Ask them to connect again.
|
|
224
|
+
- A `403` means the call is outside what the user granted; `blockedByGrant` stays `false` in token mode.
|
|
225
|
+
|
|
226
|
+
**Keeping users connected.** Tokens expire, and two things keep them fresh. One is automatic. The other is yours to run.
|
|
227
|
+
|
|
228
|
+
| | Who does it | When |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| Refresh before a call | the client, on its own | whenever you call One and the token is about to expire |
|
|
231
|
+
| Refresh for users who have not called in a while | **you**, with a daily job | once a day, for every connected user |
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// run once a day
|
|
235
|
+
for (const userId of await db.oneTokens.allUserIds()) {
|
|
236
|
+
await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The job renews both tokens when either is within 3 days of expiring. Without it, a user who stays away for 30 days has to connect again: the refresh token lives 30 days, and only a live refresh token can renew the pair. The access token lives as long as your app's **Token lifetime** says (30 days unless you changed it under **Advanced**); keep the job's window shorter than that, or every run refreshes.
|
|
241
|
+
|
|
242
|
+
The mode is whichever credential you pass: `connectKey` and `userStore` for key mode, `tokenStore` for token mode. `oneConnect.mode` tells you which one is running.
|
|
217
243
|
|
|
218
244
|
## License
|
|
219
245
|
|
package/dist/server/index.cjs.js
CHANGED
|
@@ -153,6 +153,12 @@ function tokenScopes(accessToken) {
|
|
|
153
153
|
|
|
154
154
|
/** One catalog action for a platform. */
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Everything One knows about one action: the guide a caller reads before
|
|
158
|
+
* running it (`knowledge`, Markdown), the shape of its input, and the
|
|
159
|
+
* method and path the SDK runs it with.
|
|
160
|
+
*/
|
|
161
|
+
|
|
156
162
|
/**
|
|
157
163
|
* - `not_connected`: nothing is stored for this user.
|
|
158
164
|
* - `reconnect_required` (key mode): One will not act for this user.
|
|
@@ -468,15 +474,53 @@ function createTokenCredential(tokenStore, postToken) {
|
|
|
468
474
|
* const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
|
|
469
475
|
* // in the callback route
|
|
470
476
|
* const result = await oneConnect.completeAuthorization({ userId, url, getCookie });
|
|
471
|
-
* // afterwards
|
|
477
|
+
* // afterwards, the same way the One CLI works: find the action, read
|
|
478
|
+
* // its knowledge, run it
|
|
472
479
|
* const rows = await oneConnect.listConnections(userId);
|
|
473
|
-
* const
|
|
480
|
+
* const [action] = await oneConnect.searchActions(userId, "stripe", "create an invoice");
|
|
481
|
+
* const guide = await oneConnect.getActionKnowledge(userId, action._id);
|
|
482
|
+
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId: action._id, body });
|
|
474
483
|
*
|
|
475
484
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
476
485
|
* See `./key` and `./token` for what each mode stores and sends.
|
|
477
486
|
*/
|
|
478
487
|
const CATALOG_PAGE_SIZE = 100;
|
|
479
488
|
const CATALOG_MAX_PAGES = 20;
|
|
489
|
+
const SEARCH_DEFAULT_LIMIT = 5;
|
|
490
|
+
const isPlainObject = value => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Blob) && !(value instanceof FormData);
|
|
491
|
+
|
|
492
|
+
/** Fills a path's `{{placeholders}}`. A placeholder with no value is an
|
|
493
|
+
* error the caller can read, rather than a request One cannot route. */
|
|
494
|
+
function fillPath(path, params = {}) {
|
|
495
|
+
return path.replace(/\{\{([^}]+)\}\}/g, (_match, name) => {
|
|
496
|
+
const key = name.trim();
|
|
497
|
+
const value = params[key];
|
|
498
|
+
if (value === undefined || value === null || value === "") throw new OneConnectError("request_failed", `The action's path needs a value for {{${key}}}; pass it in pathParams.`);
|
|
499
|
+
return encodeURIComponent(String(value));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** `application/x-www-form-urlencoded` with nested objects and arrays in
|
|
504
|
+
* bracket notation, as Stripe and other form providers read it. */
|
|
505
|
+
function formEncode(value) {
|
|
506
|
+
const out = new URLSearchParams();
|
|
507
|
+
const walk = (prefix, v) => {
|
|
508
|
+
if (v === undefined || v === null) return;
|
|
509
|
+
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));
|
|
510
|
+
};
|
|
511
|
+
walk("", value);
|
|
512
|
+
return out.toString();
|
|
513
|
+
}
|
|
514
|
+
const toKnowledge = row => ({
|
|
515
|
+
_id: String(row._id ?? ""),
|
|
516
|
+
title: String(row.title ?? ""),
|
|
517
|
+
method: String(row.method ?? "").toUpperCase(),
|
|
518
|
+
path: String(row.path ?? ""),
|
|
519
|
+
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
|
520
|
+
knowledge: String(row.knowledge ?? ""),
|
|
521
|
+
ioSchema: row.ioSchema,
|
|
522
|
+
platform: row.connectionPlatform ? String(row.connectionPlatform) : undefined
|
|
523
|
+
});
|
|
480
524
|
|
|
481
525
|
/** What an app does with One in either mode. */
|
|
482
526
|
|
|
@@ -651,29 +695,34 @@ function createOneConnect(config) {
|
|
|
651
695
|
const body = await response.json();
|
|
652
696
|
return body.rows ?? [];
|
|
653
697
|
};
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Turns a catalog refusal into the right error. A One API that does not
|
|
701
|
+
* take the credential on its catalog answers the same bare 403 as a
|
|
702
|
+
* consent that is gone; asking what the grant reaches tells them apart.
|
|
703
|
+
*/
|
|
704
|
+
const catalogRefusal = async (userId, what, response) => {
|
|
705
|
+
const refused = credential.refusal(response.status, await response.text());
|
|
706
|
+
if (refused?.code === "reconnect_required") {
|
|
707
|
+
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);
|
|
708
|
+
return refused;
|
|
709
|
+
}
|
|
710
|
+
if (refused) return refused;
|
|
711
|
+
return new OneConnectError("request_failed", `One refused the ${what} request (HTTP ${response.status}).`, response.status);
|
|
712
|
+
};
|
|
713
|
+
const slim = rows => rows.map(row => ({
|
|
714
|
+
_id: String(row._id ?? row.systemId ?? ""),
|
|
715
|
+
title: String(row.title ?? ""),
|
|
716
|
+
method: String(row.method ?? "").toUpperCase(),
|
|
717
|
+
path: String(row.path ?? ""),
|
|
718
|
+
...(Array.isArray(row.tags) ? {
|
|
719
|
+
tags: row.tags.map(String)
|
|
720
|
+
} : {})
|
|
721
|
+
}));
|
|
654
722
|
const listActions = async (userId, platform) => {
|
|
655
723
|
const pageUrl = page => `/knowledge?connectionPlatform=${encodeURIComponent(platform)}&limit=${CATALOG_PAGE_SIZE}&page=${page}`;
|
|
656
|
-
const slim = rows => rows.map(row => ({
|
|
657
|
-
_id: String(row._id ?? ""),
|
|
658
|
-
title: String(row.title ?? ""),
|
|
659
|
-
method: String(row.method ?? ""),
|
|
660
|
-
path: String(row.path ?? "")
|
|
661
|
-
}));
|
|
662
724
|
const first = await oneFetch(userId, pageUrl(1));
|
|
663
|
-
if (!first.ok)
|
|
664
|
-
const refused = credential.refusal(first.status, await first.text());
|
|
665
|
-
if (refused?.code === "reconnect_required") {
|
|
666
|
-
// The same bare 403 is what a One API answers when its catalog
|
|
667
|
-
// does not take the connect key. If the consent stands, that is
|
|
668
|
-
// what happened, and asking the user to reconnect would not help.
|
|
669
|
-
if (await consentStands(userId, refused)) {
|
|
670
|
-
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);
|
|
671
|
-
}
|
|
672
|
-
throw refused;
|
|
673
|
-
}
|
|
674
|
-
if (refused) throw refused;
|
|
675
|
-
throw new OneConnectError("request_failed", `One refused the catalog request (HTTP ${first.status}).`, first.status);
|
|
676
|
-
}
|
|
725
|
+
if (!first.ok) throw await catalogRefusal(userId, "action catalog", first);
|
|
677
726
|
const page1 = await first.json();
|
|
678
727
|
const pages = Math.min(Math.max(page1.pages ?? 1, 1), CATALOG_MAX_PAGES);
|
|
679
728
|
const rest = await Promise.all(Array.from({
|
|
@@ -681,19 +730,90 @@ function createOneConnect(config) {
|
|
|
681
730
|
}, (_, index) => oneFetch(userId, pageUrl(index + 2)).then(response => response.ok ? response.json() : null).catch(() => null)));
|
|
682
731
|
return slim([page1, ...rest].flatMap(page => page?.rows ?? []));
|
|
683
732
|
};
|
|
733
|
+
|
|
734
|
+
/** The actions that fit a request in words, best first: One's search,
|
|
735
|
+
* the one the One CLI's `actions search` uses. */
|
|
736
|
+
const searchActions = async (userId, platform, query, options = {}) => {
|
|
737
|
+
const params = new URLSearchParams({
|
|
738
|
+
query,
|
|
739
|
+
limit: String(options.limit ?? SEARCH_DEFAULT_LIMIT),
|
|
740
|
+
[options.mode === "knowledge" ? "knowledgeAgent" : "executeAgent"]: "true"
|
|
741
|
+
});
|
|
742
|
+
const response = await oneFetch(userId, `/available-actions/search/${encodeURIComponent(platform)}?${params}`);
|
|
743
|
+
if (!response.ok) throw await catalogRefusal(userId, "action search", response);
|
|
744
|
+
const body = await response.json();
|
|
745
|
+
return slim(Array.isArray(body) ? body : body.rows ?? []);
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/** One action's knowledge, cached for the life of the client: an
|
|
749
|
+
* action's guide does not change between two calls. */
|
|
750
|
+
const knowledgeCache = new Map();
|
|
751
|
+
const getActionKnowledge = (userId, actionId) => {
|
|
752
|
+
const cached = knowledgeCache.get(actionId);
|
|
753
|
+
if (cached) return cached;
|
|
754
|
+
const loading = (async () => {
|
|
755
|
+
const response = await oneFetch(userId, `/knowledge?_id=${encodeURIComponent(actionId)}`);
|
|
756
|
+
if (!response.ok) throw await catalogRefusal(userId, "action knowledge", response);
|
|
757
|
+
const body = await response.json();
|
|
758
|
+
const row = body.rows?.[0];
|
|
759
|
+
if (!row) throw new OneConnectError("request_failed", `One has no action with the id ${actionId}.`, 404);
|
|
760
|
+
return toKnowledge(row);
|
|
761
|
+
})();
|
|
762
|
+
knowledgeCache.set(actionId, loading);
|
|
763
|
+
loading.catch(() => knowledgeCache.delete(actionId));
|
|
764
|
+
return loading;
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Runs one action through One the way the One CLI's `actions execute`
|
|
769
|
+
* does: the method and path come from the action itself, the path's
|
|
770
|
+
* placeholders are filled, an action One serves gets the connection key
|
|
771
|
+
* in its body, and the body is encoded as the provider reads it.
|
|
772
|
+
*/
|
|
684
773
|
const runAction = async (userId, input) => {
|
|
685
|
-
|
|
686
|
-
|
|
774
|
+
// The caller may hand over method and path from the catalog; otherwise
|
|
775
|
+
// the action says, and its tags say whether it is one One serves.
|
|
776
|
+
const action = input.method && input.path ? {
|
|
777
|
+
method: input.method,
|
|
778
|
+
path: input.path,
|
|
779
|
+
tags: []
|
|
780
|
+
} : await getActionKnowledge(userId, input.actionId);
|
|
781
|
+
const method = action.method.toUpperCase();
|
|
782
|
+
const path = fillPath(action.path, input.pathParams);
|
|
783
|
+
const search = new URLSearchParams();
|
|
784
|
+
for (const [key, value] of Object.entries(input.query ?? {})) for (const item of Array.isArray(value) ? value : [value]) search.append(key, item);
|
|
785
|
+
const query = search.size ? `?${search}` : "";
|
|
687
786
|
const headers = {
|
|
787
|
+
...input.headers,
|
|
688
788
|
"x-one-connection-key": input.connectionKey,
|
|
689
789
|
"x-one-action-id": input.actionId
|
|
690
790
|
};
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
791
|
+
const takesBody = method !== "GET" && method !== "HEAD";
|
|
792
|
+
let payload = input.body;
|
|
793
|
+
// An action One serves itself reads the connection key from its body.
|
|
794
|
+
if (takesBody && action.tags.includes("custom") && (payload === undefined || isPlainObject(payload))) payload = {
|
|
795
|
+
...(payload ?? {}),
|
|
796
|
+
connectionKey: input.connectionKey
|
|
797
|
+
};
|
|
798
|
+
let body;
|
|
799
|
+
if (takesBody && payload !== undefined) {
|
|
800
|
+
const encoding = input.encoding ?? "json";
|
|
801
|
+
if (encoding === "form") {
|
|
802
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
803
|
+
body = formEncode(payload);
|
|
804
|
+
} else if (encoding === "multipart") {
|
|
805
|
+
const form = new FormData();
|
|
806
|
+
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));
|
|
807
|
+
body = form; // fetch sets the multipart boundary itself
|
|
808
|
+
} else {
|
|
809
|
+
headers["Content-Type"] = "application/json";
|
|
810
|
+
body = JSON.stringify(payload);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const response = await oneFetch(userId, `/passthrough${path}${query}`, {
|
|
694
814
|
method,
|
|
695
815
|
headers,
|
|
696
|
-
body
|
|
816
|
+
body
|
|
697
817
|
});
|
|
698
818
|
const text = await response.text();
|
|
699
819
|
if (!response.ok) {
|
|
@@ -737,6 +857,8 @@ function createOneConnect(config) {
|
|
|
737
857
|
disconnect: credential.disconnect,
|
|
738
858
|
listConnections,
|
|
739
859
|
listActions,
|
|
860
|
+
searchActions,
|
|
861
|
+
getActionKnowledge,
|
|
740
862
|
runAction,
|
|
741
863
|
fetch: oneFetch
|
|
742
864
|
};
|
|
@@ -761,6 +883,8 @@ function createOneConnect(config) {
|
|
|
761
883
|
exports.OneConnectError = OneConnectError;
|
|
762
884
|
exports.createOneConnect = createOneConnect;
|
|
763
885
|
exports.encodeUserReference = encodeUserReference;
|
|
886
|
+
exports.fillPath = fillPath;
|
|
887
|
+
exports.formEncode = formEncode;
|
|
764
888
|
exports.parseUserReference = parseUserReference;
|
|
765
889
|
exports.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
|
766
890
|
exports.tenancyHeaders = tenancyHeaders;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectKeyConfig, type OneConnectMode, type OneConnectServerConfig, type OneConnectTokenConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RefreshIfExpiringOptions, type RunActionInput, type RunActionResult, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
|
|
1
|
+
import { type ActionKnowledge, type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectKeyConfig, type OneConnectMode, type OneConnectServerConfig, type OneConnectTokenConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RefreshIfExpiringOptions, type RunActionInput, type RunActionResult, type SearchActionsOptions, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
|
|
2
2
|
export * from "./types";
|
|
3
3
|
export { encodeUserReference, parseUserReference } from "./key";
|
|
4
4
|
export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
5
|
+
/** Fills a path's `{{placeholders}}`. A placeholder with no value is an
|
|
6
|
+
* error the caller can read, rather than a request One cannot route. */
|
|
7
|
+
export declare function fillPath(path: string, params?: Record<string, string | number | boolean>): string;
|
|
8
|
+
/** `application/x-www-form-urlencoded` with nested objects and arrays in
|
|
9
|
+
* bracket notation, as Stripe and other form providers read it. */
|
|
10
|
+
export declare function formEncode(value: unknown): string;
|
|
5
11
|
/** What an app does with One in either mode. */
|
|
6
12
|
export interface OneConnectClient {
|
|
7
13
|
/** How this client holds each user's grant. */
|
|
@@ -24,7 +30,13 @@ export interface OneConnectClient {
|
|
|
24
30
|
/** Every catalog action of a platform. What exists, not what is
|
|
25
31
|
* permitted; the grant decides that when the action runs. */
|
|
26
32
|
listActions: (userId: string, platform: string) => Promise<PlatformAction[]>;
|
|
27
|
-
/**
|
|
33
|
+
/** The actions of a platform that fit a request in words, best first. */
|
|
34
|
+
searchActions: (userId: string, platform: string, query: string, options?: SearchActionsOptions) => Promise<PlatformAction[]>;
|
|
35
|
+
/** An action's guide, input shape, method and path. Read it before
|
|
36
|
+
* running an action for the first time. */
|
|
37
|
+
getActionKnowledge: (userId: string, actionId: string) => Promise<ActionKnowledge>;
|
|
38
|
+
/** Runs one action through One with the grant: fills the path, encodes
|
|
39
|
+
* the body, adds what the action needs. */
|
|
28
40
|
runAction: (userId: string, input: RunActionInput) => Promise<RunActionResult>;
|
|
29
41
|
/** Any authenticated request to One's /v1 API, headers handled. */
|
|
30
42
|
fetch: (userId: string, path: string, init?: RequestInit) => Promise<Response>;
|