@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 +55 -56
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +94 -98
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
|
|
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
|
|
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 ·
|
|
102
|
+
## 3 · The server client
|
|
104
103
|
|
|
105
|
-
Your server holds
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
195
|
-
|
|
196
|
-
| `not_connected` |
|
|
197
|
-
| `reconnect_required` |
|
|
198
|
-
| `
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
-
|
|
215
|
-
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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` |
|
|
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_...
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
|
|
204
|
-
|
|
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` |
|
|
209
|
-
|
|
210
|
-
| `not_connected` |
|
|
211
|
-
| `reconnect_required` |
|
|
212
|
-
| `
|
|
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"
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
##
|
|
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
|
-
-
|
|
241
|
-
|
|
242
|
-
-
|
|
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
|
-
##
|
|
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.
|
|
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`
|
|
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`
|
|
259
|
-
|
|
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.
|