@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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Key mode: the app holds one connect key, and one permanent id per
3
+ * user. Both go on every call; One reads the user's consent live each
4
+ * time. Nothing expires, so there is nothing to refresh, rotate or lock.
5
+ *
6
+ * X-One-Secret: the app's connect key
7
+ * X-One-Connect-User-Id: cu_… for the user being acted for
8
+ *
9
+ * The id comes from the one code exchange the callback makes. It is the
10
+ * same for a user for the life of the app, through revocation and
11
+ * re-consent, so it is saved once and never deleted by the SDK.
12
+ */
13
+ import type { Credential } from "./credential";
14
+ import { type OneConnectUserReference, type OneConnectUserStore } from "./types";
15
+ /**
16
+ * The one value an app stores per user, as a string for one column:
17
+ * `cu_…`, then the space the user granted from when it is not their
18
+ * personal one (`cu_…;org=<id>;project=<id>`).
19
+ *
20
+ * The space travels with the id because One resolves a call's tenant
21
+ * from headers: a grant made from an organization lives there, and a
22
+ * call that names none runs in the user's personal space and reaches
23
+ * nothing. In token mode the access token names the space on every
24
+ * call; here there is no token after the exchange, so it is kept.
25
+ */
26
+ export declare function encodeUserReference(reference: OneConnectUserReference): string;
27
+ /** Reads a stored value back. Null when it is not one this SDK wrote. */
28
+ export declare function parseUserReference(value: string): OneConnectUserReference | null;
29
+ export interface KeyCredential extends Credential {
30
+ getConnectUserId: (userId: string) => Promise<string | null>;
31
+ }
32
+ export declare function createKeyCredential(connectKey: string, userStore: OneConnectUserStore): KeyCredential;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Token mode: the app holds an access token and a refresh token per
3
+ * user, and the SDK refreshes them with One.
4
+ *
5
+ * Lifetimes. The access token lives as long as the app's Token lifetime
6
+ * setting says (dashboard, Advanced, when creating or editing the app: 7
7
+ * days, 30 days, 90 days or 1 year; 30 days unless changed). An app that
8
+ * never had the setting gets an hour. The refresh token always lives 30
9
+ * days, whatever the access token's lifetime, and only a live refresh
10
+ * token can buy a new pair.
11
+ *
12
+ * Who refreshes. Before each call the client refreshes a pair that is
13
+ * within a minute of expiring. Refreshing earlier than that is the app's
14
+ * job: it runs `refreshIfExpiring` on a schedule, so the refresh token
15
+ * never runs out. When it has run out, the access token is used until it
16
+ * expires too, and only then is the user asked to connect again.
17
+ *
18
+ * Rotation. Every refresh rotates both tokens, and One treats a second
19
+ * use of a rotated refresh token as theft and revokes the whole grant.
20
+ * So the client refreshes one user at a time (in this process always,
21
+ * across processes through `tokenStore.withLock`), re-reads the store
22
+ * before spending a refresh token, clears tokens only when the grant is
23
+ * dead, and never lets a failing old pair delete a newer one.
24
+ */
25
+ import type { Credential, PostToken } from "./credential";
26
+ import { type OneConnectTokenStore, type OneConnectTokens, type RefreshIfExpiringOptions } from "./types";
27
+ export interface TokenCredential extends Credential {
28
+ getAccessToken: (userId: string) => Promise<string>;
29
+ getTokens: (userId: string) => Promise<OneConnectTokens | null>;
30
+ refreshTokens: (userId: string) => Promise<OneConnectTokens>;
31
+ refreshIfExpiring: (userId: string, options?: RefreshIfExpiringOptions) => Promise<OneConnectTokens>;
32
+ }
33
+ export declare function createTokenCredential(tokenStore: OneConnectTokenStore, postToken: PostToken): TokenCredential;
@@ -2,6 +2,39 @@
2
2
  * Types for `@withone/connect/server`: the half of the SDK that runs on
3
3
  * the app's server and holds the client secret.
4
4
  */
5
+ /**
6
+ * How the app holds a user's grant.
7
+ *
8
+ * - `"key"`: the app's connect key plus a permanent id per user. Nothing
9
+ * expires, so there is nothing to refresh. The default.
10
+ * - `"token"`: an access token and a refresh token per user, which the
11
+ * SDK keeps fresh.
12
+ */
13
+ export type OneConnectMode = "key" | "token";
14
+ /** Key mode: who a stored user is to One, and where their grant lives. */
15
+ export interface OneConnectUserReference {
16
+ /** One's permanent id for this user, for this app: `cu_…`. */
17
+ connectUserId: string;
18
+ /** The organization the user granted from; absent for their personal
19
+ * space. */
20
+ organizationId?: string;
21
+ /** The project the user granted from, when it was one. */
22
+ projectId?: string;
23
+ }
24
+ /**
25
+ * Key mode: where the app keeps the one value the SDK gives it per user.
26
+ * A single string, written when the user connects and the same until
27
+ * they connect again, so one column on the app's user row is enough.
28
+ * `userId` is the app's own id for its user.
29
+ *
30
+ * It is an identifier, not a credential: it does nothing without the
31
+ * app's connect key. No lock is needed, because nothing rotates.
32
+ */
33
+ export interface OneConnectUserStore {
34
+ saveUser: (userId: string, reference: string) => Promise<void>;
35
+ loadUser: (userId: string) => Promise<string | null>;
36
+ clearUser: (userId: string) => Promise<void>;
37
+ }
5
38
  /** What the app stores per user after the exchange. */
6
39
  export interface OneConnectTokens {
7
40
  accessToken: string;
@@ -47,10 +80,13 @@ export interface OneConnectTokenStore {
47
80
  }
48
81
  export interface RefreshIfExpiringOptions {
49
82
  /** Refresh when the access token or the refresh token expires within
50
- * this many milliseconds. One minute when omitted. */
83
+ * this many milliseconds. One minute when omitted. A refresh token
84
+ * that has already run out cannot be refreshed: the pair is returned
85
+ * as it is while its access token still works. */
51
86
  withinMs?: number;
52
87
  }
53
- export interface OneConnectServerConfig {
88
+ /** What every app configures, whichever mode it uses. */
89
+ export interface OneConnectBaseConfig {
54
90
  /** The app's client id from the dashboard. */
55
91
  clientId: string;
56
92
  /** The app's client secret. Server only. */
@@ -69,8 +105,35 @@ export interface OneConnectServerConfig {
69
105
  /** OAuth scopes. All three tenancy tiers when omitted, so the user may
70
106
  * grant from any space. */
71
107
  scopes?: string[];
108
+ }
109
+ /**
110
+ * Key mode, the default: pass the app's connect key and a place to keep
111
+ * one value per user.
112
+ */
113
+ export interface OneConnectKeyConfig extends OneConnectBaseConfig {
114
+ mode?: "key";
115
+ /** The app's connect key, minted on the app's page in the dashboard.
116
+ * Server only. One key per environment. */
117
+ connectKey: string;
118
+ userStore: OneConnectUserStore;
119
+ tokenStore?: never;
120
+ }
121
+ /**
122
+ * Token mode: pass a token store and the SDK keeps each user's tokens
123
+ * fresh.
124
+ */
125
+ export interface OneConnectTokenConfig extends OneConnectBaseConfig {
126
+ mode?: "token";
72
127
  tokenStore: OneConnectTokenStore;
128
+ connectKey?: never;
129
+ userStore?: never;
73
130
  }
131
+ /**
132
+ * The mode is whichever credential is configured: a `connectKey` is key
133
+ * mode, a `tokenStore` alone is token mode. Set `mode` to say so
134
+ * explicitly.
135
+ */
136
+ export type OneConnectServerConfig = OneConnectKeyConfig | OneConnectTokenConfig;
74
137
  /** The transaction cookie the authorize leg sets and the callback reads. */
75
138
  export interface OneConnectCookie {
76
139
  name: string;
@@ -160,18 +223,23 @@ export interface RunActionResult {
160
223
  status: number;
161
224
  ok: boolean;
162
225
  /** True when One refused the call because it is outside the grant.
163
- * The provider was never called. Do not retry. */
226
+ * The provider was never called. Do not retry. Key mode sets it; in
227
+ * token mode it stays false today, so treat any 403 there as refused. */
164
228
  blockedByGrant: boolean;
165
229
  data: unknown;
166
230
  }
167
231
  /**
168
- * - `not_connected`: no tokens are stored for this user.
169
- * - `refresh_failed`: One declared the grant dead (revoked, expired or
170
- * reused). The tokens were cleared; ask the user to connect again.
232
+ * - `not_connected`: nothing is stored for this user.
233
+ * - `reconnect_required` (key mode): One will not act for this user.
234
+ * Their consent was revoked, or the app is deactivated. What the app
235
+ * stored is kept; ask the user to connect again.
236
+ * - `refresh_failed` (token mode): One declared the grant dead (revoked,
237
+ * expired or reused). The tokens were cleared; ask the user to connect
238
+ * again.
171
239
  * - `request_failed`: One answered with an error or could not be
172
- * reached. During a refresh the tokens are kept, so retry later.
240
+ * reached. Nothing stored was changed, so retry later.
173
241
  */
174
- export type OneConnectErrorCode = "not_connected" | "refresh_failed" | "request_failed";
242
+ export type OneConnectErrorCode = "not_connected" | "reconnect_required" | "refresh_failed" | "request_failed";
175
243
  export declare class OneConnectError extends Error {
176
244
  readonly code: OneConnectErrorCode;
177
245
  readonly status?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
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,13 +1,14 @@
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, 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, storing and refreshing the grant, and calling One with it.
4
4
  ---
5
5
 
6
6
  # One Connect
7
7
 
8
8
  You are adding One Connect to this application. Its users will grant the app
9
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.
10
+ you wire four things: a button, two routes, a daily refresh job, and the calls
11
+ made with the grant.
11
12
 
12
13
  ```
13
14
  Browser Your backend One
@@ -16,8 +17,12 @@ Browser Your backend One
16
17
  stores the tokens, redirects home
17
18
  <------ onSuccess fires
18
19
  Later: oneConnect.runAction(userId, ...) -> One, grant enforced
20
+ Daily: oneConnect.refreshIfExpiring(userId, ...) for every connected user
19
21
  ```
20
22
 
23
+ Use **token mode**, described in sections 1 to 8. Key mode (section 9) is a
24
+ second way to hold the grant; use it only when the human asks for it.
25
+
21
26
  Secrets and tokens stay on the server.
22
27
 
23
28
  ## 1 - Ask the human for these
@@ -30,7 +35,9 @@ They create the app in the One dashboard: Developers -> Connect -> New app.
30
35
  | `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
31
36
  | Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
32
37
  | `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. |
38
+
39
+ Never ask the human to paste the secret into the chat. Tell them which
40
+ environment variable to set and read it from there.
34
41
 
35
42
  ## 2 - Environment (server only)
36
43
 
@@ -39,7 +46,6 @@ ONE_CLIENT_ID=...
39
46
  ONE_CLIENT_SECRET=one_secret_...
40
47
  ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
41
48
  ONE_PERMISSION_SET=... # optional
42
- ONE_API_URL=... # optional
43
49
  ```
44
50
 
45
51
  ## 3 - Install and create the client
@@ -57,7 +63,6 @@ export const oneConnect = createOneConnect({
57
63
  clientSecret: process.env.ONE_CLIENT_SECRET!,
58
64
  redirectUri: process.env.ONE_REDIRECT_URI!,
59
65
  permissionSet: process.env.ONE_PERMISSION_SET,
60
- oneApiUrl: process.env.ONE_API_URL,
61
66
  tokenStore: {
62
67
  saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
63
68
  loadTokens: (userId) => /* read; null when never connected */,
@@ -115,7 +120,33 @@ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
115
120
  `<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
116
121
  A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
117
122
 
118
- ## 6 - Calling One with the grant
123
+ ## 6 - Keeping users connected
124
+
125
+ Tokens expire. Two things keep them fresh, and only the first is automatic.
126
+
127
+ | | Who does it | When |
128
+ |---|---|---|
129
+ | Refresh before a call | the package, on its own | whenever the app calls One and the token is about to expire |
130
+ | Refresh for users who have not called in a while | **the app**, with a daily job | once a day, for every connected user |
131
+
132
+ You must add the daily job. Without it, a user who stays away for 30 days
133
+ has to connect again: the refresh token lives 30 days, and only a live
134
+ refresh token can renew the pair.
135
+
136
+ ```ts
137
+ // a scheduled job, once a day
138
+ for (const userId of /* every user with stored tokens */) {
139
+ await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
140
+ }
141
+ ```
142
+
143
+ It renews both tokens when either is within 3 days of expiring, and does
144
+ nothing otherwise. The access token lives as long as the app's Token lifetime
145
+ says (30 days unless changed under Advanced when creating the app); keep the
146
+ window shorter than that, or every run refreshes. Use the scheduler the app
147
+ already has (a cron route, a queue, a worker).
148
+
149
+ ## 7 - Calling One with the grant
119
150
 
120
151
  ```ts
121
152
  const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
@@ -131,23 +162,95 @@ const reply = await oneConnect.runAction(userId, {
131
162
  // { status, ok, data }
132
163
  ```
133
164
 
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.
165
+ Do not set any header. The package adds the user's credential to every call
166
+ it makes, and refreshes it first when it is about to expire.
167
+
168
+ A `403` reply means the call is outside what the user granted. Do not retry it.
169
+
170
+ Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
171
+
172
+ | `code` | Meaning | Do |
173
+ |---|---|---|
174
+ | `not_connected` | Nothing is stored for this user. | Show the Connect button. |
175
+ | `refresh_failed` | The grant ended. The tokens were cleared. | Ask the user to connect again. |
176
+ | `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
177
+
178
+ ```ts
179
+ import { OneConnectError } from "@withone/connect/server";
180
+
181
+ try {
182
+ await oneConnect.runAction(userId, action);
183
+ } catch (error) {
184
+ if (
185
+ error instanceof OneConnectError &&
186
+ ["not_connected", "refresh_failed"].includes(error.code)
187
+ ) {
188
+ // show the Connect button again
189
+ } else {
190
+ throw error;
191
+ }
192
+ }
193
+ ```
136
194
 
137
- ## 7 - Rules
195
+ ## 8 - Rules
138
196
 
139
- - Never put the client secret in browser code, logs or error reports.
197
+ - Never put the client secret in browser code, logs, error reports, source
198
+ files or prompts. Environment variables only.
140
199
  - Store tokens encrypted, keyed by the app's user.
200
+ - Add the daily refresh job. It is part of the integration, not an extra.
141
201
  - The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
142
202
  - Do not build a completion page; the callback redirect is the completion.
143
- - Do not write OAuth steps by hand; use the package.
203
+ - Do not write OAuth steps or One request headers by hand; use the package.
204
+ - Do not switch an existing app from one mode to the other unless asked.
205
+
206
+ ## 9 - Key mode (only when asked)
207
+
208
+ A second way to hold the grant: one connect key for the app and one permanent
209
+ id per user, with nothing to refresh. The routes, the button and the calls are
210
+ the same. Two limits today:
211
+
212
+ - `listActions` does not work in key mode yet. The app has to know the
213
+ actions it runs.
214
+ - It works in Production only. The human creates the key with the dashboard
215
+ on Production.
216
+
217
+ The human creates the key on the app's page: Credentials -> Connect key ->
218
+ Create key. It is shown once. They set it as `ONE_CONNECT_KEY`; never ask
219
+ them to paste it into the chat.
220
+
221
+ ```ts
222
+ export const oneConnect = createOneConnect({
223
+ clientId: process.env.ONE_CLIENT_ID!,
224
+ clientSecret: process.env.ONE_CLIENT_SECRET!,
225
+ redirectUri: process.env.ONE_REDIRECT_URI!,
226
+ permissionSet: process.env.ONE_PERMISSION_SET,
227
+ connectKey: process.env.ONE_CONNECT_KEY!,
228
+ userStore: {
229
+ saveUser: (userId, reference) => /* save the string on the app's user row */,
230
+ loadUser: (userId) => /* read it; null when never connected */,
231
+ clearUser: (userId) => /* set it to null */,
232
+ },
233
+ });
234
+ ```
235
+
236
+ - `reference` is one short string. Store it as given and hand it back
237
+ unchanged; do not parse or rebuild it. It is an identifier, not a secret.
238
+ - There is no daily refresh job in key mode.
239
+ - When the user revokes access, the next call throws `reconnect_required`
240
+ (not `refresh_failed`). Ask them to connect again. The stored value is kept.
241
+ - A `403` reply carries `blockedByGrant: true` when the call is outside what
242
+ the user granted.
243
+ - The mode is whichever credential `createOneConnect` is given: `tokenStore`
244
+ for token mode, `connectKey` and `userStore` for key mode.
144
245
 
145
- ## 8 - Done when
246
+ ## 10 - Done when
146
247
 
147
248
  1. The button leads to One's page; after signing in and authorizing, the user
148
249
  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
250
+ 2. The tokens are saved for the user.
251
+ 3. `listConnections` returns only the granted connections.
252
+ 4. `runAction` works for an action inside the grant and returns `403` for
151
253
  one outside it.
152
- 4. After the user revokes the app in their One dashboard, the app asks them
254
+ 5. The daily refresh job exists and runs for every connected user.
255
+ 6. After the user revokes the app in their One dashboard, the app asks them
153
256
  to connect again.
package/src/next.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
 
19
19
  export interface OneConnectRoutesOptions {
20
20
  /** The app's own id for the signed-in user, or null when nobody is
@@ -80,7 +80,7 @@ export function routeFor(url: string): string {
80
80
  }
81
81
 
82
82
  export function createOneConnectRoutes(
83
- oneConnect: OneConnect,
83
+ oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">,
84
84
  options: OneConnectRoutesOptions,
85
85
  ): OneConnectRoutes {
86
86
  const cookiePath = (): string =>
package/src/node.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
18
 
19
19
  import { createOneConnectRoutes, type OneConnectRoutesOptions } from "@withone/connect/next";
20
- import type { OneConnect } from "./server";
20
+ import type { OneConnectClient } from "./server";
21
21
 
22
22
  export interface OneConnectNodeOptions {
23
23
  /** The app's own id for the signed-in user, or null when nobody is
@@ -76,7 +76,7 @@ async function send(response: ServerResponse, web: Response): Promise<void> {
76
76
  }
77
77
 
78
78
  export function createOneConnectHandlers(
79
- oneConnect: OneConnect,
79
+ oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">,
80
80
  options: OneConnectNodeOptions,
81
81
  ): OneConnectHandlers {
82
82
  // The web adapter receives a Request; the Node callbacks want the
@@ -0,0 +1,44 @@
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
+
16
+ /** What One's token endpoint answers on success. `connect_user_id` is
17
+ * present whenever a durable grant backs the token. */
18
+ export interface TokenResponse {
19
+ access_token: string;
20
+ refresh_token: string;
21
+ expires_in: number;
22
+ connect_user_id?: string;
23
+ }
24
+
25
+ /** What One's token endpoint answered. A network failure throws instead. */
26
+ export type TokenAnswer =
27
+ | { ok: true; body: TokenResponse }
28
+ | { ok: false; status: number; error?: string };
29
+
30
+ export type PostToken = (body: URLSearchParams) => Promise<TokenAnswer>;
31
+
32
+ export interface Credential {
33
+ /** The callback exchanged the code: keep what this mode needs. */
34
+ connected: (userId: string, response: TokenResponse) => Promise<void>;
35
+ /** The headers that make a /v1 call act for this user. Throws
36
+ * `not_connected` when the user has nothing stored. */
37
+ headers: (userId: string) => Promise<Record<string, string>>;
38
+ isConnected: (userId: string) => Promise<boolean>;
39
+ /** Drops the app's copy. The user revokes the grant itself in One. */
40
+ disconnect: (userId: string) => Promise<void>;
41
+ /** One refused the credential itself, rather than the call it carried:
42
+ * the error to throw, or null when this answer is not that. */
43
+ refusal: (status: number, body: string) => OneConnectError | null;
44
+ }