@withone/connect 0.13.0 → 0.13.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
@@ -45,15 +45,14 @@ Using a coding agent? `npx skills add withoneai/connect` teaches it the whole se
45
45
 
46
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.
47
47
 
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.
48
+ Then, on the app's page, under **Credentials → Connect keys**, create a key with your dashboard on Production. It is shown once.
49
49
 
50
50
  ```env
51
51
  ONE_CLIENT_ID=…
52
52
  ONE_CLIENT_SECRET=one_secret_… # server only
53
- ONE_CONNECT_KEY=sk_live_… # server only: the app's connect key
53
+ ONE_CONNECT_KEY=sk_live_… # server only
54
54
  ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
55
55
  ONE_PERMISSION_SET=… # optional: the tools you ask for
56
- ONE_API_URL=https://api.withone.ai # optional: production when unset
57
56
  ```
58
57
 
59
58
  ## 2 · The button
@@ -100,19 +99,9 @@ Your own button in React: `const { open, status } = useOneConnect({ authorizeUrl
100
99
 
101
100
  To match your design, set `--one-connect-font` and `--one-connect-radius`, or style `::part(button)`.
102
101
 
103
- ## 3 · Choose a mode
102
+ ## 3 · The server client
104
103
 
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.**
106
-
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 |
114
-
115
- **Key mode**: pass the connect key and a place to save one value per user.
104
+ Your server holds one **connect key** for the app and saves one id per user who connects. Nothing expires and nothing is refreshed: One checks the user's consent on every call. This is **key mode**, the default.
116
105
 
117
106
  ```ts
118
107
  // lib/one.ts
@@ -123,8 +112,7 @@ export const oneConnect = createOneConnect({
123
112
  clientSecret: process.env.ONE_CLIENT_SECRET!,
124
113
  redirectUri: process.env.ONE_REDIRECT_URI!,
125
114
  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
115
+ connectKey: process.env.ONE_CONNECT_KEY!,
128
116
  userStore: {
129
117
  saveUser: (userId, reference) => db.users.update(userId, { oneConnect: reference }),
130
118
  loadUser: async (userId) => (await db.users.find(userId))?.oneConnect ?? null,
@@ -133,31 +121,10 @@ export const oneConnect = createOneConnect({
133
121
  });
134
122
  ```
135
123
 
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.
137
-
138
- **Token mode**: pass a token store instead.
139
-
140
- ```ts
141
- export const oneConnect = createOneConnect({
142
- clientId: process.env.ONE_CLIENT_ID!,
143
- clientSecret: process.env.ONE_CLIENT_SECRET!,
144
- redirectUri: process.env.ONE_REDIRECT_URI!,
145
- permissionSet: process.env.ONE_PERMISSION_SET,
146
- oneApiUrl: process.env.ONE_API_URL,
147
- tokenStore: { // ← this makes it token mode
148
- saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
149
- loadTokens: (userId) => db.oneTokens.find(userId),
150
- clearTokens: (userId) => db.oneTokens.delete(userId),
151
- },
152
- });
153
- ```
154
-
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.
124
+ `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 unchanged. It is an identifier, not a secret, and it stays the same if the user disconnects and connects again.
156
125
 
157
126
  ## 4 · The two routes
158
127
 
159
- The same file in both modes:
160
-
161
128
  ```ts
162
129
  // app/api/one/[action]/route.ts (Next.js; also Remix, SvelteKit, Hono, Bun)
163
130
  import { createOneConnectRoutes } from "@withone/connect/next";
@@ -172,8 +139,6 @@ That file serves `/api/one/authorize` and `/api/one/callback`. For Express or pl
172
139
 
173
140
  ## 5 · Using the grant
174
141
 
175
- The same calls in both modes:
176
-
177
142
  ```ts
178
143
  const connections = await oneConnect.listConnections(userId); // what the user granted
179
144
  const actions = await oneConnect.listActions(userId, "gmail"); // what a platform can do
@@ -187,34 +152,68 @@ const reply = await oneConnect.runAction(userId, {
187
152
  // { status, ok, blockedByGrant, data }
188
153
  ```
189
154
 
190
- A `403` means the call is outside what the user granted. Don't retry it. In key mode `blockedByGrant` is `true` for those.
155
+ You never set a header: the client adds the connect key and the user's id to every call it makes.
156
+
157
+ A `403` with `blockedByGrant: true` means the call is outside what the user granted. Don't retry it.
191
158
 
192
159
  Errors are `OneConnectError` with a `code`:
193
160
 
194
- | `code` | Mode | What it means | What to do |
195
- |---|---|---|---|
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. |
161
+ | `code` | What it means | What to do |
162
+ |---|---|---|
163
+ | `not_connected` | Nothing is stored for this user. | Show the Connect button. |
164
+ | `reconnect_required` | One will not act for this user: they revoked access, or the app is deactivated. Your stored value is kept. | Ask the user to connect again. |
165
+ | `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
200
166
 
201
167
  ## 6 · Key mode notes
202
168
 
203
169
  - 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.
170
+ - Connect keys work in Production only. Create the key with your dashboard on Production.
205
171
  - `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
172
  - `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**.
173
+ - Lost or leaked a key? Create another on the app's page, move your servers to it, then revoke the old one there.
174
+
175
+ 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.
176
+
177
+ ## 7 · Token mode
208
178
 
209
- ## 7 · Token mode notes
179
+ The other way to hold a grant: your server stores an access token and a refresh token per user, as a standard OAuth client. The button, the routes and the calls stay the same. Use it when you need bearer tokens, or when the app already has them; an app that passes only a `tokenStore` keeps running in token mode with no change.
210
180
 
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.
181
+ ```ts
182
+ export const oneConnect = createOneConnect({
183
+ clientId: process.env.ONE_CLIENT_ID!,
184
+ clientSecret: process.env.ONE_CLIENT_SECRET!,
185
+ redirectUri: process.env.ONE_REDIRECT_URI!,
186
+ permissionSet: process.env.ONE_PERMISSION_SET,
187
+ tokenStore: {
188
+ saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
189
+ loadTokens: (userId) => db.oneTokens.find(userId),
190
+ clearTokens: (userId) => db.oneTokens.delete(userId),
191
+ },
192
+ });
193
+ ```
194
+
195
+ - Store the tokens in your database, encrypted, keyed by your user id.
213
196
  - 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.
197
+ - When a user revokes access, the next refresh throws `refresh_failed` and the tokens are cleared. Ask them to connect again.
198
+ - A `403` means the call is outside what the user granted; `blockedByGrant` stays `false` in token mode.
216
199
 
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.
200
+ **Keeping users connected.** Tokens expire, and two things keep them fresh. One is automatic. The other is yours to run.
201
+
202
+ | | Who does it | When |
203
+ |---|---|---|
204
+ | Refresh before a call | the client, on its own | whenever you call One and the token is about to expire |
205
+ | Refresh for users who have not called in a while | **you**, with a daily job | once a day, for every connected user |
206
+
207
+ ```ts
208
+ // run once a day
209
+ for (const userId of await db.oneTokens.allUserIds()) {
210
+ await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
211
+ }
212
+ ```
213
+
214
+ The job renews both tokens when either is within 3 days of expiring. Without it, a user who stays away for 30 days has to connect again: the refresh token lives 30 days, and only a live refresh token can renew the pair. The access token lives as long as your app's **Token lifetime** says (30 days unless you changed it under **Advanced**); keep the job's window shorter than that, or every run refreshes.
215
+
216
+ The mode is whichever credential you pass: `connectKey` and `userStore` for key mode, `tokenStore` for token mode. `oneConnect.mode` tells you which one is running.
218
217
 
219
218
  ## License
220
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.13.0",
3
+ "version": "0.13.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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: one-connect
3
- description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, choosing key mode or token mode, and calling One with the grant.
3
+ description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, the connect key, and calling One with the grant.
4
4
  ---
5
5
 
6
6
  # One Connect
@@ -18,6 +18,11 @@ Browser Your backend One
18
18
  Later: oneConnect.runAction(userId, ...) -> One, grant enforced
19
19
  ```
20
20
 
21
+ Use **key mode**, described in sections 1 to 7: the app holds one connect
22
+ key and saves one id per user, and nothing is refreshed. Token mode
23
+ (section 9) is the other way to hold the grant; use it only when the human
24
+ asks for it, or when the app already passes a `tokenStore`.
25
+
21
26
  The client secret and the connect key stay on the server.
22
27
 
23
28
  ## 1 - Ask the human for these
@@ -28,10 +33,9 @@ They create the app in the One dashboard: Developers -> Connect -> New app.
28
33
  |---|---|
29
34
  | `ONE_CLIENT_ID` | From the app. |
30
35
  | `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
31
- | `ONE_CONNECT_KEY` | Key mode only. On the app's page: Credentials -> Connect key -> Create key. Shown once. It is made for the environment the dashboard is on (Production or Sandbox). |
36
+ | `ONE_CONNECT_KEY` | On the app's page: Credentials -> Connect keys -> Create key, with the dashboard on Production. Shown once. |
32
37
  | Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
33
38
  | `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
34
- | `ONE_API_URL` | Optional. Production when unset; `https://development-api.withone.ai` for the development dashboard. |
35
39
 
36
40
  Never ask the human to paste the secret or the connect key into the chat.
37
41
  Tell them which environment variable to set and read it from there.
@@ -41,51 +45,17 @@ Tell them which environment variable to set and read it from there.
41
45
  ```bash
42
46
  ONE_CLIENT_ID=...
43
47
  ONE_CLIENT_SECRET=one_secret_...
44
- ONE_CONNECT_KEY=sk_live_... # key mode
48
+ ONE_CONNECT_KEY=sk_live_...
45
49
  ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
46
50
  ONE_PERMISSION_SET=... # optional
47
- ONE_API_URL=... # optional
48
51
  ```
49
52
 
50
- ## 3 - Choose a mode
51
-
52
- The server holds a user's grant in one of two ways. The button, the two
53
- routes and every call are identical in both. The mode is whichever
54
- credential you pass to `createOneConnect`.
55
-
56
- | | Key mode (default) | Token mode |
57
- |---|---|---|
58
- | The server holds | one connect key for the app | nothing app-wide |
59
- | Stored per user | one string, saved once | an access token and a refresh token |
60
- | Expires | nothing; One checks the consent on every call | the tokens, on the lifetime set on the app |
61
- | Refreshing | none | the SDK does it; several servers need a shared lock |
62
-
63
- How to decide:
64
-
65
- - New integration: **key mode**.
66
- - The app already passes a `tokenStore`: it is in token mode. Leave it as it
67
- is unless the human asks to move.
68
- - The human asks for OAuth bearer tokens: token mode.
69
-
70
- ```ts
71
- // key mode
72
- createOneConnect({ ...app, connectKey: process.env.ONE_CONNECT_KEY!, userStore });
73
-
74
- // token mode
75
- createOneConnect({ ...app, tokenStore });
76
- ```
77
-
78
- `mode: "key"` or `mode: "token"` may be added to be explicit.
79
- `oneConnect.mode` reports which one is running.
80
-
81
- ## 4 - Install and create the client
53
+ ## 3 - Install and create the client
82
54
 
83
55
  ```bash
84
56
  npm install @withone/connect
85
57
  ```
86
58
 
87
- Key mode:
88
-
89
59
  ```ts
90
60
  // lib/one.ts (server only)
91
61
  import { createOneConnect } from "@withone/connect/server";
@@ -95,7 +65,6 @@ export const oneConnect = createOneConnect({
95
65
  clientSecret: process.env.ONE_CLIENT_SECRET!,
96
66
  redirectUri: process.env.ONE_REDIRECT_URI!,
97
67
  permissionSet: process.env.ONE_PERMISSION_SET,
98
- oneApiUrl: process.env.ONE_API_URL,
99
68
  connectKey: process.env.ONE_CONNECT_KEY!,
100
69
  userStore: {
101
70
  saveUser: (userId, reference) => /* save the string on the app's user row */,
@@ -105,43 +74,16 @@ export const oneConnect = createOneConnect({
105
74
  });
106
75
  ```
107
76
 
77
+ Use the app's own user id as the key and the database it already has.
78
+
108
79
  `reference` is one short string: the user's permanent One id and the space
109
80
  they granted from. One text column on the user row is enough. Store it as
110
81
  given and hand it back unchanged; do not parse or rebuild it. It is an
111
- identifier, not a secret, so it needs no encryption and no lock.
112
-
113
- Token mode:
114
-
115
- ```ts
116
- export const oneConnect = createOneConnect({
117
- clientId: process.env.ONE_CLIENT_ID!,
118
- clientSecret: process.env.ONE_CLIENT_SECRET!,
119
- redirectUri: process.env.ONE_REDIRECT_URI!,
120
- permissionSet: process.env.ONE_PERMISSION_SET,
121
- oneApiUrl: process.env.ONE_API_URL,
122
- tokenStore: {
123
- saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
124
- loadTokens: (userId) => /* read; null when never connected */,
125
- clearTokens: (userId) => /* delete */,
126
- },
127
- });
128
- ```
129
-
130
- In token mode, if the app runs more than one server process or a background
131
- worker, also add `withLock: (userId, run) => ...`, which runs `run()` while
132
- holding a per-user lock all processes share (for example a Postgres advisory
133
- lock). The access token lives as long as the app's Token lifetime says (30
134
- days unless changed under Advanced when creating the app); the refresh token
135
- lives 30 days. Required: once a day, for each connected user, call
136
- `oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 })`. It
137
- renews both tokens when either is within 3 days of expiring; without it,
138
- users have to connect again when the tokens run out. Keep the window shorter
139
- than the Token lifetime, or every run refreshes.
82
+ identifier, not a secret, so it needs no encryption and no lock. The
83
+ package writes it in the callback and reads it on every call; the app
84
+ never passes it anywhere.
140
85
 
141
- In both modes use the app's own user id as the key and the database it
142
- already has.
143
-
144
- ## 5 - The two routes
86
+ ## 4 - The two routes
145
87
 
146
88
  Next.js App Router (also Remix, SvelteKit, Hono, Bun):
147
89
 
@@ -161,7 +103,7 @@ Express or plain Node: `createOneConnectHandlers(oneConnect, { identifyUser })`
161
103
  from `@withone/connect/node`, mounted at `/api/one/authorize` and
162
104
  `/api/one/callback`.
163
105
 
164
- ## 6 - The button
106
+ ## 5 - The button
165
107
 
166
108
  ```tsx
167
109
  import { ConnectButton } from "@withone/connect/react";
@@ -184,7 +126,7 @@ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
184
126
  `<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
185
127
  A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
186
128
 
187
- ## 7 - Calling One with the grant
129
+ ## 6 - Calling One with the grant
188
130
 
189
131
  ```ts
190
132
  const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
@@ -200,17 +142,19 @@ const reply = await oneConnect.runAction(userId, {
200
142
  // { status, ok, blockedByGrant, data }
201
143
  ```
202
144
 
203
- A `403` reply means the call is outside what the user granted. Do not retry
204
- it. In key mode `blockedByGrant` is `true` for those.
145
+ Do not set any header. The package adds the connect key and the user's id
146
+ to every call it makes.
147
+
148
+ A `403` reply with `blockedByGrant: true` means the call is outside what
149
+ the user granted. Do not retry it.
205
150
 
206
151
  Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
207
152
 
208
- | `code` | Mode | Meaning | Do |
209
- |---|---|---|---|
210
- | `not_connected` | both | Nothing is stored for this user. | Show the Connect button. |
211
- | `reconnect_required` | key | One will not act for this user: they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
212
- | `refresh_failed` | token | The grant ended. The tokens were cleared. | Ask the user to connect again. |
213
- | `request_failed` | both | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
153
+ | `code` | Meaning | Do |
154
+ |---|---|---|
155
+ | `not_connected` | Nothing is stored for this user. | Show the Connect button. |
156
+ | `reconnect_required` | One will not act for this user: they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
157
+ | `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
214
158
 
215
159
  ```ts
216
160
  import { OneConnectError } from "@withone/connect/server";
@@ -220,7 +164,7 @@ try {
220
164
  } catch (error) {
221
165
  if (
222
166
  error instanceof OneConnectError &&
223
- ["not_connected", "reconnect_required", "refresh_failed"].includes(error.code)
167
+ ["not_connected", "reconnect_required"].includes(error.code)
224
168
  ) {
225
169
  // show the Connect button again
226
170
  } else {
@@ -229,31 +173,83 @@ try {
229
173
  }
230
174
  ```
231
175
 
232
- In key mode `isConnected(userId)` says the user has connected before. One
233
- confirms the consent on each call, so a user who revoked is found by the
234
- next call throwing `reconnect_required`.
176
+ `isConnected(userId)` says the user has connected before. One confirms the
177
+ consent on each call, so a user who revoked is found by the next call
178
+ throwing `reconnect_required`.
235
179
 
236
- ## 8 - Rules
180
+ ## 7 - Rules
237
181
 
238
182
  - Never put the client secret or the connect key in browser code, logs,
239
183
  error reports, source files or prompts. Environment variables only.
240
- - A connect key works in one environment. Use the Production key against
241
- production and the Sandbox key against sandbox.
242
- - Key mode: store the per-user string as given. Token mode: store tokens
243
- encrypted, keyed by the app's user.
184
+ - Connect keys work in Production only; the human creates the key with
185
+ the dashboard on Production.
186
+ - Store the per-user string as given.
244
187
  - The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
245
188
  - Do not build a completion page; the callback redirect is the completion.
246
189
  - Do not write OAuth steps or One request headers by hand; use the package.
247
190
  - Do not switch an existing app from one mode to the other unless asked.
248
191
 
249
- ## 9 - Done when
192
+ ## 8 - Done when
250
193
 
251
194
  1. The button leads to One's page; after signing in and authorizing, the user
252
195
  lands back in the app and `onSuccess` fires.
253
- 2. Key mode: one string is saved for the user. Token mode: the tokens are saved.
196
+ 2. One string is saved for the user.
254
197
  3. `listConnections` returns only the granted connections.
255
- 4. `runAction` works for an action inside the grant and returns `403` for
256
- one outside it.
198
+ 4. `runAction` works for an action inside the grant and returns `403` with
199
+ `blockedByGrant: true` for one outside it.
257
200
  5. After the user revokes the app in their One dashboard, the next call
258
- fails with `reconnect_required` (key mode) or `refresh_failed` (token
259
- mode) and the app asks them to connect again.
201
+ fails with `reconnect_required` and the app asks them to connect again.
202
+
203
+ ## 9 - Token mode (only when asked)
204
+
205
+ The other way to hold the grant: the app stores an access token and a
206
+ refresh token per user, as a standard OAuth client. The routes, the button
207
+ and the calls are the same. No `ONE_CONNECT_KEY` is needed.
208
+
209
+ ```ts
210
+ export const oneConnect = createOneConnect({
211
+ clientId: process.env.ONE_CLIENT_ID!,
212
+ clientSecret: process.env.ONE_CLIENT_SECRET!,
213
+ redirectUri: process.env.ONE_REDIRECT_URI!,
214
+ permissionSet: process.env.ONE_PERMISSION_SET,
215
+ tokenStore: {
216
+ saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
217
+ loadTokens: (userId) => /* read; null when never connected */,
218
+ clearTokens: (userId) => /* delete */,
219
+ },
220
+ });
221
+ ```
222
+
223
+ - Store tokens encrypted, keyed by the app's user.
224
+ - If the app runs more than one server process or a background worker, also
225
+ add `withLock: (userId, run) => ...`, which runs `run()` while holding a
226
+ per-user lock all processes share (for example a Postgres advisory lock).
227
+ - When the user revokes access, the next refresh throws `refresh_failed`
228
+ (not `reconnect_required`) and the tokens are cleared. Ask them to connect
229
+ again.
230
+ - `blockedByGrant` stays `false` in token mode; treat any `403` as outside
231
+ the grant.
232
+
233
+ Tokens expire, and two things keep them fresh. Only the first is automatic:
234
+
235
+ | | Who does it | When |
236
+ |---|---|---|
237
+ | Refresh before a call | the package, on its own | whenever the app calls One and the token is about to expire |
238
+ | Refresh for users who have not called in a while | **the app**, with a daily job | once a day, for every connected user |
239
+
240
+ ```ts
241
+ // a scheduled job, once a day
242
+ for (const userId of /* every user with stored tokens */) {
243
+ await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
244
+ }
245
+ ```
246
+
247
+ Without the job, a user who stays away for 30 days has to connect again:
248
+ the refresh token lives 30 days, and only a live refresh token can renew
249
+ the pair. The access token lives as long as the app's Token lifetime says
250
+ (30 days unless changed under Advanced when creating the app); keep the
251
+ window shorter than that, or every run refreshes. In token mode, "done"
252
+ also means the daily job exists and runs for every connected user.
253
+
254
+ The mode is whichever credential `createOneConnect` is given: `connectKey`
255
+ and `userStore` for key mode, `tokenStore` for token mode.