import type { CalendarConnectionClient, CancelEventsScriptInput, CancelEventsScriptOutput, CreateEventsScriptInput, CreateEventsScriptOutput, UpdateEventsScriptInput, UpdateEventsScriptOutput, CreateSchedulingLinkScriptInput, CreateSchedulingLinkScriptOutput, UpdateSchedulingLinkScriptInput, UpdateSchedulingLinkScriptOutput, DeleteSchedulingLinksScriptInput, DeleteSchedulingLinksScriptOutput, ListCalendarsScriptInput, ListCalendarsScriptOutput, } from "./calendar.generated.js"; import { validateOAuthConfiguration, type OAuthConfiguration } from "./oauth.js"; import { connectionTriggerDefinitions } from "./connection-trigger-definitions.generated.js"; import type { WorkflowTrigger } from "./events.generated.js"; import type { Client } from "@notionhq/client"; import { connections as providerConnections, createConnectionClient, type ConnectionClients, } from "./providers.generated.js"; import type { ConnectionActionInvoker } from "./connection-actions.js"; const MAX_WORKFLOW_CONNECTIONS = 100; /** A setup requirement. The property name in a workflow supplies its key. */ export type WorkflowConnection = { type: TProvider; oauth?: OAuthConfiguration; calendar?: CalendarConnectionOptions; }; export type CalendarConnectionOptions = { targets: Record< string, { description: string; permissions: "read" | "read_write"; multiple: boolean } >; }; export type WorkflowConnectionDeclarations = Readonly>; /** A named target bound to its workflow's Calendar connection, not a provider ID. */ export type CalendarTriggerTarget = Pick< ListCalendarsScriptInput["calendars"], "target" > & { readonly connectionKey: TKey; }; type CalendarTargets< TOptions extends CalendarConnectionOptions, TBinding = Record, > = { readonly [TTarget in keyof TOptions["targets"]]: { readonly target: TTarget } & TBinding; }; /** Declaration-time target references. No provider clients or credentials are available here. */ export type WorkflowTriggerConnections = { readonly [ TKey in WorkflowConnectionKeys ]: TConnections[TKey] extends { calendar: infer TOptions extends CalendarConnectionOptions } ? { readonly targets: CalendarTargets } : never; }; /** Serialized connection requirement sent in the workflow manifest. */ export type WorkflowConnectionRequirement = WorkflowConnection & { key: string }; /** Declared property names belonging to one provider. */ export type WorkflowConnectionKeys< TConnections extends WorkflowConnectionDeclarations, TProvider extends string, > = Extract< { [TKey in keyof TConnections]: TConnections[TKey]["type"] extends TProvider ? TKey : never; }[keyof TConnections], string >; type ConnectionBinding = { key: string; type: string; id: string }; /** Requirements are declarations, not credentials or permission grants. */ export const connections = { ...providerConnections, oauth(options: OAuthConfiguration): WorkflowConnection<"oauth"> { validateOAuthConfiguration(options); return { type: "oauth", oauth: structuredClone(options) }; }, }; export type { OAuthConfiguration } from "./oauth.js"; export type OAuthConnection = { accessToken(): Promise }; type WorkflowConnectionClients = ConnectionClients & { oauth: OAuthConnection }; type WorkflowConnectionClient = WorkflowConnectionClients[keyof WorkflowConnectionClients]; /** Write groups require one calendar returned by listCalendars, never implicit fan-out. */ type CalendarWriteTarget = { [ TKey in keyof TOptions["targets"] ]: TOptions["targets"][TKey]["permissions"] extends "read_write" ? | { target: TKey; calendarKey: string } | (TOptions["targets"][TKey]["multiple"] extends false ? { target: TKey } : never) : never; }[keyof TOptions["targets"]]; type CalendarWorkflowClient = Omit< CalendarConnectionClient, | "listCalendars" | "createEvents" | "updateEvents" | "cancelEvents" | "createSchedulingLink" | "updateSchedulingLink" | "deleteSchedulingLinks" > & { readonly targets: CalendarTargets; listCalendars>( args: Omit & { calendars: ListCalendarsScriptInput["calendars"] & { target: TKey }; }, ): Promise>; createEvents( args: Omit & { events: Array< Omit & { calendar: CalendarWriteTarget; } >; }, ): Promise; updateEvents( args: Omit & { calendar: CalendarWriteTarget; }, ): Promise; cancelEvents( args: Omit & { calendar: CalendarWriteTarget; }, ): Promise; createSchedulingLink( args: Omit & { bookingCalendar: CalendarWriteTarget; }, ): Promise; updateSchedulingLink( args: Omit & { calendar: CalendarWriteTarget; patch?: Omit< NonNullable, "bookingCalendar" > & { bookingCalendar?: CalendarWriteTarget }; }, ): Promise; deleteSchedulingLinks( args: Omit & { calendar: CalendarWriteTarget; }, ): Promise; }; export type WorkflowConnections< TConnections extends WorkflowConnectionDeclarations = WorkflowConnectionDeclarations, > = { readonly [TKey in keyof TConnections]: TConnections[TKey] extends { calendar: infer TOptions extends CalendarConnectionOptions; } ? CalendarWorkflowClient : WorkflowConnectionClients[Extract< keyof WorkflowConnectionClients, TConnections[TKey]["type"] >]; }; /** @internal */ export function normalizeConnectionRequirements( declarations: WorkflowConnectionDeclarations, ): WorkflowConnectionRequirement[] { if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) { throw new Error("Workflow connections must be an object keyed by connection name."); } const prototype: unknown = Object.getPrototypeOf(declarations); if (prototype !== Object.prototype && prototype !== null) { throw new Error("Workflow connections must contain only own connection declarations."); } const keys = Object.keys(declarations); if (Reflect.ownKeys(declarations).length !== keys.length) { throw new Error("Workflow connection keys must be enumerable strings."); } if (keys.length > MAX_WORKFLOW_CONNECTIONS) throw new Error("Workflows support at most 100 connections."); if ( Object.values(declarations).filter((requirement) => requirement.type === "calendar") .length > 1 ) throw new Error("Declare one Calendar connection with reusable targets per workflow."); return Object.entries(declarations).map(([key, requirement]) => { if ( !/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(key) || ["__proto__", "prototype", "constructor"].includes(key) ) { throw new Error("Invalid workflow connection key: " + key); } if ( typeof requirement !== "object" || requirement === null || typeof requirement.type !== "string" || !requirement.type || !Object.hasOwn(requirement, "type") ) { throw new Error("A workflow connection needs a provider type."); } if ("key" in requirement) throw new Error( "Declare a connection key as its workflow object property, not in the provider options.", ); if (requirement.type === "calendar") { const options = requirement.calendar; if ( !options || !options.targets || Array.isArray(options.targets) || typeof options.targets !== "object" || Object.keys(options).some((key) => key !== "targets") ) throw new Error( "Calendar connections require named targets with descriptions and needed permissions.", ); const targets = Object.entries(options.targets); if ( targets.length === 0 || targets.length > MAX_WORKFLOW_CONNECTIONS || targets.some( ([key, target]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(key) || ["__proto__", "prototype", "constructor"].includes(key) || !target || typeof target.description !== "string" || !target.description.trim() || !["read", "read_write"].includes(target.permissions) || typeof target.multiple !== "boolean" || Object.keys(target).some( (key) => key !== "description" && key !== "permissions" && key !== "multiple", ), ) ) throw new Error( "Calendar targets need valid keys, descriptions, read or read_write permissions, and a multiple boolean.", ); } else if (requirement.calendar !== undefined) { throw new Error("Calendar options are only valid on Calendar connections."); } if (requirement.type === "oauth") { if (!requirement.oauth) throw new Error("OAuth connections require provider configuration."); validateOAuthConfiguration(requirement.oauth); } else if (requirement.oauth !== undefined) { throw new Error("OAuth configuration is only valid on OAuth connections."); } return { type: requirement.type, key, ...(requirement.calendar === undefined ? {} : { calendar: structuredClone(requirement.calendar) }), ...(requirement.oauth === undefined ? {} : { oauth: structuredClone(requirement.oauth) }), }; }); } /** @internal */ export function validateTriggerConnections( triggers: readonly WorkflowTrigger[], requirements: readonly WorkflowConnectionRequirement[], ): void { const seen = new Set(); for (const trigger of triggers) { const definition = connectionTriggerDefinitions.find( (definition) => definition.type === trigger.type, ); if ( definition?.provider === "calendar" && (!("calendarTarget" in trigger) || typeof trigger.calendarTarget !== "string" || !trigger.calendarTarget || !("connectionKey" in trigger) || typeof trigger.connectionKey !== "string" || !trigger.connectionKey) ) { throw new Error(`Trigger "${trigger.type}" requires a declared Calendar target.`); } if (!("connectionKey" in trigger) || trigger.connectionKey === undefined) continue; const requirement = requirements.find( (connection) => connection.key === trigger.connectionKey, ); if (!requirement || requirement.type !== definition?.provider) { throw new Error( `Trigger "${trigger.type}" requires a declared ${definition?.provider ?? "supported"} connection: ${trigger.connectionKey}`, ); } if ( "calendarTarget" in trigger && (!requirement.calendar || !Object.hasOwn(requirement.calendar.targets, trigger.calendarTarget)) ) { throw new Error( `Trigger "${trigger.type}" requires a declared Calendar target: ${trigger.calendarTarget}`, ); } const identity = JSON.stringify([trigger.type, trigger.connectionKey]); if (seen.has(identity)) throw new Error( `Duplicate connection trigger: ${trigger.type} (${trigger.connectionKey})`, ); seen.add(identity); } } /** @internal */ export function createWorkflowConnections( notion: Client, declarations: TConnections | undefined, ): WorkflowConnections; export function createWorkflowConnections( notion: Client, declarations: WorkflowConnectionDeclarations | undefined, ): Record { const requirements = normalizeConnectionRequirements(declarations ?? {}); // Capture this execution's metadata, but do not parse unused bindings. const rawBindings = process.env.NOTION_CONNECTION_BINDINGS; const clients: Record = {}; Object.setPrototypeOf(clients, null); for (const requirement of requirements) { let client: WorkflowConnectionClient | undefined; Object.defineProperty(clients, requirement.key, { enumerable: true, get() { if (client) return client; const binding = resolveBinding(requirement.key); if (binding.type !== requirement.type) { throw new Error( `Workflow connection "${requirement.key}" is ${binding.type}, not ${requirement.type}.`, ); } client = requirement.type === "oauth" ? createOAuthConnection(binding) : createConnectionClient(requirement.type, invoker(binding)); if (requirement.calendar) { Object.assign(client, { targets: Object.freeze( Object.fromEntries( Object.keys(requirement.calendar.targets).map((target) => [ target, Object.freeze({ target }), ]), ), ), }); } return client; }, }); } return Object.freeze(clients); function resolveBinding(key: string): ConnectionBinding { const bindings: unknown = JSON.parse(rawBindings ?? "[]"); if (!Array.isArray(bindings) || !bindings.every(isConnectionBinding)) { throw new Error("Invalid NOTION_CONNECTION_BINDINGS metadata."); } const matches = bindings.filter((binding: ConnectionBinding) => binding.key === key); if (matches.length !== 1 || !matches[0]) { throw new Error( `Workflow connection "${key}" is not bound. Deploy and configure this requirement before running it.`, ); } return { ...matches[0] }; } function invoker(binding: ConnectionBinding): ConnectionActionInvoker { return async (action: string, args: object): Promise => { const response = await notion.request<{ result: Result }>({ path: "tools/run/internal", method: "post", body: { connection: binding.id, action, args }, }); return response.result; }; } } function createOAuthConnection(binding: ConnectionBinding): OAuthConnection { const envKey = `NOTION_OAUTH_CONNECTION_${binding.id.replaceAll("-", "").toUpperCase()}`; const token = process.env[envKey]; return { async accessToken() { if (!token) throw new Error( `Workflow OAuth connection "${binding.key}" requires authorization.`, ); return token; }, }; } function isConnectionBinding(value: unknown): value is ConnectionBinding { return ( typeof value === "object" && value !== null && "key" in value && typeof value.key === "string" && value.key.length > 0 && "type" in value && typeof value.type === "string" && value.type.length > 0 && "id" in value && typeof value.id === "string" && value.id.length > 0 ); }