@withone/connect 0.13.2 → 0.15.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.
@@ -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 reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
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
- /** Runs one action through One with the grant. */
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
- const method = input.method.toUpperCase();
425
- const query = input.query ? `?${new URLSearchParams(input.query)}` : "";
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 hasBody = method !== "GET" && method !== "HEAD" && input.body !== undefined;
431
- if (hasBody) headers["Content-Type"] = "application/json";
432
- const response = await oneFetch(userId, `/passthrough${input.path}${query}`, {
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: hasBody ? JSON.stringify(input.body) : undefined,
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
  };
@@ -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: string;
232
- /** The action's path, appended to /v1/passthrough. */
233
- path: string;
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 {
package/src/svelte.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Svelte compiler or dependency is involved:
6
6
  *
7
7
  * <div use:connectButton={{ authorizeUrl: "/api/one/authorize",
8
- * platforms: ["stripe", "notion"], connected: data.hasOneGrant,
8
+ * logos: ["stripe", "notion"], connected: data.hasOneGrant,
9
9
  * onSuccess: () => { ... } }} />
10
10
  */
11
11
  import {
package/src/types.ts CHANGED
@@ -31,8 +31,6 @@ export interface OneConnectFlowOptions {
31
31
  /** Theme for One's hosted page. Carried on the URL fragment, which
32
32
  * survives the redirect chain, so the backend forwards nothing. */
33
33
  connectTheme?: OneConnectTheme;
34
- /** @deprecated Renamed to `connectTheme`; removed in the next minor. */
35
- appTheme?: OneConnectTheme;
36
34
  /** The grant completed and the backend stored the tokens. Fires once
37
35
  * per page load, on the first flow still mounted when the tab
38
36
  * returns. Treat it as a hint to refetch: your server is the truth. */
@@ -62,20 +60,26 @@ export interface OneConnectReturn {
62
60
  }
63
61
 
64
62
  /**
65
- * A connector chip on the button. Pass One's connector slug ("stripe",
63
+ * A logo chip on the button. Pass One's connector slug ("stripe",
66
64
  * "google-calendar") and the SDK shows its logo and name; pass an
67
- * object to override either.
65
+ * object to override either. Decoration only: what One asks the user
66
+ * for comes from the app's permission set, not from this list.
68
67
  */
69
- export type ConnectButtonPlatformInput =
68
+ export type ConnectButtonLogoInput =
70
69
  string | { slug?: string; name?: string; imageUrl?: string };
71
70
 
72
71
  /** A normalized chip: what the button actually draws. */
73
- export interface ConnectButtonPlatform {
72
+ export interface ConnectButtonLogo {
74
73
  slug: string;
75
74
  name: string;
76
75
  imageUrl: string;
77
76
  }
78
77
 
78
+ /** @deprecated Renamed to `ConnectButtonLogoInput`; removed in the next minor. */
79
+ export type ConnectButtonPlatformInput = ConnectButtonLogoInput;
80
+ /** @deprecated Renamed to `ConnectButtonLogo`; removed in the next minor. */
81
+ export type ConnectButtonPlatform = ConnectButtonLogo;
82
+
79
83
  export type ConnectButtonVariant = "default" | "accent" | "block";
80
84
  export type ConnectButtonSize = "sm" | "md" | "lg";
81
85
  export type ConnectButtonState = "idle" | "connecting" | "connected";
@@ -85,17 +89,22 @@ export type ConnectButtonState = "idle" | "connecting" | "connected";
85
89
  export interface ConnectButtonProps {
86
90
  /** The app's own backend authorize route; relative is fine. */
87
91
  authorizeUrl: string;
88
- /** Connector slugs, or objects that override the name or the logo.
89
- * The first three draw as logos; the rest fold into a "+N" chip. */
90
- platforms?: ConnectButtonPlatformInput[];
92
+ /** Logos to draw on the button: connector slugs, or objects that
93
+ * override the name or the image. The first three draw; the rest fold
94
+ * into a "+N" chip. Decoration only; the permission set decides what
95
+ * One asks for. */
96
+ logos?: ConnectButtonLogoInput[];
97
+ /** @deprecated Renamed to `logos`; removed in the next minor. */
98
+ platforms?: ConnectButtonLogoInput[];
91
99
  /** Whether this user has a live grant, from your server. When set, it
92
100
  * decides the Connected state. When omitted, the button shows
93
101
  * Connected only right after a successful return. */
94
102
  connected?: boolean;
95
103
  /** Not clickable, for example until terms are accepted. */
96
104
  disabled?: boolean;
97
- /** default = neutral, accent = your brand colour, block = a card with
98
- * a description and a "Secured by One" foot. */
105
+ /** default = neutral, accent = your brand colour (set
106
+ * `--one-connect-accent` and `--one-connect-accent-fg` on the host),
107
+ * block = a card with a description and a "Secured by One" foot. */
99
108
  variant?: ConnectButtonVariant;
100
109
  size?: ConnectButtonSize;
101
110
  /** Stretches to the width of its container. */
@@ -104,11 +113,6 @@ export interface ConnectButtonProps {
104
113
  theme?: ConnectButtonTheme;
105
114
  /** Theme of One's hosted page. */
106
115
  connectTheme?: OneConnectTheme;
107
- /** @deprecated Renamed to `connectTheme`; removed in the next minor. */
108
- appTheme?: OneConnectTheme;
109
- /** Fill of the accent variant; One's lime when omitted. The label is
110
- * black or white, whichever reads better on it. */
111
- accentColor?: string;
112
116
  /** "Connect your apps" unless set. */
113
117
  label?: string;
114
118
  /** "Connected" unless set. */
package/src/vue.ts CHANGED
@@ -23,7 +23,7 @@ import type { PropType } from "vue";
23
23
  import {
24
24
  renderConnectButton,
25
25
  type ConnectButtonHandle,
26
- type ConnectButtonPlatformInput,
26
+ type ConnectButtonLogoInput,
27
27
  type ConnectButtonProps,
28
28
  type ConnectButtonSize,
29
29
  type ConnectButtonTheme,
@@ -40,8 +40,13 @@ export const ConnectButton = defineComponent({
40
40
  name: "OneConnectButton",
41
41
  props: {
42
42
  authorizeUrl: { type: String, required: true },
43
+ logos: {
44
+ type: Array as PropType<ConnectButtonLogoInput[]>,
45
+ default: undefined,
46
+ },
47
+ /** @deprecated use logos */
43
48
  platforms: {
44
- type: Array as PropType<ConnectButtonPlatformInput[]>,
49
+ type: Array as PropType<ConnectButtonLogoInput[]>,
45
50
  default: undefined,
46
51
  },
47
52
  connected: optionalBoolean,
@@ -57,9 +62,6 @@ export const ConnectButton = defineComponent({
57
62
  type: String as PropType<OneConnectTheme>,
58
63
  default: undefined,
59
64
  },
60
- /** @deprecated use connectTheme */
61
- appTheme: { type: String as PropType<OneConnectTheme>, default: undefined },
62
- accentColor: { type: String, default: undefined },
63
65
  label: { type: String, default: undefined },
64
66
  connectedLabel: { type: String, default: undefined },
65
67
  description: { type: String, default: undefined },