@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.
package/README.md CHANGED
@@ -20,355 +20,201 @@
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
+ saves one id for the user, 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.
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.
60
47
 
61
- Environment variables, server side:
48
+ Then, on the app's page, under **Credentials → Connect key**, create a key. It is shown once, and it is made for the environment your dashboard is on: switch the dashboard to Sandbox to create a Sandbox key.
62
49
 
63
50
  ```env
64
51
  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
52
+ ONE_CLIENT_SECRET=one_secret_… # server only
53
+ ONE_CONNECT_KEY=sk_live_… # server only: the app's connect key
54
+ ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
55
+ ONE_PERMISSION_SET=… # optional: the tools you ask for
56
+ ONE_API_URL=https://api.withone.ai # optional: production when unset
69
57
  ```
70
58
 
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
59
  ## 2 · The button
80
60
 
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
61
  ```tsx
84
- // React / Next.js: safe to import from a Server Component (the bundle is "use client")
85
62
  import { ConnectButton } from "@withone/connect/react";
86
63
 
87
64
  <ConnectButton
88
65
  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)}
66
+ platforms={["gmail", "google-calendar", "stripe"]}
67
+ connected={user.hasOneGrant} // from your server
68
+ onSuccess={() => refresh()}
69
+ onError={(message) => showError(message)}
93
70
  />
94
71
  ```
95
72
 
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
- ```
73
+ | Prop | What it does |
74
+ |---|---|
75
+ | `authorizeUrl` | Your authorize route. Required. |
76
+ | `platforms` | Connector slugs to show as logos. |
77
+ | `connected` | Whether the user already has a grant, from your server. |
78
+ | `variant` | `default`, `accent` (your brand colour via `accentColor`), or `block` (a card with a `description`). |
79
+ | `size` | `sm`, `md` or `lg`. |
80
+ | `fullWidth` | Fills its container. |
81
+ | `theme` | `light`, `dark` or `auto`. `connectTheme` sets One's page. |
82
+ | `label` | Button text. |
83
+ | `disabled` | Not clickable. |
84
+ | `onSuccess` / `onError` / `onCancel` | How the flow ended. |
85
+
86
+ Other frameworks take the same props:
105
87
 
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 }} />
88
+ ```vue
89
+ <ConnectButton authorize-url="/api/one/authorize" :platforms="['gmail']" @success="onConnected" /> <!-- @withone/connect/vue -->
112
90
  ```
113
91
 
114
92
  ```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>
93
+ <one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe"></one-connect-button>
94
+ <script type="module">import "@withone/connect";</script>
121
95
  ```
122
96
 
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.
97
+ Svelte: `use:connectButton={{ authorizeUrl, platforms }}` from `@withone/connect/svelte`.
124
98
 
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
- ```
99
+ Your own button in React: `const { open, status } = useOneConnect({ authorizeUrl: "/api/one/authorize" })`.
155
100
 
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.
101
+ To match your design, set `--one-connect-font` and `--one-connect-radius`, or style `::part(button)`.
157
102
 
158
- **Your own element.** In React, use the hook:
103
+ ## 3 · Choose a mode
159
104
 
160
- ```tsx
161
- import { useOneConnect } from "@withone/connect/react";
105
+ Your server holds a user's grant in one of two ways. The button, the two routes and every call are the same in both. **The mode is whichever credential you configure.**
162
106
 
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>
166
- ```
107
+ | | Key mode (default) | Token mode |
108
+ |---|---|---|
109
+ | Your server holds | one connect key for the app | nothing app-wide |
110
+ | You store per user | one id, saved once | an access token and a refresh token |
111
+ | Expires | nothing expires; One checks the consent on every call | the tokens expire, on the lifetime set on your app |
112
+ | Refreshing | none | the SDK does it; several servers need a shared lock |
113
+ | Pick it when | you call One from your own server. Start here. | you need a standard OAuth bearer token |
167
114
 
168
- Anywhere else, use `createConnectFlow`:
115
+ **Key mode**: pass the connect key and a place to save one value per user.
169
116
 
170
117
  ```ts
171
- import { createConnectFlow } from "@withone/connect";
118
+ // lib/one.ts
119
+ import { createOneConnect } from "@withone/connect/server";
172
120
 
173
- const flow = createConnectFlow({
174
- authorizeUrl: "/api/one/authorize",
175
- onSuccess: () => {},
176
- onError: (message, code) => {},
177
- onCancel: () => {},
121
+ export const oneConnect = createOneConnect({
122
+ clientId: process.env.ONE_CLIENT_ID!,
123
+ clientSecret: process.env.ONE_CLIENT_SECRET!,
124
+ redirectUri: process.env.ONE_REDIRECT_URI!,
125
+ permissionSet: process.env.ONE_PERMISSION_SET,
126
+ oneApiUrl: process.env.ONE_API_URL,
127
+ connectKey: process.env.ONE_CONNECT_KEY!, // ← this makes it key mode
128
+ userStore: {
129
+ saveUser: (userId, reference) => db.users.update(userId, { oneConnect: reference }),
130
+ loadUser: async (userId) => (await db.users.find(userId))?.oneConnect ?? null,
131
+ clearUser: (userId) => db.users.update(userId, { oneConnect: null }),
132
+ },
178
133
  });
179
- button.addEventListener("click", flow.open);
180
- // later: flow.destroy()
181
134
  ```
182
135
 
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.
136
+ `reference` is one short string: the user's permanent One id, plus the space they granted from. Save it in one column and hand it back. It is an identifier, not a secret, and it stays the same if the user disconnects and connects again.
184
137
 
185
- ## 3 · The two routes, as one import
186
-
187
- Create the client once, on the server:
138
+ **Token mode**: pass a token store instead.
188
139
 
189
140
  ```ts
190
- // lib/one.ts
191
- import { createOneConnect } from "@withone/connect/server";
192
-
193
141
  export const oneConnect = createOneConnect({
194
142
  clientId: process.env.ONE_CLIENT_ID!,
195
143
  clientSecret: process.env.ONE_CLIENT_SECRET!,
196
144
  redirectUri: process.env.ONE_REDIRECT_URI!,
197
145
  permissionSet: process.env.ONE_PERMISSION_SET,
198
146
  oneApiUrl: process.env.ONE_API_URL,
199
- tokenStore: {
200
- // Your database, keyed by your own user id. Store them encrypted.
147
+ tokenStore: { // ← this makes it token mode
201
148
  saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
202
149
  loadTokens: (userId) => db.oneTokens.find(userId),
203
150
  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
151
  },
207
152
  });
208
153
  ```
209
154
 
210
- Then mount the routes for your server.
155
+ To be explicit, add `mode: "key"` or `mode: "token"`. `oneConnect.mode` tells you which one is running. An app written before key mode existed passes only a `tokenStore`, so it keeps running in token mode with no change.
156
+
157
+ ## 4 · The two routes
211
158
 
212
- **Next.js (App Router), Remix, SvelteKit, Hono, Bun** and anything else that speaks the web `Request`:
159
+ The same file in both modes:
213
160
 
214
161
  ```ts
215
- // app/api/one/[action]/route.ts
162
+ // app/api/one/[action]/route.ts (Next.js; also Remix, SvelteKit, Hono, Bun)
216
163
  import { createOneConnectRoutes } from "@withone/connect/next";
217
164
  import { oneConnect } from "@/lib/one";
218
165
 
219
166
  export const { GET } = createOneConnectRoutes(oneConnect, {
220
167
  identifyUser: async (request) => (await getSession(request))?.userId ?? null,
221
- loginHintFor: async (request) => (await getSession(request))?.email ?? null,
222
- signInUrl: "/login",
223
168
  });
224
169
  ```
225
170
 
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
- ```
171
+ That file serves `/api/one/authorize` and `/api/one/callback`. For Express or plain Node, use `createOneConnectHandlers` from `@withone/connect/node`.
240
172
 
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.
173
+ ## 5 · Using the grant
242
174
 
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.
244
-
245
- ## 4 · Using the grant
246
-
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)).
175
+ The same calls in both modes:
248
176
 
249
177
  ```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", … } }]
253
-
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 }]
178
+ const connections = await oneConnect.listConnections(userId); // what the user granted
179
+ const actions = await oneConnect.listActions(userId, "gmail"); // what a platform can do
257
180
 
258
- // Run one
259
181
  const reply = await oneConnect.runAction(userId, {
260
182
  connectionKey: connections[0].key,
261
183
  actionId: actions[0]._id,
262
184
  method: actions[0].method,
263
185
  path: actions[0].path,
264
- body: { … },
265
186
  });
266
187
  // { status, ok, blockedByGrant, data }
267
188
  ```
268
189
 
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
- ```
323
-
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.
325
-
326
- **Refreshing ahead of time**
327
-
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
- ```
190
+ A `403` means the call is outside what the user granted. Don't retry it. In key mode `blockedByGrant` is `true` for those.
339
191
 
340
- **When a refresh fails**
192
+ Errors are `OneConnectError` with a `code`:
341
193
 
342
- | `OneConnectError.code` | What happened | Tokens | What to do |
194
+ | `code` | Mode | What it means | What to do |
343
195
  |---|---|---|---|
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.
196
+ | `not_connected` | both | Nothing is stored for this user. | Show the Connect button. |
197
+ | `reconnect_required` | key | One will not act for this user: they revoked access, or the app is deactivated. | Ask the user to connect again. Your stored value is kept. |
198
+ | `refresh_failed` | token | The grant ended (revoked or expired). The tokens were cleared. | Ask the user to connect again. |
199
+ | `request_failed` | both | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
353
200
 
354
- ## What your users see
201
+ ## 6 · Key mode notes
355
202
 
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.
203
+ - Keep the connect key on the server, in an environment variable. It is bound to your app and does nothing without a user's id.
204
+ - A key works in one environment. Use the Production key in production and the Sandbox key in sandbox; the wrong one refuses every user.
205
+ - `isConnected(userId)` says whether the user has connected before. One confirms the consent on each call, so a user who revoked is found by the next call throwing `reconnect_required`.
206
+ - `oneConnect.getConnectUserId(userId)` returns the user's One id (`cu_…`) if you want it for your own records.
207
+ - Lost or leaked a key? Create another on the app's page and delete the old one under **Developers → API keys**.
357
208
 
358
- In *your* dashboard the app lists every user who said yes, what each one granted, and lets you revoke a user.
209
+ ## 7 · Token mode notes
359
210
 
360
- ## Security notes
211
+ - Store the tokens in your database, encrypted, keyed by your user id. The SDK refreshes them for you.
212
+ - The access token lives as long as your app's **Token lifetime** says: 30 days unless you changed it under **Advanced** when creating or editing the app. The refresh token lives 30 days, and only a live refresh token can renew the pair.
213
+ - 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.
214
+ - Required: once a day, for each connected user, call `oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 })`. It renews both tokens when either is within 3 days of expiring. Without it, users have to connect again when the tokens run out. Keep the window shorter than your Token lifetime, or every run refreshes.
215
+ - `getAccessToken`, `getTokens`, `refreshTokens` and `refreshIfExpiring` exist in token mode only.
361
216
 
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
- ```
217
+ 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
218
 
373
219
  ## License
374
220
 
package/dist/next.d.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
  export interface OneConnectRoutesOptions {
19
19
  /** The app's own id for the signed-in user, or null when nobody is
20
20
  * signed in. The grant is stored under this id. */
@@ -33,4 +33,4 @@ export declare const CALLBACK_ROUTE = "callback";
33
33
  export declare function readCookie(request: Request, name: string): string | undefined;
34
34
  /** The last path segment names the leg: ".../authorize" or ".../callback". */
35
35
  export declare function routeFor(url: string): string;
36
- export declare function createOneConnectRoutes(oneConnect: OneConnect, options: OneConnectRoutesOptions): OneConnectRoutes;
36
+ export declare function createOneConnectRoutes(oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">, options: OneConnectRoutesOptions): OneConnectRoutes;
package/dist/node.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * app.get("/api/one/callback", callback);
16
16
  */
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
- import type { OneConnect } from "./server";
18
+ import type { OneConnectClient } from "./server";
19
19
  export interface OneConnectNodeOptions {
20
20
  /** The app's own id for the signed-in user, or null when nobody is
21
21
  * signed in. */
@@ -30,4 +30,4 @@ export interface OneConnectHandlers {
30
30
  authorize: NodeHandler;
31
31
  callback: NodeHandler;
32
32
  }
33
- export declare function createOneConnectHandlers(oneConnect: OneConnect, options: OneConnectNodeOptions): OneConnectHandlers;
33
+ export declare function createOneConnectHandlers(oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">, options: OneConnectNodeOptions): OneConnectHandlers;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The seam between the two ways an app can hold a user's grant.
3
+ *
4
+ * Everything around it is shared: the authorize leg, the callback, and
5
+ * the calls made with the grant. A credential answers only what differs:
6
+ * what to keep when a user connects, what to send on a call, and how to
7
+ * read One refusing it.
8
+ *
9
+ * - key mode (`./key`): the app's connect key plus a permanent id for
10
+ * the user. Nothing expires, so nothing is refreshed.
11
+ * - token mode (`./token`): an access token and a refresh token per
12
+ * user, rotated before they expire.
13
+ */
14
+ import type { OneConnectError } from "./types";
15
+ /** What One's token endpoint answers on success. `connect_user_id` is
16
+ * present whenever a durable grant backs the token. */
17
+ export interface TokenResponse {
18
+ access_token: string;
19
+ refresh_token: string;
20
+ expires_in: number;
21
+ connect_user_id?: string;
22
+ }
23
+ /** What One's token endpoint answered. A network failure throws instead. */
24
+ export type TokenAnswer = {
25
+ ok: true;
26
+ body: TokenResponse;
27
+ } | {
28
+ ok: false;
29
+ status: number;
30
+ error?: string;
31
+ };
32
+ export type PostToken = (body: URLSearchParams) => Promise<TokenAnswer>;
33
+ export interface Credential {
34
+ /** The callback exchanged the code: keep what this mode needs. */
35
+ connected: (userId: string, response: TokenResponse) => Promise<void>;
36
+ /** The headers that make a /v1 call act for this user. Throws
37
+ * `not_connected` when the user has nothing stored. */
38
+ headers: (userId: string) => Promise<Record<string, string>>;
39
+ isConnected: (userId: string) => Promise<boolean>;
40
+ /** Drops the app's copy. The user revokes the grant itself in One. */
41
+ disconnect: (userId: string) => Promise<void>;
42
+ /** One refused the credential itself, rather than the call it carried:
43
+ * the error to throw, or null when this answer is not that. */
44
+ refusal: (status: number, body: string) => OneConnectError | null;
45
+ }