@keystrokehq/notion 0.0.9 → 0.0.11

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,770 @@
1
+ import { CredentialSet, Operation } from "@keystrokehq/core";
2
+ import { z } from "zod";
3
+
4
+ //#region ../../packages/integration-authoring/dist/official/runtime.mjs
5
+ const REGISTRY_KEY = "__ks_official_integration_metadata_registry";
6
+ function getRegistry() {
7
+ const globalStore = globalThis;
8
+ if (!globalStore[REGISTRY_KEY]) globalStore[REGISTRY_KEY] = /* @__PURE__ */ new WeakMap();
9
+ return globalStore[REGISTRY_KEY];
10
+ }
11
+ function registerOfficialOperation(operation, metadata) {
12
+ getRegistry().set(operation, Object.freeze({ ...metadata }));
13
+ }
14
+
15
+ //#endregion
16
+ //#region ../../packages/integration-authoring/dist/official/index.mjs
17
+ const OFFICIAL_CREDENTIAL_SET_ID_PREFIX = "keystroke:";
18
+ function officialCredentialSetId(id) {
19
+ return `${OFFICIAL_CREDENTIAL_SET_ID_PREFIX}${id}`;
20
+ }
21
+ function stripOfficialCredentialSetIdPrefix(id) {
22
+ return id.startsWith(OFFICIAL_CREDENTIAL_SET_ID_PREFIX) ? id.slice(10) : id;
23
+ }
24
+ /**
25
+ * Creates a factory for Keystroke-official integration operations.
26
+ *
27
+ * It keeps the same flat `run(input, credentials)` ergonomics as the generic
28
+ * operation factory, while registering official metadata for builder/runtime
29
+ * operation metadata.
30
+ */
31
+ function createOfficialOperationFactory(credentialSet) {
32
+ const integrationId = stripOfficialCredentialSetIdPrefix(credentialSet.id);
33
+ return (config) => {
34
+ const wrappedRun = async (input, ctx) => {
35
+ const creds = ctx.credentials[credentialSet.id];
36
+ return config.run(input, creds);
37
+ };
38
+ const operation = new Operation({
39
+ id: config.id,
40
+ name: config.name,
41
+ description: config.description,
42
+ input: config.input,
43
+ output: config.output,
44
+ credentialSets: [credentialSet],
45
+ ...config.tags !== void 0 ? { tags: config.tags } : {},
46
+ ...config.needsApproval !== void 0 ? { needsApproval: config.needsApproval } : {},
47
+ ...config.requiredOAuthScopes !== void 0 ? { requiredOAuthScopes: config.requiredOAuthScopes } : {},
48
+ ...config.retries !== void 0 ? { retries: config.retries } : {},
49
+ ...config.timeout !== void 0 ? { timeout: config.timeout } : {},
50
+ ...config.maxDurationMs !== void 0 ? { maxDurationMs: config.maxDurationMs } : {},
51
+ run: wrappedRun
52
+ });
53
+ registerOfficialOperation(operation, { integrationId });
54
+ return operation;
55
+ };
56
+ }
57
+ /**
58
+ * Creates an official integration bundle from a single config object.
59
+ *
60
+ * - Creates the public `CredentialSet` internally.
61
+ * - Accepts optional `internal` fields for Keystroke-owned platform credentials.
62
+ */
63
+ function defineOfficialIntegration(config) {
64
+ const internalCredentialSets = config.internal ?? {};
65
+ const allInternalCredentialSets = [
66
+ ...internalCredentialSets.providerApp ? [internalCredentialSets.providerApp] : [],
67
+ ...internalCredentialSets.providerAppVariants ?? [],
68
+ ...internalCredentialSets.webhookVerification ? [internalCredentialSets.webhookVerification] : [],
69
+ ...internalCredentialSets.other ?? []
70
+ ];
71
+ const credentialSet = new CredentialSet({
72
+ id: config.id,
73
+ name: config.name,
74
+ description: config.description,
75
+ auth: config.auth,
76
+ ...config.connections ? { connections: config.connections } : {},
77
+ ...config.proxy ? { proxy: config.proxy } : {},
78
+ ...config.needsRawSecret === true ? { needsRawSecret: true } : {}
79
+ });
80
+ return Object.freeze({
81
+ integration: {
82
+ id: config.id,
83
+ name: config.name,
84
+ ...config.description !== void 0 ? { description: config.description } : {},
85
+ auth: config.auth,
86
+ ...config.proxy ? { proxy: config.proxy } : {},
87
+ ...config.needsRawSecret === true ? { needsRawSecret: true } : {},
88
+ ...config.connections ? { connections: config.connections } : {}
89
+ },
90
+ credentialSet,
91
+ internalCredentialSets,
92
+ allInternalCredentialSets
93
+ });
94
+ }
95
+
96
+ //#endregion
97
+ //#region src/_official/provider-app.ts
98
+ const notionAppCredentialSet = new CredentialSet({
99
+ id: officialCredentialSetId("notion-app"),
100
+ exposure: "platform-only",
101
+ name: "Notion App",
102
+ auth: z.object({
103
+ clientId: z.string().min(1),
104
+ clientSecret: z.string().min(1)
105
+ })
106
+ });
107
+ const notionOfficialProviderSeed = {
108
+ provider: "notion",
109
+ appRef: "notion-official",
110
+ displayName: "Notion Platform",
111
+ credentialSetName: "Keystroke Notion Platform App",
112
+ envShape: {
113
+ KEYSTROKE_OFFICIAL_NOTION_CLIENT_ID: z.string().optional(),
114
+ KEYSTROKE_OFFICIAL_NOTION_CLIENT_SECRET: z.string().optional()
115
+ },
116
+ requiredEnvKeys: ["KEYSTROKE_OFFICIAL_NOTION_CLIENT_ID", "KEYSTROKE_OFFICIAL_NOTION_CLIENT_SECRET"],
117
+ externalAppIdEnvKey: "KEYSTROKE_OFFICIAL_NOTION_CLIENT_ID",
118
+ buildCredentials: (env) => ({
119
+ clientId: env.KEYSTROKE_OFFICIAL_NOTION_CLIENT_ID,
120
+ clientSecret: env.KEYSTROKE_OFFICIAL_NOTION_CLIENT_SECRET
121
+ })
122
+ };
123
+ const notionWebhookCredentialSet = new CredentialSet({
124
+ id: officialCredentialSetId("notion-webhook"),
125
+ exposure: "platform-only",
126
+ name: "Notion Webhook",
127
+ auth: z.object({ verificationToken: z.string().min(1) })
128
+ });
129
+
130
+ //#endregion
131
+ //#region ../../packages/credential-connection/dist/chunk-DQk6qfdC.mjs
132
+ var __defProp = Object.defineProperty;
133
+ var __exportAll = (all, no_symbols) => {
134
+ let target = {};
135
+ for (var name in all) __defProp(target, name, {
136
+ get: all[name],
137
+ enumerable: true
138
+ });
139
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
140
+ return target;
141
+ };
142
+
143
+ //#endregion
144
+ //#region ../../packages/credential-connection/dist/defaults-YE9AvLIc.mjs
145
+ /**
146
+ * Shared OAuth 2.0 token-response plumbing.
147
+ *
148
+ * `parseOAuthTokenResponse` handles the two common wire formats (JSON and
149
+ * form-urlencoded, the latter used by GitHub unless you explicitly ask for
150
+ * JSON). `normalizeOAuthTokens` pulls the standard fields out of the parsed
151
+ * body in both the flat form and Slack's nested `authed_user.access_token`
152
+ * form. These helpers are exposed so integration authors overriding
153
+ * `exchangeCode` / `refreshToken` do not have to re-implement them.
154
+ */
155
+ var TokenExchangeError = class extends Error {
156
+ httpStatus;
157
+ constructor(httpStatus) {
158
+ super(`Token exchange failed: HTTP ${httpStatus}`);
159
+ this.name = "TokenExchangeError";
160
+ this.httpStatus = httpStatus;
161
+ }
162
+ };
163
+ async function parseOAuthTokenResponse(response) {
164
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
165
+ if (contentType.includes("application/json")) return await response.json();
166
+ const text = await response.text();
167
+ if (!text.trim()) return {};
168
+ if (contentType.includes("application/x-www-form-urlencoded") || text.includes("=")) {
169
+ const params = new URLSearchParams(text);
170
+ return Object.fromEntries(params.entries());
171
+ }
172
+ throw new Error(`Unsupported OAuth token response content type: ${contentType || "unknown"}`);
173
+ }
174
+ function normalizeOAuthTokens(tokenData) {
175
+ const authedUser = tokenData.authed_user;
176
+ const rawAccessToken = tokenData.access_token ?? authedUser?.access_token;
177
+ const rawRefreshToken = tokenData.refresh_token;
178
+ const rawExpiresIn = tokenData.expires_in;
179
+ const rawInstanceUrl = tokenData.instance_url;
180
+ return {
181
+ accessToken: typeof rawAccessToken === "string" ? rawAccessToken : void 0,
182
+ refreshToken: typeof rawRefreshToken === "string" ? rawRefreshToken : void 0,
183
+ expiresIn: typeof rawExpiresIn === "number" ? rawExpiresIn : typeof rawExpiresIn === "string" ? Number(rawExpiresIn) || void 0 : void 0,
184
+ instanceUrl: typeof rawInstanceUrl === "string" ? rawInstanceUrl : void 0
185
+ };
186
+ }
187
+ function buildAuthUrl(ctx) {
188
+ const { oauthClient, redirectUri, state, connection } = ctx;
189
+ const scopes = ctx.requestedScopes ?? connection.scopes;
190
+ const url = new URL(connection.authUrl);
191
+ url.searchParams.set("client_id", oauthClient.clientId);
192
+ url.searchParams.set("scope", scopes.join(" "));
193
+ url.searchParams.set("redirect_uri", redirectUri);
194
+ url.searchParams.set("state", state);
195
+ url.searchParams.set("response_type", "code");
196
+ return url.toString();
197
+ }
198
+ async function exchangeToken(params) {
199
+ const response = await fetch(params.tokenUrl, {
200
+ method: "POST",
201
+ headers: {
202
+ Accept: "application/json",
203
+ "Content-Type": "application/x-www-form-urlencoded"
204
+ },
205
+ body: params.body
206
+ });
207
+ if (!response.ok) throw new TokenExchangeError(response.status);
208
+ const raw = await parseOAuthTokenResponse(response);
209
+ const { accessToken, refreshToken, expiresIn, instanceUrl } = normalizeOAuthTokens(raw);
210
+ if (!accessToken) throw new Error("No access token in response");
211
+ return {
212
+ accessToken,
213
+ refreshToken,
214
+ expiresIn,
215
+ instanceUrl,
216
+ raw
217
+ };
218
+ }
219
+ async function exchangeCode(ctx) {
220
+ const { oauthClient, code, redirectUri, connection } = ctx;
221
+ if (!code) throw new Error("Missing authorization code");
222
+ return exchangeToken({
223
+ tokenUrl: connection.tokenUrl,
224
+ body: new URLSearchParams({
225
+ code,
226
+ client_id: oauthClient.clientId,
227
+ client_secret: oauthClient.clientSecret,
228
+ redirect_uri: redirectUri,
229
+ grant_type: "authorization_code"
230
+ })
231
+ });
232
+ }
233
+ async function refreshToken(ctx) {
234
+ const { oauthClient, refreshToken, connection } = ctx;
235
+ return exchangeToken({
236
+ tokenUrl: connection.tokenUrl,
237
+ body: new URLSearchParams({
238
+ grant_type: "refresh_token",
239
+ refresh_token: refreshToken,
240
+ client_id: oauthClient.clientId,
241
+ client_secret: oauthClient.clientSecret
242
+ })
243
+ });
244
+ }
245
+ const oauthDefaults = {
246
+ buildAuthUrl,
247
+ exchangeCode,
248
+ exchangeToken,
249
+ refreshToken
250
+ };
251
+
252
+ //#endregion
253
+ //#region ../../packages/credential-connection/dist/index.mjs
254
+ /**
255
+ * Compose a base hook with zero or more customizers.
256
+ *
257
+ * Each customizer wraps the result of the previous. Execution order for
258
+ * `pipe(base, a, b, c)` on input `ctx`:
259
+ *
260
+ * ```
261
+ * c-pre → b-pre → a-pre → base(ctx) → a-post → b-post → c-post
262
+ * ```
263
+ *
264
+ * Most presets in `./presets` only post-process, so ordering among them does
265
+ * not matter. Presets that pre-process (rewrite `ctx` before `next`) state
266
+ * their ordering sensitivity in their TSDoc.
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * import { oauthDefaults, oauthPresets as p, pipe } from '@keystrokehq/credential-connection';
271
+ *
272
+ * buildAuthUrl: pipe(
273
+ * oauthDefaults.buildAuthUrl,
274
+ * p.scopeDelimiter(','),
275
+ * p.extraAuthParams({ prompt: 'consent' })
276
+ * );
277
+ * ```
278
+ */
279
+ function pipe(base, ...customizers) {
280
+ return customizers.reduce((acc, customizer) => customizer(acc), base);
281
+ }
282
+ var presets_exports = /* @__PURE__ */ __exportAll({
283
+ basicAuthJsonBody: () => basicAuthJsonBody,
284
+ dynamicAuthUrlHost: () => dynamicAuthUrlHost,
285
+ dynamicTokenHost: () => dynamicTokenHost,
286
+ extraAuthParams: () => extraAuthParams,
287
+ fetchIdentityAfter: () => fetchIdentityAfter,
288
+ noScopeParam: () => noScopeParam,
289
+ rejectErrorField: () => rejectErrorField,
290
+ requireSuccessFlag: () => requireSuccessFlag,
291
+ scopeDelimiter: () => scopeDelimiter,
292
+ tokenExchangeUsing: () => tokenExchangeUsing
293
+ });
294
+ /**
295
+ * Replace the `scope` query parameter's delimiter on the authorize URL.
296
+ *
297
+ * The default OAuth 2.0 implementation joins scopes with spaces. Slack and
298
+ * Strava require commas.
299
+ *
300
+ * Ordering: post-processing only. Safe to compose in any order with other
301
+ * authorize-URL presets.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * buildAuthUrl: pipe(oauthDefaults.buildAuthUrl, scopeDelimiter(','));
306
+ * ```
307
+ *
308
+ * @see integrations/slack/src/oauth-connection.ts
309
+ */
310
+ function scopeDelimiter(delim) {
311
+ return (next) => async (ctx) => {
312
+ const built = await next(ctx);
313
+ const url = new URL(built);
314
+ const scopes = ctx.requestedScopes ?? ctx.connection.scopes;
315
+ url.searchParams.set("scope", scopes.join(delim));
316
+ return url.toString();
317
+ };
318
+ }
319
+ /**
320
+ * Remove the `scope` query parameter from the authorize URL entirely.
321
+ *
322
+ * Use this for providers that configure scopes at the app level (Notion,
323
+ * Intercom) rather than at authorize time.
324
+ *
325
+ * Ordering: post-processing only.
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * buildAuthUrl: pipe(oauthDefaults.buildAuthUrl, noScopeParam);
330
+ * ```
331
+ *
332
+ * @see integrations/notion/src/oauth-connection.ts
333
+ */
334
+ const noScopeParam = (next) => async (ctx) => {
335
+ const built = await next(ctx);
336
+ const url = new URL(built);
337
+ url.searchParams.delete("scope");
338
+ return url.toString();
339
+ };
340
+ /**
341
+ * Append extra query parameters to the authorize URL.
342
+ *
343
+ * Accepts a static record (for fixed parameters like Google's
344
+ * `access_type=offline`) or a function of the build context (for parameters
345
+ * that depend on `oauthClient.metadata` or `initiateInput`).
346
+ *
347
+ * Ordering: post-processing only.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * // Static
352
+ * extraAuthParams({ access_type: 'offline', prompt: 'consent' });
353
+ * // Dynamic (Shopify per-shop)
354
+ * extraAuthParams(({ initiateInput }) => ({ shop: String(initiateInput?.shop) }));
355
+ * ```
356
+ *
357
+ * @see integrations/google/src/oauth-connection.ts
358
+ */
359
+ function extraAuthParams(params) {
360
+ return (next) => async (ctx) => {
361
+ const built = await next(ctx);
362
+ const url = new URL(built);
363
+ const resolved = typeof params === "function" ? params(ctx) : params;
364
+ for (const [key, value] of Object.entries(resolved)) url.searchParams.set(key, value);
365
+ return url.toString();
366
+ };
367
+ }
368
+ /**
369
+ * Rewrite the authorize URL's host with a runtime-resolved origin.
370
+ *
371
+ * Use for providers where the OAuth host is tenant-specific (Shopify
372
+ * per-shop) or environment-specific (Salesforce sandbox vs production). The
373
+ * resolver should return a full origin string (`https://host[:port]`); the
374
+ * path, query, and hash from `ctx.connection.authUrl` are preserved.
375
+ *
376
+ * Ordering: pre-processing. Compose BEFORE post-processing presets so the
377
+ * final URL already reflects the resolved host.
378
+ *
379
+ * @example
380
+ * ```ts
381
+ * // Shopify
382
+ * dynamicAuthUrlHost(({ initiateInput }) => `https://${String(initiateInput?.shop)}`);
383
+ * // Salesforce sandbox
384
+ * dynamicAuthUrlHost(({ oauthClient }) =>
385
+ * oauthClient.metadata?.environment === 'sandbox'
386
+ * ? 'https://test.salesforce.com'
387
+ * : 'https://login.salesforce.com'
388
+ * );
389
+ * ```
390
+ */
391
+ function dynamicAuthUrlHost(resolve) {
392
+ return (next) => async (ctx) => {
393
+ const originalUrl = new URL(ctx.connection.authUrl);
394
+ const newBase = new URL(resolve(ctx));
395
+ originalUrl.host = newBase.host;
396
+ originalUrl.protocol = newBase.protocol;
397
+ if (newBase.port) originalUrl.port = newBase.port;
398
+ const rewrittenConnection = {
399
+ ...ctx.connection,
400
+ authUrl: originalUrl.toString()
401
+ };
402
+ return next({
403
+ ...ctx,
404
+ connection: rewrittenConnection
405
+ });
406
+ };
407
+ }
408
+ /**
409
+ * Matched exchange + refresh customizers that POST to the token endpoint
410
+ * using the selected authentication style and body format.
411
+ *
412
+ * This preset REPLACES `next`'s body-building logic entirely. Reach for it
413
+ * when the provider diverges fundamentally (JSON body, HTTP Basic auth)
414
+ * rather than just tweaking behavior around the default. Applying this via
415
+ * `pipe(default, tokenExchangeUsing({...}).exchangeCode)` discards `next`.
416
+ *
417
+ * @remarks
418
+ * Replacement, not wrapping. Stated twice because this is the single most
419
+ * error-prone preset shape in the catalog.
420
+ */
421
+ function tokenExchangeUsing(options) {
422
+ return {
423
+ exchangeCode: () => async ({ oauthClient, code, redirectUri, connection }) => {
424
+ if (!code) throw new Error("Missing authorization code");
425
+ return doTokenExchange(options.tokenUrl ?? connection.tokenUrl, options, oauthClient, {
426
+ grant_type: "authorization_code",
427
+ code,
428
+ redirect_uri: redirectUri
429
+ });
430
+ },
431
+ refreshToken: () => async ({ oauthClient, refreshToken, connection }) => {
432
+ return doTokenExchange(options.tokenUrl ?? connection.tokenUrl, options, oauthClient, {
433
+ grant_type: "refresh_token",
434
+ refresh_token: refreshToken
435
+ });
436
+ }
437
+ };
438
+ }
439
+ /**
440
+ * Shorthand for {@link tokenExchangeUsing} with `{ auth: 'basic', bodyFormat: 'json' }`.
441
+ *
442
+ * Notion and Asana both require HTTP Basic auth + a JSON body on their token
443
+ * endpoints.
444
+ *
445
+ * @remarks Replacement semantics inherited from {@link tokenExchangeUsing}.
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * const tokenExchange = basicAuthJsonBody();
450
+ * exchangeCode: pipe(oauthDefaults.exchangeCode, tokenExchange.exchangeCode);
451
+ * refreshToken: pipe(oauthDefaults.refreshToken, tokenExchange.refreshToken);
452
+ * ```
453
+ *
454
+ * @see integrations/notion/src/oauth-connection.ts
455
+ */
456
+ function basicAuthJsonBody() {
457
+ return tokenExchangeUsing({
458
+ auth: "basic",
459
+ bodyFormat: "json"
460
+ });
461
+ }
462
+ /**
463
+ * Rewrite the token endpoint's host with a runtime-resolved origin before
464
+ * invoking the downstream exchange / refresh hook.
465
+ *
466
+ * Use for providers where the token endpoint is tenant-specific (Shopify) or
467
+ * environment-specific (Salesforce, Gusto).
468
+ *
469
+ * Ordering: pre-processing. Compose BEFORE other token-exchange presets so
470
+ * they see the rewritten tokenUrl.
471
+ *
472
+ * @example
473
+ * ```ts
474
+ * // Salesforce sandbox
475
+ * dynamicTokenHost(({ oauthClient }) =>
476
+ * oauthClient.metadata?.environment === 'sandbox'
477
+ * ? 'https://test.salesforce.com'
478
+ * : 'https://login.salesforce.com'
479
+ * );
480
+ * ```
481
+ */
482
+ function dynamicTokenHost(resolve) {
483
+ return {
484
+ exchangeCode: (next) => async (ctx) => {
485
+ return next({
486
+ ...ctx,
487
+ connection: rewriteTokenUrl(ctx.connection, resolve(ctx))
488
+ });
489
+ },
490
+ refreshToken: (next) => async (ctx) => {
491
+ return next({
492
+ ...ctx,
493
+ connection: rewriteTokenUrl(ctx.connection, resolve(ctx))
494
+ });
495
+ }
496
+ };
497
+ }
498
+ /**
499
+ * Throw when the token response is missing a truthy success flag.
500
+ *
501
+ * Slack returns `{ ok: true | false, ... }` on its v2 access endpoint; an
502
+ * `ok: false` response still comes back HTTP 200 and often omits the
503
+ * `access_token` field, so the default `!response.ok` and
504
+ * "no access token" checks are both insufficient.
505
+ *
506
+ * @remarks REPLACEMENT, not wrapping. Because the flag check must happen
507
+ * against the raw response body before the normalization step that throws on
508
+ * missing `access_token`, this preset performs its own POST + parse instead
509
+ * of delegating to `next`. Apply via
510
+ * `pipe(oauthDefaults.exchangeCode, requireSuccessFlag('ok'))` for authoring
511
+ * symmetry with other presets; `oauthDefaults.exchangeCode` is effectively
512
+ * discarded.
513
+ *
514
+ * @throws An `Error` when `raw[key]` is falsy. The message includes
515
+ * `raw[errorKey]` when present, so the resulting error surface is
516
+ * actionable.
517
+ *
518
+ * @see integrations/slack/src/oauth-connection.ts
519
+ */
520
+ function requireSuccessFlag(key = "ok", errorKey = "error") {
521
+ return () => async ({ oauthClient, code, redirectUri, connection }) => {
522
+ if (!code) throw new Error("Missing authorization code");
523
+ const response = await fetch(connection.tokenUrl, {
524
+ method: "POST",
525
+ headers: {
526
+ Accept: "application/json",
527
+ "Content-Type": "application/x-www-form-urlencoded"
528
+ },
529
+ body: new URLSearchParams({
530
+ client_id: oauthClient.clientId,
531
+ client_secret: oauthClient.clientSecret,
532
+ code,
533
+ redirect_uri: redirectUri,
534
+ grant_type: "authorization_code"
535
+ })
536
+ });
537
+ if (!response.ok) throw new TokenExchangeError(response.status);
538
+ const raw = await parseOAuthTokenResponse(response);
539
+ if (!raw[key]) {
540
+ const error = raw[errorKey];
541
+ const reason = typeof error === "string" ? error : "unknown error";
542
+ throw new Error(`Token exchange failed: ${reason}`);
543
+ }
544
+ const { accessToken, refreshToken, expiresIn, instanceUrl } = normalizeOAuthTokens(raw);
545
+ if (!accessToken) throw new Error("No access token in response");
546
+ return {
547
+ accessToken,
548
+ refreshToken,
549
+ expiresIn,
550
+ instanceUrl,
551
+ raw
552
+ };
553
+ };
554
+ }
555
+ /**
556
+ * Throw when the token response carries a truthy `error` field.
557
+ *
558
+ * Intercom and some other providers report failures with HTTP 200 + an
559
+ * `error` / `error_description` pair. This preset converts that shape into a
560
+ * normalized exception.
561
+ *
562
+ * @remarks REPLACEMENT, not wrapping. Mirrors {@link requireSuccessFlag}:
563
+ * the error-field check must happen against the raw response body before
564
+ * the normalization step that throws on missing `access_token`, so this
565
+ * preset performs its own POST + parse instead of delegating to `next`.
566
+ *
567
+ * @throws An `Error` when `raw[errorKey]` is truthy. The message uses
568
+ * `raw[descriptionKey]` when present, otherwise falls back to
569
+ * `raw[errorKey]`.
570
+ */
571
+ function rejectErrorField(errorKey = "error", descriptionKey = "error_description") {
572
+ return () => async ({ oauthClient, code, redirectUri, connection }) => {
573
+ if (!code) throw new Error("Missing authorization code");
574
+ const response = await fetch(connection.tokenUrl, {
575
+ method: "POST",
576
+ headers: {
577
+ Accept: "application/json",
578
+ "Content-Type": "application/x-www-form-urlencoded"
579
+ },
580
+ body: new URLSearchParams({
581
+ client_id: oauthClient.clientId,
582
+ client_secret: oauthClient.clientSecret,
583
+ code,
584
+ redirect_uri: redirectUri,
585
+ grant_type: "authorization_code"
586
+ })
587
+ });
588
+ if (!response.ok) throw new TokenExchangeError(response.status);
589
+ const raw = await parseOAuthTokenResponse(response);
590
+ const error = raw[errorKey];
591
+ if (error) {
592
+ const description = raw[descriptionKey];
593
+ const reason = typeof description === "string" ? description : typeof error === "string" ? error : "unknown error";
594
+ throw new Error(`Token exchange failed: ${reason}`);
595
+ }
596
+ const { accessToken, refreshToken, expiresIn, instanceUrl } = normalizeOAuthTokens(raw);
597
+ if (!accessToken) throw new Error("No access token in response");
598
+ return {
599
+ accessToken,
600
+ refreshToken,
601
+ expiresIn,
602
+ instanceUrl,
603
+ raw
604
+ };
605
+ };
606
+ }
607
+ /**
608
+ * After a successful token exchange, call an identity / me endpoint and stash
609
+ * fields on `tokenResult.raw` under a namespaced prefix. Replaces the
610
+ * duplicated `fetch(...).json()` + `result.raw._providerFoo = ...` pattern in
611
+ * Linear, Google, Sentry, and Strava.
612
+ *
613
+ * Enrichment is best-effort — network errors and non-2xx responses during
614
+ * the identity fetch do not abort the exchange. If the identity data is
615
+ * required for correctness, use a raw {@link ExchangeCodeCustomizer} and
616
+ * throw explicitly.
617
+ *
618
+ * @remarks Stashed values are string-keyed on `raw`; reading them back in
619
+ * `extractInstallationInfo` or in a function-form `vault` requires a cast.
620
+ * That matches the status quo — a typed thread-through is a future
621
+ * improvement.
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * exchangeCode: pipe(
626
+ * oauthDefaults.exchangeCode,
627
+ * fetchIdentityAfter<LinearViewerResponse>({
628
+ * url: 'https://api.linear.app/graphql',
629
+ * method: 'POST',
630
+ * body: { query: '{ viewer { id name } organization { id name } }' },
631
+ * extract: (res) => ({
632
+ * viewer: res.data?.viewer,
633
+ * organization: res.data?.organization,
634
+ * }),
635
+ * namespace: '_linear',
636
+ * }),
637
+ * );
638
+ * ```
639
+ *
640
+ * @see integrations/linear/src/oauth-connection.ts
641
+ */
642
+ function fetchIdentityAfter(options) {
643
+ return (next) => async (ctx) => {
644
+ const result = await next(ctx);
645
+ const namespace = options.namespace ?? "_identity";
646
+ try {
647
+ const url = typeof options.url === "function" ? options.url(result) : options.url;
648
+ const extraHeaders = typeof options.headers === "function" ? options.headers(result) : options.headers;
649
+ const headers = {
650
+ Authorization: `Bearer ${result.accessToken}`,
651
+ Accept: "application/json",
652
+ ...extraHeaders
653
+ };
654
+ const requestInit = {
655
+ method: options.method ?? "GET",
656
+ headers
657
+ };
658
+ if (options.body !== void 0) {
659
+ headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
660
+ requestInit.body = JSON.stringify(options.body);
661
+ }
662
+ const response = await fetch(url, requestInit);
663
+ if (!response.ok) return result;
664
+ const json = await response.json();
665
+ const extracted = options.extract(json);
666
+ for (const [key, value] of Object.entries(extracted)) result.raw[`${namespace}:${key}`] = value;
667
+ } catch {}
668
+ return result;
669
+ };
670
+ }
671
+ async function doTokenExchange(tokenUrl, options, oauthClient, grantBody) {
672
+ const body = { ...grantBody };
673
+ const headers = { Accept: "application/json" };
674
+ if (options.auth === "basic") headers.Authorization = `Basic ${Buffer.from(`${oauthClient.clientId}:${oauthClient.clientSecret}`, "utf8").toString("base64")}`;
675
+ else {
676
+ body.client_id = oauthClient.clientId;
677
+ body.client_secret = oauthClient.clientSecret;
678
+ }
679
+ let requestBody;
680
+ if (options.bodyFormat === "json") {
681
+ headers["Content-Type"] = "application/json";
682
+ requestBody = JSON.stringify(body);
683
+ } else {
684
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
685
+ requestBody = new URLSearchParams(body);
686
+ }
687
+ const response = await fetch(tokenUrl, {
688
+ method: "POST",
689
+ headers,
690
+ body: requestBody
691
+ });
692
+ if (!response.ok) throw new TokenExchangeError(response.status);
693
+ const raw = await parseOAuthTokenResponse(response);
694
+ const { accessToken, refreshToken, expiresIn, instanceUrl } = normalizeOAuthTokens(raw);
695
+ if (!accessToken) throw new Error("No access token in response");
696
+ return {
697
+ accessToken,
698
+ refreshToken,
699
+ expiresIn,
700
+ instanceUrl,
701
+ raw
702
+ };
703
+ }
704
+ function rewriteTokenUrl(connection, resolved) {
705
+ const originalUrl = new URL(connection.tokenUrl);
706
+ const newBase = new URL(resolved);
707
+ originalUrl.host = newBase.host;
708
+ originalUrl.protocol = newBase.protocol;
709
+ if (newBase.port) originalUrl.port = newBase.port;
710
+ return {
711
+ ...connection,
712
+ tokenUrl: originalUrl.toString()
713
+ };
714
+ }
715
+
716
+ //#endregion
717
+ //#region src/oauth-connection.ts
718
+ const notionTokenExchange = presets_exports.basicAuthJsonBody();
719
+ const notionOAuthConnection = {
720
+ kind: "oauth",
721
+ tokenType: "refreshable",
722
+ authUrl: "https://api.notion.com/v1/oauth/authorize",
723
+ tokenUrl: "https://api.notion.com/v1/oauth/token",
724
+ revokeUrl: "https://api.notion.com/v1/oauth/revoke",
725
+ scopes: [
726
+ "content:read",
727
+ "content:write",
728
+ "comments:read",
729
+ "comments:write",
730
+ "users:read"
731
+ ],
732
+ vault: { accessToken: "NOTION_ACCESS_TOKEN" },
733
+ oauth: { id: "official.notion.oauth" },
734
+ buildAuthUrl: pipe(oauthDefaults.buildAuthUrl, presets_exports.noScopeParam, presets_exports.extraAuthParams({ owner: "user" })),
735
+ exchangeCode: pipe(oauthDefaults.exchangeCode, notionTokenExchange.exchangeCode),
736
+ refreshToken: pipe(oauthDefaults.refreshToken, notionTokenExchange.refreshToken)
737
+ };
738
+
739
+ //#endregion
740
+ //#region src/integration.ts
741
+ const notionAuthSchema = z.object({ NOTION_ACCESS_TOKEN: z.string().min(1) });
742
+ const notionOfficialIntegration = {
743
+ id: "notion",
744
+ name: "Notion",
745
+ description: "Notion pages, blocks, data sources, comments, users, search, and triggers",
746
+ auth: notionAuthSchema,
747
+ connections: [{
748
+ id: "oauth",
749
+ label: "Connect Notion workspace",
750
+ recommended: true,
751
+ ...notionOAuthConnection
752
+ }, {
753
+ id: "integration-token",
754
+ kind: "manual",
755
+ label: "Use internal integration token",
756
+ advanced: true,
757
+ input: notionAuthSchema
758
+ }]
759
+ };
760
+ const notionBundle = defineOfficialIntegration({
761
+ ...notionOfficialIntegration,
762
+ internal: {
763
+ providerApp: notionAppCredentialSet,
764
+ webhookVerification: notionWebhookCredentialSet
765
+ }
766
+ });
767
+ const notion = notionBundle.credentialSet;
768
+
769
+ //#endregion
770
+ export { notionOfficialProviderSeed as a, notionAppCredentialSet as i, notionBundle as n, notionWebhookCredentialSet as o, notionOfficialIntegration as r, createOfficialOperationFactory as s, notion as t };