@withone/connect 0.12.1 → 0.13.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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Key mode: the app holds one connect key, and one permanent id per
3
+ * user. Both go on every call; One reads the user's consent live each
4
+ * time. Nothing expires, so there is nothing to refresh, rotate or lock.
5
+ *
6
+ * X-One-Secret: the app's connect key
7
+ * X-One-Connect-User-Id: cu_… for the user being acted for
8
+ *
9
+ * The id comes from the one code exchange the callback makes. It is the
10
+ * same for a user for the life of the app, through revocation and
11
+ * re-consent, so it is saved once and never deleted by the SDK.
12
+ */
13
+ import type { Credential } from "./credential";
14
+ import { type OneConnectUserReference, type OneConnectUserStore } from "./types";
15
+ /**
16
+ * The one value an app stores per user, as a string for one column:
17
+ * `cu_…`, then the space the user granted from when it is not their
18
+ * personal one (`cu_…;org=<id>;project=<id>`).
19
+ *
20
+ * The space travels with the id because One resolves a call's tenant
21
+ * from headers: a grant made from an organization lives there, and a
22
+ * call that names none runs in the user's personal space and reaches
23
+ * nothing. In token mode the access token names the space on every
24
+ * call; here there is no token after the exchange, so it is kept.
25
+ */
26
+ export declare function encodeUserReference(reference: OneConnectUserReference): string;
27
+ /** Reads a stored value back. Null when it is not one this SDK wrote. */
28
+ export declare function parseUserReference(value: string): OneConnectUserReference | null;
29
+ export interface KeyCredential extends Credential {
30
+ getConnectUserId: (userId: string) => Promise<string | null>;
31
+ }
32
+ export declare function createKeyCredential(connectKey: string, userStore: OneConnectUserStore): KeyCredential;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Token mode: the app holds an access token and a refresh token per
3
+ * user, and the SDK refreshes them with One.
4
+ *
5
+ * Lifetimes. The access token lives as long as the app's Token lifetime
6
+ * setting says (dashboard, Advanced, when creating or editing the app: 7
7
+ * days, 30 days, 90 days or 1 year; 30 days unless changed). An app that
8
+ * never had the setting gets an hour. The refresh token always lives 30
9
+ * days, whatever the access token's lifetime, and only a live refresh
10
+ * token can buy a new pair.
11
+ *
12
+ * Who refreshes. Before each call the client refreshes a pair that is
13
+ * within a minute of expiring. Refreshing earlier than that is the app's
14
+ * job: it runs `refreshIfExpiring` on a schedule, so the refresh token
15
+ * never runs out. When it has run out, the access token is used until it
16
+ * expires too, and only then is the user asked to connect again.
17
+ *
18
+ * Rotation. Every refresh rotates both tokens, and One treats a second
19
+ * use of a rotated refresh token as theft and revokes the whole grant.
20
+ * So the client refreshes one user at a time (in this process always,
21
+ * across processes through `tokenStore.withLock`), re-reads the store
22
+ * before spending a refresh token, clears tokens only when the grant is
23
+ * dead, and never lets a failing old pair delete a newer one.
24
+ */
25
+ import type { Credential, PostToken } from "./credential";
26
+ import { type OneConnectTokenStore, type OneConnectTokens, type RefreshIfExpiringOptions } from "./types";
27
+ export interface TokenCredential extends Credential {
28
+ getAccessToken: (userId: string) => Promise<string>;
29
+ getTokens: (userId: string) => Promise<OneConnectTokens | null>;
30
+ refreshTokens: (userId: string) => Promise<OneConnectTokens>;
31
+ refreshIfExpiring: (userId: string, options?: RefreshIfExpiringOptions) => Promise<OneConnectTokens>;
32
+ }
33
+ export declare function createTokenCredential(tokenStore: OneConnectTokenStore, postToken: PostToken): TokenCredential;
@@ -2,6 +2,39 @@
2
2
  * Types for `@withone/connect/server`: the half of the SDK that runs on
3
3
  * the app's server and holds the client secret.
4
4
  */
5
+ /**
6
+ * How the app holds a user's grant.
7
+ *
8
+ * - `"key"`: the app's connect key plus a permanent id per user. Nothing
9
+ * expires, so there is nothing to refresh. The default.
10
+ * - `"token"`: an access token and a refresh token per user, which the
11
+ * SDK keeps fresh.
12
+ */
13
+ export type OneConnectMode = "key" | "token";
14
+ /** Key mode: who a stored user is to One, and where their grant lives. */
15
+ export interface OneConnectUserReference {
16
+ /** One's permanent id for this user, for this app: `cu_…`. */
17
+ connectUserId: string;
18
+ /** The organization the user granted from; absent for their personal
19
+ * space. */
20
+ organizationId?: string;
21
+ /** The project the user granted from, when it was one. */
22
+ projectId?: string;
23
+ }
24
+ /**
25
+ * Key mode: where the app keeps the one value the SDK gives it per user.
26
+ * A single string, written when the user connects and the same until
27
+ * they connect again, so one column on the app's user row is enough.
28
+ * `userId` is the app's own id for its user.
29
+ *
30
+ * It is an identifier, not a credential: it does nothing without the
31
+ * app's connect key. No lock is needed, because nothing rotates.
32
+ */
33
+ export interface OneConnectUserStore {
34
+ saveUser: (userId: string, reference: string) => Promise<void>;
35
+ loadUser: (userId: string) => Promise<string | null>;
36
+ clearUser: (userId: string) => Promise<void>;
37
+ }
5
38
  /** What the app stores per user after the exchange. */
6
39
  export interface OneConnectTokens {
7
40
  accessToken: string;
@@ -47,10 +80,13 @@ export interface OneConnectTokenStore {
47
80
  }
48
81
  export interface RefreshIfExpiringOptions {
49
82
  /** Refresh when the access token or the refresh token expires within
50
- * this many milliseconds. One minute when omitted. */
83
+ * this many milliseconds. One minute when omitted. A refresh token
84
+ * that has already run out cannot be refreshed: the pair is returned
85
+ * as it is while its access token still works. */
51
86
  withinMs?: number;
52
87
  }
53
- export interface OneConnectServerConfig {
88
+ /** What every app configures, whichever mode it uses. */
89
+ export interface OneConnectBaseConfig {
54
90
  /** The app's client id from the dashboard. */
55
91
  clientId: string;
56
92
  /** The app's client secret. Server only. */
@@ -69,8 +105,35 @@ export interface OneConnectServerConfig {
69
105
  /** OAuth scopes. All three tenancy tiers when omitted, so the user may
70
106
  * grant from any space. */
71
107
  scopes?: string[];
108
+ }
109
+ /**
110
+ * Key mode, the default: pass the app's connect key and a place to keep
111
+ * one value per user.
112
+ */
113
+ export interface OneConnectKeyConfig extends OneConnectBaseConfig {
114
+ mode?: "key";
115
+ /** The app's connect key, minted on the app's page in the dashboard.
116
+ * Server only. One key per environment. */
117
+ connectKey: string;
118
+ userStore: OneConnectUserStore;
119
+ tokenStore?: never;
120
+ }
121
+ /**
122
+ * Token mode: pass a token store and the SDK keeps each user's tokens
123
+ * fresh.
124
+ */
125
+ export interface OneConnectTokenConfig extends OneConnectBaseConfig {
126
+ mode?: "token";
72
127
  tokenStore: OneConnectTokenStore;
128
+ connectKey?: never;
129
+ userStore?: never;
73
130
  }
131
+ /**
132
+ * The mode is whichever credential is configured: a `connectKey` is key
133
+ * mode, a `tokenStore` alone is token mode. Set `mode` to say so
134
+ * explicitly.
135
+ */
136
+ export type OneConnectServerConfig = OneConnectKeyConfig | OneConnectTokenConfig;
74
137
  /** The transaction cookie the authorize leg sets and the callback reads. */
75
138
  export interface OneConnectCookie {
76
139
  name: string;
@@ -160,18 +223,23 @@ export interface RunActionResult {
160
223
  status: number;
161
224
  ok: boolean;
162
225
  /** True when One refused the call because it is outside the grant.
163
- * The provider was never called. Do not retry. */
226
+ * The provider was never called. Do not retry. Key mode sets it; in
227
+ * token mode it stays false today, so treat any 403 there as refused. */
164
228
  blockedByGrant: boolean;
165
229
  data: unknown;
166
230
  }
167
231
  /**
168
- * - `not_connected`: no tokens are stored for this user.
169
- * - `refresh_failed`: One declared the grant dead (revoked, expired or
170
- * reused). The tokens were cleared; ask the user to connect again.
232
+ * - `not_connected`: nothing is stored for this user.
233
+ * - `reconnect_required` (key mode): One will not act for this user.
234
+ * Their consent was revoked, or the app is deactivated. What the app
235
+ * stored is kept; ask the user to connect again.
236
+ * - `refresh_failed` (token mode): One declared the grant dead (revoked,
237
+ * expired or reused). The tokens were cleared; ask the user to connect
238
+ * again.
171
239
  * - `request_failed`: One answered with an error or could not be
172
- * reached. During a refresh the tokens are kept, so retry later.
240
+ * reached. Nothing stored was changed, so retry later.
173
241
  */
174
- export type OneConnectErrorCode = "not_connected" | "refresh_failed" | "request_failed";
242
+ export type OneConnectErrorCode = "not_connected" | "reconnect_required" | "refresh_failed" | "request_failed";
175
243
  export declare class OneConnectError extends Error {
176
244
  readonly code: OneConnectErrorCode;
177
245
  readonly status?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.12.1",
3
+ "version": "0.13.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",
@@ -1,68 +1,118 @@
1
1
  ---
2
2
  name: one-connect
3
- description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, and calling One with the grant.
3
+ description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, choosing key mode or token mode, and calling One with the grant.
4
4
  ---
5
5
 
6
6
  # One Connect
7
7
 
8
8
  You are adding One Connect to this application. Its users will grant the app
9
- scoped, revocable access to their own One-connected tools. The package does
10
- the OAuth work; you wire three things: a button, the two routes, and the
11
- calls you make with the grant.
9
+ scoped, revocable access to their own tools. The package does the OAuth work;
10
+ you wire three things: a button, two routes, and the calls made with the grant.
12
11
 
13
12
  ```
14
- Browser Your backend One
15
- <ConnectButton> ------> GET /api/one/authorize ---302---> One's hosted page (connect.withone.ai)
16
- sign-in code, pick tools, set access
17
- GET /api/one/callback <--302---- ?code=...&state=...
18
- exchanges the code, stores the tokens
19
- 302 -> /?one_connect=success
20
- <------ SDK reads the param and fires onSuccess
21
- Later: oneConnect.runAction(userId, ...) -> /v1/passthrough, grant enforced by One
13
+ Browser Your backend One
14
+ <ConnectButton> ------> GET /api/one/authorize ---302---> hosted page: sign in, pick tools, set access
15
+ GET /api/one/callback <--302---- ?code&state
16
+ saves one id for the user, redirects home
17
+ <------ onSuccess fires
18
+ Later: oneConnect.runAction(userId, ...) -> One, grant enforced
22
19
  ```
23
20
 
24
- Secrets and tokens never reach the browser.
21
+ The client secret and the connect key stay on the server.
25
22
 
26
- ## 1 - Collect from the human first
23
+ ## 1 - Ask the human for these
27
24
 
28
- The human creates the app in the One dashboard: Developers -> Connect ->
29
- New app. Confidential client. The secret is shown once.
25
+ They create the app in the One dashboard: Developers -> Connect -> New app.
30
26
 
31
27
  | Value | Notes |
32
28
  |---|---|
33
- | `ONE_CLIENT_ID` | 40 hex characters |
34
- | `ONE_CLIENT_SECRET` | starts with `one_secret_` |
35
- | Registered redirect URI | Must be the exact URL of the callback route (scheme, host, path, no trailing slash). One compares the string as-is and answers `invalid redirect_uri` on any difference. Use `https` for anything public; `http://localhost:3000/api/one/callback` is fine locally. |
36
- | `ONE_PERMISSION_SET` (optional) | The id of the app's ask: the connectors and levels the consent page opens on. Users can narrow it, never widen it. Without one, the page lists everything the user has connected. |
37
- | Environment | Production is the default. For the development dashboard set `ONE_API_URL=https://development-api.withone.ai`. |
29
+ | `ONE_CLIENT_ID` | From the app. |
30
+ | `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
31
+ | `ONE_CONNECT_KEY` | Key mode only. On the app's page: Credentials -> Connect key -> Create key. Shown once. It is made for the environment the dashboard is on (Production or Sandbox). |
32
+ | Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
33
+ | `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
34
+ | `ONE_API_URL` | Optional. Production when unset; `https://development-api.withone.ai` for the development dashboard. |
38
35
 
39
- Two facts to tell the human:
40
-
41
- - The app can show its users one line saying why it asks. It is set on the
42
- app in the dashboard, not sent by this package.
43
- - Users can change or revoke the grant from their One dashboard at any time.
44
- Treat a `401` from One as "ask the user to reconnect", never as a bug.
36
+ Never ask the human to paste the secret or the connect key into the chat.
37
+ Tell them which environment variable to set and read it from there.
45
38
 
46
39
  ## 2 - Environment (server only)
47
40
 
48
41
  ```bash
49
42
  ONE_CLIENT_ID=...
50
43
  ONE_CLIENT_SECRET=one_secret_...
51
- ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered value
52
- ONE_PERMISSION_SET=... # optional
53
- ONE_API_URL=https://api.withone.ai # optional; development-api.withone.ai for development
44
+ ONE_CONNECT_KEY=sk_live_... # key mode
45
+ ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
46
+ ONE_PERMISSION_SET=... # optional
47
+ ONE_API_URL=... # optional
54
48
  ```
55
49
 
56
- ## 3 - Install and create the client
50
+ ## 3 - Choose a mode
51
+
52
+ The server holds a user's grant in one of two ways. The button, the two
53
+ routes and every call are identical in both. The mode is whichever
54
+ credential you pass to `createOneConnect`.
55
+
56
+ | | Key mode (default) | Token mode |
57
+ |---|---|---|
58
+ | The server holds | one connect key for the app | nothing app-wide |
59
+ | Stored per user | one string, saved once | an access token and a refresh token |
60
+ | Expires | nothing; One checks the consent on every call | the tokens, on the lifetime set on the app |
61
+ | Refreshing | none | the SDK does it; several servers need a shared lock |
62
+
63
+ How to decide:
64
+
65
+ - New integration: **key mode**.
66
+ - The app already passes a `tokenStore`: it is in token mode. Leave it as it
67
+ is unless the human asks to move.
68
+ - The human asks for OAuth bearer tokens: token mode.
69
+
70
+ ```ts
71
+ // key mode
72
+ createOneConnect({ ...app, connectKey: process.env.ONE_CONNECT_KEY!, userStore });
73
+
74
+ // token mode
75
+ createOneConnect({ ...app, tokenStore });
76
+ ```
77
+
78
+ `mode: "key"` or `mode: "token"` may be added to be explicit.
79
+ `oneConnect.mode` reports which one is running.
80
+
81
+ ## 4 - Install and create the client
57
82
 
58
83
  ```bash
59
84
  npm install @withone/connect
60
85
  ```
61
86
 
87
+ Key mode:
88
+
62
89
  ```ts
63
90
  // lib/one.ts (server only)
64
91
  import { createOneConnect } from "@withone/connect/server";
65
92
 
93
+ export const oneConnect = createOneConnect({
94
+ clientId: process.env.ONE_CLIENT_ID!,
95
+ clientSecret: process.env.ONE_CLIENT_SECRET!,
96
+ redirectUri: process.env.ONE_REDIRECT_URI!,
97
+ permissionSet: process.env.ONE_PERMISSION_SET,
98
+ oneApiUrl: process.env.ONE_API_URL,
99
+ connectKey: process.env.ONE_CONNECT_KEY!,
100
+ userStore: {
101
+ saveUser: (userId, reference) => /* save the string on the app's user row */,
102
+ loadUser: (userId) => /* read it; null when never connected */,
103
+ clearUser: (userId) => /* set it to null */,
104
+ },
105
+ });
106
+ ```
107
+
108
+ `reference` is one short string: the user's permanent One id and the space
109
+ they granted from. One text column on the user row is enough. Store it as
110
+ given and hand it back unchanged; do not parse or rebuild it. It is an
111
+ identifier, not a secret, so it needs no encryption and no lock.
112
+
113
+ Token mode:
114
+
115
+ ```ts
66
116
  export const oneConnect = createOneConnect({
67
117
  clientId: process.env.ONE_CLIENT_ID!,
68
118
  clientSecret: process.env.ONE_CLIENT_SECRET!,
@@ -70,30 +120,30 @@ export const oneConnect = createOneConnect({
70
120
  permissionSet: process.env.ONE_PERMISSION_SET,
71
121
  oneApiUrl: process.env.ONE_API_URL,
72
122
  tokenStore: {
73
- saveTokens: (userId, tokens) => /* write to this app's database, encrypted, keyed by its user */,
74
- loadTokens: (userId) => /* read; null when the user never connected */,
75
- clearTokens: (userId, failed) => /* delete; when `failed` is set, only if the stored refreshToken is still failed.refreshToken */,
76
- // Required when the app runs more than one process (serverless, several instances, a worker):
77
- withLock: (userId, run) => /* run() while holding a per-user lock all processes share, e.g. pg_advisory_xact_lock */,
123
+ saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
124
+ loadTokens: (userId) => /* read; null when never connected */,
125
+ clearTokens: (userId) => /* delete */,
78
126
  },
79
127
  });
80
128
  ```
81
129
 
82
- `tokens` is `{ accessToken, refreshToken, expiresAt }`. Use the app's own user
83
- id as the key. Implement the store on whatever the app already uses; do not
84
- add a database for it.
130
+ In token mode, if the app runs more than one server process or a background
131
+ worker, also add `withLock: (userId, run) => ...`, which runs `run()` while
132
+ holding a per-user lock all processes share (for example a Postgres advisory
133
+ lock). The access token lives as long as the app's Token lifetime says (30
134
+ days unless changed under Advanced when creating the app); the refresh token
135
+ lives 30 days. Required: once a day, for each connected user, call
136
+ `oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 })`. It
137
+ renews both tokens when either is within 3 days of expiring; without it,
138
+ users have to connect again when the tokens run out. Keep the window shorter
139
+ than the Token lifetime, or every run refreshes.
85
140
 
86
- One rotates the refresh token on every refresh and revokes the whole grant if
87
- an old one is used again. So when two processes could refresh the same user,
88
- `withLock` is not optional: without it, a web request and a worker refreshing
89
- together disconnect the user. The README's "Tokens" section has a Postgres
90
- version. For background work, call `refreshIfExpiring(userId, { withinMs })`:
91
- every few minutes with a window of minutes to keep access tokens warm, and
92
- once a day with a window of days so idle users' 30-day refresh tokens renew.
141
+ In both modes use the app's own user id as the key and the database it
142
+ already has.
93
143
 
94
- ## 4 - The two routes
144
+ ## 5 - The two routes
95
145
 
96
- Next.js App Router (also Remix, SvelteKit, Hono, Bun: anything with the web `Request`):
146
+ Next.js App Router (also Remix, SvelteKit, Hono, Bun):
97
147
 
98
148
  ```ts
99
149
  // app/api/one/[action]/route.ts
@@ -101,87 +151,44 @@ import { createOneConnectRoutes } from "@withone/connect/next";
101
151
  import { oneConnect } from "@/lib/one";
102
152
 
103
153
  export const { GET } = createOneConnectRoutes(oneConnect, {
104
- identifyUser: async (request) => /* this app's signed-in user id, or null */,
105
- loginHintFor: async (request) => /* their email, to pre-fill One's sign-in; or null */,
106
- signInUrl: "/login", // where to send a visitor who is not signed in
154
+ identifyUser: async (request) => /* the signed-in user's id, or null */,
155
+ loginHintFor: async (request) => /* their email, optional */,
156
+ signInUrl: "/login",
107
157
  });
108
158
  ```
109
159
 
110
- That file serves `/api/one/authorize` and `/api/one/callback`.
160
+ Express or plain Node: `createOneConnectHandlers(oneConnect, { identifyUser })`
161
+ from `@withone/connect/node`, mounted at `/api/one/authorize` and
162
+ `/api/one/callback`.
111
163
 
112
- Express, Fastify, Koa, plain Node:
113
-
114
- ```ts
115
- import { createOneConnectHandlers } from "@withone/connect/node";
116
- import { oneConnect } from "./one";
117
-
118
- const { authorize, callback } = createOneConnectHandlers(oneConnect, {
119
- identifyUser: (request) => /* user id or null */,
120
- });
121
- app.get("/api/one/authorize", authorize);
122
- app.get("/api/one/callback", callback);
123
- ```
124
-
125
- The routes mint `state` and PKCE, keep them in a per-flow httpOnly cookie
126
- (`one_tx_<state>`, SameSite=Lax, 30 minutes), verify the state on return,
127
- exchange the code with the secret over HTTP Basic, store both tokens, and
128
- redirect to `/` with `?one_connect=success` or
129
- `?one_connect=error&one_connect_error=declined|expired|failed` (a code, never
130
- free text: the SDK shows fixed text for it). Pass `returnTo` to
131
- `createOneConnect` to land somewhere else.
132
-
133
- ## 5 - The button
164
+ ## 6 - The button
134
165
 
135
166
  ```tsx
136
- import { ConnectButton } from "@withone/connect/react"; // "use client" bundle: fine in a Server Component
167
+ import { ConnectButton } from "@withone/connect/react";
137
168
 
138
169
  <ConnectButton
139
170
  authorizeUrl="/api/one/authorize"
140
- platforms={["stripe", "google-calendar"]} // connector slugs; logos from One's CDN, names from the slug
141
- connected={hasGrant} // from the server (e.g. await oneConnect.isConnected(userId))
142
- onSuccess={() => { /* refetch app state; the tokens are already stored */ }}
143
- onError={(message, code) => { /* show message; code: declined | expired | failed */ }}
171
+ platforms={["gmail", "stripe"]} // connector slugs
172
+ connected={hasGrant} // from the server: await oneConnect.isConnected(userId)
173
+ onSuccess={() => { /* refetch app state */ }}
174
+ onError={(message) => { /* show message */ }}
144
175
  />
145
176
  ```
146
177
 
147
- Other props: `disabled`, `variant` ("default" | "accent" | "block"), `size`
148
- ("sm" | "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
149
- `connectTheme` (One's page), `accentColor`, `label`, `connectedLabel`,
150
- `description` (block), `onCancel` (user pressed Back on One's page).
151
-
152
- Always pass `connected` from the server. Without it the button forgets after
153
- a reload and asks the user to connect again.
178
+ Optional props: `variant` ("default" | "accent" | "block"), `accentColor`,
179
+ `size` ("sm" | "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
180
+ `label`, `description`, `disabled`.
154
181
 
155
- Vue: `import { ConnectButton } from "@withone/connect/vue"` with the same props
156
- in kebab case (`authorize-url`, `:connected`), and `@success`, `@error`,
157
- `@cancel`. Svelte: `import { connectButton } from "@withone/connect/svelte"`
158
- as `use:connectButton={{ authorizeUrl, platforms, connected, onSuccess }}`.
159
- Anything else: `import "@withone/connect"` registers
160
- `<one-connect-button authorize-url="/api/one/authorize" platforms="stripe, notion" connected>`,
161
- which dispatches `success`, `error` (detail `{ message, code }`) and `cancel`.
162
- A custom button in React: `const { open, status, error } = useOneConnect({ authorizeUrl })`
163
- from `@withone/connect/react`. Elsewhere: `createConnectFlow({ authorizeUrl, onSuccess, onError }).open`.
182
+ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
183
+ `@withone/connect/svelte`. Anything else: `import "@withone/connect"` and use
184
+ `<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
185
+ A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
164
186
 
165
- The button renders in a shadow root with a constructed stylesheet, so it
166
- works under a strict CSP. Allow `https://assets.withone.ai` in `img-src` for
167
- the logos. Style it with `--one-connect-font`, `--one-connect-radius` and
168
- `::part(button)`; do not wrap it in extra styling divs.
169
-
170
- The flow is a same-tab redirect. The outcome is read once per page load:
171
- every button shows it, and the callbacks fire once, on the first button
172
- still mounted.
173
-
174
- ## 6 - Calling One with the grant
175
-
176
- Everything runs on the server through the client. Tokens refresh themselves.
187
+ ## 7 - Calling One with the grant
177
188
 
178
189
  ```ts
179
- const connections = await oneConnect.listConnections(userId);
180
- // [{ key, platform, name, title, image, access }]
181
- // access.policy: "full" | "methods" (+ methods: ["GET", ...]) | "actions" (+ actions: [{ actionId, title, method }])
182
-
183
- const actions = await oneConnect.listActions(userId, "gmail");
184
- // [{ _id, title, method, path }] - what exists, not what is permitted
190
+ const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
191
+ const actions = await oneConnect.listActions(userId, "gmail"); // [{ _id, title, method, path }]
185
192
 
186
193
  const reply = await oneConnect.runAction(userId, {
187
194
  connectionKey: connection.key,
@@ -193,38 +200,60 @@ const reply = await oneConnect.runAction(userId, {
193
200
  // { status, ok, blockedByGrant, data }
194
201
  ```
195
202
 
196
- `blockedByGrant` true means One refused the call because it is outside the
197
- grant; the provider was never called. Do not retry. Any other `/v1` call:
198
- `oneConnect.fetch(userId, "/connections", init)`.
199
-
200
- `OneConnectError` codes: `not_connected` (no tokens stored), `refresh_failed`
201
- (One declared the grant dead: revoked, expired or reused; the tokens were
202
- cleared; ask the user to connect again), `request_failed` (One answered with
203
- an error or could not be reached; `status` carries it; during a refresh the
204
- tokens are KEPT, so retry later rather than asking the user to reconnect).
205
-
206
- To ask users for more tools later, edit the app's permission set in the
207
- dashboard. The next Connect shows only the new tools as "needs one more
208
- connection" and the callback stores the new pair; no code change.
209
-
210
- ## 7 - Rules
211
-
212
- - Never put the secret in a client bundle, a log line or an error report.
213
- - Store tokens encrypted, keyed by the app's user. Delete them when the user
214
- is deleted. The client deletes them itself only when One declares the
215
- grant dead, and never deletes a newer pair saved in the meantime.
216
- - Give the store `withLock` whenever more than one process can refresh.
217
- - The registered redirect URI and `ONE_REDIRECT_URI` must be the same string.
218
- - Do not build a completion page. The callback redirect is the completion.
219
- - Do not write the OAuth steps by hand when the package exposes them.
220
-
221
- ## 8 - Done when all of these pass
222
-
223
- 1. Button -> One's hosted page -> sign-in code from a real inbox -> choose
224
- tools and levels -> "You're all set" -> back in the app with `onSuccess`
225
- fired. Repeat once in a private window.
226
- 2. `listConnections` returns only the granted connections, each with `access`.
227
- 3. `runAction` on an action inside the grant reaches the provider; one outside
228
- it returns `blockedByGrant: true`.
229
- 4. Revoking the app from the user's One dashboard makes the next call throw
230
- `refresh_failed` or return `401`, and the app shows its reconnect prompt.
203
+ A `403` reply means the call is outside what the user granted. Do not retry
204
+ it. In key mode `blockedByGrant` is `true` for those.
205
+
206
+ Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
207
+
208
+ | `code` | Mode | Meaning | Do |
209
+ |---|---|---|---|
210
+ | `not_connected` | both | Nothing is stored for this user. | Show the Connect button. |
211
+ | `reconnect_required` | key | One will not act for this user: they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
212
+ | `refresh_failed` | token | The grant ended. The tokens were cleared. | Ask the user to connect again. |
213
+ | `request_failed` | both | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
214
+
215
+ ```ts
216
+ import { OneConnectError } from "@withone/connect/server";
217
+
218
+ try {
219
+ await oneConnect.runAction(userId, action);
220
+ } catch (error) {
221
+ if (
222
+ error instanceof OneConnectError &&
223
+ ["not_connected", "reconnect_required", "refresh_failed"].includes(error.code)
224
+ ) {
225
+ // show the Connect button again
226
+ } else {
227
+ throw error;
228
+ }
229
+ }
230
+ ```
231
+
232
+ In key mode `isConnected(userId)` says the user has connected before. One
233
+ confirms the consent on each call, so a user who revoked is found by the
234
+ next call throwing `reconnect_required`.
235
+
236
+ ## 8 - Rules
237
+
238
+ - Never put the client secret or the connect key in browser code, logs,
239
+ error reports, source files or prompts. Environment variables only.
240
+ - A connect key works in one environment. Use the Production key against
241
+ production and the Sandbox key against sandbox.
242
+ - Key mode: store the per-user string as given. Token mode: store tokens
243
+ encrypted, keyed by the app's user.
244
+ - The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
245
+ - Do not build a completion page; the callback redirect is the completion.
246
+ - Do not write OAuth steps or One request headers by hand; use the package.
247
+ - Do not switch an existing app from one mode to the other unless asked.
248
+
249
+ ## 9 - Done when
250
+
251
+ 1. The button leads to One's page; after signing in and authorizing, the user
252
+ lands back in the app and `onSuccess` fires.
253
+ 2. Key mode: one string is saved for the user. Token mode: the tokens are saved.
254
+ 3. `listConnections` returns only the granted connections.
255
+ 4. `runAction` works for an action inside the grant and returns `403` for
256
+ one outside it.
257
+ 5. After the user revokes the app in their One dashboard, the next call
258
+ fails with `reconnect_required` (key mode) or `refresh_failed` (token
259
+ mode) and the app asks them to connect again.
package/src/next.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * That serves /api/one/authorize and /api/one/callback. Register
15
15
  * `https://yourapp.com/api/one/callback` as the app's redirect URI.
16
16
  */
17
- import type { OneConnect } from "./server";
17
+ import type { OneConnectClient } from "./server";
18
18
 
19
19
  export interface OneConnectRoutesOptions {
20
20
  /** The app's own id for the signed-in user, or null when nobody is
@@ -80,7 +80,7 @@ export function routeFor(url: string): string {
80
80
  }
81
81
 
82
82
  export function createOneConnectRoutes(
83
- oneConnect: OneConnect,
83
+ oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">,
84
84
  options: OneConnectRoutesOptions,
85
85
  ): OneConnectRoutes {
86
86
  const cookiePath = (): string =>
package/src/node.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
18
 
19
19
  import { createOneConnectRoutes, type OneConnectRoutesOptions } from "@withone/connect/next";
20
- import type { OneConnect } from "./server";
20
+ import type { OneConnectClient } from "./server";
21
21
 
22
22
  export interface OneConnectNodeOptions {
23
23
  /** The app's own id for the signed-in user, or null when nobody is
@@ -76,7 +76,7 @@ async function send(response: ServerResponse, web: Response): Promise<void> {
76
76
  }
77
77
 
78
78
  export function createOneConnectHandlers(
79
- oneConnect: OneConnect,
79
+ oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">,
80
80
  options: OneConnectNodeOptions,
81
81
  ): OneConnectHandlers {
82
82
  // The web adapter receives a Request; the Node callbacks want the