@withone/connect 0.1.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 ADDED
@@ -0,0 +1,361 @@
1
+ <img src="https://assets.withone.ai/banners/connect.png" alt="One Connect — Let your users grant your app scoped, revocable access to their own tools." style="border-radius: 5px;">
2
+
3
+ <h3 align="center">One Connect</h3>
4
+
5
+ <p align="center">
6
+ <a href="https://withone.ai"><strong>Website</strong></a>
7
+ &nbsp;·&nbsp;
8
+ <a href="https://withone.ai/docs/connect"><strong>Docs</strong></a>
9
+ &nbsp;·&nbsp;
10
+ <a href="https://app.withone.ai"><strong>Dashboard</strong></a>
11
+ &nbsp;·&nbsp;
12
+ <a href="https://withone.ai/changelog"><strong>Changelog</strong></a>
13
+ &nbsp;·&nbsp;
14
+ <a href="https://x.com/withoneai"><strong>X</strong></a>
15
+ &nbsp;·&nbsp;
16
+ <a href="https://linkedin.com/company/withoneai"><strong>LinkedIn</strong></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="https://npmjs.com/package/@withone/connect"><img src="https://img.shields.io/npm/v/%40withone%2Fconnect" alt="npm version"></a>
21
+ </p>
22
+
23
+ One Connect lets your users grant your application **scoped, revocable access to their own One-connected tools** — Gmail, Slack, Notion, Stripe and 500+ more — through the standard OAuth 2.1 authorization-code flow with PKCE.
24
+
25
+ Your user owns their connections inside One. You never see their Gmail password — you hold a One access token scoped to **exactly what they granted**, and they can narrow or revoke it at any time. One enforces the grant on every call.
26
+
27
+ Fully compatible with popular frameworks such as React, Next.js, Vue, Svelte, and more.
28
+
29
+ > **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 own One account** and hands you a scoped grant.
30
+
31
+ ## Install
32
+
33
+ With npm:
34
+
35
+ ```bash
36
+ npm i @withone/connect
37
+ ```
38
+
39
+ With yarn:
40
+
41
+ ```bash
42
+ yarn add @withone/connect
43
+ ```
44
+
45
+ ## How it works
46
+
47
+ Everything sensitive — `state`, the PKCE verifier, your client secret, the tokens — lives on **your server**. The SDK is a thin modal opener: it never touches a token.
48
+
49
+ You build exactly **two backend routes and one button**.
50
+
51
+ ```mermaid
52
+ sequenceDiagram
53
+ participant User
54
+ participant YourApp as Your Application
55
+ participant YourBackend as Your Backend
56
+ participant One as One Connect
57
+
58
+ User->>YourApp: Clicks "Connect your tools"
59
+ YourApp->>YourBackend: Open modal → GET /api/one/authorize
60
+ YourBackend->>YourBackend: Mint state + PKCE, set httpOnly cookie
61
+ YourBackend->>One: 302 → /oauth/authorize
62
+ User->>One: Sign in, connect tools, narrow & grant access
63
+ One->>YourBackend: 302 → /api/one/callback?code&state
64
+ YourBackend->>YourBackend: Verify state
65
+ YourBackend->>One: POST /oauth/token (code + verifier + secret)
66
+ One->>YourBackend: Access token + refresh token
67
+ YourBackend->>YourApp: 302 → /?one_connect=success
68
+ YourApp->>User: SDK closes the modal, onSuccess() fires
69
+ ```
70
+
71
+ The SDK watches the modal frame for `?one_connect=` on your own origin — so **there is no completion page to build**.
72
+
73
+ ## 1 · Create your OAuth app
74
+
75
+ Dashboard → **Settings → OAuth Apps → New OAuth app**.
76
+
77
+ - You get a **Client ID** (public) and a **Client Secret** (shown once — server-only, never in a browser).
78
+ - Register your **redirect URI** (e.g. `https://yourapp.com/api/one/callback`).
79
+ - Pick the **access-token lifetime**: 7 days, 30 days, 90 days or 1 year.
80
+ - Optionally create a **permission set** — the connectors your app needs and the access level for each (full / read & write / read only / specific actions). Your user sees it pre-filled at consent and can only *narrow* it. Without one, your app asks for access to the user's connections generally, which they can also narrow.
81
+
82
+ ### Environment Variables
83
+
84
+ ```env
85
+ ONE_CLIENT_ID=...
86
+ ONE_CLIENT_SECRET=one_secret_...
87
+ ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
88
+ ONE_PERMISSION_SET=79659c66-...
89
+ ```
90
+
91
+ | Variable | Required | Description |
92
+ |---|---|---|
93
+ | `ONE_CLIENT_ID` | Yes | Public client ID from your OAuth app |
94
+ | `ONE_CLIENT_SECRET` | Yes | Server-only secret. Never ship to a browser. |
95
+ | `ONE_REDIRECT_URI` | Yes | Must exactly match the URI registered in the dashboard |
96
+ | `ONE_PERMISSION_SET` | No | Pre-fills the consent screen with the access your app needs |
97
+
98
+ ## 2 · Using the Connect component
99
+
100
+ Replace the `authorize URL` with your backend authorize endpoint.
101
+
102
+ > ⚠️ **Must be a full URL** — relative paths like `/api/one/authorize` won't work because the Connect card runs in an iframe. Use the complete URL (e.g., `https://your-domain.com/api/one/authorize`).
103
+
104
+ ```tsx
105
+ "use client";
106
+
107
+ import { useOneConnect } from "@withone/connect";
108
+
109
+ export function ConnectWithOne() {
110
+ const { open } = useOneConnect({
111
+ authorize: {
112
+ url: "https://your-domain.com/api/one/authorize",
113
+ },
114
+ appTheme: "light",
115
+ onSuccess: () => {
116
+ // Your backend already stored the tokens by the time this fires.
117
+ console.log("Access granted");
118
+ },
119
+ onError: (error) => {
120
+ console.error("Connect failed:", error);
121
+ },
122
+ onClose: () => {
123
+ console.log("Connect modal closed");
124
+ },
125
+ });
126
+
127
+ return <button onClick={open}>Connect your tools</button>;
128
+ }
129
+ ```
130
+
131
+ ### Configuration Options
132
+
133
+ | Option | Type | Description |
134
+ |---|---|---|
135
+ | `authorize.url` | `string` | Full URL of your backend authorize endpoint. Must be absolute. |
136
+ | `appTheme` | `"dark" \| "light"` | Theme for the Connect card. The SDK carries it on the URL fragment — nothing for your backend to forward. |
137
+ | `onSuccess` | `() => void` | The grant completed and your server stored the tokens |
138
+ | `onError` | `(error: string) => void` | The flow failed, with a human-readable message |
139
+ | `onClose` | `() => void` | The user closed the card without a result |
140
+
141
+ ### Returned handle
142
+
143
+ | Method | Description |
144
+ |---|---|
145
+ | `open()` | Opens the Connect modal over the current page |
146
+ | `close()` | Tears down the modal frame and its listeners |
147
+
148
+ ## 3 · Backend — the authorize route
149
+
150
+ Generates `state` (CSRF proof) and PKCE (proof that whoever redeems the code is this server), stashes both in an httpOnly cookie, and 302s the browser to One.
151
+
152
+ ```typescript
153
+ // app/api/one/authorize/route.ts (Next.js App Router)
154
+ import { createHash, randomBytes } from "crypto";
155
+ import { NextRequest, NextResponse } from "next/server";
156
+
157
+ const ONE_AUTHORIZE_URL = "https://api.withone.ai/oauth/authorize";
158
+
159
+ export async function GET(req: NextRequest) {
160
+ const state = randomBytes(16).toString("hex");
161
+ const verifier = randomBytes(32).toString("base64url");
162
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
163
+
164
+ const url = new URL(ONE_AUTHORIZE_URL);
165
+ url.searchParams.set("client_id", process.env.ONE_CLIENT_ID!);
166
+ url.searchParams.set("redirect_uri", process.env.ONE_REDIRECT_URI!);
167
+ url.searchParams.set("response_type", "code");
168
+ url.searchParams.set("scope", "user:connections:read user:connections:write");
169
+ url.searchParams.set("state", state);
170
+ url.searchParams.set("code_challenge", challenge);
171
+ url.searchParams.set("code_challenge_method", "S256");
172
+
173
+ if (process.env.ONE_PERMISSION_SET) {
174
+ url.searchParams.set("permission_set", process.env.ONE_PERMISSION_SET);
175
+ }
176
+
177
+ // Optional: your user's email. One pre-fills (never locks) their sign-in.
178
+ const userEmail = await getCurrentUserEmail(req); // ← your code
179
+ if (userEmail) url.searchParams.set("login_hint", userEmail);
180
+
181
+ const res = NextResponse.redirect(url.toString(), 302);
182
+ res.cookies.set("one_tx", JSON.stringify({ state, verifier }), {
183
+ httpOnly: true,
184
+ secure: true,
185
+ sameSite: "lax",
186
+ maxAge: 600, // matches One's 10-minute authorization-code lifetime
187
+ path: "/api/one",
188
+ });
189
+ return res;
190
+ }
191
+ ```
192
+
193
+ ## 4 · Backend — the callback route
194
+
195
+ One redirects back with a **single-use code**, worthless without your secret and the PKCE verifier. Exchange it server-side, store the tokens, then redirect anywhere on your site with `?one_connect=success` appended — the SDK watches the frame for that parameter and closes the modal.
196
+
197
+ ```typescript
198
+ // app/api/one/callback/route.ts
199
+ import { NextRequest, NextResponse } from "next/server";
200
+
201
+ const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
202
+
203
+ export async function GET(req: NextRequest) {
204
+ const code = req.nextUrl.searchParams.get("code");
205
+ const state = req.nextUrl.searchParams.get("state");
206
+ const tx = req.cookies.get("one_tx")?.value;
207
+
208
+ const parsed = tx ? JSON.parse(tx) : null;
209
+ if (!code || !state || !parsed || parsed.state !== state) {
210
+ return NextResponse.redirect(
211
+ new URL(
212
+ "/?one_connect=error&one_connect_message=" +
213
+ encodeURIComponent("The sign-in attempt expired or was tampered with."),
214
+ req.url,
215
+ ),
216
+ 302,
217
+ );
218
+ }
219
+
220
+ const basic = Buffer.from(
221
+ `${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
222
+ ).toString("base64");
223
+
224
+ const tokenRes = await fetch(ONE_TOKEN_URL, {
225
+ method: "POST",
226
+ headers: {
227
+ Authorization: `Basic ${basic}`,
228
+ "Content-Type": "application/x-www-form-urlencoded",
229
+ },
230
+ body: new URLSearchParams({
231
+ grant_type: "authorization_code",
232
+ code,
233
+ redirect_uri: process.env.ONE_REDIRECT_URI!,
234
+ code_verifier: parsed.verifier,
235
+ }),
236
+ });
237
+
238
+ const res = NextResponse.redirect(
239
+ new URL(
240
+ tokenRes.ok
241
+ ? "/?one_connect=success"
242
+ : "/?one_connect=error&one_connect_message=" +
243
+ encodeURIComponent("Token exchange failed."),
244
+ req.url,
245
+ ),
246
+ 302,
247
+ );
248
+ res.cookies.delete("one_tx");
249
+
250
+ if (tokenRes.ok) {
251
+ // { access_token, refresh_token, token_type: "bearer", expires_in, scope }
252
+ const tokens = await tokenRes.json();
253
+ await saveOneTokens(req, { // ← your code
254
+ accessToken: tokens.access_token,
255
+ refreshToken: tokens.refresh_token,
256
+ expiresAt: Date.now() + tokens.expires_in * 1000,
257
+ });
258
+ }
259
+ return res;
260
+ }
261
+ ```
262
+
263
+ **Token response (200):**
264
+
265
+ ```json
266
+ {
267
+ "access_token": "one_at_...",
268
+ "refresh_token": "one_rt_...",
269
+ "token_type": "bearer",
270
+ "expires_in": 2592000,
271
+ "scope": "user:connections:read user:connections:write"
272
+ }
273
+ ```
274
+
275
+ ## 5 · Backend — refreshing the token
276
+
277
+ Access tokens last as long as you chose when creating the app (7 days to 1 year). Refresh tokens last **30 days** and are **rotated on every use** — always store BOTH new tokens. Reusing an old refresh token revokes the entire token family (theft protection).
278
+
279
+ ```typescript
280
+ const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
281
+
282
+ export async function getOneAccessToken(userId: string): Promise<string> {
283
+ const t = await loadOneTokens(userId); // ← your code
284
+ if (Date.now() < t.expiresAt - 60_000) return t.accessToken;
285
+
286
+ // The refresh exchange is authenticated exactly like the code exchange —
287
+ // same Basic header. The public-client form (client_id in the body, no
288
+ // secret) is rejected with 401.
289
+ const basic = Buffer.from(
290
+ `${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
291
+ ).toString("base64");
292
+
293
+ const res = await fetch(ONE_TOKEN_URL, {
294
+ method: "POST",
295
+ headers: {
296
+ Authorization: `Basic ${basic}`,
297
+ "Content-Type": "application/x-www-form-urlencoded",
298
+ },
299
+ body: new URLSearchParams({
300
+ grant_type: "refresh_token",
301
+ refresh_token: t.refreshToken,
302
+ }),
303
+ });
304
+ if (!res.ok) throw new Error("One refresh failed — re-run the connect flow");
305
+
306
+ const tokens = await res.json();
307
+ await saveOneTokens(userId, { // BOTH tokens — rotation!
308
+ accessToken: tokens.access_token,
309
+ refreshToken: tokens.refresh_token,
310
+ expiresAt: Date.now() + tokens.expires_in * 1000,
311
+ });
312
+ return tokens.access_token;
313
+ }
314
+ ```
315
+
316
+ ## 6 · Backend — using the grant
317
+
318
+ The bearer token works on One's standard `/v1` API — the same routes every other credential uses.
319
+
320
+ ```typescript
321
+ const token = await getOneAccessToken(userId);
322
+
323
+ // Discover what the user granted. Ungranted connections are invisible,
324
+ // not merely forbidden.
325
+ const res = await fetch("https://api.withone.ai/v1/connections", {
326
+ headers: { Authorization: `Bearer ${token}` },
327
+ });
328
+ ```
329
+
330
+ Execute actions through `/v1/passthrough/*` with the same bearer. Every call is checked inside One against what the user granted — a call outside the grant returns `403`, and your code cannot override it. That is the point.
331
+
332
+ ## Custom completion pages
333
+
334
+ The standard integration needs no completion page: your callback's final redirect carries `?one_connect=success` on any same-origin URL, and the SDK reads it off the frame directly.
335
+
336
+ If you render your own completion page instead, signal the SDK explicitly:
337
+
338
+ ```tsx
339
+ import { completeOneConnect } from "@withone/connect";
340
+
341
+ // Returns false when there was nothing to do (not inside a frame) —
342
+ // render fallback UI in that case.
343
+ completeOneConnect({ status: "success" });
344
+ ```
345
+
346
+ ## What your users see
347
+
348
+ In their own One dashboard, your app appears under **Authorized apps** with what they granted and when it was last used. They can revoke it at any time — handle `401`/`403` by prompting them to reconnect.
349
+
350
+ In *your* dashboard, your OAuth app lists every user who granted access, and you can revoke individual users too.
351
+
352
+ ## Security notes
353
+
354
+ - The client secret lives on your server only. Authenticate the token exchange with the `Authorization: Basic` header, as shown above.
355
+ - The authorization code is single-use and expires in 10 minutes.
356
+ - The SDK never handles tokens. It opens One's card in a modal iframe and watches for the `?one_connect=` result — there is nothing sensitive in the browser to leak.
357
+ - The SDK only trusts messages originating from its own iframe, and only accepts results from your own origin.
358
+
359
+ ## License
360
+
361
+ This project is licensed under the GPL-3.0 license. See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,12 @@
1
+ import type { OneConnectResult } from "./types";
2
+ /**
3
+ * OPTIONAL. The standard integration needs no completion page at all:
4
+ * the callback route's final redirect carries ?one_connect=success (or
5
+ * error) on any same-origin URL, and the SDK reads it off the frame's
6
+ * location directly. Use this helper only if you render a custom
7
+ * completion page and want to signal the SDK from it explicitly.
8
+ *
9
+ * Returns false when it had nothing to do (not inside a frame) — e.g.
10
+ * the user opened the page directly. Render fallback UI in that case.
11
+ */
12
+ export declare function completeOneConnect(result?: OneConnectResult): boolean;
@@ -0,0 +1,15 @@
1
+ /** postMessage envelope type for the result, completion page → host. */
2
+ export declare const MESSAGE_TYPE = "@withone/connect:result";
3
+ /** Posted by One's connect page (cross-origin) when the user closes the
4
+ * card without a result. */
5
+ export declare const EXIT_MESSAGE_TYPE = "@withone/connect:exit";
6
+ /** Query params the consumer's callback route puts on its final
7
+ * redirect (any same-origin URL). The SDK reads them straight off the
8
+ * iframe's location once the frame is back on the consumer's origin —
9
+ * no completion page, no postMessage required. */
10
+ export declare const RETURN_STATUS_PARAM = "one_connect";
11
+ export declare const RETURN_MESSAGE_PARAM = "one_connect_message";
12
+ /** Fragment key on the authorize URL carrying the app-chosen theme.
13
+ * A fragment never reaches any server and survives the whole redirect
14
+ * chain, so the consumer's backend forwards nothing. */
15
+ export declare const THEME_PARAM = "one_theme";
@@ -0,0 +1,3 @@
1
+ export { useOneConnect } from "./useOneConnect";
2
+ export { completeOneConnect } from "./complete";
3
+ export type { OneConnectProps, OneConnectHandle, OneConnectResult, } from "./types";
@@ -0,0 +1 @@
1
+ const e="@withone/connect:result",t="one-connect-frame";function n(){const e=document.getElementById(t);e&&e.remove()}const o="one-connect-success";function i(){var e;null===(e=document.getElementById(o))||void 0===e||e.remove()}function r(){return document.getElementById(t)}const s=s=>{let a=null,c=!1;const d=()=>{"undefined"!=typeof window&&a&&(window.removeEventListener("message",a),a=null),n()},l=(e,t)=>{if(!c){c=!0,d(),"success"===e&&function(e){var t;i();const n="dark"===e,r=document.createElement("div");r.id=o,Object.assign(r.style,{position:"fixed",inset:"0",zIndex:"2147483000",display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(8, 8, 8, 0.5)",opacity:"0",transition:"opacity 160ms ease"});const s=document.createElement("div");Object.assign(s.style,{width:"320px",maxWidth:"calc(100vw - 32px)",padding:"40px 32px",borderRadius:"28px",background:n?"rgba(25, 25, 25, 0.97)":"rgba(255, 255, 255, 0.97)",border:"1px solid "+(n?"rgba(255,255,255,0.08)":"rgba(228,228,223,0.9)"),display:"flex",flexDirection:"column",alignItems:"center",gap:"16px",textAlign:"center",fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"});const a=n?"#a1a1aa":"#6b7280";s.style.position="relative",s.innerHTML=`<button data-one-close aria-label="Close" style="position:absolute;top:16px;right:16px;width:20px;height:20px;padding:0;border:0;background:none;cursor:pointer;color:${a};line-height:0;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button><div style="width:56px;height:56px;border-radius:50%;background:#10b981;display:flex;align-items:center;justify-content:center;"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div><div style="font-size:17px;font-weight:600;letter-spacing:-0.01em;color:${n?"#fafafa":"#111114"};">Access granted</div><div style="font-size:13px;line-height:1.5;max-width:240px;color:${a};">Your tools are connected. You can pick up right where you left off.</div><button data-one-close style="width:100%;margin-top:8px;padding:10px 0;border:0;border-radius:12px;cursor:pointer;font-size:14px;font-weight:500;background:${n?"#fafafa":"#111114"};color:${n?"#111114":"#fafafa"};">Close</button>`,r.appendChild(s),document.body.appendChild(r),requestAnimationFrame(()=>{r.style.opacity="1"});const c=()=>{window.removeEventListener("keydown",d),r.style.opacity="0",window.setTimeout(()=>r.remove(),180)};function d(e){"Escape"===e.key&&c()}for(const e of s.querySelectorAll("[data-one-close]"))e.addEventListener("click",c);r.addEventListener("click",e=>{e.target===r&&c()}),window.addEventListener("keydown",d),null===(t=s.querySelector("[data-one-close]"))||void 0===t||t.focus()}(s.appTheme);try{var n,r;if("success"===e)null===(n=s.onSuccess)||void 0===n||n.call(s);else null===(r=s.onError)||void 0===r||r.call(s,null!=t?t:"The connection was not completed.")}catch{}}},u=t=>{const n=t.data;if(!n)return;const o=r();if(o&&t.source===o.contentWindow)if("@withone/connect:exit"!==n.type)n.type!==e||t.origin!==window.location.origin||"success"!==n.status&&"error"!==n.status||l(n.status,n.message);else{d();try{var i;null===(i=s.onClose)||void 0===i||i.call(s)}catch{}}},p=()=>{var e;const t=r();if(!t)return;let n,o;try{var i,s;n=null!==(i=null===(s=t.contentWindow)||void 0===s?void 0:s.location.href)&&void 0!==i?i:""}catch{return}try{o=new URL(n).searchParams}catch{return}const a=o.get("one_connect");"success"!==a&&"error"!==a||(t.style.visibility="hidden",l(a,null!==(e=o.get("one_connect_message"))&&void 0!==e?e:void 0))};return{open:()=>{if("undefined"==typeof window)return;c=!1,a=u,window.addEventListener("message",a);(function(e){n();const o=document.createElement("iframe");return o.id=t,o.src=e,o.setAttribute("allowtransparency","true"),Object.assign(o.style,{position:"fixed",inset:"0",width:"100%",height:"100%",border:"0",zIndex:"2147483000",background:"transparent",colorScheme:"normal"}),document.body.appendChild(o),o})((()=>{try{const e=new URL(s.authorize.url);return s.appTheme&&(e.hash=`one_theme=${s.appTheme}`),e.toString()}catch{return s.authorize.url}})()).addEventListener("load",p)},close:()=>{i(),d()}}};function a(t={status:"success"}){if("undefined"==typeof window)return!1;if(window.parent&&window.parent!==window){const n={type:e,status:t.status,message:t.message};try{return window.parent.postMessage(n,window.location.origin),!0}catch{return!1}}return!1}export{a as completeOneConnect,s as useOneConnect};
@@ -0,0 +1 @@
1
+ "use strict";const e="@withone/connect:result",t="one-connect-frame";function n(){const e=document.getElementById(t);e&&e.remove()}const o="one-connect-success";function i(){var e;null===(e=document.getElementById(o))||void 0===e||e.remove()}function r(){return document.getElementById(t)}exports.completeOneConnect=function(t={status:"success"}){if("undefined"==typeof window)return!1;if(window.parent&&window.parent!==window){const n={type:e,status:t.status,message:t.message};try{return window.parent.postMessage(n,window.location.origin),!0}catch{return!1}}return!1},exports.useOneConnect=s=>{let c=null,a=!1;const d=()=>{"undefined"!=typeof window&&c&&(window.removeEventListener("message",c),c=null),n()},l=(e,t)=>{if(!a){a=!0,d(),"success"===e&&function(e){var t;i();const n="dark"===e,r=document.createElement("div");r.id=o,Object.assign(r.style,{position:"fixed",inset:"0",zIndex:"2147483000",display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(8, 8, 8, 0.5)",opacity:"0",transition:"opacity 160ms ease"});const s=document.createElement("div");Object.assign(s.style,{width:"320px",maxWidth:"calc(100vw - 32px)",padding:"40px 32px",borderRadius:"28px",background:n?"rgba(25, 25, 25, 0.97)":"rgba(255, 255, 255, 0.97)",border:"1px solid "+(n?"rgba(255,255,255,0.08)":"rgba(228,228,223,0.9)"),display:"flex",flexDirection:"column",alignItems:"center",gap:"16px",textAlign:"center",fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"});const c=n?"#a1a1aa":"#6b7280";s.style.position="relative",s.innerHTML=`<button data-one-close aria-label="Close" style="position:absolute;top:16px;right:16px;width:20px;height:20px;padding:0;border:0;background:none;cursor:pointer;color:${c};line-height:0;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button><div style="width:56px;height:56px;border-radius:50%;background:#10b981;display:flex;align-items:center;justify-content:center;"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div><div style="font-size:17px;font-weight:600;letter-spacing:-0.01em;color:${n?"#fafafa":"#111114"};">Access granted</div><div style="font-size:13px;line-height:1.5;max-width:240px;color:${c};">Your tools are connected. You can pick up right where you left off.</div><button data-one-close style="width:100%;margin-top:8px;padding:10px 0;border:0;border-radius:12px;cursor:pointer;font-size:14px;font-weight:500;background:${n?"#fafafa":"#111114"};color:${n?"#111114":"#fafafa"};">Close</button>`,r.appendChild(s),document.body.appendChild(r),requestAnimationFrame(()=>{r.style.opacity="1"});const a=()=>{window.removeEventListener("keydown",d),r.style.opacity="0",window.setTimeout(()=>r.remove(),180)};function d(e){"Escape"===e.key&&a()}for(const e of s.querySelectorAll("[data-one-close]"))e.addEventListener("click",a);r.addEventListener("click",e=>{e.target===r&&a()}),window.addEventListener("keydown",d),null===(t=s.querySelector("[data-one-close]"))||void 0===t||t.focus()}(s.appTheme);try{var n,r;if("success"===e)null===(n=s.onSuccess)||void 0===n||n.call(s);else null===(r=s.onError)||void 0===r||r.call(s,null!=t?t:"The connection was not completed.")}catch{}}},u=t=>{const n=t.data;if(!n)return;const o=r();if(o&&t.source===o.contentWindow)if("@withone/connect:exit"!==n.type)n.type!==e||t.origin!==window.location.origin||"success"!==n.status&&"error"!==n.status||l(n.status,n.message);else{d();try{var i;null===(i=s.onClose)||void 0===i||i.call(s)}catch{}}},p=()=>{var e;const t=r();if(!t)return;let n,o;try{var i,s;n=null!==(i=null===(s=t.contentWindow)||void 0===s?void 0:s.location.href)&&void 0!==i?i:""}catch{return}try{o=new URL(n).searchParams}catch{return}const c=o.get("one_connect");"success"!==c&&"error"!==c||(t.style.visibility="hidden",l(c,null!==(e=o.get("one_connect_message"))&&void 0!==e?e:void 0))};return{open:()=>{if("undefined"==typeof window)return;a=!1,c=u,window.addEventListener("message",c);(function(e){n();const o=document.createElement("iframe");return o.id=t,o.src=e,o.setAttribute("allowtransparency","true"),Object.assign(o.style,{position:"fixed",inset:"0",width:"100%",height:"100%",border:"0",zIndex:"2147483000",background:"transparent",colorScheme:"normal"}),document.body.appendChild(o),o})((()=>{try{const e=new URL(s.authorize.url);return s.appTheme&&(e.hash=`one_theme=${s.appTheme}`),e.toString()}catch{return s.authorize.url}})()).addEventListener("load",p)},close:()=>{i(),d()}}};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public types for @withone/connect.
3
+ *
4
+ * The SDK deliberately knows nothing about OAuth internals: state, PKCE
5
+ * and the client secret live on the consumer's backend (see README).
6
+ * The SDK only opens One's connect experience as a modal over the host
7
+ * page and reports how the flow ended.
8
+ */
9
+
10
+ /** Result posted back from the consumer's completion page. */
11
+ export interface OneConnectResult {
12
+ status: "success" | "error";
13
+ /** Human-readable detail for the error case. */
14
+ message?: string;
15
+ }
16
+
17
+ export interface OneConnectProps {
18
+ /**
19
+ * The consumer's OWN backend route that starts the flow. It must
20
+ * generate `state` + PKCE, set them in an httpOnly cookie, and 302
21
+ * to One's /oauth/authorize (full recipe in the README). Must be an
22
+ * absolute URL.
23
+ */
24
+ authorize: {
25
+ url: string;
26
+ };
27
+ /** Theme for One's card. Carried on the URL fragment (#one_theme=…),
28
+ * which survives the redirect chain — the consumer's backend forwards
29
+ * nothing. */
30
+ appTheme?: "dark" | "light";
31
+ /** Fired when the completion page reports success. The token exchange
32
+ * already happened on the consumer's backend by this point. */
33
+ onSuccess?: () => void;
34
+ /** Fired when the completion page reports an error. */
35
+ onError?: (error: string) => void;
36
+ /** Fired when the user closes the card without a result. */
37
+ onClose?: () => void;
38
+ }
39
+
40
+ export interface OneConnectHandle {
41
+ /** Opens One's connect modal over the current page. */
42
+ open: () => void;
43
+ /** Tears everything down: modal frame + listeners. */
44
+ close: () => void;
45
+ }
46
+
47
+ /** Message posted from the completion page up to the host page. */
48
+ export interface OneConnectMessage {
49
+ type: string; // MESSAGE_TYPE constant
50
+ status: "success" | "error";
51
+ message?: string;
52
+ }
@@ -0,0 +1,2 @@
1
+ import type { OneConnectHandle, OneConnectProps } from "./types";
2
+ export declare const useOneConnect: (props: OneConnectProps) => OneConnectHandle;
@@ -0,0 +1,13 @@
1
+ export declare const IFRAME_ID = "one-connect-frame";
2
+ export declare function createEmbedIframe(url: string): HTMLIFrameElement;
3
+ export declare function removeEmbedIframe(): void;
4
+ export declare const SUCCESS_ID = "one-connect-success";
5
+ /** The "Access granted" confirmation shown after the grant completes —
6
+ * the same beat as authkit's "Connection established" screen, and
7
+ * dismissed the same way: the user closes it with the ✕ or the Close
8
+ * button, never a timer. By this point the card's iframe has already
9
+ * navigated home and been removed, so the SDK paints this itself.
10
+ * Pure inline styles — the SDK ships no CSS and loads no assets. */
11
+ export declare function showSuccessOverlay(theme?: "dark" | "light"): void;
12
+ export declare function removeSuccessOverlay(): void;
13
+ export declare function getEmbedIframe(): HTMLIFrameElement | null;
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@withone/connect",
3
+ "version": "0.1.0",
4
+ "description": "Frontend bindings for One Connect, a drop-in OAuth flow that lets your users grant your application scoped, revocable access to their own connected tools. The user owns the connections; you hold a token scoped to exactly what they granted.",
5
+ "files": [
6
+ "dist",
7
+ "src",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "main": "dist/index.umd.js",
12
+ "module": "dist/index.esm.js",
13
+ "types": "dist/index.d.ts",
14
+ "devDependencies": {
15
+ "@babel/core": "^7.24.0",
16
+ "@babel/plugin-transform-class-properties": "^7.28.6",
17
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
18
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
19
+ "@babel/preset-env": "^7.24.0",
20
+ "@babel/preset-typescript": "^7.28.5",
21
+ "@rollup/plugin-babel": "^6.0.4",
22
+ "@rollup/plugin-commonjs": "^25.0.7",
23
+ "@rollup/plugin-json": "^6.1.0",
24
+ "@rollup/plugin-node-resolve": "^15.2.3",
25
+ "@rollup/plugin-terser": "^0.4.4",
26
+ "@tsconfig/recommended": "^1.0.3",
27
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
28
+ "@typescript-eslint/parser": "^7.0.0",
29
+ "eslint": "^8.57.0",
30
+ "prettier": "^3.2.5",
31
+ "rollup": "^4.12.0",
32
+ "rollup-plugin-copy": "^3.5.0",
33
+ "rollup-plugin-typescript2": "^0.36.0",
34
+ "tslib": "^2.6.2",
35
+ "typescript": "^5.3.3"
36
+ },
37
+ "author": "@withoneai",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "prepublishOnly": "npm run build",
43
+ "build": "rollup -c && npm run verify",
44
+ "verify": "node -e \"const fs=require('fs'),p=require('./package.json');const miss=[p.main,p.module,p.types].filter(f=>!fs.existsSync(f));if(miss.length){console.error('Build output missing: '+miss.join(', '));process.exit(1)}console.log('Build verified: '+[p.main,p.module,p.types].join(', '))\"",
45
+ "lint": "eslint src --ext .ts,.tsx",
46
+ "format": "prettier --write \"src/**/*.{ts,tsx}\""
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/withoneai/connect"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/withoneai/connect/issues"
54
+ },
55
+ "keywords": [
56
+ "withone",
57
+ "oauth",
58
+ "integrations",
59
+ "agents"
60
+ ],
61
+ "license": "GPL-3.0",
62
+ "homepage": "https://withone.ai"
63
+ }
@@ -0,0 +1,36 @@
1
+ import { MESSAGE_TYPE } from "./constants";
2
+ import type { OneConnectMessage, OneConnectResult } from "./types";
3
+
4
+ /**
5
+ * OPTIONAL. The standard integration needs no completion page at all:
6
+ * the callback route's final redirect carries ?one_connect=success (or
7
+ * error) on any same-origin URL, and the SDK reads it off the frame's
8
+ * location directly. Use this helper only if you render a custom
9
+ * completion page and want to signal the SDK from it explicitly.
10
+ *
11
+ * Returns false when it had nothing to do (not inside a frame) — e.g.
12
+ * the user opened the page directly. Render fallback UI in that case.
13
+ */
14
+ export function completeOneConnect(
15
+ result: OneConnectResult = { status: "success" }
16
+ ): boolean {
17
+ if (typeof window === "undefined") return false;
18
+
19
+ if (window.parent && window.parent !== window) {
20
+ const message: OneConnectMessage = {
21
+ type: MESSAGE_TYPE,
22
+ status: result.status,
23
+ message: result.message,
24
+ };
25
+ try {
26
+ // Target the consumer origin only — never "*". The host page
27
+ // additionally checks event.origin before trusting the message.
28
+ window.parent.postMessage(message, window.location.origin);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ return false;
36
+ }
@@ -0,0 +1,23 @@
1
+ // Wire-protocol constants shared by useOneConnect (host page) and
2
+ // completeOneConnect (the consumer's completion page). Changing any of
3
+ // these is a breaking change between SDK versions running on the two
4
+ // pages — bump with care.
5
+
6
+ /** postMessage envelope type for the result, completion page → host. */
7
+ export const MESSAGE_TYPE = "@withone/connect:result";
8
+
9
+ /** Posted by One's connect page (cross-origin) when the user closes the
10
+ * card without a result. */
11
+ export const EXIT_MESSAGE_TYPE = "@withone/connect:exit";
12
+
13
+ /** Query params the consumer's callback route puts on its final
14
+ * redirect (any same-origin URL). The SDK reads them straight off the
15
+ * iframe's location once the frame is back on the consumer's origin —
16
+ * no completion page, no postMessage required. */
17
+ export const RETURN_STATUS_PARAM = "one_connect";
18
+ export const RETURN_MESSAGE_PARAM = "one_connect_message";
19
+
20
+ /** Fragment key on the authorize URL carrying the app-chosen theme.
21
+ * A fragment never reaches any server and survives the whole redirect
22
+ * chain, so the consumer's backend forwards nothing. */
23
+ export const THEME_PARAM = "one_theme";
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { useOneConnect } from "./useOneConnect";
2
+ export { completeOneConnect } from "./complete";
3
+ export type {
4
+ OneConnectProps,
5
+ OneConnectHandle,
6
+ OneConnectResult,
7
+ } from "./types";