@withone/connect 0.12.1 → 0.12.2

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
@@ -20,171 +20,84 @@
20
20
  <a href="https://npmjs.com/package/@withone/connect"><img src="https://img.shields.io/npm/v/%40withone%2Fconnect" alt="npm version"></a>
21
21
  </p>
22
22
 
23
- One Connect lets your users grant your app **scoped, revocable access to their own One-connected tools**: Gmail, Slack, Notion, Stripe and 500 more. Your user keeps their connections in One. Your app holds only what they granted, and One checks that on every call.
23
+ One Connect lets your users grant your app **scoped, revocable access to their own tools**: Gmail, Slack, Notion, Stripe and 500 more. The connections stay in your user's One account. Your app holds only what they granted, and One checks that on every call.
24
24
 
25
- You build three things: a button, two backend routes, and the calls you make with the grant. This package gives you all three.
25
+ You add three things: a button, two backend routes, and the calls you make with the grant.
26
26
 
27
27
  ```
28
- Browser Your server One
29
- <ConnectButton> ───────► GET /api/one/authorize ──302──► One's hosted connect page
30
- sign in · pick tools · set access
28
+ Browser Your server One
29
+ <ConnectButton> ──────► GET /api/one/authorize ──302──► hosted page: sign in, pick tools, set access
31
30
  GET /api/one/callback ◄──302── ?code&state
32
- exchanges the code, stores the tokens
33
- 302 → /?one_connect=success
34
- onSuccess() fires ◄────────
35
- later: oneConnect.runAction(userId, …) ──► /v1/passthrough (grant enforced)
31
+ stores the tokens, redirects home
32
+ onSuccess() ◄──────
33
+ later: oneConnect.runAction(userId, …) ──► One, grant enforced
36
34
  ```
37
35
 
38
- > **Connect vs. Auth.** [`@withone/auth`](https://github.com/withoneai/auth) puts connections in *your* One project: you own them. Connect puts connections in *your user's* One account and hands you a grant.
39
-
40
36
  ## Install
41
37
 
42
38
  ```bash
43
39
  npm install @withone/connect
44
40
  ```
45
41
 
46
- Or let your coding agent do the whole setup:
47
-
48
- ```bash
49
- npx skills add withoneai/connect
50
- ```
42
+ Using a coding agent? `npx skills add withoneai/connect` teaches it the whole setup.
51
43
 
52
44
  ## 1 · Create your app in One
53
45
 
54
- Dashboard → **Developers → Connect → New app**.
55
-
56
- - You get a **client id** (public) and a **client secret** (shown once; server only).
57
- - Register the **redirect URI** of your callback route, exactly: `https://yourapp.com/api/one/callback`.
58
- - Choose what the app asks for: specific connectors and levels (an *ask*, which gives you a permission set id), or everything the user has connected.
59
- - Optionally write the one line users see about why you ask.
60
-
61
- Environment variables, server side:
46
+ Dashboard → **Developers → Connect → New app**. Register your callback URL exactly, for example `https://yourapp.com/api/one/callback`. Optionally choose the tools and access levels to ask for; the consent page lists them in the order you add them.
62
47
 
63
48
  ```env
64
49
  ONE_CLIENT_ID=…
65
- ONE_CLIENT_SECRET=one_secret_…
66
- ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
67
- ONE_PERMISSION_SET=… # optional: the ask to open on
68
- ONE_API_URL=https://api.withone.ai # optional: production when unset
50
+ ONE_CLIENT_SECRET=one_secret_… # server only
51
+ ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
52
+ ONE_PERMISSION_SET=… # optional: the tools you ask for
53
+ ONE_API_URL=https://api.withone.ai # optional: production when unset
69
54
  ```
70
55
 
71
- | Variable | Required | What it is |
72
- |---|---|---|
73
- | `ONE_CLIENT_ID` | yes | Public client id from your app |
74
- | `ONE_CLIENT_SECRET` | yes | Server only. Never in a browser, a log or an error report. |
75
- | `ONE_REDIRECT_URI` | yes | Must equal the registered URI character for character |
76
- | `ONE_PERMISSION_SET` | no | The ask the consent page opens on. Without it, the page lists everything the user has connected. |
77
- | `ONE_API_URL` | no | `https://development-api.withone.ai` for the development environment |
78
-
79
56
  ## 2 · The button
80
57
 
81
- Any element wired to `open()` works. The pre-built button draws connector logos from their slugs and handles Connect → Connecting → Connected itself. It renders in its own shadow root, so your page's CSS can't break it, and it keeps its look under a strict Content Security Policy (`style-src 'self'`).
82
-
83
58
  ```tsx
84
- // React / Next.js: safe to import from a Server Component (the bundle is "use client")
85
59
  import { ConnectButton } from "@withone/connect/react";
86
60
 
87
61
  <ConnectButton
88
62
  authorizeUrl="/api/one/authorize"
89
- platforms={["stripe", "google-calendar", "gmail"]}
90
- connected={user.hasOneGrant} // from your server; see below
91
- onSuccess={() => refreshAppState()}
92
- onError={(message, code) => showBanner(message)}
63
+ platforms={["gmail", "google-calendar", "stripe"]}
64
+ connected={user.hasOneGrant} // from your server
65
+ onSuccess={() => refresh()}
66
+ onError={(message) => showError(message)}
93
67
  />
94
68
  ```
95
69
 
96
- ```vue
97
- <!-- Vue 3 -->
98
- <script setup>
99
- import { ConnectButton } from "@withone/connect/vue";
100
- </script>
101
- <template>
102
- <ConnectButton authorize-url="/api/one/authorize" :platforms="['stripe', 'notion']" :connected="hasGrant" @success="onConnected" />
103
- </template>
104
- ```
70
+ | Prop | What it does |
71
+ |---|---|
72
+ | `authorizeUrl` | Your authorize route. Required. |
73
+ | `platforms` | Connector slugs to show as logos. |
74
+ | `connected` | Whether the user already has a grant, from your server. |
75
+ | `variant` | `default`, `accent` (your brand colour via `accentColor`), or `block` (a card with a `description`). |
76
+ | `size` | `sm`, `md` or `lg`. |
77
+ | `fullWidth` | Fills its container. |
78
+ | `theme` | `light`, `dark` or `auto`. `connectTheme` sets One's page. |
79
+ | `label` | Button text. |
80
+ | `disabled` | Not clickable. |
81
+ | `onSuccess` / `onError` / `onCancel` | How the flow ended. |
82
+
83
+ Other frameworks take the same props:
105
84
 
106
- ```svelte
107
- <!-- Svelte, as an action -->
108
- <script>
109
- import { connectButton } from "@withone/connect/svelte";
110
- </script>
111
- <div use:connectButton={{ authorizeUrl: "/api/one/authorize", platforms: ["stripe", "notion"], connected: data.hasGrant, onSuccess }} />
85
+ ```vue
86
+ <ConnectButton authorize-url="/api/one/authorize" :platforms="['gmail']" @success="onConnected" /> <!-- @withone/connect/vue -->
112
87
  ```
113
88
 
114
89
  ```html
115
- <!-- Plain HTML or any other framework: importing the package registers the element -->
116
- <one-connect-button authorize-url="/api/one/authorize" platforms="stripe, notion" connected></one-connect-button>
117
- <script type="module">
118
- import "@withone/connect";
119
- document.querySelector("one-connect-button").addEventListener("success", () => location.reload());
120
- </script>
121
- ```
122
-
123
- Every surface takes the same props. Attributes are the kebab-case names, and `connected`, `disabled` and `full-width` are boolean attributes. The React and Vue components render the host element themselves, `<span class="one-connect">`, and React's `className` and `style` apply to it. The button inside never changes the host's attributes, so server-rendered pages hydrate without a mismatch, and `fullWidth` fills whatever container you put the button in, flex rows included.
124
-
125
- | Prop (attribute) | What it does |
126
- |---|---|
127
- | `authorizeUrl` (`authorize-url`) | Your authorize route. Relative paths resolve against the page. Required. |
128
- | `platforms` | Connector slugs: `["stripe", "google-calendar"]`. Logos come from One's CDN and names from the slug, spelled the way each brand spells itself (`hubspot` → HubSpot). Pass `{ slug, name, imageUrl }` to override either. As an attribute: `"stripe, notion"`. The first three draw as logos; the rest fold into a `+N` chip. |
129
- | `connected` | Whether this user has a live grant, **from your server**. When set, it decides the Connected state, so the button stays right after a reload. When omitted, the button shows Connected only right after a successful return. |
130
- | `disabled` | Not clickable, for example until terms are accepted. |
131
- | `variant` | `default` neutral · `accent` your brand colour · `block` a card with a description and a "Secured by One" foot |
132
- | `size` | `sm` · `md` (default) · `lg` |
133
- | `fullWidth` (`full-width`) | Stretches to its container. |
134
- | `theme` | `light` (default), `dark`, or `auto` to follow the visitor's setting. Matches *your* page. |
135
- | `connectTheme` (`connect-theme`) | `light` or `dark` for One's hosted page. `appTheme` still works and is deprecated. |
136
- | `accentColor` (`accent-color`) | Fill of the `accent` variant; One's lime when omitted. The label is black or white, whichever reads better on it. |
137
- | `label` · `connectedLabel` (`connected-label`) | Button text. Defaults: "Connect your apps" · "Connected". |
138
- | `description` | Sub-line on the `block` variant. |
139
- | `onSuccess` | The grant was stored. Fires once per page load, on the first button still mounted. Refetch; your server is the truth. |
140
- | `onError(message, code)` | The flow ended without a grant. `code` is `declined`, `expired` or `failed`, and `message` is the SDK's fixed text for it, safe to show. |
141
- | `onCancel` | The user came back with the browser's Back button before finishing. The button is already clickable again. |
142
-
143
- The custom element dispatches `success`, `error` (`detail: { message, code }`) and `cancel` events.
144
-
145
- **Matching your design system.** The button inherits your page's font. Two custom properties and two parts do the rest:
146
-
147
- ```css
148
- one-connect-button, .one-connect {
149
- --one-connect-font: var(--font-sans);
150
- --one-connect-radius: 8px;
151
- }
152
- .one-connect::part(button) { box-shadow: none; } /* the React, Vue and Svelte host */
153
- one-connect-button::part(label) { font-weight: 600; }
154
- ```
155
-
156
- Under a strict CSP, allow One's connector logos in `img-src` (`https://assets.withone.ai`). If they're blocked, each chip falls back to the connector's first letter.
157
-
158
- **Your own element.** In React, use the hook:
159
-
160
- ```tsx
161
- import { useOneConnect } from "@withone/connect/react";
162
-
163
- const { open, status, error } = useOneConnect({ authorizeUrl: "/api/one/authorize" });
164
- // status: "idle" | "connecting" | "connected" | "error"
165
- <button onClick={open} disabled={status === "connecting"}>Connect your tools</button>
90
+ <one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe"></one-connect-button>
91
+ <script type="module">import "@withone/connect";</script>
166
92
  ```
167
93
 
168
- Anywhere else, use `createConnectFlow`:
169
-
170
- ```ts
171
- import { createConnectFlow } from "@withone/connect";
172
-
173
- const flow = createConnectFlow({
174
- authorizeUrl: "/api/one/authorize",
175
- onSuccess: () => {},
176
- onError: (message, code) => {},
177
- onCancel: () => {},
178
- });
179
- button.addEventListener("click", flow.open);
180
- // later: flow.destroy()
181
- ```
94
+ Svelte: `use:connectButton={{ authorizeUrl, platforms }}` from `@withone/connect/svelte`.
182
95
 
183
- **How the return works.** The flow is a full-page redirect in the same tab, so it works in every browser with no popup or iframe. When your callback route redirects home, it appends `?one_connect=success` or `?one_connect=error&one_connect_error=<code>`. The SDK reads that once per page load, shows it on every button, calls back once, and removes the params from the address bar. Only the code travels on the URL. The text comes from the SDK, so a crafted link can't put its own words in front of your users. `readConnectReturn()` returns the same outcome if you want to show it yourself.
96
+ Your own button in React: `const { open, status } = useOneConnect({ authorizeUrl: "/api/one/authorize" })`.
184
97
 
185
- ## 3 · The two routes, as one import
98
+ To match your design, set `--one-connect-font` and `--one-connect-radius`, or style `::part(button)`.
186
99
 
187
- Create the client once, on the server:
100
+ ## 3 · The two routes
188
101
 
189
102
  ```ts
190
103
  // lib/one.ts
@@ -197,178 +110,50 @@ export const oneConnect = createOneConnect({
197
110
  permissionSet: process.env.ONE_PERMISSION_SET,
198
111
  oneApiUrl: process.env.ONE_API_URL,
199
112
  tokenStore: {
200
- // Your database, keyed by your own user id. Store them encrypted.
201
113
  saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
202
114
  loadTokens: (userId) => db.oneTokens.find(userId),
203
115
  clearTokens: (userId) => db.oneTokens.delete(userId),
204
- // Required when you run more than one server or a worker. See "Tokens" below.
205
- withLock: (userId, run) => db.withUserLock(userId, run),
206
116
  },
207
117
  });
208
118
  ```
209
119
 
210
- Then mount the routes for your server.
211
-
212
- **Next.js (App Router), Remix, SvelteKit, Hono, Bun** and anything else that speaks the web `Request`:
213
-
214
120
  ```ts
215
- // app/api/one/[action]/route.ts
121
+ // app/api/one/[action]/route.ts (Next.js; also Remix, SvelteKit, Hono, Bun)
216
122
  import { createOneConnectRoutes } from "@withone/connect/next";
217
123
  import { oneConnect } from "@/lib/one";
218
124
 
219
125
  export const { GET } = createOneConnectRoutes(oneConnect, {
220
126
  identifyUser: async (request) => (await getSession(request))?.userId ?? null,
221
- loginHintFor: async (request) => (await getSession(request))?.email ?? null,
222
- signInUrl: "/login",
223
127
  });
224
128
  ```
225
129
 
226
- That one file serves `/api/one/authorize` and `/api/one/callback`.
227
-
228
- **Express, Fastify, Koa, plain Node:**
229
-
230
- ```ts
231
- import { createOneConnectHandlers } from "@withone/connect/node";
232
- import { oneConnect } from "./one";
233
-
234
- const { authorize, callback } = createOneConnectHandlers(oneConnect, {
235
- identifyUser: (request) => request.session?.userId ?? null,
236
- });
237
- app.get("/api/one/authorize", authorize);
238
- app.get("/api/one/callback", callback);
239
- ```
240
-
241
- What the routes do for you: mint `state` and a PKCE verifier, keep them in a per-flow httpOnly cookie, send the browser to One, verify the returned state, exchange the code with your secret over HTTP Basic, store both tokens through your `tokenStore`, and redirect home with the outcome. A declined consent, an expired attempt and a failed exchange come back as `?one_connect=error&one_connect_error=declined|expired|failed`. `completeAuthorization` also returns a `message` describing what happened, for your logs only.
242
-
243
- **Another language?** The routes are ordinary OAuth 2.1 authorization code with PKCE. The reference behaviour is in `src/server/index.ts`; the same steps work in Python, Go or Ruby.
130
+ That file serves `/api/one/authorize` and `/api/one/callback`. For Express or plain Node, use `createOneConnectHandlers` from `@withone/connect/node`.
244
131
 
245
132
  ## 4 · Using the grant
246
133
 
247
- Everything runs on your server through the same client. Every call refreshes the tokens first when they are about to expire (see [Tokens](#5--tokens-storing-refreshing-keeping-alive)).
248
-
249
134
  ```ts
250
- // What the grant reaches, each connection with its access
251
- const connections = await oneConnect.listConnections(userId);
252
- // [{ key, platform, name, title, image, access: { policy: "full" | "methods" | "actions", … } }]
135
+ const connections = await oneConnect.listConnections(userId); // what the user granted
136
+ const actions = await oneConnect.listActions(userId, "gmail"); // what a platform can do
253
137
 
254
- // What actions a platform has (what exists, not what is permitted)
255
- const actions = await oneConnect.listActions(userId, "gmail");
256
- // [{ _id, title, method, path }]
257
-
258
- // Run one
259
138
  const reply = await oneConnect.runAction(userId, {
260
139
  connectionKey: connections[0].key,
261
140
  actionId: actions[0]._id,
262
141
  method: actions[0].method,
263
142
  path: actions[0].path,
264
- body: { … },
265
143
  });
266
- // { status, ok, blockedByGrant, data }
144
+ // { status, ok, data }
267
145
  ```
268
146
 
269
- `blockedByGrant` is true when One refused the call because it is outside what the user granted. The provider was never called. Do not retry; the user chose that. Anything else on One's `/v1` API: `oneConnect.fetch(userId, "/connections", init)` adds the bearer and the tenancy headers for you.
270
-
271
- Other calls on the client: `isConnected`, `getAccessToken`, `getTokens`, `refreshTokens`, `refreshIfExpiring`, `disconnect`.
272
-
273
- ## 5 · Tokens: storing, refreshing, keeping alive
274
-
275
- Your app owns the tokens. The SDK never stores anything itself: it calls your `tokenStore`, and it refreshes through it.
276
-
277
- **What One issues**
278
-
279
- | | Lifetime | On refresh |
280
- |---|---|---|
281
- | Access token | The lifetime set on your app: 1 hour by default, or 7, 30, 90 or 365 days | Replaced |
282
- | Refresh token | 30 days | Replaced, with a fresh 30 days |
283
-
284
- Every refresh returns a **new pair** and retires the old refresh token. If the old refresh token is ever used again, One treats it as stolen and **revokes the whole grant**. The user then has to connect again.
285
-
286
- **Store them in your database, encrypted, keyed by your user id**
287
-
288
- ```sql
289
- create table one_tokens (
290
- user_id text primary key,
291
- access_token text not null, -- encrypted
292
- refresh_token text not null, -- encrypted
293
- expires_at timestamptz not null -- tokens.expiresAt
294
- );
295
- ```
296
-
297
- **Give the store a lock when more than one process can refresh**
298
-
299
- Serverless functions, several instances, a background worker: any two of them can decide to refresh the same user at the same moment.
300
-
301
- ```
302
- without a lock with withLock
303
- web ──refresh(R1)──► One: here is R2 web ──lock──refresh(R1)──► R2 ──save──unlock
304
- worker ─refresh(R1)─► One: R1 reused! worker ──wait─────────────────────────────┐
305
- revoke everything ✗ reload: R2 is fresh, use it ✓
306
- ```
307
-
308
- The SDK takes the lock around every refresh and around the save in the callback. It re-reads the store inside the lock, so the process that waited uses the pair the first one saved instead of spending the old refresh token again. The SDK never takes the lock twice for the same call. Postgres, with a transaction-scoped advisory lock:
309
-
310
- ```ts
311
- withLock: async (userId, run) => {
312
- const client = await pool.connect();
313
- try {
314
- await client.query("begin");
315
- await client.query("select pg_advisory_xact_lock(hashtextextended($1, 0))", [`one-connect:${userId}`]);
316
- return await run();
317
- } finally {
318
- await client.query("commit").catch(() => {});
319
- client.release();
320
- }
321
- },
322
- ```
147
+ A `403` means the call is outside what the user granted. Don't retry it.
323
148
 
324
- A single long-running process can leave `withLock` out; the SDK already runs one refresh per user at a time inside a process. If your lock has a timeout, make it at least 60 seconds, because it spans one call to One.
149
+ ## 5 · Tokens
325
150
 
326
- **Refreshing ahead of time**
151
+ - Store them in your database, encrypted, keyed by your user id. The SDK refreshes them for you.
152
+ - Running more than one server or a background worker? Add `withLock(userId, run)` to your token store, for example a Postgres advisory lock. Two servers refreshing at once would otherwise disconnect the user.
153
+ - Keep idle users connected: once a day, call `oneConnect.refreshIfExpiring(userId, { withinMs: 7 * 24 * 3_600_000 })`.
154
+ - A `refresh_failed` error means the grant ended (revoked or expired). Ask the user to connect again.
327
155
 
328
- `getAccessToken`, `runAction`, `listConnections` and `fetch` refresh on their own when the access token has less than a minute left. For work that runs in the background, refresh ahead with `refreshIfExpiring`. It refreshes only when the access token **or** the refresh token expires within the window, and otherwise returns the stored pair without calling One.
329
-
330
- ```ts
331
- // Every 10 minutes: users whose access token expires in the next 15 minutes
332
- // (your table knows expires_at).
333
- await oneConnect.refreshIfExpiring(userId, { withinMs: 15 * 60_000 });
334
-
335
- // Once a day, for every connected user: renews refresh tokens before their
336
- // 30 days run out, so a user who has not been active stays connected.
337
- await oneConnect.refreshIfExpiring(userId, { withinMs: 7 * 24 * 3_600_000 });
338
- ```
339
-
340
- **When a refresh fails**
341
-
342
- | `OneConnectError.code` | What happened | Tokens | What to do |
343
- |---|---|---|---|
344
- | `refresh_failed` | One declared the grant dead: the user revoked it, the refresh token expired, or it was reused | Cleared | Show "Reconnect", which runs Connect again |
345
- | `request_failed` | One could not be reached, answered with a server error, or refused your client credentials | **Kept** | Retry later. Check `status` for a 401, which means your client secret is wrong. |
346
- | `not_connected` | No tokens are stored for this user | — | Show "Connect" |
347
-
348
- The SDK clears tokens only when One says the grant is dead (`invalid_grant`), or when the refresh token's own expiry has passed. It calls `clearTokens(userId, failed)` with the pair that failed. Just before that, it re-reads the store: if a newer pair has landed (the user reconnected while an old refresh was failing), it keeps and returns the newer pair. To make that check atomic, delete only when the stored refresh token is still `failed.refreshToken`. `disconnect(userId)` calls `clearTokens(userId)` without `failed`, and always deletes.
349
-
350
- **Asking for more tools later**
351
-
352
- Edit the app's permission set in the dashboard (Developers → Connect). The next time a user presses the button, One shows them the new tools as "{your app} needs one more connection", with what they already granted pre-selected. Authorizing replaces the user's grant and old tokens at once, and the callback saves the new pair.
353
-
354
- ## What your users see
355
-
356
- In their One dashboard the app appears under **Access control**, with what they granted and when it was last used. They can lower a level, remove an account, or revoke the app at any time. Your next call reflects it: a `401` means reconnect, a `403` means outside the grant.
357
-
358
- In *your* dashboard the app lists every user who said yes, what each one granted, and lets you revoke a user.
359
-
360
- ## Security notes
361
-
362
- - The client secret is used on your server only, for the code exchange and refresh, over HTTP Basic.
363
- - The authorization code is single use and expires ten minutes after consent.
364
- - Refresh tokens rotate on every use. Reusing an old one revokes the whole family, which is why the client refreshes one user at a time: inside a process on its own, and across processes through `tokenStore.withLock`.
365
- - The browser half of this package never sees a token. It navigates and reads one query parameter.
366
-
367
- ## Development
368
-
369
- ```bash
370
- npm run check # typecheck, tests, build
371
- ```
156
+ To ask for more tools later, edit your app's tools in the dashboard. Users see only the new ones the next time they connect.
372
157
 
373
158
  ## License
374
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
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",
@@ -6,51 +6,40 @@ description: Add One Connect to an application so its users can grant the app sc
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
+ stores the tokens, 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
+ Secrets and tokens 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`. |
38
-
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.
29
+ | `ONE_CLIENT_ID` | From the app. |
30
+ | `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
31
+ | Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
32
+ | `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
33
+ | `ONE_API_URL` | Optional. Production when unset; `https://development-api.withone.ai` for the development dashboard. |
45
34
 
46
35
  ## 2 - Environment (server only)
47
36
 
48
37
  ```bash
49
38
  ONE_CLIENT_ID=...
50
39
  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
40
+ ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
41
+ ONE_PERMISSION_SET=... # optional
42
+ ONE_API_URL=... # optional
54
43
  ```
55
44
 
56
45
  ## 3 - Install and create the client
@@ -70,30 +59,22 @@ export const oneConnect = createOneConnect({
70
59
  permissionSet: process.env.ONE_PERMISSION_SET,
71
60
  oneApiUrl: process.env.ONE_API_URL,
72
61
  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 */,
62
+ saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
63
+ loadTokens: (userId) => /* read; null when never connected */,
64
+ clearTokens: (userId) => /* delete */,
78
65
  },
79
66
  });
80
67
  ```
81
68
 
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.
69
+ Use the app's own user id as the key and the database it already has.
85
70
 
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.
71
+ If the app runs more than one server process or a background worker, also
72
+ add `withLock: (userId, run) => ...`, which runs `run()` while holding a
73
+ per-user lock all processes share (for example a Postgres advisory lock).
93
74
 
94
75
  ## 4 - The two routes
95
76
 
96
- Next.js App Router (also Remix, SvelteKit, Hono, Bun: anything with the web `Request`):
77
+ Next.js App Router (also Remix, SvelteKit, Hono, Bun):
97
78
 
98
79
  ```ts
99
80
  // app/api/one/[action]/route.ts
@@ -101,87 +82,44 @@ import { createOneConnectRoutes } from "@withone/connect/next";
101
82
  import { oneConnect } from "@/lib/one";
102
83
 
103
84
  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
107
- });
108
- ```
109
-
110
- That file serves `/api/one/authorize` and `/api/one/callback`.
111
-
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 */,
85
+ identifyUser: async (request) => /* the signed-in user's id, or null */,
86
+ loginHintFor: async (request) => /* their email, optional */,
87
+ signInUrl: "/login",
120
88
  });
121
- app.get("/api/one/authorize", authorize);
122
- app.get("/api/one/callback", callback);
123
89
  ```
124
90
 
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.
91
+ Express or plain Node: `createOneConnectHandlers(oneConnect, { identifyUser })`
92
+ from `@withone/connect/node`, mounted at `/api/one/authorize` and
93
+ `/api/one/callback`.
132
94
 
133
95
  ## 5 - The button
134
96
 
135
97
  ```tsx
136
- import { ConnectButton } from "@withone/connect/react"; // "use client" bundle: fine in a Server Component
98
+ import { ConnectButton } from "@withone/connect/react";
137
99
 
138
100
  <ConnectButton
139
101
  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 */ }}
102
+ platforms={["gmail", "stripe"]} // connector slugs
103
+ connected={hasGrant} // from the server: await oneConnect.isConnected(userId)
104
+ onSuccess={() => { /* refetch app state */ }}
105
+ onError={(message) => { /* show message */ }}
144
106
  />
145
107
  ```
146
108
 
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.
154
-
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`.
164
-
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.
109
+ Optional props: `variant` ("default" | "accent" | "block"), `accentColor`,
110
+ `size` ("sm" | "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
111
+ `label`, `description`, `disabled`.
173
112
 
174
- ## 6 - Calling One with the grant
113
+ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
114
+ `@withone/connect/svelte`. Anything else: `import "@withone/connect"` and use
115
+ `<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
116
+ A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
175
117
 
176
- Everything runs on the server through the client. Tokens refresh themselves.
118
+ ## 6 - Calling One with the grant
177
119
 
178
120
  ```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
121
+ const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
122
+ const actions = await oneConnect.listActions(userId, "gmail"); // [{ _id, title, method, path }]
185
123
 
186
124
  const reply = await oneConnect.runAction(userId, {
187
125
  connectionKey: connection.key,
@@ -190,41 +128,26 @@ const reply = await oneConnect.runAction(userId, {
190
128
  path: action.path,
191
129
  body: payload,
192
130
  });
193
- // { status, ok, blockedByGrant, data }
131
+ // { status, ok, data }
194
132
  ```
195
133
 
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.
134
+ A `403` means the call is outside what the user granted. Do not retry it.
135
+ A `refresh_failed` error means the grant ended; ask the user to connect again.
209
136
 
210
137
  ## 7 - Rules
211
138
 
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.
139
+ - Never put the client secret in browser code, logs or error reports.
140
+ - Store tokens encrypted, keyed by the app's user.
141
+ - The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
142
+ - Do not build a completion page; the callback redirect is the completion.
143
+ - Do not write OAuth steps by hand; use the package.
144
+
145
+ ## 8 - Done when
146
+
147
+ 1. The button leads to One's page; after signing in and authorizing, the user
148
+ lands back in the app and `onSuccess` fires.
149
+ 2. `listConnections` returns only the granted connections.
150
+ 3. `runAction` works for an action inside the grant and returns `403` for
151
+ one outside it.
152
+ 4. After the user revokes the app in their One dashboard, the app asks them
153
+ to connect again.