@withone/connect 0.13.2 → 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 +33 -7
- 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 +19 -7
- package/src/server/index.ts +212 -38
- package/src/server/types.ts +45 -5
package/README.md
CHANGED
|
@@ -139,20 +139,46 @@ That file serves `/api/one/authorize` and `/api/one/callback`. For Express or pl
|
|
|
139
139
|
|
|
140
140
|
## 5 · Using the grant
|
|
141
141
|
|
|
142
|
+
Work the way the One CLI does: find the action, read its knowledge, run it.
|
|
143
|
+
|
|
142
144
|
```ts
|
|
143
|
-
|
|
144
|
-
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");
|
|
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
|
|
145
155
|
|
|
156
|
+
// 4. run it: the method and path come from the action; you pass the input
|
|
146
157
|
const reply = await oneConnect.runAction(userId, {
|
|
147
|
-
connectionKey:
|
|
148
|
-
actionId:
|
|
149
|
-
|
|
150
|
-
path: actions[0].path,
|
|
158
|
+
connectionKey: stripe.key,
|
|
159
|
+
actionId: action._id,
|
|
160
|
+
body: { limit: 10 },
|
|
151
161
|
});
|
|
152
162
|
// { status, ok, blockedByGrant, data }
|
|
153
163
|
```
|
|
154
164
|
|
|
155
|
-
|
|
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.
|
|
166
|
+
|
|
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.
|
|
156
182
|
|
|
157
183
|
A `403` with `blockedByGrant: true` means the call is outside what the user granted. Don't retry it.
|
|
158
184
|
|
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>;
|
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",
|
|
@@ -128,22 +128,34 @@ A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, sta
|
|
|
128
128
|
|
|
129
129
|
## 6 - Calling One with the grant
|
|
130
130
|
|
|
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.
|
|
134
|
+
|
|
131
135
|
```ts
|
|
132
|
-
const connections = await oneConnect.listConnections(userId);
|
|
133
|
-
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 }
|
|
134
139
|
|
|
135
140
|
const reply = await oneConnect.runAction(userId, {
|
|
136
141
|
connectionKey: connection.key,
|
|
137
142
|
actionId: action._id,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
141
148
|
});
|
|
142
149
|
// { status, ok, blockedByGrant, data }
|
|
143
150
|
```
|
|
144
151
|
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
147
159
|
|
|
148
160
|
A `403` reply with `blockedByGrant: true` means the call is outside what
|
|
149
161
|
the user granted. Do not retry it.
|
package/src/server/index.ts
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
|
|
20
20
|
* // in the callback route
|
|
21
21
|
* const result = await oneConnect.completeAuthorization({ userId, url, getCookie });
|
|
22
|
-
* // afterwards
|
|
22
|
+
* // afterwards, the same way the One CLI works: find the action, read
|
|
23
|
+
* // its knowledge, run it
|
|
23
24
|
* const rows = await oneConnect.listConnections(userId);
|
|
24
|
-
* const
|
|
25
|
+
* const [action] = await oneConnect.searchActions(userId, "stripe", "create an invoice");
|
|
26
|
+
* const guide = await oneConnect.getActionKnowledge(userId, action._id);
|
|
27
|
+
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId: action._id, body });
|
|
25
28
|
*
|
|
26
29
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
27
30
|
* See `./key` and `./token` for what each mode stores and sends.
|
|
@@ -41,6 +44,7 @@ import {
|
|
|
41
44
|
import { createTokenCredential } from "./token";
|
|
42
45
|
import {
|
|
43
46
|
OneConnectError,
|
|
47
|
+
type ActionKnowledge,
|
|
44
48
|
type CompleteAuthorizationInput,
|
|
45
49
|
type CompleteAuthorizationResult,
|
|
46
50
|
type ConnectFailureCode,
|
|
@@ -54,6 +58,7 @@ import {
|
|
|
54
58
|
type RefreshIfExpiringOptions,
|
|
55
59
|
type RunActionInput,
|
|
56
60
|
type RunActionResult,
|
|
61
|
+
type SearchActionsOptions,
|
|
57
62
|
type StartAuthorizationInput,
|
|
58
63
|
type StartAuthorizationResult,
|
|
59
64
|
} from "./types";
|
|
@@ -64,6 +69,60 @@ export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
|
64
69
|
|
|
65
70
|
const CATALOG_PAGE_SIZE = 100;
|
|
66
71
|
const CATALOG_MAX_PAGES = 20;
|
|
72
|
+
const SEARCH_DEFAULT_LIMIT = 5;
|
|
73
|
+
|
|
74
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
75
|
+
typeof value === "object" &&
|
|
76
|
+
value !== null &&
|
|
77
|
+
!Array.isArray(value) &&
|
|
78
|
+
!(value instanceof Blob) &&
|
|
79
|
+
!(value instanceof FormData);
|
|
80
|
+
|
|
81
|
+
/** Fills a path's `{{placeholders}}`. A placeholder with no value is an
|
|
82
|
+
* error the caller can read, rather than a request One cannot route. */
|
|
83
|
+
export function fillPath(
|
|
84
|
+
path: string,
|
|
85
|
+
params: Record<string, string | number | boolean> = {},
|
|
86
|
+
): string {
|
|
87
|
+
return path.replace(/\{\{([^}]+)\}\}/g, (_match, name: string) => {
|
|
88
|
+
const key = name.trim();
|
|
89
|
+
const value = params[key];
|
|
90
|
+
if (value === undefined || value === null || value === "")
|
|
91
|
+
throw new OneConnectError(
|
|
92
|
+
"request_failed",
|
|
93
|
+
`The action's path needs a value for {{${key}}}; pass it in pathParams.`,
|
|
94
|
+
);
|
|
95
|
+
return encodeURIComponent(String(value));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `application/x-www-form-urlencoded` with nested objects and arrays in
|
|
100
|
+
* bracket notation, as Stripe and other form providers read it. */
|
|
101
|
+
export function formEncode(value: unknown): string {
|
|
102
|
+
const out = new URLSearchParams();
|
|
103
|
+
const walk = (prefix: string, v: unknown) => {
|
|
104
|
+
if (v === undefined || v === null) return;
|
|
105
|
+
if (Array.isArray(v))
|
|
106
|
+
v.forEach((item, i) => walk(`${prefix}[${typeof item === "object" ? i : ""}]`, item));
|
|
107
|
+
else if (typeof v === "object")
|
|
108
|
+
for (const [k, inner] of Object.entries(v as Record<string, unknown>))
|
|
109
|
+
walk(prefix ? `${prefix}[${k}]` : k, inner);
|
|
110
|
+
else out.append(prefix, String(v));
|
|
111
|
+
};
|
|
112
|
+
walk("", value);
|
|
113
|
+
return out.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const toKnowledge = (row: Record<string, unknown>): ActionKnowledge => ({
|
|
117
|
+
_id: String(row._id ?? ""),
|
|
118
|
+
title: String(row.title ?? ""),
|
|
119
|
+
method: String(row.method ?? "").toUpperCase(),
|
|
120
|
+
path: String(row.path ?? ""),
|
|
121
|
+
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
|
122
|
+
knowledge: String(row.knowledge ?? ""),
|
|
123
|
+
ioSchema: row.ioSchema,
|
|
124
|
+
platform: row.connectionPlatform ? String(row.connectionPlatform) : undefined,
|
|
125
|
+
});
|
|
67
126
|
|
|
68
127
|
/** What an app does with One in either mode. */
|
|
69
128
|
export interface OneConnectClient {
|
|
@@ -91,7 +150,18 @@ export interface OneConnectClient {
|
|
|
91
150
|
/** Every catalog action of a platform. What exists, not what is
|
|
92
151
|
* permitted; the grant decides that when the action runs. */
|
|
93
152
|
listActions: (userId: string, platform: string) => Promise<PlatformAction[]>;
|
|
94
|
-
/**
|
|
153
|
+
/** The actions of a platform that fit a request in words, best first. */
|
|
154
|
+
searchActions: (
|
|
155
|
+
userId: string,
|
|
156
|
+
platform: string,
|
|
157
|
+
query: string,
|
|
158
|
+
options?: SearchActionsOptions,
|
|
159
|
+
) => Promise<PlatformAction[]>;
|
|
160
|
+
/** An action's guide, input shape, method and path. Read it before
|
|
161
|
+
* running an action for the first time. */
|
|
162
|
+
getActionKnowledge: (userId: string, actionId: string) => Promise<ActionKnowledge>;
|
|
163
|
+
/** Runs one action through One with the grant: fills the path, encodes
|
|
164
|
+
* the body, adds what the action needs. */
|
|
95
165
|
runAction: (userId: string, input: RunActionInput) => Promise<RunActionResult>;
|
|
96
166
|
/** Any authenticated request to One's /v1 API, headers handled. */
|
|
97
167
|
fetch: (
|
|
@@ -362,42 +432,51 @@ export function createOneConnect(
|
|
|
362
432
|
return body.rows ?? [];
|
|
363
433
|
};
|
|
364
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Turns a catalog refusal into the right error. A One API that does not
|
|
437
|
+
* take the credential on its catalog answers the same bare 403 as a
|
|
438
|
+
* consent that is gone; asking what the grant reaches tells them apart.
|
|
439
|
+
*/
|
|
440
|
+
const catalogRefusal = async (
|
|
441
|
+
userId: string,
|
|
442
|
+
what: string,
|
|
443
|
+
response: Response,
|
|
444
|
+
): Promise<OneConnectError> => {
|
|
445
|
+
const refused = credential.refusal(response.status, await response.text());
|
|
446
|
+
if (refused?.code === "reconnect_required") {
|
|
447
|
+
if (await consentStands(userId, refused))
|
|
448
|
+
return new OneConnectError(
|
|
449
|
+
"request_failed",
|
|
450
|
+
`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}.`,
|
|
451
|
+
response.status,
|
|
452
|
+
);
|
|
453
|
+
return refused;
|
|
454
|
+
}
|
|
455
|
+
if (refused) return refused;
|
|
456
|
+
return new OneConnectError(
|
|
457
|
+
"request_failed",
|
|
458
|
+
`One refused the ${what} request (HTTP ${response.status}).`,
|
|
459
|
+
response.status,
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const slim = (rows: Record<string, unknown>[]): PlatformAction[] =>
|
|
464
|
+
rows.map((row) => ({
|
|
465
|
+
_id: String(row._id ?? row.systemId ?? ""),
|
|
466
|
+
title: String(row.title ?? ""),
|
|
467
|
+
method: String(row.method ?? "").toUpperCase(),
|
|
468
|
+
path: String(row.path ?? ""),
|
|
469
|
+
...(Array.isArray(row.tags) ? { tags: row.tags.map(String) } : {}),
|
|
470
|
+
}));
|
|
471
|
+
|
|
365
472
|
const listActions = async (
|
|
366
473
|
userId: string,
|
|
367
474
|
platform: string,
|
|
368
475
|
): Promise<PlatformAction[]> => {
|
|
369
476
|
const pageUrl = (page: number) =>
|
|
370
477
|
`/knowledge?connectionPlatform=${encodeURIComponent(platform)}&limit=${CATALOG_PAGE_SIZE}&page=${page}`;
|
|
371
|
-
const slim = (rows: Record<string, unknown>[]): PlatformAction[] =>
|
|
372
|
-
rows.map((row) => ({
|
|
373
|
-
_id: String(row._id ?? ""),
|
|
374
|
-
title: String(row.title ?? ""),
|
|
375
|
-
method: String(row.method ?? ""),
|
|
376
|
-
path: String(row.path ?? ""),
|
|
377
|
-
}));
|
|
378
478
|
const first = await oneFetch(userId, pageUrl(1));
|
|
379
|
-
if (!first.ok)
|
|
380
|
-
const refused = credential.refusal(first.status, await first.text());
|
|
381
|
-
if (refused?.code === "reconnect_required") {
|
|
382
|
-
// The same bare 403 is what a One API answers when its catalog
|
|
383
|
-
// does not take the connect key. If the consent stands, that is
|
|
384
|
-
// what happened, and asking the user to reconnect would not help.
|
|
385
|
-
if (await consentStands(userId, refused)) {
|
|
386
|
-
throw new OneConnectError(
|
|
387
|
-
"request_failed",
|
|
388
|
-
"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.",
|
|
389
|
-
first.status,
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
throw refused;
|
|
393
|
-
}
|
|
394
|
-
if (refused) throw refused;
|
|
395
|
-
throw new OneConnectError(
|
|
396
|
-
"request_failed",
|
|
397
|
-
`One refused the catalog request (HTTP ${first.status}).`,
|
|
398
|
-
first.status,
|
|
399
|
-
);
|
|
400
|
-
}
|
|
479
|
+
if (!first.ok) throw await catalogRefusal(userId, "action catalog", first);
|
|
401
480
|
const page1 = (await first.json()) as {
|
|
402
481
|
rows?: Record<string, unknown>[];
|
|
403
482
|
pages?: number;
|
|
@@ -417,22 +496,115 @@ export function createOneConnect(
|
|
|
417
496
|
return slim([page1, ...rest].flatMap((page) => page?.rows ?? []));
|
|
418
497
|
};
|
|
419
498
|
|
|
499
|
+
/** The actions that fit a request in words, best first: One's search,
|
|
500
|
+
* the one the One CLI's `actions search` uses. */
|
|
501
|
+
const searchActions = async (
|
|
502
|
+
userId: string,
|
|
503
|
+
platform: string,
|
|
504
|
+
query: string,
|
|
505
|
+
options: SearchActionsOptions = {},
|
|
506
|
+
): Promise<PlatformAction[]> => {
|
|
507
|
+
const params = new URLSearchParams({
|
|
508
|
+
query,
|
|
509
|
+
limit: String(options.limit ?? SEARCH_DEFAULT_LIMIT),
|
|
510
|
+
[options.mode === "knowledge" ? "knowledgeAgent" : "executeAgent"]: "true",
|
|
511
|
+
});
|
|
512
|
+
const response = await oneFetch(
|
|
513
|
+
userId,
|
|
514
|
+
`/available-actions/search/${encodeURIComponent(platform)}?${params}`,
|
|
515
|
+
);
|
|
516
|
+
if (!response.ok) throw await catalogRefusal(userId, "action search", response);
|
|
517
|
+
const body = (await response.json()) as
|
|
518
|
+
| Record<string, unknown>[]
|
|
519
|
+
| { rows?: Record<string, unknown>[] };
|
|
520
|
+
return slim(Array.isArray(body) ? body : (body.rows ?? []));
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** One action's knowledge, cached for the life of the client: an
|
|
524
|
+
* action's guide does not change between two calls. */
|
|
525
|
+
const knowledgeCache = new Map<string, Promise<ActionKnowledge>>();
|
|
526
|
+
const getActionKnowledge = (userId: string, actionId: string): Promise<ActionKnowledge> => {
|
|
527
|
+
const cached = knowledgeCache.get(actionId);
|
|
528
|
+
if (cached) return cached;
|
|
529
|
+
const loading = (async () => {
|
|
530
|
+
const response = await oneFetch(userId, `/knowledge?_id=${encodeURIComponent(actionId)}`);
|
|
531
|
+
if (!response.ok) throw await catalogRefusal(userId, "action knowledge", response);
|
|
532
|
+
const body = (await response.json()) as { rows?: Record<string, unknown>[] };
|
|
533
|
+
const row = body.rows?.[0];
|
|
534
|
+
if (!row)
|
|
535
|
+
throw new OneConnectError("request_failed", `One has no action with the id ${actionId}.`, 404);
|
|
536
|
+
return toKnowledge(row);
|
|
537
|
+
})();
|
|
538
|
+
knowledgeCache.set(actionId, loading);
|
|
539
|
+
loading.catch(() => knowledgeCache.delete(actionId));
|
|
540
|
+
return loading;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Runs one action through One the way the One CLI's `actions execute`
|
|
545
|
+
* does: the method and path come from the action itself, the path's
|
|
546
|
+
* placeholders are filled, an action One serves gets the connection key
|
|
547
|
+
* in its body, and the body is encoded as the provider reads it.
|
|
548
|
+
*/
|
|
420
549
|
const runAction = async (
|
|
421
550
|
userId: string,
|
|
422
551
|
input: RunActionInput,
|
|
423
552
|
): Promise<RunActionResult> => {
|
|
424
|
-
|
|
425
|
-
|
|
553
|
+
// The caller may hand over method and path from the catalog; otherwise
|
|
554
|
+
// the action says, and its tags say whether it is one One serves.
|
|
555
|
+
const action =
|
|
556
|
+
input.method && input.path
|
|
557
|
+
? { method: input.method, path: input.path, tags: [] as string[] }
|
|
558
|
+
: await getActionKnowledge(userId, input.actionId);
|
|
559
|
+
const method = action.method.toUpperCase();
|
|
560
|
+
const path = fillPath(action.path, input.pathParams);
|
|
561
|
+
const search = new URLSearchParams();
|
|
562
|
+
for (const [key, value] of Object.entries(input.query ?? {}))
|
|
563
|
+
for (const item of Array.isArray(value) ? value : [value]) search.append(key, item);
|
|
564
|
+
const query = search.size ? `?${search}` : "";
|
|
565
|
+
|
|
426
566
|
const headers: Record<string, string> = {
|
|
567
|
+
...input.headers,
|
|
427
568
|
"x-one-connection-key": input.connectionKey,
|
|
428
569
|
"x-one-action-id": input.actionId,
|
|
429
570
|
};
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
571
|
+
const takesBody = method !== "GET" && method !== "HEAD";
|
|
572
|
+
let payload: unknown = input.body;
|
|
573
|
+
// An action One serves itself reads the connection key from its body.
|
|
574
|
+
if (
|
|
575
|
+
takesBody &&
|
|
576
|
+
action.tags.includes("custom") &&
|
|
577
|
+
(payload === undefined || isPlainObject(payload))
|
|
578
|
+
)
|
|
579
|
+
payload = { ...(payload ?? {}), connectionKey: input.connectionKey };
|
|
580
|
+
let body: BodyInit | undefined;
|
|
581
|
+
if (takesBody && payload !== undefined) {
|
|
582
|
+
const encoding = input.encoding ?? "json";
|
|
583
|
+
if (encoding === "form") {
|
|
584
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
585
|
+
body = formEncode(payload);
|
|
586
|
+
} else if (encoding === "multipart") {
|
|
587
|
+
const form = new FormData();
|
|
588
|
+
for (const [key, value] of Object.entries(isPlainObject(payload) ? payload : {}))
|
|
589
|
+
form.append(
|
|
590
|
+
key,
|
|
591
|
+
value instanceof Blob
|
|
592
|
+
? value
|
|
593
|
+
: typeof value === "object"
|
|
594
|
+
? JSON.stringify(value)
|
|
595
|
+
: String(value),
|
|
596
|
+
);
|
|
597
|
+
body = form; // fetch sets the multipart boundary itself
|
|
598
|
+
} else {
|
|
599
|
+
headers["Content-Type"] = "application/json";
|
|
600
|
+
body = JSON.stringify(payload);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const response = await oneFetch(userId, `/passthrough${path}${query}`, {
|
|
433
605
|
method,
|
|
434
606
|
headers,
|
|
435
|
-
body
|
|
607
|
+
body,
|
|
436
608
|
});
|
|
437
609
|
const text = await response.text();
|
|
438
610
|
if (!response.ok) {
|
|
@@ -472,6 +644,8 @@ export function createOneConnect(
|
|
|
472
644
|
disconnect: credential.disconnect,
|
|
473
645
|
listConnections,
|
|
474
646
|
listActions,
|
|
647
|
+
searchActions,
|
|
648
|
+
getActionKnowledge,
|
|
475
649
|
runAction,
|
|
476
650
|
fetch: oneFetch,
|
|
477
651
|
};
|
package/src/server/types.ts
CHANGED
|
@@ -221,18 +221,58 @@ export interface PlatformAction {
|
|
|
221
221
|
title: string;
|
|
222
222
|
method: string;
|
|
223
223
|
path: string;
|
|
224
|
+
/** One's tags for the action. "custom" marks an action One itself
|
|
225
|
+
* serves, which takes the connection key in its body. */
|
|
226
|
+
tags?: string[];
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
export interface SearchActionsOptions {
|
|
230
|
+
/** How many candidates to return. Five when omitted. */
|
|
231
|
+
limit?: number;
|
|
232
|
+
/** What the search ranks for: actions to run now (the default), or
|
|
233
|
+
* actions to write code and flows against. */
|
|
234
|
+
mode?: "execute" | "knowledge";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Everything One knows about one action: the guide a caller reads before
|
|
239
|
+
* running it (`knowledge`, Markdown), the shape of its input, and the
|
|
240
|
+
* method and path the SDK runs it with.
|
|
241
|
+
*/
|
|
242
|
+
export interface ActionKnowledge extends PlatformAction {
|
|
243
|
+
tags: string[];
|
|
244
|
+
/** The action's documentation: what it does, every field it takes,
|
|
245
|
+
* what it answers. Markdown. */
|
|
246
|
+
knowledge: string;
|
|
247
|
+
/** The input and output shape, when One has one. */
|
|
248
|
+
ioSchema?: unknown;
|
|
249
|
+
/** The platform the action belongs to. */
|
|
250
|
+
platform?: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type ActionBodyEncoding = "json" | "form" | "multipart";
|
|
254
|
+
|
|
226
255
|
export interface RunActionInput {
|
|
227
256
|
/** From `listConnections`. */
|
|
228
257
|
connectionKey: string;
|
|
229
|
-
/** From `listActions`. */
|
|
258
|
+
/** From `searchActions`, `listActions` or `getActionKnowledge`. */
|
|
230
259
|
actionId: string;
|
|
231
|
-
method
|
|
232
|
-
|
|
233
|
-
path
|
|
260
|
+
/** The action's method. Looked up from `actionId` when omitted. */
|
|
261
|
+
method?: string;
|
|
262
|
+
/** The action's path, appended to /v1/passthrough. Looked up from
|
|
263
|
+
* `actionId` when omitted. */
|
|
264
|
+
path?: string;
|
|
265
|
+
/** Values for the path's `{{placeholders}}`, such as `{ calendarId: "primary" }`. */
|
|
266
|
+
pathParams?: Record<string, string | number | boolean>;
|
|
234
267
|
body?: unknown;
|
|
235
|
-
query?: Record<string, string>;
|
|
268
|
+
query?: Record<string, string | string[]>;
|
|
269
|
+
/** Extra request headers an action's knowledge asks for, such as a
|
|
270
|
+
* provider's version header. */
|
|
271
|
+
headers?: Record<string, string>;
|
|
272
|
+
/** How `body` is sent. JSON when omitted; "form" for providers that
|
|
273
|
+
* take `application/x-www-form-urlencoded` (nested fields in bracket
|
|
274
|
+
* notation); "multipart" for file-style uploads. */
|
|
275
|
+
encoding?: ActionBodyEncoding;
|
|
236
276
|
}
|
|
237
277
|
|
|
238
278
|
export interface RunActionResult {
|