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