@withone/connect 0.8.2 → 0.9.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.
Files changed (55) hide show
  1. package/README.md +158 -370
  2. package/dist/button.d.ts +9 -10
  3. package/dist/constants.d.ts +8 -4
  4. package/dist/index.cjs.js +1 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.esm.js +1 -1
  7. package/dist/next.cjs.js +104 -0
  8. package/dist/next.d.ts +36 -0
  9. package/dist/next.esm.js +98 -0
  10. package/dist/node.cjs.js +69 -0
  11. package/dist/node.d.ts +33 -0
  12. package/dist/node.esm.js +67 -0
  13. package/dist/platforms.d.ts +21 -0
  14. package/dist/react.cjs.js +1 -1
  15. package/dist/react.d.ts +2 -19
  16. package/dist/react.esm.js +1 -1
  17. package/dist/return.d.ts +11 -0
  18. package/dist/server/index.cjs.js +349 -0
  19. package/dist/server/index.d.ts +32 -0
  20. package/dist/server/index.esm.js +344 -0
  21. package/dist/server/oauth.d.ts +24 -0
  22. package/dist/server/types.d.ts +136 -0
  23. package/dist/svelte.cjs.js +1 -1
  24. package/dist/svelte.d.ts +14 -4
  25. package/dist/svelte.esm.js +1 -1
  26. package/dist/types.d.ts +80 -0
  27. package/dist/useOneConnect.d.ts +13 -2
  28. package/dist/vue.cjs.js +1 -1
  29. package/dist/vue.d.ts +20 -18
  30. package/dist/vue.esm.js +1 -1
  31. package/dist/wrapper-options.d.ts +20 -15
  32. package/package.json +37 -15
  33. package/skills/one-connect/SKILL.md +194 -0
  34. package/src/button.ts +106 -137
  35. package/src/constants.ts +10 -4
  36. package/src/index.ts +17 -2
  37. package/src/next.ts +133 -0
  38. package/src/node.ts +107 -0
  39. package/src/platforms.ts +89 -0
  40. package/src/react.ts +23 -66
  41. package/src/return.ts +30 -0
  42. package/src/server/index.ts +382 -0
  43. package/src/server/oauth.ts +98 -0
  44. package/src/server/types.ts +155 -0
  45. package/src/svelte.ts +15 -15
  46. package/src/types.ts +87 -0
  47. package/src/useOneConnect.ts +36 -69
  48. package/src/vue.ts +23 -29
  49. package/src/wrapper-options.ts +46 -23
  50. package/dist/index.umd.js +0 -1
  51. package/dist/types/index.d.ts +0 -92
  52. package/src/react-types.d.ts +0 -23
  53. package/src/svelte-types.d.ts +0 -27
  54. package/src/types/index.d.ts +0 -92
  55. package/src/vue-types.d.ts +0 -16
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <img src="https://assets.withone.ai/banners/connect.png" alt="One Connect — Let your users grant your app scoped, revocable access to their own tools." style="border-radius: 5px;">
1
+ <img src="https://assets.withone.ai/banners/connect.png" alt="One Connect. Let your users grant your app scoped, revocable access to their own tools." style="border-radius: 5px;">
2
2
 
3
3
  <h3 align="center">One Connect</h3>
4
4
 
@@ -7,7 +7,7 @@
7
7
  &nbsp;·&nbsp;
8
8
  <a href="https://withone.ai/docs/connect"><strong>Docs</strong></a>
9
9
  &nbsp;·&nbsp;
10
- <a href="https://app.withone.ai"><strong>Dashboard</strong></a>
10
+ <a href="https://app.withone.ai/developers/connect"><strong>Dashboard</strong></a>
11
11
  &nbsp;·&nbsp;
12
12
  <a href="https://withone.ai/changelog"><strong>Changelog</strong></a>
13
13
  &nbsp;·&nbsp;
@@ -20,157 +20,77 @@
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 application **scoped, revocable access to their own One-connected tools** — Gmail, Slack, Notion, Stripe and 500+ more — through the standard OAuth 2.1 authorization-code flow with PKCE.
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.
24
24
 
25
- Your user owns their connections inside One. You never see their Gmail password — you hold a One access token scoped to **exactly what they granted**, and they can narrow or revoke it at any time. One enforces the grant on every call.
25
+ You build three things: a button, two backend routes, and the calls you make with the grant. This package gives you all three.
26
26
 
27
- Fully compatible with popular frameworks such as React, Next.js, Vue, Svelte, and more.
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
31
+ 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)
36
+ ```
28
37
 
29
- > **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 own One account** and hands you a scoped grant.
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.
30
39
 
31
40
  ## Install
32
41
 
33
- With npm:
34
-
35
42
  ```bash
36
- npm i @withone/connect
43
+ npm install @withone/connect
37
44
  ```
38
45
 
39
- With yarn:
46
+ Or let your coding agent do the whole setup:
40
47
 
41
48
  ```bash
42
- yarn add @withone/connect
49
+ npx skills add withoneai/connect
43
50
  ```
44
51
 
45
- ## How it works
46
-
47
- Everything sensitive — `state`, the PKCE verifier, your client secret, the tokens — lives on **your server**. The SDK is a thin navigator: it sends the tab to One's hosted connect page and never touches a token.
48
-
49
- You build exactly **two backend routes and one button**.
50
-
51
- ```mermaid
52
- sequenceDiagram
53
- participant User
54
- participant YourApp as Your Application
55
- participant YourBackend as Your Backend
56
- participant One as One Connect
57
-
58
- User->>YourApp: Clicks "Connect your tools"
59
- YourApp->>YourBackend: Navigate tab → GET /api/one/authorize
60
- YourBackend->>YourBackend: Mint state + PKCE, set httpOnly cookie
61
- YourBackend->>One: 302 → /oauth/authorize
62
- User->>One: Sign in, connect tools, narrow & grant access
63
- One->>YourBackend: 302 → /api/one/callback?code&state
64
- YourBackend->>YourBackend: Verify state
65
- YourBackend->>One: POST /oauth/token (code + verifier + secret)
66
- One->>YourBackend: Access token + refresh token
67
- YourBackend->>YourApp: 302 → /?one_connect=success
68
- YourApp->>User: SDK detects the return, onSuccess() fires
69
- ```
52
+ ## 1 · Create your app in One
70
53
 
71
- The SDK watches for `?one_connect=` on the page URL when the tab returns — so **there is no completion page to build**.
54
+ Dashboard → **Developers → Connect → New app**.
72
55
 
73
- ## 1 · Create your OAuth app
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.
74
60
 
75
- Dashboard → **Settings → OAuth Apps → New OAuth app**.
76
-
77
- - You get a **Client ID** (public) and a **Client Secret** (shown once — server-only, never in a browser).
78
- - Register your **redirect URI** (e.g. `https://yourapp.com/api/one/callback`).
79
- - Pick the **access-token lifetime**: 7 days, 30 days, 90 days or 1 year.
80
- - Optionally create a **permission set** — the connectors your app needs and the access level for each (full / read & write / read only / specific actions). Your user sees it pre-filled at consent and can only *narrow* it. Without one, your app asks for access to the user's connections generally, which they can also narrow.
81
-
82
- ### Environment Variables
61
+ Environment variables, server side:
83
62
 
84
63
  ```env
85
- ONE_CLIENT_ID=...
86
- ONE_CLIENT_SECRET=one_secret_...
64
+ ONE_CLIENT_ID=…
65
+ ONE_CLIENT_SECRET=one_secret_…
87
66
  ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
88
- ONE_PERMISSION_SET=79659c66-...
67
+ ONE_PERMISSION_SET=… # optional: the ask to open on
68
+ ONE_API_URL=https://api.withone.ai # optional: production when unset
89
69
  ```
90
70
 
91
- | Variable | Required | Description |
71
+ | Variable | Required | What it is |
92
72
  |---|---|---|
93
- | `ONE_CLIENT_ID` | Yes | Public client ID from your OAuth app |
94
- | `ONE_CLIENT_SECRET` | Yes | Server-only secret. Never ship to a browser. |
95
- | `ONE_REDIRECT_URI` | Yes | Must exactly match the URI registered in the dashboard |
96
- | `ONE_PERMISSION_SET` | No | Pre-fills the consent screen with the access your app needs |
97
-
98
- ## 2 · Using the Connect component
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 |
99
78
 
100
- Replace the `authorize URL` with your backend authorize endpoint.
79
+ ## 2 · The button
101
80
 
102
- > Relative paths like `/api/one/authorize` work — the SDK resolves them against your page's origin. A full URL is only required if the authorize route lives on a different origin than the page.
81
+ Any element wired to `open()` works. The pre-built button draws connector logos from their slugs, and manages Connect → Connecting → Connected on its own.
103
82
 
104
83
  ```tsx
105
- "use client";
106
-
107
- import { useOneConnect } from "@withone/connect";
108
-
109
- export function ConnectWithOne() {
110
- const { open } = useOneConnect({
111
- authorize: {
112
- url: "https://your-domain.com/api/one/authorize",
113
- },
114
- appTheme: "light",
115
- onSuccess: () => {
116
- // Your backend already stored the tokens by the time this fires.
117
- console.log("Access granted");
118
- },
119
- onError: (error) => {
120
- console.error("Connect failed:", error);
121
- },
122
- onClose: () => {
123
- console.log("Connect flow closed");
124
- },
125
- });
126
-
127
- return <button onClick={open}>Connect your tools</button>;
128
- }
129
- ```
130
-
131
- ### Configuration Options
132
-
133
- | Option | Type | Description |
134
- |---|---|---|
135
- | `authorize.url` | `string` | Full URL of your backend authorize endpoint. Must be absolute. |
136
- | `appTheme` | `"dark" \| "light"` | Theme for the Connect card. The SDK carries it on the URL fragment — nothing for your backend to forward. |
137
- | `onSuccess` | `() => void` | The grant completed and your server stored the tokens |
138
- | `onError` | `(error: string) => void` | The flow failed, with a human-readable message |
139
- | `onClose` | `() => void` | The user closed the card without a result |
140
-
141
- ### Returned handle
142
-
143
- | Method | Description |
144
- |---|---|
145
- | `open()` | Navigates the tab to One's hosted connect flow |
146
- | `close()` | No-op kept for API stability — safe to call on unmount |
147
-
148
-
149
- ### Optional: the pre-built button
150
-
151
- Any element wired to `open()` works — the button is **optional**. It
152
- ships as a custom element, `<one-connect-button>`, so the SAME tag
153
- works in React, Next, Vue, Svelte, or plain HTML — no refs, no mount
154
- calls. Importing the package registers it. It wires the whole flow
155
- itself and manages Connect → Connecting → Connected.
156
-
157
- ```tsx
158
- // React / Next — a real component:
84
+ // React / Next.js
159
85
  import { ConnectButton } from "@withone/connect/react";
160
86
 
161
- export function ConnectWithOne() {
162
- return (
163
- <ConnectButton
164
- authorizeUrl="/api/one/authorize"
165
- label="Connect your apps"
166
- platforms={[
167
- { name: "Stripe", imageUrl: "/icons/stripe.svg" },
168
- { name: "PostHog", imageUrl: "/icons/posthog.svg" },
169
- ]}
170
- onSuccess={() => {/* tokens stored server-side — refresh app state */}}
171
- />
172
- );
173
- }
87
+ <ConnectButton
88
+ authorizeUrl="/api/one/authorize"
89
+ platforms={["stripe", "google-calendar", "gmail"]}
90
+ moreCount={274}
91
+ onSuccess={() => refreshAppState()}
92
+ onError={(message) => showBanner(message)}
93
+ />
174
94
  ```
175
95
 
176
96
  ```vue
@@ -179,293 +99,161 @@ export function ConnectWithOne() {
179
99
  import { ConnectButton } from "@withone/connect/vue";
180
100
  </script>
181
101
  <template>
182
- <ConnectButton
183
- authorize-url="/api/one/authorize"
184
- :platforms="[{ name: 'Stripe', imageUrl: '/icons/stripe.svg' }]"
185
- @success="onConnected"
186
- />
102
+ <ConnectButton authorize-url="/api/one/authorize" :platforms="['stripe', 'notion']" @success="onConnected" />
187
103
  </template>
188
104
  ```
189
105
 
190
106
  ```svelte
191
- <!-- Svelte (an action — the idiomatic Svelte shape) -->
107
+ <!-- Svelte, as an action -->
192
108
  <script>
193
109
  import { connectButton } from "@withone/connect/svelte";
194
110
  </script>
195
- <div use:connectButton={{
196
- authorizeUrl: "/api/one/authorize",
197
- platforms: [{ name: "Stripe", imageUrl: "/icons/stripe.svg" }],
198
- onSuccess: () => { /* refresh app state */ },
199
- }} />
111
+ <div use:connectButton={{ authorizeUrl: "/api/one/authorize", platforms: ["stripe", "notion"], onSuccess }} />
200
112
  ```
201
113
 
202
- Plain HTML (or any other framework) uses the SAME widget as a custom
203
- element — `import "@withone/connect"` registers `<one-connect-button>`:
204
-
205
114
  ```html
206
- <!-- Plain HTML / any framework -->
207
- <one-connect-button
208
- authorize-url="/api/one/authorize"
209
- label="Connect your apps"
210
- ></one-connect-button>
211
- <script>
212
- document.querySelector("one-connect-button")
213
- .addEventListener("success", () => location.reload());
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" more-count="274"></one-connect-button>
117
+ <script type="module">
118
+ import "@withone/connect";
119
+ document.querySelector("one-connect-button").addEventListener("success", () => location.reload());
214
120
  </script>
215
121
  ```
216
122
 
217
- | Attribute | What it does |
123
+ | Prop (attribute) | What it does |
218
124
  |---|---|
219
- | `authorize-url` | Your backend authorize route (required) |
220
- | `label` | Button text (default "Connect your apps") |
221
- | `variant` | `default` pill · `accent` brand pill · `block` consent card |
222
- | `theme` | `light` / `dark` — matches YOUR page |
223
- | `app-theme` | Theme for One's card |
224
- | `platforms` | JSON array of `{name, imageUrl}` — provider chips, fan on hover |
225
- | `more-count` | The `+N` chip (e.g. `274`) |
125
+ | `authorizeUrl` (`authorize-url`) | Your authorize route. Relative paths resolve against the page. Required. |
126
+ | `platforms` | Connector slugs: `["stripe", "google-calendar"]`. Logos and names come from One. To override either, pass `{ slug, name, imageUrl }`. As an attribute: `"stripe, notion"`. |
127
+ | `moreCount` (`more-count`) | The `+N` chip after the first three logos |
128
+ | `label` | Button text. Default "Connect your apps". |
129
+ | `connectedLabel` (`connected-label`) | Text after a successful return. Default "Connected". |
130
+ | `variant` | `default` pill · `accent` brand-colored pill · `block` card with a description |
226
131
  | `description` | Sub-line on the `block` variant |
227
- | `accent-color` | Fill for the `accent` variant (lime fallback) |
228
- | `connected-label` | Label after success |
132
+ | `theme` | `light` or `dark`, matching *your* page |
133
+ | `appTheme` (`app-theme`) | `light` or `dark` for One's page |
134
+ | `accentColor` (`accent-color`) | Fill of the `accent` variant. One's lime when omitted. |
135
+ | `onSuccess` / `onError` | Fire once when the tab returns. `onError` receives a message safe to show. The element also dispatches `success` and `error` events. |
229
136
 
230
- Events: `success`, `error` (detail = message), `close` — or set the
231
- `onSuccess` / `onError` / `onClose` function props (React 19, Vue and
232
- Svelte set these naturally).
233
-
234
- TypeScript + React: add this once so JSX accepts the tag:
137
+ Your own element:
235
138
 
236
139
  ```ts
237
- // one-connect-button.d.ts
238
- declare module "react" {
239
- namespace JSX {
240
- interface IntrinsicElements {
241
- "one-connect-button": React.DetailedHTMLProps<
242
- React.HTMLAttributes<HTMLElement>,
243
- HTMLElement
244
- > & {
245
- "authorize-url"?: string; "app-theme"?: string; label?: string;
246
- variant?: string; theme?: string; platforms?: string;
247
- "more-count"?: string; description?: string;
248
- "accent-color"?: string; "connected-label"?: string;
249
- onSuccess?: () => void; onError?: (e: string) => void;
250
- onClose?: () => void;
251
- };
252
- }
253
- }
254
- }
255
- export {};
256
- ```
140
+ import { useOneConnect } from "@withone/connect";
257
141
 
258
- Programmatic alternative: `mountConnectButton(container, options)`
259
- takes the same options as an object (plus `connect: {…useOneConnect
260
- props}`) and returns `{ setState, destroy }`.
261
-
262
- ## 3 · Backend — the authorize route
263
-
264
- Generates `state` (CSRF proof) and PKCE (proof that whoever redeems the code is this server), stashes both in an httpOnly cookie, and 302s the browser to One.
265
-
266
- ```typescript
267
- // app/api/one/authorize/route.ts (Next.js App Router)
268
- import { createHash, randomBytes } from "crypto";
269
- import { NextRequest, NextResponse } from "next/server";
270
-
271
- const ONE_AUTHORIZE_URL = "https://api.withone.ai/oauth/authorize";
272
-
273
- export async function GET(req: NextRequest) {
274
- const state = randomBytes(16).toString("hex");
275
- const verifier = randomBytes(32).toString("base64url");
276
- const challenge = createHash("sha256").update(verifier).digest("base64url");
277
-
278
- const url = new URL(ONE_AUTHORIZE_URL);
279
- url.searchParams.set("client_id", process.env.ONE_CLIENT_ID!);
280
- url.searchParams.set("redirect_uri", process.env.ONE_REDIRECT_URI!);
281
- url.searchParams.set("response_type", "code");
282
- url.searchParams.set("scope", "user:connections:read user:connections:write org:connections:read org:connections:write project:connections:read project:connections:write"); // all 3 tenancy tiers — org/project grants 403 without theirs
283
- url.searchParams.set("state", state);
284
- url.searchParams.set("code_challenge", challenge);
285
- url.searchParams.set("code_challenge_method", "S256");
286
-
287
- if (process.env.ONE_PERMISSION_SET) {
288
- url.searchParams.set("permission_set", process.env.ONE_PERMISSION_SET);
289
- }
290
-
291
- // Optional: your user's email. One pre-fills (never locks) their sign-in.
292
- const userEmail = await getCurrentUserEmail(req); // ← your code
293
- if (userEmail) url.searchParams.set("login_hint", userEmail);
294
-
295
- const res = NextResponse.redirect(url.toString(), 302);
296
- // One cookie PER flow — the name carries the state. Users open the
297
- // flow more than once (retries, second tabs); a single shared cookie
298
- // would be overwritten by each start, so only the LAST-opened flow
299
- // could ever complete. Expiry reaps the strays.
300
- res.cookies.set(`one_tx_${state}`, verifier, {
301
- httpOnly: true,
302
- secure: true,
303
- // The callback is a TOP-LEVEL navigation on your own site, so Lax
304
- // survives the cross-site redirect chain (One -> here).
305
- sameSite: "lax",
306
- maxAge: 600, // matches One's 10-minute authorization-code lifetime
307
- path: "/api/one",
308
- });
309
- return res;
310
- }
142
+ const { open } = useOneConnect({
143
+ authorizeUrl: "/api/one/authorize",
144
+ onSuccess: () => {},
145
+ onError: (message) => {},
146
+ });
147
+ button.addEventListener("click", open);
311
148
  ```
312
149
 
313
- ## 4 · Backend — the callback route
314
-
315
- One redirects back with a **single-use code**, worthless without your secret and the PKCE verifier. Exchange it server-side, store the tokens, then redirect anywhere on your site with `?one_connect=success` appended — the SDK reads that parameter off the page URL on return.
316
-
317
- ```typescript
318
- // app/api/one/callback/route.ts
319
- import { NextRequest, NextResponse } from "next/server";
320
-
321
- const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
322
-
323
- export async function GET(req: NextRequest) {
324
- const code = req.nextUrl.searchParams.get("code");
325
- const state = req.nextUrl.searchParams.get("state");
326
- // The state that came back selects its own cookie — not finding one
327
- // IS the CSRF failure (forged or stale state has no cookie).
328
- const verifier = state ? req.cookies.get(`one_tx_${state}`)?.value : undefined;
329
-
330
- if (!code || !state || !verifier) {
331
- return NextResponse.redirect(
332
- new URL(
333
- "/?one_connect=error&one_connect_message=" +
334
- encodeURIComponent("The sign-in attempt expired or was tampered with."),
335
- req.url,
336
- ),
337
- 302,
338
- );
339
- }
340
-
341
- const basic = Buffer.from(
342
- `${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
343
- ).toString("base64");
344
-
345
- const tokenRes = await fetch(ONE_TOKEN_URL, {
346
- method: "POST",
347
- headers: {
348
- Authorization: `Basic ${basic}`,
349
- "Content-Type": "application/x-www-form-urlencoded",
350
- },
351
- body: new URLSearchParams({
352
- grant_type: "authorization_code",
353
- code,
354
- redirect_uri: process.env.ONE_REDIRECT_URI!,
355
- code_verifier: verifier,
356
- }),
357
- });
358
-
359
- const res = NextResponse.redirect(
360
- new URL(
361
- tokenRes.ok
362
- ? "/?one_connect=success"
363
- : "/?one_connect=error&one_connect_message=" +
364
- encodeURIComponent("Token exchange failed."),
365
- req.url,
366
- ),
367
- 302,
368
- );
369
- res.cookies.delete(`one_tx_${state}`);
370
-
371
- if (tokenRes.ok) {
372
- // { access_token, refresh_token, token_type: "bearer", expires_in, scope }
373
- const tokens = await tokenRes.json();
374
- await saveOneTokens(req, { // ← your code
375
- accessToken: tokens.access_token,
376
- refreshToken: tokens.refresh_token,
377
- expiresAt: Date.now() + tokens.expires_in * 1000,
378
- });
379
- }
380
- return res;
381
- }
382
- ```
150
+ 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_message=…`); the SDK reads that on load, fires your callback once, and removes the params from the address bar.
151
+
152
+ ## 3 · The two routes, as one import
383
153
 
384
- **Token response (200):**
154
+ Create the client once, on the server:
385
155
 
386
- ```json
387
- {
388
- "access_token": "one_at_...",
389
- "refresh_token": "one_rt_...",
390
- "token_type": "bearer",
391
- "expires_in": 2592000,
392
- "scope": "user:connections:read user:connections:write"
393
- }
156
+ ```ts
157
+ // lib/one.ts
158
+ import { createOneConnect } from "@withone/connect/server";
159
+
160
+ export const oneConnect = createOneConnect({
161
+ clientId: process.env.ONE_CLIENT_ID!,
162
+ clientSecret: process.env.ONE_CLIENT_SECRET!,
163
+ redirectUri: process.env.ONE_REDIRECT_URI!,
164
+ permissionSet: process.env.ONE_PERMISSION_SET,
165
+ oneApiUrl: process.env.ONE_API_URL,
166
+ tokenStore: {
167
+ // Your database, keyed by your own user id. Store them encrypted.
168
+ saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
169
+ loadTokens: (userId) => db.oneTokens.find(userId),
170
+ clearTokens: (userId) => db.oneTokens.delete(userId),
171
+ },
172
+ });
394
173
  ```
395
174
 
396
- ## 5 · Backend — refreshing the token
397
-
398
- Access tokens last as long as you chose when creating the app (7 days to 1 year). Refresh tokens last **30 days** and are **rotated on every use** — always store BOTH new tokens. Reusing an old refresh token revokes the entire token family (theft protection).
399
-
400
- ```typescript
401
- const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
402
-
403
- export async function getOneAccessToken(userId: string): Promise<string> {
404
- const t = await loadOneTokens(userId); // ← your code
405
- if (Date.now() < t.expiresAt - 60_000) return t.accessToken;
406
-
407
- // The refresh exchange is authenticated exactly like the code exchange —
408
- // same Basic header. The public-client form (client_id in the body, no
409
- // secret) is rejected with 401.
410
- const basic = Buffer.from(
411
- `${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
412
- ).toString("base64");
413
-
414
- const res = await fetch(ONE_TOKEN_URL, {
415
- method: "POST",
416
- headers: {
417
- Authorization: `Basic ${basic}`,
418
- "Content-Type": "application/x-www-form-urlencoded",
419
- },
420
- body: new URLSearchParams({
421
- grant_type: "refresh_token",
422
- refresh_token: t.refreshToken,
423
- }),
424
- });
425
- if (!res.ok) throw new Error("One refresh failed — re-run the connect flow");
426
-
427
- const tokens = await res.json();
428
- await saveOneTokens(userId, { // BOTH tokens — rotation!
429
- accessToken: tokens.access_token,
430
- refreshToken: tokens.refresh_token,
431
- expiresAt: Date.now() + tokens.expires_in * 1000,
432
- });
433
- return tokens.access_token;
434
- }
175
+ Then mount the routes for your server.
176
+
177
+ **Next.js (App Router), Remix, SvelteKit, Hono, Bun** and anything else that speaks the web `Request`:
178
+
179
+ ```ts
180
+ // app/api/one/[action]/route.ts
181
+ import { createOneConnectRoutes } from "@withone/connect/next";
182
+ import { oneConnect } from "@/lib/one";
183
+
184
+ export const { GET } = createOneConnectRoutes(oneConnect, {
185
+ identifyUser: async (request) => (await getSession(request))?.userId ?? null,
186
+ loginHintFor: async (request) => (await getSession(request))?.email ?? null,
187
+ signInUrl: "/login",
188
+ });
435
189
  ```
436
190
 
437
- ## 6 · Backend — using the grant
191
+ That one file serves `/api/one/authorize` and `/api/one/callback`.
438
192
 
439
- The bearer token works on One's standard `/v1` API — the same routes every other credential uses.
193
+ **Express, Fastify, Koa, plain Node:**
440
194
 
441
- ```typescript
442
- const token = await getOneAccessToken(userId);
195
+ ```ts
196
+ import { createOneConnectHandlers } from "@withone/connect/node";
197
+ import { oneConnect } from "./one";
443
198
 
444
- // Discover what the user granted. Ungranted connections are invisible,
445
- // not merely forbidden.
446
- const res = await fetch("https://api.withone.ai/v1/connections", {
447
- headers: { Authorization: `Bearer ${token}` },
199
+ const { authorize, callback } = createOneConnectHandlers(oneConnect, {
200
+ identifyUser: (request) => request.session?.userId ?? null,
448
201
  });
202
+ app.get("/api/one/authorize", authorize);
203
+ app.get("/api/one/callback", callback);
449
204
  ```
450
205
 
451
- Execute actions through `/v1/passthrough/*` with the same bearer. Every call is checked inside One against what the user granted — a call outside the grant returns `403`, and your code cannot override it. That is the point.
206
+ 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 all come back as `?one_connect=error` with a message you can show.
207
+
208
+ **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.
209
+
210
+ ## 4 · Using the grant
211
+
212
+ Everything runs on your server through the same client. Tokens are refreshed for you before they expire; a refresh One refuses clears the stored tokens and throws `OneConnectError` with code `refresh_failed`, which means "ask the user to connect again".
213
+
214
+ ```ts
215
+ // What the grant reaches, each connection with its access
216
+ const connections = await oneConnect.listConnections(userId);
217
+ // [{ key, platform, name, title, image, access: { policy: "full" | "methods" | "actions", … } }]
218
+
219
+ // What actions a platform has (what exists, not what is permitted)
220
+ const actions = await oneConnect.listActions(userId, "gmail");
221
+ // [{ _id, title, method, path }]
222
+
223
+ // Run one
224
+ const reply = await oneConnect.runAction(userId, {
225
+ connectionKey: connections[0].key,
226
+ actionId: actions[0]._id,
227
+ method: actions[0].method,
228
+ path: actions[0].path,
229
+ body: { … },
230
+ });
231
+ // { status, ok, blockedByGrant, data }
232
+ ```
452
233
 
453
- ## Completion
234
+ `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.
454
235
 
455
- There is no completion page to build: your callback's final redirect carries `?one_connect=success` on any same-origin URL. The SDK detects it, fires your callbacks, and scrubs the params from the address bar. The SDK paints no result UI of its own — One's hosted page already showed the success beat before redirecting back. If you want your own celebratory screen, just have the callback redirect there; append the `?one_connect=` params wherever the SDK is mounted.
236
+ Other calls on the client: `isConnected`, `getAccessToken`, `getTokens`, `refreshTokens`, `disconnect`.
456
237
 
457
238
  ## What your users see
458
239
 
459
- In their own One dashboard, your app appears under **Authorized apps** with what they granted and when it was last used. They can revoke it at any time — handle `401`/`403` by prompting them to reconnect.
240
+ 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.
460
241
 
461
- In *your* dashboard, your OAuth app lists every user who granted access, and you can revoke individual users too.
242
+ In *your* dashboard the app lists every user who said yes, what each one granted, and lets you revoke a user.
462
243
 
463
244
  ## Security notes
464
245
 
465
- - The client secret lives on your server only. Authenticate the token exchange with the `Authorization: Basic` header, as shown above.
466
- - The authorization code is single-use and expires in 10 minutes.
467
- - The SDK never handles tokens. It navigates the tab to One's hosted connect page and watches for the `?one_connect=` result on the way back — there is nothing sensitive in the browser to leak.
246
+ - The client secret is used on your server only, for the code exchange and refresh, over HTTP Basic.
247
+ - The authorization code is single use and expires ten minutes after consent.
248
+ - Refresh tokens rotate on every use. Reusing an old one revokes the whole family, which is why the client serialises refreshes per user.
249
+ - The browser half of this package never sees a token. It navigates and reads one query parameter.
250
+
251
+ ## Development
252
+
253
+ ```bash
254
+ npm run check # typecheck, tests, build
255
+ ```
468
256
 
469
257
  ## License
470
258
 
471
- This project is licensed under the GPL-3.0 license. See the [LICENSE](LICENSE) file for details.
259
+ GPL-3.0. See [LICENSE](LICENSE).