@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 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.
@@ -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
  };
@@ -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
  };
@@ -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.23.1",
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`): a plan limit, not the grant. Wait and retry later.
312
- Do not ask the user to reconnect.
313
- - `"rate_limit"` (`429`): too many calls. Wait and retry.
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
 
@@ -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 { DEFAULT_ONE_API_URL, RETURN_ERROR_PARAM, RETURN_STATUS_PARAM } from "../constants";
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" && data !== null && "correlationId" in (data as Record<string, unknown>);
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) => walk(`${prefix}[${typeof item === "object" ? i : ""}]`, item));
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: (userId: string, actionId: string) => Promise<ActionKnowledge>;
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: (userId: string, input: RunActionInput) => Promise<RunActionResult>;
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> & Partial<OneConnectTokenConfig>;
286
+ const given = config as Partial<OneConnectKeyConfig> &
287
+ Partial<OneConnectTokenConfig>;
239
288
  const mode: OneConnectMode | undefined =
240
- given.mode ?? (given.connectKey ? "key" : given.tokenStore ? "token" : undefined);
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(config: OneConnectKeyConfig): OneConnectKeyClient;
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((config as OneConnectTokenConfig).tokenStore, postToken)
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(state, verifier, config.redirectUri, appPath(input.returnTo)),
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("expired", "The attempt expired, or its state cookie was missing.");
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 = error instanceof OneConnectError ? error.status : undefined;
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(await credential.headers(userId)))
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 (userId: string, refused: OneConnectError): Promise<boolean> => {
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(response.status, await response.text());
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(Math.max(first.pages ?? 1, 1), CONNECTIONS_MAX_PAGES);
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<{ rows?: Record<string, unknown>[] }>)
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"]: "true",
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) throw await catalogRefusal(userId, "action search", response);
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 = (userId: string, actionId: string): Promise<ActionKnowledge> => {
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(userId, `/knowledge?_id=${encodeURIComponent(actionId)}`);
602
- if (!response.ok) throw await catalogRefusal(userId, "action knowledge", response);
603
- const body = (await response.json()) as { rows?: Record<string, unknown>[] };
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("request_failed", `One has no action with the id ${actionId}.`, 404);
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]) search.append(key, item);
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(isPlainObject(payload) ? payload : {}))
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 { status: response.status, ok: false, blockedByGrant: true, refusal: "grant", data: text };
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 { status: response.status, ok: response.ok, blockedByGrant: refusal === "grant", refusal, data };
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 = {
@@ -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 = OneConnectKeyConfig | OneConnectTokenConfig;
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
- | "not_connected"
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;