@withone/connect 0.12.2 → 0.13.1
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 +70 -10
- package/dist/next.d.ts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/server/credential.d.ts +45 -0
- package/dist/server/index.cjs.js +441 -135
- package/dist/server/index.d.ts +42 -22
- package/dist/server/index.esm.js +440 -136
- package/dist/server/key.d.ts +32 -0
- package/dist/server/token.d.ts +33 -0
- package/dist/server/types.d.ts +76 -8
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +118 -15
- package/src/next.ts +2 -2
- package/src/node.ts +2 -2
- package/src/server/credential.ts +44 -0
- package/src/server/index.ts +197 -221
- package/src/server/key.ts +142 -0
- package/src/server/token.ts +235 -0
- package/src/server/types.ts +82 -7
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ You add three things: a button, two backend routes, and the calls you make with
|
|
|
28
28
|
Browser Your server One
|
|
29
29
|
<ConnectButton> ──────► GET /api/one/authorize ──302──► hosted page: sign in, pick tools, set access
|
|
30
30
|
GET /api/one/callback ◄──302── ?code&state
|
|
31
|
-
|
|
31
|
+
saves the grant for the user, redirects home
|
|
32
32
|
onSuccess() ◄──────
|
|
33
33
|
later: oneConnect.runAction(userId, …) ──► One, grant enforced
|
|
34
34
|
```
|
|
@@ -50,7 +50,6 @@ ONE_CLIENT_ID=…
|
|
|
50
50
|
ONE_CLIENT_SECRET=one_secret_… # server only
|
|
51
51
|
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
|
|
52
52
|
ONE_PERMISSION_SET=… # optional: the tools you ask for
|
|
53
|
-
ONE_API_URL=https://api.withone.ai # optional: production when unset
|
|
54
53
|
```
|
|
55
54
|
|
|
56
55
|
## 2 · The button
|
|
@@ -97,7 +96,9 @@ Your own button in React: `const { open, status } = useOneConnect({ authorizeUrl
|
|
|
97
96
|
|
|
98
97
|
To match your design, set `--one-connect-font` and `--one-connect-radius`, or style `::part(button)`.
|
|
99
98
|
|
|
100
|
-
## 3 · The
|
|
99
|
+
## 3 · The server client
|
|
100
|
+
|
|
101
|
+
Your server keeps an access token and a refresh token for each user who connects. This is **token mode**, and it is the one to use.
|
|
101
102
|
|
|
102
103
|
```ts
|
|
103
104
|
// lib/one.ts
|
|
@@ -108,7 +109,6 @@ export const oneConnect = createOneConnect({
|
|
|
108
109
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
109
110
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
110
111
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
111
|
-
oneApiUrl: process.env.ONE_API_URL,
|
|
112
112
|
tokenStore: {
|
|
113
113
|
saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
|
|
114
114
|
loadTokens: (userId) => db.oneTokens.find(userId),
|
|
@@ -117,6 +117,12 @@ export const oneConnect = createOneConnect({
|
|
|
117
117
|
});
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
Store the tokens in your database, encrypted, keyed by your user id.
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
## 4 · The two routes
|
|
125
|
+
|
|
120
126
|
```ts
|
|
121
127
|
// app/api/one/[action]/route.ts (Next.js; also Remix, SvelteKit, Hono, Bun)
|
|
122
128
|
import { createOneConnectRoutes } from "@withone/connect/next";
|
|
@@ -129,7 +135,7 @@ export const { GET } = createOneConnectRoutes(oneConnect, {
|
|
|
129
135
|
|
|
130
136
|
That file serves `/api/one/authorize` and `/api/one/callback`. For Express or plain Node, use `createOneConnectHandlers` from `@withone/connect/node`.
|
|
131
137
|
|
|
132
|
-
##
|
|
138
|
+
## 5 · Using the grant
|
|
133
139
|
|
|
134
140
|
```ts
|
|
135
141
|
const connections = await oneConnect.listConnections(userId); // what the user granted
|
|
@@ -144,17 +150,71 @@ const reply = await oneConnect.runAction(userId, {
|
|
|
144
150
|
// { status, ok, data }
|
|
145
151
|
```
|
|
146
152
|
|
|
153
|
+
You never set a header: the client adds the user's credential to every call it makes.
|
|
154
|
+
|
|
147
155
|
A `403` means the call is outside what the user granted. Don't retry it.
|
|
148
156
|
|
|
149
|
-
|
|
157
|
+
Errors are `OneConnectError` with a `code`:
|
|
158
|
+
|
|
159
|
+
| `code` | What it means | What to do |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `not_connected` | Nothing is stored for this user. | Show the Connect button. |
|
|
162
|
+
| `refresh_failed` | The grant ended (revoked or expired). The tokens were cleared. | Ask the user to connect again. |
|
|
163
|
+
| `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
|
|
164
|
+
|
|
165
|
+
## 6 · Keeping users connected
|
|
166
|
+
|
|
167
|
+
Tokens expire, and two things keep them fresh. One is automatic. The other is yours to run.
|
|
150
168
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
| | Who does it | When |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| Refresh before a call | the client, on its own | whenever you call One and the token is about to expire |
|
|
172
|
+
| Refresh for users who have not called in a while | **you**, with a daily job | once a day, for every connected user |
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// run once a day
|
|
176
|
+
for (const userId of await db.oneTokens.allUserIds()) {
|
|
177
|
+
await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
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. Keep the job's window shorter than that, or every run refreshes.
|
|
155
184
|
|
|
156
185
|
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.
|
|
157
186
|
|
|
187
|
+
## 7 · Key mode
|
|
188
|
+
|
|
189
|
+
A second way to hold a grant: one **connect key** for your app and one permanent id per user, with nothing to refresh. The button, the routes and the calls stay the same.
|
|
190
|
+
|
|
191
|
+
Two limits today, so use token mode unless you have a reason not to:
|
|
192
|
+
|
|
193
|
+
- `listActions` does not work in key mode yet. Your app has to know the actions it runs.
|
|
194
|
+
- It works in Production only. Create the key with your dashboard on Production.
|
|
195
|
+
|
|
196
|
+
Create the key on your app's page, under **Credentials → Connect key**. It is shown once. Keep it on the server as `ONE_CONNECT_KEY`.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
export const oneConnect = createOneConnect({
|
|
200
|
+
clientId: process.env.ONE_CLIENT_ID!,
|
|
201
|
+
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
202
|
+
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
203
|
+
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
204
|
+
connectKey: process.env.ONE_CONNECT_KEY!,
|
|
205
|
+
userStore: {
|
|
206
|
+
saveUser: (userId, reference) => db.users.update(userId, { oneConnect: reference }),
|
|
207
|
+
loadUser: async (userId) => (await db.users.find(userId))?.oneConnect ?? null,
|
|
208
|
+
clearUser: (userId) => db.users.update(userId, { oneConnect: null }),
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `reference` is one short string. Save it in one column and hand it back unchanged. It is an identifier, not a secret.
|
|
214
|
+
- When a user revokes access, the next call throws `reconnect_required` instead of `refresh_failed`. Ask them to connect again; your stored value is kept.
|
|
215
|
+
- A `403` reply carries `blockedByGrant: true` when the call is outside what the user granted.
|
|
216
|
+
- The mode is whichever credential you pass: `tokenStore` for token mode, `connectKey` and `userStore` for key mode. `oneConnect.mode` tells you which one is running.
|
|
217
|
+
|
|
158
218
|
## License
|
|
159
219
|
|
|
160
220
|
GPL-3.0. See [LICENSE](LICENSE).
|
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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
+
}
|