@withone/connect 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
- stores the tokens, redirects home
31
+ saves one id for the user, redirects home
32
32
  onSuccess() ◄──────
33
33
  later: oneConnect.runAction(userId, …) ──► One, grant enforced
34
34
  ```
@@ -45,9 +45,12 @@ 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.
49
+
48
50
  ```env
49
51
  ONE_CLIENT_ID=…
50
52
  ONE_CLIENT_SECRET=one_secret_… # server only
53
+ ONE_CONNECT_KEY=sk_live_… # server only: the app's connect key
51
54
  ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
52
55
  ONE_PERMISSION_SET=… # optional: the tools you ask for
53
56
  ONE_API_URL=https://api.withone.ai # optional: production when unset
@@ -97,7 +100,19 @@ Your own button in React: `const { open, status } = useOneConnect({ authorizeUrl
97
100
 
98
101
  To match your design, set `--one-connect-font` and `--one-connect-radius`, or style `::part(button)`.
99
102
 
100
- ## 3 · The two routes
103
+ ## 3 · Choose a mode
104
+
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.
101
116
 
102
117
  ```ts
103
118
  // lib/one.ts
@@ -109,7 +124,27 @@ export const oneConnect = createOneConnect({
109
124
  redirectUri: process.env.ONE_REDIRECT_URI!,
110
125
  permissionSet: process.env.ONE_PERMISSION_SET,
111
126
  oneApiUrl: process.env.ONE_API_URL,
112
- tokenStore: {
127
+ connectKey: process.env.ONE_CONNECT_KEY!, // ← this makes it key mode
128
+ userStore: {
129
+ saveUser: (userId, reference) => db.users.update(userId, { oneConnect: reference }),
130
+ loadUser: async (userId) => (await db.users.find(userId))?.oneConnect ?? null,
131
+ clearUser: (userId) => db.users.update(userId, { oneConnect: null }),
132
+ },
133
+ });
134
+ ```
135
+
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
113
148
  saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
114
149
  loadTokens: (userId) => db.oneTokens.find(userId),
115
150
  clearTokens: (userId) => db.oneTokens.delete(userId),
@@ -117,6 +152,12 @@ export const oneConnect = createOneConnect({
117
152
  });
118
153
  ```
119
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.
156
+
157
+ ## 4 · The two routes
158
+
159
+ The same file in both modes:
160
+
120
161
  ```ts
121
162
  // app/api/one/[action]/route.ts (Next.js; also Remix, SvelteKit, Hono, Bun)
122
163
  import { createOneConnectRoutes } from "@withone/connect/next";
@@ -129,7 +170,9 @@ export const { GET } = createOneConnectRoutes(oneConnect, {
129
170
 
130
171
  That file serves `/api/one/authorize` and `/api/one/callback`. For Express or plain Node, use `createOneConnectHandlers` from `@withone/connect/node`.
131
172
 
132
- ## 4 · Using the grant
173
+ ## 5 · Using the grant
174
+
175
+ The same calls in both modes:
133
176
 
134
177
  ```ts
135
178
  const connections = await oneConnect.listConnections(userId); // what the user granted
@@ -141,17 +184,35 @@ const reply = await oneConnect.runAction(userId, {
141
184
  method: actions[0].method,
142
185
  path: actions[0].path,
143
186
  });
144
- // { status, ok, data }
187
+ // { status, ok, blockedByGrant, data }
145
188
  ```
146
189
 
147
- A `403` means the call is outside what the user granted. Don't retry it.
190
+ A `403` means the call is outside what the user granted. Don't retry it. In key mode `blockedByGrant` is `true` for those.
191
+
192
+ Errors are `OneConnectError` with a `code`:
193
+
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. |
200
+
201
+ ## 6 · Key mode notes
202
+
203
+ - Keep the connect key on the server, in an environment variable. It is bound to your app and does nothing without a user's id.
204
+ - A key works in one environment. Use the Production key in production and the Sandbox key in sandbox; the wrong one refuses every user.
205
+ - `isConnected(userId)` says whether the user has connected before. One confirms the consent on each call, so a user who revoked is found by the next call throwing `reconnect_required`.
206
+ - `oneConnect.getConnectUserId(userId)` returns the user's One id (`cu_…`) if you want it for your own records.
207
+ - Lost or leaked a key? Create another on the app's page and delete the old one under **Developers → API keys**.
148
208
 
149
- ## 5 · Tokens
209
+ ## 7 · Token mode notes
150
210
 
151
- - Store them in your database, encrypted, keyed by your user id. The SDK refreshes them for you.
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.
152
213
  - Running more than one server or a background worker? Add `withLock(userId, run)` to your token store, for example a Postgres advisory lock. Two servers refreshing at once would otherwise disconnect the user.
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.
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.
155
216
 
156
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.
157
218
 
package/dist/next.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * That serves /api/one/authorize and /api/one/callback. Register
15
15
  * `https://yourapp.com/api/one/callback` as the app's redirect URI.
16
16
  */
17
- import type { OneConnect } from "./server";
17
+ import type { OneConnectClient } from "./server";
18
18
  export interface OneConnectRoutesOptions {
19
19
  /** The app's own id for the signed-in user, or null when nobody is
20
20
  * signed in. The grant is stored under this id. */
@@ -33,4 +33,4 @@ export declare const CALLBACK_ROUTE = "callback";
33
33
  export declare function readCookie(request: Request, name: string): string | undefined;
34
34
  /** The last path segment names the leg: ".../authorize" or ".../callback". */
35
35
  export declare function routeFor(url: string): string;
36
- export declare function createOneConnectRoutes(oneConnect: OneConnect, options: OneConnectRoutesOptions): OneConnectRoutes;
36
+ export declare function createOneConnectRoutes(oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">, options: OneConnectRoutesOptions): OneConnectRoutes;
package/dist/node.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * app.get("/api/one/callback", callback);
16
16
  */
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
- import type { OneConnect } from "./server";
18
+ import type { OneConnectClient } from "./server";
19
19
  export interface OneConnectNodeOptions {
20
20
  /** The app's own id for the signed-in user, or null when nobody is
21
21
  * signed in. */
@@ -30,4 +30,4 @@ export interface OneConnectHandlers {
30
30
  authorize: NodeHandler;
31
31
  callback: NodeHandler;
32
32
  }
33
- export declare function createOneConnectHandlers(oneConnect: OneConnect, options: OneConnectNodeOptions): OneConnectHandlers;
33
+ export declare function createOneConnectHandlers(oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">, options: OneConnectNodeOptions): OneConnectHandlers;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The seam between the two ways an app can hold a user's grant.
3
+ *
4
+ * Everything around it is shared: the authorize leg, the callback, and
5
+ * the calls made with the grant. A credential answers only what differs:
6
+ * what to keep when a user connects, what to send on a call, and how to
7
+ * read One refusing it.
8
+ *
9
+ * - key mode (`./key`): the app's connect key plus a permanent id for
10
+ * the user. Nothing expires, so nothing is refreshed.
11
+ * - token mode (`./token`): an access token and a refresh token per
12
+ * user, rotated before they expire.
13
+ */
14
+ import type { OneConnectError } from "./types";
15
+ /** What One's token endpoint answers on success. `connect_user_id` is
16
+ * present whenever a durable grant backs the token. */
17
+ export interface TokenResponse {
18
+ access_token: string;
19
+ refresh_token: string;
20
+ expires_in: number;
21
+ connect_user_id?: string;
22
+ }
23
+ /** What One's token endpoint answered. A network failure throws instead. */
24
+ export type TokenAnswer = {
25
+ ok: true;
26
+ body: TokenResponse;
27
+ } | {
28
+ ok: false;
29
+ status: number;
30
+ error?: string;
31
+ };
32
+ export type PostToken = (body: URLSearchParams) => Promise<TokenAnswer>;
33
+ export interface Credential {
34
+ /** The callback exchanged the code: keep what this mode needs. */
35
+ connected: (userId: string, response: TokenResponse) => Promise<void>;
36
+ /** The headers that make a /v1 call act for this user. Throws
37
+ * `not_connected` when the user has nothing stored. */
38
+ headers: (userId: string) => Promise<Record<string, string>>;
39
+ isConnected: (userId: string) => Promise<boolean>;
40
+ /** Drops the app's copy. The user revokes the grant itself in One. */
41
+ disconnect: (userId: string) => Promise<void>;
42
+ /** One refused the credential itself, rather than the call it carried:
43
+ * the error to throw, or null when this answer is not that. */
44
+ refusal: (status: number, body: string) => OneConnectError | null;
45
+ }