@withone/connect 0.23.1 → 0.24.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 +3 -1
- package/dist/server/index.cjs.js +36 -0
- package/dist/server/index.esm.js +36 -0
- package/dist/server/types.d.ts +20 -0
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +10 -4
- package/src/server/index.ts +143 -31
- package/src/server/types.ts +24 -5
package/README.md
CHANGED
|
@@ -348,9 +348,11 @@ const reply = await oneConnect.runAction(userId, {
|
|
|
348
348
|
actionId: action._id,
|
|
349
349
|
body: { limit: 10 },
|
|
350
350
|
});
|
|
351
|
-
// { status, ok, blockedByGrant, refusal, data }
|
|
351
|
+
// { status, ok, blockedByGrant, refusal, rateLimit, data }
|
|
352
352
|
```
|
|
353
353
|
|
|
354
|
+
`rateLimit` is One's limit for that user after the call: `{ limit, remaining, resetsIn, retryAfter }`, calls per minute and seconds. The app owner's plan sets the size and each of your users has a bucket of their own. Slow down as `remaining` nears 0, and on a `429` wait `retryAfter` seconds. Any value One didn't send is `null`, `limit` and `remaining` are `null` when nothing limits the user, and `rateLimit` is `null` when One sent none of them. A `402` (`refusal: "plan_limit"`) means the user's calls for the month are used up: stop calling for them until next month rather than retrying.
|
|
355
|
+
|
|
354
356
|
Each connection carries its `access`: `{ policy: "full" }`, `{ policy: "methods", methods }` or `{ policy: "actions", actions }`. Plan from it before you call.
|
|
355
357
|
|
|
356
358
|
`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.
|
package/dist/server/index.cjs.js
CHANGED
|
@@ -200,6 +200,12 @@ function tokenScopes(accessToken) {
|
|
|
200
200
|
* a backoff.
|
|
201
201
|
*/
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* One's rate limit for the user a call was made for, as One reported it on
|
|
205
|
+
* that call. The app owner's plan sets the size; each of the app's users
|
|
206
|
+
* has a bucket of their own. Any value One didn't send is `null`.
|
|
207
|
+
*/
|
|
208
|
+
|
|
203
209
|
/**
|
|
204
210
|
* - `not_connected`: nothing is stored for this user.
|
|
205
211
|
* - `reconnect_required` (key mode): One will not act for this user.
|
|
@@ -542,6 +548,33 @@ function oneRefusal(response, data) {
|
|
|
542
548
|
if (response.status === 429) return "rate_limit";
|
|
543
549
|
return "one_error";
|
|
544
550
|
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* One's rate limit from a reply's headers, or `null` when it sent none of
|
|
554
|
+
* them. A missing or unreadable header reads `null` on its own and never
|
|
555
|
+
* throws. One writes "no limit" as the largest 64-bit integer, which
|
|
556
|
+
* reads as `null` too.
|
|
557
|
+
*/
|
|
558
|
+
function rateLimitFrom(headers) {
|
|
559
|
+
const read = name => {
|
|
560
|
+
const value = headers.get(name)?.trim();
|
|
561
|
+
if (!value) return null;
|
|
562
|
+
const number = Number(value);
|
|
563
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
564
|
+
};
|
|
565
|
+
const limit = read("x-ratelimit-limit");
|
|
566
|
+
const remaining = read("x-ratelimit-remaining");
|
|
567
|
+
const resetsIn = read("x-ratelimit-reset");
|
|
568
|
+
const retryAfter = read("retry-after");
|
|
569
|
+
if (limit === null && remaining === null && resetsIn === null && retryAfter === null) return null;
|
|
570
|
+
const unlimited = limit !== null && limit > Number.MAX_SAFE_INTEGER;
|
|
571
|
+
return {
|
|
572
|
+
limit: unlimited ? null : limit,
|
|
573
|
+
remaining: unlimited ? null : remaining,
|
|
574
|
+
resetsIn,
|
|
575
|
+
retryAfter
|
|
576
|
+
};
|
|
577
|
+
}
|
|
545
578
|
const CATALOG_PAGE_SIZE = 100;
|
|
546
579
|
/** One's largest page; reading every connection takes the fewest calls. */
|
|
547
580
|
const CONNECTIONS_PAGE_SIZE = 150;
|
|
@@ -901,6 +934,7 @@ function createOneConnect(config) {
|
|
|
901
934
|
body
|
|
902
935
|
});
|
|
903
936
|
const text = await response.text();
|
|
937
|
+
const rateLimit = rateLimitFrom(response.headers);
|
|
904
938
|
if (!response.ok) {
|
|
905
939
|
const refused = credential.refusal(response.status, text);
|
|
906
940
|
if (refused?.code === "reconnect_required") {
|
|
@@ -913,6 +947,7 @@ function createOneConnect(config) {
|
|
|
913
947
|
ok: false,
|
|
914
948
|
blockedByGrant: true,
|
|
915
949
|
refusal: "grant",
|
|
950
|
+
rateLimit,
|
|
916
951
|
data: text
|
|
917
952
|
};
|
|
918
953
|
throw refused;
|
|
@@ -932,6 +967,7 @@ function createOneConnect(config) {
|
|
|
932
967
|
ok: response.ok,
|
|
933
968
|
blockedByGrant: refusal === "grant",
|
|
934
969
|
refusal,
|
|
970
|
+
rateLimit,
|
|
935
971
|
data
|
|
936
972
|
};
|
|
937
973
|
};
|
package/dist/server/index.esm.js
CHANGED
|
@@ -198,6 +198,12 @@ function tokenScopes(accessToken) {
|
|
|
198
198
|
* a backoff.
|
|
199
199
|
*/
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* One's rate limit for the user a call was made for, as One reported it on
|
|
203
|
+
* that call. The app owner's plan sets the size; each of the app's users
|
|
204
|
+
* has a bucket of their own. Any value One didn't send is `null`.
|
|
205
|
+
*/
|
|
206
|
+
|
|
201
207
|
/**
|
|
202
208
|
* - `not_connected`: nothing is stored for this user.
|
|
203
209
|
* - `reconnect_required` (key mode): One will not act for this user.
|
|
@@ -540,6 +546,33 @@ function oneRefusal(response, data) {
|
|
|
540
546
|
if (response.status === 429) return "rate_limit";
|
|
541
547
|
return "one_error";
|
|
542
548
|
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One's rate limit from a reply's headers, or `null` when it sent none of
|
|
552
|
+
* them. A missing or unreadable header reads `null` on its own and never
|
|
553
|
+
* throws. One writes "no limit" as the largest 64-bit integer, which
|
|
554
|
+
* reads as `null` too.
|
|
555
|
+
*/
|
|
556
|
+
function rateLimitFrom(headers) {
|
|
557
|
+
const read = name => {
|
|
558
|
+
const value = headers.get(name)?.trim();
|
|
559
|
+
if (!value) return null;
|
|
560
|
+
const number = Number(value);
|
|
561
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
562
|
+
};
|
|
563
|
+
const limit = read("x-ratelimit-limit");
|
|
564
|
+
const remaining = read("x-ratelimit-remaining");
|
|
565
|
+
const resetsIn = read("x-ratelimit-reset");
|
|
566
|
+
const retryAfter = read("retry-after");
|
|
567
|
+
if (limit === null && remaining === null && resetsIn === null && retryAfter === null) return null;
|
|
568
|
+
const unlimited = limit !== null && limit > Number.MAX_SAFE_INTEGER;
|
|
569
|
+
return {
|
|
570
|
+
limit: unlimited ? null : limit,
|
|
571
|
+
remaining: unlimited ? null : remaining,
|
|
572
|
+
resetsIn,
|
|
573
|
+
retryAfter
|
|
574
|
+
};
|
|
575
|
+
}
|
|
543
576
|
const CATALOG_PAGE_SIZE = 100;
|
|
544
577
|
/** One's largest page; reading every connection takes the fewest calls. */
|
|
545
578
|
const CONNECTIONS_PAGE_SIZE = 150;
|
|
@@ -899,6 +932,7 @@ function createOneConnect(config) {
|
|
|
899
932
|
body
|
|
900
933
|
});
|
|
901
934
|
const text = await response.text();
|
|
935
|
+
const rateLimit = rateLimitFrom(response.headers);
|
|
902
936
|
if (!response.ok) {
|
|
903
937
|
const refused = credential.refusal(response.status, text);
|
|
904
938
|
if (refused?.code === "reconnect_required") {
|
|
@@ -911,6 +945,7 @@ function createOneConnect(config) {
|
|
|
911
945
|
ok: false,
|
|
912
946
|
blockedByGrant: true,
|
|
913
947
|
refusal: "grant",
|
|
948
|
+
rateLimit,
|
|
914
949
|
data: text
|
|
915
950
|
};
|
|
916
951
|
throw refused;
|
|
@@ -930,6 +965,7 @@ function createOneConnect(config) {
|
|
|
930
965
|
ok: response.ok,
|
|
931
966
|
blockedByGrant: refusal === "grant",
|
|
932
967
|
refusal,
|
|
968
|
+
rateLimit,
|
|
933
969
|
data
|
|
934
970
|
};
|
|
935
971
|
};
|
package/dist/server/types.d.ts
CHANGED
|
@@ -281,6 +281,21 @@ export interface RunActionInput {
|
|
|
281
281
|
* a backoff.
|
|
282
282
|
*/
|
|
283
283
|
export type OneRefusal = "grant" | "plan_limit" | "rate_limit" | "one_error";
|
|
284
|
+
/**
|
|
285
|
+
* One's rate limit for the user a call was made for, as One reported it on
|
|
286
|
+
* that call. The app owner's plan sets the size; each of the app's users
|
|
287
|
+
* has a bucket of their own. Any value One didn't send is `null`.
|
|
288
|
+
*/
|
|
289
|
+
export interface RateLimit {
|
|
290
|
+
/** Calls this user may make a minute. `null` when nothing limits them. */
|
|
291
|
+
limit: number | null;
|
|
292
|
+
/** Calls left in the current minute. `null` when nothing limits them. */
|
|
293
|
+
remaining: number | null;
|
|
294
|
+
/** Seconds until the minute resets. */
|
|
295
|
+
resetsIn: number | null;
|
|
296
|
+
/** Seconds to wait before trying again. Set on a 429 only. */
|
|
297
|
+
retryAfter: number | null;
|
|
298
|
+
}
|
|
284
299
|
export interface RunActionResult {
|
|
285
300
|
status: number;
|
|
286
301
|
ok: boolean;
|
|
@@ -293,6 +308,11 @@ export interface RunActionResult {
|
|
|
293
308
|
/** Why One turned the call down, or `null` when it didn't (the call
|
|
294
309
|
* succeeded, or the provider answered with its own error). */
|
|
295
310
|
refusal: OneRefusal | null;
|
|
311
|
+
/** One's rate limit for this user after the call, or `null` when One
|
|
312
|
+
* sent none of its rate-limit headers. A `refusal` of `"plan_limit"`
|
|
313
|
+
* (402) means this user's calls for the month are used up: back off
|
|
314
|
+
* for the month, don't retry. */
|
|
315
|
+
rateLimit: RateLimit | null;
|
|
296
316
|
data: unknown;
|
|
297
317
|
}
|
|
298
318
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withone/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|
|
@@ -279,9 +279,13 @@ const reply = await oneConnect.runAction(userId, {
|
|
|
279
279
|
encoding: "json", // or "form" / "multipart", as the guide says
|
|
280
280
|
headers: {}, // only when the guide names one
|
|
281
281
|
});
|
|
282
|
-
// { status, ok, blockedByGrant, refusal, data }
|
|
282
|
+
// { status, ok, blockedByGrant, refusal, rateLimit, data }
|
|
283
283
|
```
|
|
284
284
|
|
|
285
|
+
`rateLimit` is `{ limit, remaining, resetsIn, retryAfter }` for that user
|
|
286
|
+
(calls per minute, seconds), or `null` when One sent none. Slow down as
|
|
287
|
+
`remaining` nears 0; on a `429` wait `retryAfter` seconds.
|
|
288
|
+
|
|
285
289
|
Each connection's `access` is `{ policy: "full" }`, `{ policy: "methods",
|
|
286
290
|
methods }` or `{ policy: "actions", actions }`; plan from it before calling.
|
|
287
291
|
`runAction` takes the method and path from the action, fills the path's
|
|
@@ -308,9 +312,11 @@ user's id to every call it makes.
|
|
|
308
312
|
|
|
309
313
|
- `"grant"` (`403`, `blockedByGrant: true`): outside what the user granted.
|
|
310
314
|
Do not retry it.
|
|
311
|
-
- `"plan_limit"` (`402`):
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
- `"plan_limit"` (`402`): the user's calls for the month are used up, not
|
|
316
|
+
the grant. Stop calling for this user until next month. Do not ask the
|
|
317
|
+
user to reconnect.
|
|
318
|
+
- `"rate_limit"` (`429`): too many calls. Wait `rateLimit.retryAfter`
|
|
319
|
+
seconds and retry.
|
|
314
320
|
- `"one_error"` (other, e.g. `5xx`): retry with a backoff.
|
|
315
321
|
- `null`: not One's refusal; the provider's own answer, or a success.
|
|
316
322
|
|
package/src/server/index.ts
CHANGED
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
30
30
|
* See `./key` and `./token` for what each mode stores and sends.
|
|
31
31
|
*/
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_ONE_API_URL,
|
|
34
|
+
RETURN_ERROR_PARAM,
|
|
35
|
+
RETURN_STATUS_PARAM,
|
|
36
|
+
} from "../constants";
|
|
33
37
|
import type { Credential, PostToken, TokenResponse } from "./credential";
|
|
34
38
|
import { createKeyCredential } from "./key";
|
|
35
39
|
import {
|
|
@@ -61,6 +65,7 @@ import {
|
|
|
61
65
|
type RefreshIfExpiringOptions,
|
|
62
66
|
type RunActionInput,
|
|
63
67
|
type OneRefusal,
|
|
68
|
+
type RateLimit,
|
|
64
69
|
type RunActionResult,
|
|
65
70
|
type SearchActionsOptions,
|
|
66
71
|
type StartAuthorizationInput,
|
|
@@ -81,7 +86,9 @@ export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
|
81
86
|
function oneRefusal(response: Response, data: unknown): OneRefusal | null {
|
|
82
87
|
if (response.ok) return null;
|
|
83
88
|
const fromOne =
|
|
84
|
-
typeof data === "object" &&
|
|
89
|
+
typeof data === "object" &&
|
|
90
|
+
data !== null &&
|
|
91
|
+
"correlationId" in (data as Record<string, unknown>);
|
|
85
92
|
if (!fromOne) return null;
|
|
86
93
|
if (response.status === 403) return "grant";
|
|
87
94
|
if (response.status === 402) return "plan_limit";
|
|
@@ -89,6 +96,39 @@ function oneRefusal(response: Response, data: unknown): OneRefusal | null {
|
|
|
89
96
|
return "one_error";
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* One's rate limit from a reply's headers, or `null` when it sent none of
|
|
101
|
+
* them. A missing or unreadable header reads `null` on its own and never
|
|
102
|
+
* throws. One writes "no limit" as the largest 64-bit integer, which
|
|
103
|
+
* reads as `null` too.
|
|
104
|
+
*/
|
|
105
|
+
function rateLimitFrom(headers: Headers): RateLimit | null {
|
|
106
|
+
const read = (name: string): number | null => {
|
|
107
|
+
const value = headers.get(name)?.trim();
|
|
108
|
+
if (!value) return null;
|
|
109
|
+
const number = Number(value);
|
|
110
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
111
|
+
};
|
|
112
|
+
const limit = read("x-ratelimit-limit");
|
|
113
|
+
const remaining = read("x-ratelimit-remaining");
|
|
114
|
+
const resetsIn = read("x-ratelimit-reset");
|
|
115
|
+
const retryAfter = read("retry-after");
|
|
116
|
+
if (
|
|
117
|
+
limit === null &&
|
|
118
|
+
remaining === null &&
|
|
119
|
+
resetsIn === null &&
|
|
120
|
+
retryAfter === null
|
|
121
|
+
)
|
|
122
|
+
return null;
|
|
123
|
+
const unlimited = limit !== null && limit > Number.MAX_SAFE_INTEGER;
|
|
124
|
+
return {
|
|
125
|
+
limit: unlimited ? null : limit,
|
|
126
|
+
remaining: unlimited ? null : remaining,
|
|
127
|
+
resetsIn,
|
|
128
|
+
retryAfter,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
92
132
|
const CATALOG_PAGE_SIZE = 100;
|
|
93
133
|
/** One's largest page; reading every connection takes the fewest calls. */
|
|
94
134
|
const CONNECTIONS_PAGE_SIZE = 150;
|
|
@@ -128,7 +168,9 @@ export function formEncode(value: unknown): string {
|
|
|
128
168
|
const walk = (prefix: string, v: unknown) => {
|
|
129
169
|
if (v === undefined || v === null) return;
|
|
130
170
|
if (Array.isArray(v))
|
|
131
|
-
v.forEach((item, i) =>
|
|
171
|
+
v.forEach((item, i) =>
|
|
172
|
+
walk(`${prefix}[${typeof item === "object" ? i : ""}]`, item),
|
|
173
|
+
);
|
|
132
174
|
else if (typeof v === "object")
|
|
133
175
|
for (const [k, inner] of Object.entries(v as Record<string, unknown>))
|
|
134
176
|
walk(prefix ? `${prefix}[${k}]` : k, inner);
|
|
@@ -188,10 +230,16 @@ export interface OneConnectClient {
|
|
|
188
230
|
) => Promise<PlatformAction[]>;
|
|
189
231
|
/** An action's guide, input shape, method and path. Read it before
|
|
190
232
|
* running an action for the first time. */
|
|
191
|
-
getActionKnowledge: (
|
|
233
|
+
getActionKnowledge: (
|
|
234
|
+
userId: string,
|
|
235
|
+
actionId: string,
|
|
236
|
+
) => Promise<ActionKnowledge>;
|
|
192
237
|
/** Runs one action through One with the grant: fills the path, encodes
|
|
193
238
|
* the body, adds what the action needs. */
|
|
194
|
-
runAction: (
|
|
239
|
+
runAction: (
|
|
240
|
+
userId: string,
|
|
241
|
+
input: RunActionInput,
|
|
242
|
+
) => Promise<RunActionResult>;
|
|
195
243
|
/** Any authenticated request to One's /v1 API, headers handled. */
|
|
196
244
|
fetch: (
|
|
197
245
|
userId: string,
|
|
@@ -235,9 +283,11 @@ export interface OneConnect extends OneConnectClient {
|
|
|
235
283
|
* so an app written before key mode existed keeps working untouched.
|
|
236
284
|
*/
|
|
237
285
|
function resolveMode(config: OneConnectServerConfig): OneConnectMode {
|
|
238
|
-
const given = config as Partial<OneConnectKeyConfig> &
|
|
286
|
+
const given = config as Partial<OneConnectKeyConfig> &
|
|
287
|
+
Partial<OneConnectTokenConfig>;
|
|
239
288
|
const mode: OneConnectMode | undefined =
|
|
240
|
-
given.mode ??
|
|
289
|
+
given.mode ??
|
|
290
|
+
(given.connectKey ? "key" : given.tokenStore ? "token" : undefined);
|
|
241
291
|
if (mode === undefined) {
|
|
242
292
|
throw new TypeError(
|
|
243
293
|
"createOneConnect needs a credential: pass `connectKey` and `userStore` (key mode, recommended), or `tokenStore` (token mode).",
|
|
@@ -258,7 +308,9 @@ function resolveMode(config: OneConnectServerConfig): OneConnectMode {
|
|
|
258
308
|
}
|
|
259
309
|
|
|
260
310
|
/** Key mode: a connect key for the app and one permanent id per user. */
|
|
261
|
-
export function createOneConnect(
|
|
311
|
+
export function createOneConnect(
|
|
312
|
+
config: OneConnectKeyConfig,
|
|
313
|
+
): OneConnectKeyClient;
|
|
262
314
|
/** Token mode: an access and a refresh token per user, kept fresh. */
|
|
263
315
|
export function createOneConnect(config: OneConnectTokenConfig): OneConnect;
|
|
264
316
|
export function createOneConnect(
|
|
@@ -268,7 +320,10 @@ export function createOneConnect(
|
|
|
268
320
|
config: OneConnectServerConfig,
|
|
269
321
|
): OneConnectKeyClient | OneConnect {
|
|
270
322
|
const mode = resolveMode(config);
|
|
271
|
-
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(
|
|
323
|
+
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(
|
|
324
|
+
/\/+$/,
|
|
325
|
+
"",
|
|
326
|
+
);
|
|
272
327
|
const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
|
|
273
328
|
const tokenUrl = `${oneApiUrl}/oauth/token`;
|
|
274
329
|
const apiUrl = `${oneApiUrl}/v1`;
|
|
@@ -330,7 +385,10 @@ export function createOneConnect(
|
|
|
330
385
|
: null;
|
|
331
386
|
const tokenCredential =
|
|
332
387
|
mode === "token"
|
|
333
|
-
? createTokenCredential(
|
|
388
|
+
? createTokenCredential(
|
|
389
|
+
(config as OneConnectTokenConfig).tokenStore,
|
|
390
|
+
postToken,
|
|
391
|
+
)
|
|
334
392
|
: null;
|
|
335
393
|
const credential = (keyCredential ?? tokenCredential) as Credential;
|
|
336
394
|
|
|
@@ -352,7 +410,12 @@ export function createOneConnect(
|
|
|
352
410
|
if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
|
|
353
411
|
return {
|
|
354
412
|
redirectUrl: url.toString(),
|
|
355
|
-
cookie: txCookie(
|
|
413
|
+
cookie: txCookie(
|
|
414
|
+
state,
|
|
415
|
+
verifier,
|
|
416
|
+
config.redirectUri,
|
|
417
|
+
appPath(input.returnTo),
|
|
418
|
+
),
|
|
356
419
|
};
|
|
357
420
|
};
|
|
358
421
|
|
|
@@ -387,7 +450,10 @@ export function createOneConnect(
|
|
|
387
450
|
// The returned state names its own cookie. No cookie means a stale,
|
|
388
451
|
// foreign or forged state; the code is never exchanged in that case.
|
|
389
452
|
if (!code || !state || !verifier)
|
|
390
|
-
return fail(
|
|
453
|
+
return fail(
|
|
454
|
+
"expired",
|
|
455
|
+
"The attempt expired, or its state cookie was missing.",
|
|
456
|
+
);
|
|
391
457
|
|
|
392
458
|
try {
|
|
393
459
|
const response = await exchange(
|
|
@@ -401,7 +467,8 @@ export function createOneConnect(
|
|
|
401
467
|
// Tokens in token mode, the user's permanent id in key mode.
|
|
402
468
|
await credential.connected(input.userId, response);
|
|
403
469
|
} catch (error) {
|
|
404
|
-
const status =
|
|
470
|
+
const status =
|
|
471
|
+
error instanceof OneConnectError ? error.status : undefined;
|
|
405
472
|
return fail(
|
|
406
473
|
"failed",
|
|
407
474
|
status
|
|
@@ -425,7 +492,9 @@ export function createOneConnect(
|
|
|
425
492
|
init: RequestInit = {},
|
|
426
493
|
): Promise<Response> => {
|
|
427
494
|
const headers = new Headers(init.headers);
|
|
428
|
-
for (const [name, value] of Object.entries(
|
|
495
|
+
for (const [name, value] of Object.entries(
|
|
496
|
+
await credential.headers(userId),
|
|
497
|
+
))
|
|
429
498
|
headers.set(name, value);
|
|
430
499
|
return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
|
|
431
500
|
...init,
|
|
@@ -438,7 +507,10 @@ export function createOneConnect(
|
|
|
438
507
|
* that needs nothing but the credential. Throws when the question
|
|
439
508
|
* itself cannot be answered, rather than guessing.
|
|
440
509
|
*/
|
|
441
|
-
const consentStands = async (
|
|
510
|
+
const consentStands = async (
|
|
511
|
+
userId: string,
|
|
512
|
+
refused: OneConnectError,
|
|
513
|
+
): Promise<boolean> => {
|
|
442
514
|
const probe = await oneFetch(userId, "/connections/reachable?limit=1");
|
|
443
515
|
if (probe.ok) return true;
|
|
444
516
|
const verdict = credential.refusal(probe.status, await probe.text());
|
|
@@ -465,7 +537,10 @@ export function createOneConnect(
|
|
|
465
537
|
`/connections/reachable?limit=${limit}&page=${page}`,
|
|
466
538
|
);
|
|
467
539
|
if (!response.ok) {
|
|
468
|
-
const refused = credential.refusal(
|
|
540
|
+
const refused = credential.refusal(
|
|
541
|
+
response.status,
|
|
542
|
+
await response.text(),
|
|
543
|
+
);
|
|
469
544
|
if (refused) throw refused;
|
|
470
545
|
throw new OneConnectError(
|
|
471
546
|
"request_failed",
|
|
@@ -494,7 +569,10 @@ export function createOneConnect(
|
|
|
494
569
|
return page.rows ?? [];
|
|
495
570
|
}
|
|
496
571
|
const first = await connectionsPage(userId, CONNECTIONS_PAGE_SIZE, 1);
|
|
497
|
-
const pages = Math.min(
|
|
572
|
+
const pages = Math.min(
|
|
573
|
+
Math.max(first.pages ?? 1, 1),
|
|
574
|
+
CONNECTIONS_MAX_PAGES,
|
|
575
|
+
);
|
|
498
576
|
const rest = await Promise.all(
|
|
499
577
|
Array.from({ length: pages - 1 }, (_, index) =>
|
|
500
578
|
connectionsPage(userId, CONNECTIONS_PAGE_SIZE, index + 2),
|
|
@@ -558,7 +636,9 @@ export function createOneConnect(
|
|
|
558
636
|
oneFetch(userId, pageUrl(index + 2))
|
|
559
637
|
.then((response) =>
|
|
560
638
|
response.ok
|
|
561
|
-
? (response.json() as Promise<{
|
|
639
|
+
? (response.json() as Promise<{
|
|
640
|
+
rows?: Record<string, unknown>[];
|
|
641
|
+
}>)
|
|
562
642
|
: null,
|
|
563
643
|
)
|
|
564
644
|
.catch(() => null),
|
|
@@ -578,32 +658,46 @@ export function createOneConnect(
|
|
|
578
658
|
const params = new URLSearchParams({
|
|
579
659
|
query,
|
|
580
660
|
limit: String(options.limit ?? SEARCH_DEFAULT_LIMIT),
|
|
581
|
-
[options.mode === "knowledge" ? "knowledgeAgent" : "executeAgent"]:
|
|
661
|
+
[options.mode === "knowledge" ? "knowledgeAgent" : "executeAgent"]:
|
|
662
|
+
"true",
|
|
582
663
|
});
|
|
583
664
|
const response = await oneFetch(
|
|
584
665
|
userId,
|
|
585
666
|
`/available-actions/search/${encodeURIComponent(platform)}?${params}`,
|
|
586
667
|
);
|
|
587
|
-
if (!response.ok)
|
|
668
|
+
if (!response.ok)
|
|
669
|
+
throw await catalogRefusal(userId, "action search", response);
|
|
588
670
|
const body = (await response.json()) as
|
|
589
|
-
| Record<string, unknown>[]
|
|
590
|
-
| { rows?: Record<string, unknown>[] };
|
|
671
|
+
Record<string, unknown>[] | { rows?: Record<string, unknown>[] };
|
|
591
672
|
return slim(Array.isArray(body) ? body : (body.rows ?? []));
|
|
592
673
|
};
|
|
593
674
|
|
|
594
675
|
/** One action's knowledge, cached for the life of the client: an
|
|
595
676
|
* action's guide does not change between two calls. */
|
|
596
677
|
const knowledgeCache = new Map<string, Promise<ActionKnowledge>>();
|
|
597
|
-
const getActionKnowledge = (
|
|
678
|
+
const getActionKnowledge = (
|
|
679
|
+
userId: string,
|
|
680
|
+
actionId: string,
|
|
681
|
+
): Promise<ActionKnowledge> => {
|
|
598
682
|
const cached = knowledgeCache.get(actionId);
|
|
599
683
|
if (cached) return cached;
|
|
600
684
|
const loading = (async () => {
|
|
601
|
-
const response = await oneFetch(
|
|
602
|
-
|
|
603
|
-
|
|
685
|
+
const response = await oneFetch(
|
|
686
|
+
userId,
|
|
687
|
+
`/knowledge?_id=${encodeURIComponent(actionId)}`,
|
|
688
|
+
);
|
|
689
|
+
if (!response.ok)
|
|
690
|
+
throw await catalogRefusal(userId, "action knowledge", response);
|
|
691
|
+
const body = (await response.json()) as {
|
|
692
|
+
rows?: Record<string, unknown>[];
|
|
693
|
+
};
|
|
604
694
|
const row = body.rows?.[0];
|
|
605
695
|
if (!row)
|
|
606
|
-
throw new OneConnectError(
|
|
696
|
+
throw new OneConnectError(
|
|
697
|
+
"request_failed",
|
|
698
|
+
`One has no action with the id ${actionId}.`,
|
|
699
|
+
404,
|
|
700
|
+
);
|
|
607
701
|
return toKnowledge(row);
|
|
608
702
|
})();
|
|
609
703
|
knowledgeCache.set(actionId, loading);
|
|
@@ -631,7 +725,8 @@ export function createOneConnect(
|
|
|
631
725
|
const path = fillPath(action.path, input.pathParams);
|
|
632
726
|
const search = new URLSearchParams();
|
|
633
727
|
for (const [key, value] of Object.entries(input.query ?? {}))
|
|
634
|
-
for (const item of Array.isArray(value) ? value : [value])
|
|
728
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
729
|
+
search.append(key, item);
|
|
635
730
|
const query = search.size ? `?${search}` : "";
|
|
636
731
|
|
|
637
732
|
const headers: Record<string, string> = {
|
|
@@ -656,7 +751,9 @@ export function createOneConnect(
|
|
|
656
751
|
body = formEncode(payload);
|
|
657
752
|
} else if (encoding === "multipart") {
|
|
658
753
|
const form = new FormData();
|
|
659
|
-
for (const [key, value] of Object.entries(
|
|
754
|
+
for (const [key, value] of Object.entries(
|
|
755
|
+
isPlainObject(payload) ? payload : {},
|
|
756
|
+
))
|
|
660
757
|
form.append(
|
|
661
758
|
key,
|
|
662
759
|
value instanceof Blob
|
|
@@ -678,6 +775,7 @@ export function createOneConnect(
|
|
|
678
775
|
body,
|
|
679
776
|
});
|
|
680
777
|
const text = await response.text();
|
|
778
|
+
const rateLimit = rateLimitFrom(response.headers);
|
|
681
779
|
if (!response.ok) {
|
|
682
780
|
const refused = credential.refusal(response.status, text);
|
|
683
781
|
if (refused?.code === "reconnect_required") {
|
|
@@ -686,7 +784,14 @@ export function createOneConnect(
|
|
|
686
784
|
// tells them apart: if that still answers, the consent stands
|
|
687
785
|
// and it was this call One refused.
|
|
688
786
|
if (await consentStands(userId, refused))
|
|
689
|
-
return {
|
|
787
|
+
return {
|
|
788
|
+
status: response.status,
|
|
789
|
+
ok: false,
|
|
790
|
+
blockedByGrant: true,
|
|
791
|
+
refusal: "grant",
|
|
792
|
+
rateLimit,
|
|
793
|
+
data: text,
|
|
794
|
+
};
|
|
690
795
|
throw refused;
|
|
691
796
|
}
|
|
692
797
|
// One refusing the credential is not an answer to the action.
|
|
@@ -699,7 +804,14 @@ export function createOneConnect(
|
|
|
699
804
|
/* the provider answered with something other than JSON */
|
|
700
805
|
}
|
|
701
806
|
const refusal = oneRefusal(response, data);
|
|
702
|
-
return {
|
|
807
|
+
return {
|
|
808
|
+
status: response.status,
|
|
809
|
+
ok: response.ok,
|
|
810
|
+
blockedByGrant: refusal === "grant",
|
|
811
|
+
refusal,
|
|
812
|
+
rateLimit,
|
|
813
|
+
data,
|
|
814
|
+
};
|
|
703
815
|
};
|
|
704
816
|
|
|
705
817
|
const shared = {
|
package/src/server/types.ts
CHANGED
|
@@ -144,7 +144,8 @@ export interface OneConnectTokenConfig extends OneConnectBaseConfig {
|
|
|
144
144
|
* mode, a `tokenStore` alone is token mode. Set `mode` to say so
|
|
145
145
|
* explicitly.
|
|
146
146
|
*/
|
|
147
|
-
export type OneConnectServerConfig =
|
|
147
|
+
export type OneConnectServerConfig =
|
|
148
|
+
OneConnectKeyConfig | OneConnectTokenConfig;
|
|
148
149
|
|
|
149
150
|
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
150
151
|
export interface OneConnectCookie {
|
|
@@ -302,6 +303,22 @@ export interface RunActionInput {
|
|
|
302
303
|
*/
|
|
303
304
|
export type OneRefusal = "grant" | "plan_limit" | "rate_limit" | "one_error";
|
|
304
305
|
|
|
306
|
+
/**
|
|
307
|
+
* One's rate limit for the user a call was made for, as One reported it on
|
|
308
|
+
* that call. The app owner's plan sets the size; each of the app's users
|
|
309
|
+
* has a bucket of their own. Any value One didn't send is `null`.
|
|
310
|
+
*/
|
|
311
|
+
export interface RateLimit {
|
|
312
|
+
/** Calls this user may make a minute. `null` when nothing limits them. */
|
|
313
|
+
limit: number | null;
|
|
314
|
+
/** Calls left in the current minute. `null` when nothing limits them. */
|
|
315
|
+
remaining: number | null;
|
|
316
|
+
/** Seconds until the minute resets. */
|
|
317
|
+
resetsIn: number | null;
|
|
318
|
+
/** Seconds to wait before trying again. Set on a 429 only. */
|
|
319
|
+
retryAfter: number | null;
|
|
320
|
+
}
|
|
321
|
+
|
|
305
322
|
export interface RunActionResult {
|
|
306
323
|
status: number;
|
|
307
324
|
ok: boolean;
|
|
@@ -314,6 +331,11 @@ export interface RunActionResult {
|
|
|
314
331
|
/** Why One turned the call down, or `null` when it didn't (the call
|
|
315
332
|
* succeeded, or the provider answered with its own error). */
|
|
316
333
|
refusal: OneRefusal | null;
|
|
334
|
+
/** One's rate limit for this user after the call, or `null` when One
|
|
335
|
+
* sent none of its rate-limit headers. A `refusal` of `"plan_limit"`
|
|
336
|
+
* (402) means this user's calls for the month are used up: back off
|
|
337
|
+
* for the month, don't retry. */
|
|
338
|
+
rateLimit: RateLimit | null;
|
|
317
339
|
data: unknown;
|
|
318
340
|
}
|
|
319
341
|
|
|
@@ -329,10 +351,7 @@ export interface RunActionResult {
|
|
|
329
351
|
* reached. Nothing stored was changed, so retry later.
|
|
330
352
|
*/
|
|
331
353
|
export type OneConnectErrorCode =
|
|
332
|
-
| "
|
|
333
|
-
| "reconnect_required"
|
|
334
|
-
| "refresh_failed"
|
|
335
|
-
| "request_failed";
|
|
354
|
+
"not_connected" | "reconnect_required" | "refresh_failed" | "request_failed";
|
|
336
355
|
|
|
337
356
|
export class OneConnectError extends Error {
|
|
338
357
|
readonly code: OneConnectErrorCode;
|