@withone/connect 0.8.2 → 0.9.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.
Files changed (55) hide show
  1. package/README.md +158 -370
  2. package/dist/button.d.ts +9 -10
  3. package/dist/constants.d.ts +8 -4
  4. package/dist/index.cjs.js +1 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.esm.js +1 -1
  7. package/dist/next.cjs.js +104 -0
  8. package/dist/next.d.ts +36 -0
  9. package/dist/next.esm.js +98 -0
  10. package/dist/node.cjs.js +69 -0
  11. package/dist/node.d.ts +33 -0
  12. package/dist/node.esm.js +67 -0
  13. package/dist/platforms.d.ts +21 -0
  14. package/dist/react.cjs.js +1 -1
  15. package/dist/react.d.ts +2 -19
  16. package/dist/react.esm.js +1 -1
  17. package/dist/return.d.ts +11 -0
  18. package/dist/server/index.cjs.js +349 -0
  19. package/dist/server/index.d.ts +32 -0
  20. package/dist/server/index.esm.js +344 -0
  21. package/dist/server/oauth.d.ts +24 -0
  22. package/dist/server/types.d.ts +136 -0
  23. package/dist/svelte.cjs.js +1 -1
  24. package/dist/svelte.d.ts +14 -4
  25. package/dist/svelte.esm.js +1 -1
  26. package/dist/types.d.ts +80 -0
  27. package/dist/useOneConnect.d.ts +13 -2
  28. package/dist/vue.cjs.js +1 -1
  29. package/dist/vue.d.ts +20 -18
  30. package/dist/vue.esm.js +1 -1
  31. package/dist/wrapper-options.d.ts +20 -15
  32. package/package.json +37 -15
  33. package/skills/one-connect/SKILL.md +194 -0
  34. package/src/button.ts +106 -137
  35. package/src/constants.ts +10 -4
  36. package/src/index.ts +17 -2
  37. package/src/next.ts +133 -0
  38. package/src/node.ts +107 -0
  39. package/src/platforms.ts +89 -0
  40. package/src/react.ts +23 -66
  41. package/src/return.ts +30 -0
  42. package/src/server/index.ts +382 -0
  43. package/src/server/oauth.ts +98 -0
  44. package/src/server/types.ts +155 -0
  45. package/src/svelte.ts +15 -15
  46. package/src/types.ts +87 -0
  47. package/src/useOneConnect.ts +36 -69
  48. package/src/vue.ts +23 -29
  49. package/src/wrapper-options.ts +46 -23
  50. package/dist/index.umd.js +0 -1
  51. package/dist/types/index.d.ts +0 -92
  52. package/src/react-types.d.ts +0 -23
  53. package/src/svelte-types.d.ts +0 -27
  54. package/src/types/index.d.ts +0 -92
  55. package/src/vue-types.d.ts +0 -16
@@ -0,0 +1,382 @@
1
+ /**
2
+ * `@withone/connect/server`: the half of One Connect that runs on the
3
+ * app's server. It starts the flow, completes it, keeps the tokens fresh
4
+ * and makes every call with the grant. The client secret never leaves
5
+ * here.
6
+ *
7
+ * const oneConnect = createOneConnect({ clientId, clientSecret,
8
+ * redirectUri, permissionSet, tokenStore });
9
+ *
10
+ * // in the authorize route
11
+ * const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
12
+ * // in the callback route
13
+ * const result = await oneConnect.completeAuthorization({ userId, url, getCookie });
14
+ * // afterwards
15
+ * const rows = await oneConnect.listConnections(userId);
16
+ * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
17
+ *
18
+ * The Next.js and Node adapters turn the first two into route handlers.
19
+ */
20
+ import { DEFAULT_ONE_API_URL, RETURN_MESSAGE_PARAM, RETURN_STATUS_PARAM } from "../constants";
21
+ import {
22
+ DEFAULT_SCOPES,
23
+ basicAuthorization,
24
+ createPkceVerifier,
25
+ createState,
26
+ pkceChallenge,
27
+ tenancyHeaders,
28
+ txCookie,
29
+ txCookieName,
30
+ } from "./oauth";
31
+ import {
32
+ OneConnectError,
33
+ type CompleteAuthorizationInput,
34
+ type CompleteAuthorizationResult,
35
+ type OneConnectServerConfig,
36
+ type OneConnectTokens,
37
+ type PlatformAction,
38
+ type ReachableConnection,
39
+ type RunActionInput,
40
+ type RunActionResult,
41
+ type StartAuthorizationInput,
42
+ type StartAuthorizationResult,
43
+ } from "./types";
44
+
45
+ export * from "./types";
46
+ export { tenancyHeaders, tokenScopes } from "./oauth";
47
+
48
+ /** Refresh this long before expiry, so a call never races the clock. */
49
+ const REFRESH_MARGIN_MS = 60_000;
50
+ const CATALOG_PAGE_SIZE = 100;
51
+ const CATALOG_MAX_PAGES = 20;
52
+
53
+ interface TokenResponse {
54
+ access_token: string;
55
+ refresh_token: string;
56
+ expires_in: number;
57
+ }
58
+
59
+ export interface OneConnect {
60
+ /** The authorize leg: where to send the browser and the cookie to set. */
61
+ startAuthorization: (
62
+ input?: StartAuthorizationInput,
63
+ ) => StartAuthorizationResult;
64
+ /** The callback leg: verifies state, exchanges the code, stores the
65
+ * tokens, and says where to send the browser next. Never throws for
66
+ * a failed flow; read `outcome`. */
67
+ completeAuthorization: (
68
+ input: CompleteAuthorizationInput,
69
+ ) => Promise<CompleteAuthorizationResult>;
70
+ /** Whether the user has tokens stored. */
71
+ isConnected: (userId: string) => Promise<boolean>;
72
+ /** A live access token, refreshed first when it is about to expire. */
73
+ getAccessToken: (userId: string) => Promise<string>;
74
+ /** The stored tokens, for display. Null when not connected. */
75
+ getTokens: (userId: string) => Promise<OneConnectTokens | null>;
76
+ /** Forces a refresh now. */
77
+ refreshTokens: (userId: string) => Promise<OneConnectTokens>;
78
+ /** Drops the app's copy of the tokens. The user revokes the grant
79
+ * itself from their One dashboard. */
80
+ disconnect: (userId: string) => Promise<void>;
81
+ /** The connections the grant reaches, each with its access. */
82
+ listConnections: (userId: string) => Promise<ReachableConnection[]>;
83
+ /** Every catalog action of a platform. What exists, not what is
84
+ * permitted; the grant decides that when the action runs. */
85
+ listActions: (userId: string, platform: string) => Promise<PlatformAction[]>;
86
+ /** Runs one action through One with the grant. */
87
+ runAction: (userId: string, input: RunActionInput) => Promise<RunActionResult>;
88
+ /** Any authenticated request to One's /v1 API, headers handled. */
89
+ fetch: (
90
+ userId: string,
91
+ path: string,
92
+ init?: RequestInit,
93
+ ) => Promise<Response>;
94
+ }
95
+
96
+ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
97
+ const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
98
+ const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
99
+ const tokenUrl = `${oneApiUrl}/oauth/token`;
100
+ const apiUrl = `${oneApiUrl}/v1`;
101
+ const returnTo = config.returnTo ?? "/";
102
+ const scopes = config.scopes ?? DEFAULT_SCOPES;
103
+ const { tokenStore } = config;
104
+
105
+ /** One refresh in flight per user: two concurrent refreshes with the
106
+ * same refresh token trip One's reuse detection. */
107
+ const refreshing = new Map<string, Promise<OneConnectTokens>>();
108
+
109
+ const returnUrl = (status: "success" | "error", message?: string): string => {
110
+ const url = new URL(returnTo, config.redirectUri);
111
+ url.searchParams.set(RETURN_STATUS_PARAM, status);
112
+ if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
113
+ return url.toString();
114
+ };
115
+
116
+ const exchange = async (body: URLSearchParams): Promise<TokenResponse> => {
117
+ const response = await fetch(tokenUrl, {
118
+ method: "POST",
119
+ headers: {
120
+ Authorization: basicAuthorization(config.clientId, config.clientSecret),
121
+ "Content-Type": "application/x-www-form-urlencoded",
122
+ },
123
+ body,
124
+ });
125
+ if (!response.ok) {
126
+ throw new OneConnectError(
127
+ "request_failed",
128
+ `One refused the token request (HTTP ${response.status}).`,
129
+ response.status,
130
+ );
131
+ }
132
+ return (await response.json()) as TokenResponse;
133
+ };
134
+
135
+ const toTokens = (response: TokenResponse): OneConnectTokens => ({
136
+ accessToken: response.access_token,
137
+ refreshToken: response.refresh_token,
138
+ expiresAt: Date.now() + response.expires_in * 1000,
139
+ });
140
+
141
+ const startAuthorization = (
142
+ input: StartAuthorizationInput = {},
143
+ ): StartAuthorizationResult => {
144
+ const state = createState();
145
+ const verifier = createPkceVerifier();
146
+ const url = new URL(authorizeUrl);
147
+ url.searchParams.set("client_id", config.clientId);
148
+ url.searchParams.set("redirect_uri", config.redirectUri);
149
+ url.searchParams.set("response_type", "code");
150
+ url.searchParams.set("scope", scopes.join(" "));
151
+ url.searchParams.set("state", state);
152
+ url.searchParams.set("code_challenge", pkceChallenge(verifier));
153
+ url.searchParams.set("code_challenge_method", "S256");
154
+ if (config.permissionSet)
155
+ url.searchParams.set("permission_set", config.permissionSet);
156
+ if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
157
+ return {
158
+ redirectUrl: url.toString(),
159
+ cookie: txCookie(state, verifier, config.redirectUri),
160
+ };
161
+ };
162
+
163
+ const completeAuthorization = async (
164
+ input: CompleteAuthorizationInput,
165
+ ): Promise<CompleteAuthorizationResult> => {
166
+ const params = new URL(input.url).searchParams;
167
+ const code = params.get("code");
168
+ const state = params.get("state");
169
+ const oauthError = params.get("error");
170
+ const cookieName = state ? txCookieName(state) : undefined;
171
+ const verifier = cookieName ? input.getCookie(cookieName) : undefined;
172
+
173
+ const fail = (
174
+ outcome: "declined" | "failed",
175
+ message: string,
176
+ ): CompleteAuthorizationResult => ({
177
+ outcome,
178
+ message,
179
+ redirectUrl: returnUrl("error", message),
180
+ clearCookieName: cookieName,
181
+ });
182
+
183
+ if (oauthError === "access_denied")
184
+ return fail("declined", "You cancelled the request.");
185
+ if (oauthError)
186
+ return fail("failed", `One reported an error: ${oauthError}.`);
187
+ // The returned state names its own cookie. No cookie means a forged
188
+ // or stale state; the code is never exchanged in that case.
189
+ if (!code || !state || !verifier)
190
+ return fail("failed", "The sign-in attempt expired or was tampered with.");
191
+
192
+ try {
193
+ const tokens = toTokens(
194
+ await exchange(
195
+ new URLSearchParams({
196
+ grant_type: "authorization_code",
197
+ code,
198
+ redirect_uri: config.redirectUri,
199
+ code_verifier: verifier,
200
+ }),
201
+ ),
202
+ );
203
+ await tokenStore.saveTokens(input.userId, tokens);
204
+ } catch (error) {
205
+ const status = error instanceof OneConnectError ? error.status : undefined;
206
+ return fail(
207
+ "failed",
208
+ status
209
+ ? `One rejected the code exchange (HTTP ${status}).`
210
+ : "One could not be reached to complete the connection.",
211
+ );
212
+ }
213
+
214
+ return {
215
+ outcome: "connected",
216
+ redirectUrl: returnUrl("success"),
217
+ clearCookieName: cookieName,
218
+ };
219
+ };
220
+
221
+ const refreshTokens = (userId: string): Promise<OneConnectTokens> => {
222
+ const inFlight = refreshing.get(userId);
223
+ if (inFlight) return inFlight;
224
+ const job = (async () => {
225
+ const current = await tokenStore.loadTokens(userId);
226
+ if (!current)
227
+ throw new OneConnectError("not_connected", "This user is not connected.");
228
+ try {
229
+ const next = toTokens(
230
+ await exchange(
231
+ new URLSearchParams({
232
+ grant_type: "refresh_token",
233
+ refresh_token: current.refreshToken,
234
+ }),
235
+ ),
236
+ );
237
+ // Both tokens: One rotates the pair on every refresh.
238
+ await tokenStore.saveTokens(userId, next);
239
+ return next;
240
+ } catch (error) {
241
+ // The family is dead: revoked, expired or reused. Keeping the
242
+ // pair would only fail again; the user has to reconnect.
243
+ await tokenStore.clearTokens(userId);
244
+ const status = error instanceof OneConnectError ? error.status : undefined;
245
+ throw new OneConnectError(
246
+ "refresh_failed",
247
+ "The connection to One has expired or was revoked. Ask the user to connect again.",
248
+ status,
249
+ );
250
+ } finally {
251
+ refreshing.delete(userId);
252
+ }
253
+ })();
254
+ refreshing.set(userId, job);
255
+ return job;
256
+ };
257
+
258
+ const getAccessToken = async (userId: string): Promise<string> => {
259
+ const tokens = await tokenStore.loadTokens(userId);
260
+ if (!tokens)
261
+ throw new OneConnectError("not_connected", "This user is not connected.");
262
+ if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
263
+ return (await refreshTokens(userId)).accessToken;
264
+ };
265
+
266
+ const oneFetch = async (
267
+ userId: string,
268
+ path: string,
269
+ init: RequestInit = {},
270
+ ): Promise<Response> => {
271
+ const accessToken = await getAccessToken(userId);
272
+ const headers = new Headers(init.headers);
273
+ headers.set("Authorization", `Bearer ${accessToken}`);
274
+ for (const [name, value] of Object.entries(tenancyHeaders(accessToken)))
275
+ headers.set(name, value);
276
+ return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
277
+ ...init,
278
+ headers,
279
+ });
280
+ };
281
+
282
+ const listConnections = async (userId: string): Promise<ReachableConnection[]> => {
283
+ const response = await oneFetch(userId, "/connections/reachable");
284
+ if (!response.ok) {
285
+ throw new OneConnectError(
286
+ "request_failed",
287
+ `One refused the connections request (HTTP ${response.status}).`,
288
+ response.status,
289
+ );
290
+ }
291
+ const body = (await response.json()) as { rows?: ReachableConnection[] };
292
+ return body.rows ?? [];
293
+ };
294
+
295
+ const listActions = async (
296
+ userId: string,
297
+ platform: string,
298
+ ): Promise<PlatformAction[]> => {
299
+ const pageUrl = (page: number) =>
300
+ `/knowledge?connectionPlatform=${encodeURIComponent(platform)}&limit=${CATALOG_PAGE_SIZE}&page=${page}`;
301
+ const slim = (rows: Record<string, unknown>[]): PlatformAction[] =>
302
+ rows.map((row) => ({
303
+ _id: String(row._id ?? ""),
304
+ title: String(row.title ?? ""),
305
+ method: String(row.method ?? ""),
306
+ path: String(row.path ?? ""),
307
+ }));
308
+ const first = await oneFetch(userId, pageUrl(1));
309
+ if (!first.ok) {
310
+ throw new OneConnectError(
311
+ "request_failed",
312
+ `One refused the catalog request (HTTP ${first.status}).`,
313
+ first.status,
314
+ );
315
+ }
316
+ const page1 = (await first.json()) as {
317
+ rows?: Record<string, unknown>[];
318
+ pages?: number;
319
+ };
320
+ const pages = Math.min(Math.max(page1.pages ?? 1, 1), CATALOG_MAX_PAGES);
321
+ const rest = await Promise.all(
322
+ Array.from({ length: pages - 1 }, (_, index) =>
323
+ oneFetch(userId, pageUrl(index + 2))
324
+ .then((response) =>
325
+ response.ok
326
+ ? (response.json() as Promise<{ rows?: Record<string, unknown>[] }>)
327
+ : null,
328
+ )
329
+ .catch(() => null),
330
+ ),
331
+ );
332
+ return slim([page1, ...rest].flatMap((page) => page?.rows ?? []));
333
+ };
334
+
335
+ const runAction = async (
336
+ userId: string,
337
+ input: RunActionInput,
338
+ ): Promise<RunActionResult> => {
339
+ const method = input.method.toUpperCase();
340
+ const query = input.query ? `?${new URLSearchParams(input.query)}` : "";
341
+ const headers: Record<string, string> = {
342
+ "x-one-connection-key": input.connectionKey,
343
+ "x-one-action-id": input.actionId,
344
+ };
345
+ const hasBody = method !== "GET" && method !== "HEAD" && input.body !== undefined;
346
+ if (hasBody) headers["Content-Type"] = "application/json";
347
+ const response = await oneFetch(userId, `/passthrough${input.path}${query}`, {
348
+ method,
349
+ headers,
350
+ body: hasBody ? JSON.stringify(input.body) : undefined,
351
+ });
352
+ const text = await response.text();
353
+ let data: unknown = text;
354
+ try {
355
+ data = JSON.parse(text);
356
+ } catch {
357
+ /* the provider answered with something other than JSON */
358
+ }
359
+ // One's own refusals carry a correlationId; a provider's error does
360
+ // not. A One-shaped 403 is the grant working, not the provider.
361
+ const blockedByGrant =
362
+ !response.ok &&
363
+ typeof data === "object" &&
364
+ data !== null &&
365
+ "correlationId" in (data as Record<string, unknown>);
366
+ return { status: response.status, ok: response.ok, blockedByGrant, data };
367
+ };
368
+
369
+ return {
370
+ startAuthorization,
371
+ completeAuthorization,
372
+ isConnected: async (userId) => (await tokenStore.loadTokens(userId)) !== null,
373
+ getAccessToken,
374
+ getTokens: (userId) => tokenStore.loadTokens(userId),
375
+ refreshTokens,
376
+ disconnect: (userId) => tokenStore.clearTokens(userId),
377
+ listConnections,
378
+ listActions,
379
+ runAction,
380
+ fetch: oneFetch,
381
+ };
382
+ }
@@ -0,0 +1,98 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import type { OneConnectCookie } from "./types";
4
+
5
+ /** One cookie per flow, named by its state, so a retry or a second tab
6
+ * never overwrites the verifier of a flow still in progress. */
7
+ export const TX_COOKIE_PREFIX = "one_tx_";
8
+
9
+ /** One gives the consent 10 minutes and then the code 10 minutes, so a
10
+ * legitimate callback can arrive up to twenty minutes after the start.
11
+ * Thirty minutes leaves a margin. */
12
+ export const TX_COOKIE_MAX_AGE_SECONDS = 1800;
13
+
14
+ export const DEFAULT_SCOPES = [
15
+ "user:connections:read",
16
+ "user:connections:write",
17
+ "org:connections:read",
18
+ "org:connections:write",
19
+ "project:connections:read",
20
+ "project:connections:write",
21
+ ];
22
+
23
+ export function createState(): string {
24
+ return randomBytes(16).toString("hex");
25
+ }
26
+
27
+ export function createPkceVerifier(): string {
28
+ return randomBytes(32).toString("base64url");
29
+ }
30
+
31
+ export function pkceChallenge(verifier: string): string {
32
+ return createHash("sha256").update(verifier).digest("base64url");
33
+ }
34
+
35
+ export function txCookieName(state: string): string {
36
+ return `${TX_COOKIE_PREFIX}${state}`;
37
+ }
38
+
39
+ /** The cookie scoped to the routes that need it: the directory of the
40
+ * callback path, which is also where the authorize route lives. */
41
+ export function txCookie(
42
+ state: string,
43
+ verifier: string,
44
+ redirectUri: string,
45
+ ): OneConnectCookie {
46
+ const url = new URL(redirectUri);
47
+ const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
48
+ return {
49
+ name: txCookieName(state),
50
+ value: verifier,
51
+ options: {
52
+ httpOnly: true,
53
+ secure: url.protocol === "https:",
54
+ sameSite: "lax",
55
+ maxAge: TX_COOKIE_MAX_AGE_SECONDS,
56
+ path,
57
+ },
58
+ };
59
+ }
60
+
61
+ export function basicAuthorization(
62
+ clientId: string,
63
+ clientSecret: string,
64
+ ): string {
65
+ return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
66
+ }
67
+
68
+ /** The tenant headers a grant needs on every call. A grant made into an
69
+ * organization or project lives there; One resolves the tenant from
70
+ * these headers, and without them the call runs in the user's personal
71
+ * space. The access token's claims name the tenant, so echo them. */
72
+ export function tenancyHeaders(accessToken: string): Record<string, string> {
73
+ try {
74
+ const payload = JSON.parse(
75
+ Buffer.from(accessToken.split(".")[1], "base64url").toString(),
76
+ ) as { organization_ids?: string[]; project_ids?: string[] };
77
+ const headers: Record<string, string> = {};
78
+ const organizationId = payload.organization_ids?.[0];
79
+ const projectId = payload.project_ids?.[0];
80
+ if (organizationId) headers["X-One-Organization-Id"] = organizationId;
81
+ if (projectId) headers["X-One-Project-Id"] = projectId;
82
+ return headers;
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
88
+ /** The scopes the token was granted, from its claims. Display only. */
89
+ export function tokenScopes(accessToken: string): string[] {
90
+ try {
91
+ const payload = JSON.parse(
92
+ Buffer.from(accessToken.split(".")[1], "base64url").toString(),
93
+ ) as { scope?: string };
94
+ return (payload.scope ?? "").split(" ").filter(Boolean);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Types for `@withone/connect/server`: the half of the SDK that runs on
3
+ * the app's server and holds the client secret.
4
+ */
5
+
6
+ /** What the app stores per user after the exchange. */
7
+ export interface OneConnectTokens {
8
+ accessToken: string;
9
+ refreshToken: string;
10
+ /** Epoch milliseconds when the access token expires. */
11
+ expiresAt: number;
12
+ }
13
+
14
+ /**
15
+ * Where the app keeps each user's tokens: its database, a cache, an
16
+ * encrypted cookie. The SDK never sees a token outside these three
17
+ * calls. `userId` is the app's own id for its user.
18
+ */
19
+ export interface OneConnectTokenStore {
20
+ saveTokens: (userId: string, tokens: OneConnectTokens) => Promise<void>;
21
+ loadTokens: (userId: string) => Promise<OneConnectTokens | null>;
22
+ clearTokens: (userId: string) => Promise<void>;
23
+ }
24
+
25
+ export interface OneConnectServerConfig {
26
+ /** The app's client id from the dashboard. */
27
+ clientId: string;
28
+ /** The app's client secret. Server only. */
29
+ clientSecret: string;
30
+ /** Exactly the callback URL registered on the app, character for
31
+ * character, for example "https://yourapp.com/api/one/callback". */
32
+ redirectUri: string;
33
+ /** The ask (permission set) to open the consent card on. Omit to ask
34
+ * for everything the user has connected. */
35
+ permissionSet?: string;
36
+ /** One's API origin. Production when omitted. */
37
+ oneApiUrl?: string;
38
+ /** Where the callback sends the browser afterwards. "/" when omitted;
39
+ * the outcome is appended as `?one_connect=…`. */
40
+ returnTo?: string;
41
+ /** OAuth scopes. All three tenancy tiers when omitted, so the user may
42
+ * grant from any space. */
43
+ scopes?: string[];
44
+ tokenStore: OneConnectTokenStore;
45
+ }
46
+
47
+ /** The transaction cookie the authorize leg sets and the callback reads. */
48
+ export interface OneConnectCookie {
49
+ name: string;
50
+ value: string;
51
+ options: {
52
+ httpOnly: true;
53
+ secure: boolean;
54
+ sameSite: "lax";
55
+ maxAge: number;
56
+ path: string;
57
+ };
58
+ }
59
+
60
+ export interface StartAuthorizationInput {
61
+ /** Pre-fills the sign-in email on One's page. Never locks it. */
62
+ loginHint?: string;
63
+ }
64
+
65
+ export interface StartAuthorizationResult {
66
+ /** Send the browser here with a 302. */
67
+ redirectUrl: string;
68
+ /** Set this cookie on the same response. */
69
+ cookie: OneConnectCookie;
70
+ }
71
+
72
+ export interface CompleteAuthorizationInput {
73
+ /** The app's user the grant belongs to. */
74
+ userId: string;
75
+ /** The full callback URL One redirected to, with its query string. */
76
+ url: string;
77
+ /** Reads a cookie by name from the incoming request. */
78
+ getCookie: (name: string) => string | undefined;
79
+ }
80
+
81
+ export type AuthorizationOutcome = "connected" | "declined" | "failed";
82
+
83
+ export interface CompleteAuthorizationResult {
84
+ outcome: AuthorizationOutcome;
85
+ /** Safe to show to the user when the outcome is not "connected". */
86
+ message?: string;
87
+ /** Send the browser here with a 302; it carries `?one_connect=…`. */
88
+ redirectUrl: string;
89
+ /** Delete this cookie on the same response, when one was involved. */
90
+ clearCookieName?: string;
91
+ }
92
+
93
+ /** One connection the grant reaches, with the access it confers. */
94
+ export interface ReachableConnection {
95
+ key: string;
96
+ platform: string;
97
+ name?: string;
98
+ title?: string;
99
+ image?: string;
100
+ access: ConnectionAccess;
101
+ }
102
+
103
+ export type ConnectionAccess =
104
+ | { policy: "full" }
105
+ | { policy: "methods"; methods: string[] }
106
+ | {
107
+ policy: "actions";
108
+ actions: { actionId: string; title: string; method: string }[];
109
+ };
110
+
111
+ /** One catalog action for a platform. */
112
+ export interface PlatformAction {
113
+ _id: string;
114
+ title: string;
115
+ method: string;
116
+ path: string;
117
+ }
118
+
119
+ export interface RunActionInput {
120
+ /** From `listConnections`. */
121
+ connectionKey: string;
122
+ /** From `listActions`. */
123
+ actionId: string;
124
+ method: string;
125
+ /** The action's path, appended to /v1/passthrough. */
126
+ path: string;
127
+ body?: unknown;
128
+ query?: Record<string, string>;
129
+ }
130
+
131
+ export interface RunActionResult {
132
+ status: number;
133
+ ok: boolean;
134
+ /** True when One refused the call because it is outside the grant.
135
+ * The provider was never called. Do not retry. */
136
+ blockedByGrant: boolean;
137
+ data: unknown;
138
+ }
139
+
140
+ export type OneConnectErrorCode =
141
+ | "not_connected"
142
+ | "refresh_failed"
143
+ | "request_failed";
144
+
145
+ export class OneConnectError extends Error {
146
+ readonly code: OneConnectErrorCode;
147
+ readonly status?: number;
148
+
149
+ constructor(code: OneConnectErrorCode, message: string, status?: number) {
150
+ super(message);
151
+ this.name = "OneConnectError";
152
+ this.code = code;
153
+ this.status = status;
154
+ }
155
+ }
package/src/svelte.ts CHANGED
@@ -1,37 +1,37 @@
1
1
  /**
2
- * Svelte — `import { connectButton } from "@withone/connect/svelte"`.
2
+ * Svelte: `import { connectButton } from "@withone/connect/svelte"`.
3
3
  *
4
- * A Svelte ACTION (the idiomatic Svelte shape for DOM-mounting
5
- * libraries), so no Svelte compiler or dependency is involved:
4
+ * A Svelte action, the idiomatic shape for DOM-mounting libraries, so no
5
+ * Svelte compiler or dependency is involved:
6
6
  *
7
7
  * <div use:connectButton={{ authorizeUrl: "/api/one/authorize",
8
- * platforms: [{ name: "Stripe", imageUrl: "/icons/stripe.svg" }],
8
+ * platforms: ["stripe", "notion"],
9
9
  * onSuccess: () => { ... } }} />
10
10
  */
11
- import { mountConnectButton } from "./button";
12
11
  import {
13
- optionsFromWrapperProps,
14
- type ConnectButtonWrapperProps,
15
- } from "./wrapper-options";
16
- import type { ConnectButtonHandle } from "./types";
12
+ mountConnectButton,
13
+ optionsFromProps,
14
+ type ConnectButtonHandle,
15
+ type ConnectButtonProps,
16
+ } from "@withone/connect";
17
17
 
18
- export type { ConnectButtonWrapperProps as ConnectButtonProps };
18
+ export type { ConnectButtonProps };
19
19
 
20
20
  export function connectButton(
21
21
  node: HTMLElement,
22
- props: ConnectButtonWrapperProps,
22
+ props: ConnectButtonProps,
23
23
  ): {
24
- update: (next: ConnectButtonWrapperProps) => void;
24
+ update: (next: ConnectButtonProps) => void;
25
25
  destroy: () => void;
26
26
  } {
27
27
  let handle: ConnectButtonHandle = mountConnectButton(
28
28
  node,
29
- optionsFromWrapperProps(props),
29
+ optionsFromProps(props),
30
30
  );
31
31
  return {
32
- update(next: ConnectButtonWrapperProps) {
32
+ update(next: ConnectButtonProps) {
33
33
  handle.destroy();
34
- handle = mountConnectButton(node, optionsFromWrapperProps(next));
34
+ handle = mountConnectButton(node, optionsFromProps(next));
35
35
  },
36
36
  destroy() {
37
37
  handle.destroy();