import { isEncodable, type Encodable } from "@automate.ax/codec" import { isPlainObject } from "@zachsents/zippy" import { Client } from "@notionhq/client" import type { DistributedOmit, ExclusifyUnion, KeysOfUnion } from "type-fest" import * as z from "zod" import type { DefinedAction } from "../../automation/actions" import { defineAction } from "../../automation/actions" import type { ActionAccountConnectionRequirements, IntegrationScopeRequirement, ResolvedIntegrationAccount, } from "../../automation/integrations" import { integrationScope } from "../../automation/integrations" /** Notion API version implemented by this integration. */ export const NOTION_API_VERSION = "2026-03-11" const NOTION_OAUTH_SECRET_SCHEMA = z.object({ accessToken: z.string().min(1) }) const NOTION_PERSONAL_ACCESS_TOKEN_SECRET_SCHEMA = z.object({ apiKey: z.string().min(1), }) type InputObject = Record type NotionMethod = (...args: never[]) => Promise type NotionMethodParameters = NonNullable< Parameters[0] > /** Provider request union after removing integration-owned fields. */ type NotionInputVariants< TMethod extends NotionMethod, TExcluded extends KeysOfUnion< DistributedOmit, "auth"> >, > = DistributedOmit, "auth" | TExcluded> /** Codec-safe public output for an official Notion SDK response. */ export type NotionValue = T extends readonly (infer TItem)[] ? NotionValue[] : T extends { type: "transcription" } ? never : T extends { archived: unknown; in_trash: unknown } ? { [TKey in Exclude]: NotionValue } : T extends object ? { [TKey in keyof T]: NotionValue } : T extends Encodable ? T : never /** * Input accepted by a Notion SDK method, without its per-request auth escape * hatch. */ export type NotionInput< TMethod extends NotionMethod, TExcluded extends KeysOfUnion< DistributedOmit, "auth"> > = never, > = Extract< ExclusifyUnion>, InputObject > /** Output returned by a Notion SDK method. */ export type NotionOutput = Awaited< ReturnType > /** Portable public type for a Notion-backed action definition. */ export type NotionDefinedAction< TMethod extends NotionMethod, TExcluded extends KeysOfUnion< DistributedOmit, "auth"> > = never, TOutput extends object = NotionOutput, > = DefinedAction< z.ZodType, NotionInput>, z.ZodType>, "notion" > /** Runtime contract for one current official Notion response family. */ export interface NotionOutputContract { item?: NotionListItemContract listType?: string | readonly string[] object?: string | readonly string[] requiredKeys: readonly string[] variants?: readonly NotionOutputVariant[] } /** Runtime contract for one result in a Notion list response. */ export interface NotionListItemContract { object?: string | readonly string[] requiredKeys: readonly string[] } /** Required response fields for one object discriminator. */ export interface NotionOutputVariant { object: string requiredKeys: readonly string[] } /** Response contracts shared by Notion actions. */ export const notionOutput = { entity( object: string | readonly string[], requiredKeys: readonly string[] = ["id"], ): NotionOutputContract { return { object, requiredKeys } }, fields(...requiredKeys: string[]): NotionOutputContract { return { requiredKeys } }, list( listType?: string | readonly string[], item?: NotionListItemContract, ): NotionOutputContract { return { ...(item && { item }), ...(listType && { listType }), object: "list", requiredKeys: ["has_more", "next_cursor", "results", "type"], } }, oneOf(...variants: readonly NotionOutputVariant[]): NotionOutputContract { return { requiredKeys: [], variants } }, } as const const oauthConnection = ( requiredScope?: IntegrationScopeRequirement, ): { connectionMethodId: "oauth" requiredScope?: IntegrationScopeRequirement } => ({ connectionMethodId: "oauth", ...(requiredScope && { requiredScope }), }) const PERSONAL_ACCESS_TOKEN_CONNECTION = { connectionMethodId: "personal-access-token", requiredScope: "notion_api", } as const /** Account requirements shared by Notion actions. */ export const notionAccount = { agents: notionAccountOptions("interact_with_agents"), createView: notionAccountOptions( integrationScope.and("insert_content", "update_content"), ), createMeetingNote: notionAccountOptions( integrationScope.and("insert_content", "read_content"), ), authenticated: { connections: [oauthConnection(), PERSONAL_ACCESS_TOKEN_CONNECTION], }, insertComments: notionAccountOptions("insert_comments"), insertContent: notionAccountOptions("insert_content"), readComments: notionAccountOptions("read_comments"), readContent: notionAccountOptions("read_content"), updateContent: notionAccountOptions("update_content"), userInfo: notionAccountOptions("user_info_without_email"), usersList: { connections: [oauthConnection("user_info_without_email")], }, } as const satisfies Record /** * Creates the official authenticated Notion client at the supported API * version. * * @param account - Resolved Notion integration account. * @throws {Error} When the connection method or credential shape is invalid. */ export function getNotionApi( account: ResolvedIntegrationAccount<"notion">, ): Client { const auth = account.connectionMethodId === "oauth" ? NOTION_OAUTH_SECRET_SCHEMA.parse(account.secret).accessToken : account.connectionMethodId === "personal-access-token" ? NOTION_PERSONAL_ACCESS_TOKEN_SECRET_SCHEMA.parse(account.secret) .apiKey : undefined if (!auth) { throw new Error( `Unsupported Notion connection method: ${account.connectionMethodId}`, ) } return new Client({ auth, notionVersion: NOTION_API_VERSION }) } /** * Runtime schema for a JSON response returned by the official Notion client. * * @param contract - Required fields and discriminators for the endpoint. */ export function notionOutputSchema( contract: NotionOutputContract, ): z.ZodType> { return z.custom>( (value) => validateNotionResponse(value, contract), { message: "Expected the current Notion response shape." }, ) } /** * Runtime schema for an official Notion request. * * The SDK supplies the exact compile-time union while this schema enforces the * provider's cross-endpoint limits and rejects fields outside the current API. * * @param allowedKeys - Current top-level request fields accepted by the * endpoint. * @param requiredKeys - Top-level fields required before calling the provider. * @param refine - Optional endpoint-specific invariant validation. * @param inputSchema - Optional endpoint-specific request schema. */ export function notionInputSchema( allowedKeys: readonly (KeysOfUnion & string)[], requiredKeys: readonly (KeysOfUnion & string)[] = [], refine?: (input: T) => boolean, inputSchema?: z.ZodType, ): z.ZodType { const allowedKeySet = new Set(allowedKeys) const schema = z.custom( (value) => { if (!isPlainObject(value) || !isEncodable(value)) return false if (!Object.keys(value).every((key) => allowedKeySet.has(key))) return false if (requiredKeys.some((key) => value[key] === undefined)) return false if (!validateNotionInputFields(value)) return false if (!validateNotionValue(value)) return false if (inputSchema && !inputSchema.safeParse(value).success) return false if (!("file" in value) && !fitsNotionJsonPayload(value)) return false return true }, { message: "Expected valid current-version Notion request parameters." }, ) return refine ? schema.refine(refine) : schema } /** * Defines one fully typed Notion action backed by an official client method. * * @param options - Action metadata, authorization, request fields, and handler. * @param options.account - Supported connection methods and required * capabilities. * @param options.allowedKeys - Current top-level provider request fields. * @param options.call - Official SDK method invocation. * @param options.description - Public action description. * @param options.inputSchema - Endpoint-specific runtime request validation. * @param options.name - Public action name. * @param options.output - Current provider response contract. * @param options.replaySafety - Whether an interrupted provider call can be * replayed without duplicating a side effect. * @param options.refine - Optional endpoint-specific runtime validation. * @param options.requiredKeys - Required top-level provider request fields. */ export function notionAction< TMethod extends NotionMethod, TExcluded extends KeysOfUnion< DistributedOmit, "auth"> > = never, TOutput extends object = NotionOutput, >(options: { account: ActionAccountConnectionRequirements allowedKeys: readonly (KeysOfUnion> & string)[] call: ( client: Client, input: NotionInput, ) => Promise description: string inputSchema?: z.ZodType name: string output: NotionOutputContract replaySafety: "safe" | "unsafe" refine?: (input: NotionInput) => boolean requiredKeys?: readonly (KeysOfUnion> & string)[] }): NotionDefinedAction { const action = defineAction(options.name) .describe(options.description) .account("notion", options.account) .input( notionInputSchema( options.allowedKeys, options.requiredKeys, options.refine, options.inputSchema, ), ) .output(notionOutputSchema(options.output)) .retry({ replaySafety: options.replaySafety }) .handler(async ({ account, input }) => { return await options.call(getNotionApi(account), input) }) return action } /** * Checks an optional string discriminator against its endpoint contract. * * @param value - Provider response discriminator. * @param expected - Allowed discriminator value or values. */ function matchesNotionDiscriminator( value: unknown, expected?: string | readonly string[], ): boolean { if (!expected) return true return ( typeof value === "string" && (typeof expected === "string" ? value === expected : expected.includes(value)) ) } /** * Checks discriminator-specific response fields for a union endpoint. * * @param value - Current provider response. * @param variants - Allowed object variants and their required fields. */ function matchesNotionVariant( value: Record, variants?: readonly NotionOutputVariant[], ): boolean { if (!variants) return true return variants.some( (variant) => value.object === variant.object && validateNotionRequiredFields(value, variant.requiredKeys), ) } /** * Validates the shared structure and endpoint discriminator of a response. * * @param value - Provider response returned by the official client. * @param contract - Endpoint response contract. */ function validateNotionResponse( value: unknown, contract: NotionOutputContract, ): boolean { if (!isPlainObject(value) || !isEncodable(value)) return false if (!validateNotionRequiredFields(value, contract.requiredKeys)) return false if (!matchesNotionVariant(value, contract.variants)) return false if (!matchesNotionDiscriminator(value.object, contract.object)) return false if (!matchesNotionDiscriminator(value.type, contract.listType)) return false if (value.object === "async_task" && !validateNotionAsyncTask(value)) return false if (!validateNotionEntity(value)) return false return value.object !== "list" || validateNotionList(value, contract) } /** * Validates Notion's status-dependent async-task response union. * * @param value - Async-task response candidate. */ function validateNotionAsyncTask(value: Record): boolean { if ( typeof value.id !== "string" || typeof value.status_url !== "string" || typeof value.created_time !== "string" || !isPlainObject(value.operation) || !["rest", "mcp"].includes(String(value.operation.surface)) || typeof value.operation.name !== "string" ) { return false } switch (value.status) { case "queued": case "running": case "retrying": return ( typeof value.poll_after_seconds === "number" && Number.isFinite(value.poll_after_seconds) && value.poll_after_seconds >= 0 ) case "succeeded": return isPlainObject(value.result) case "failed": return ( isPlainObject(value.error) && typeof value.error.code === "string" && typeof value.error.message === "string" && typeof value.error.status === "number" ) default: return false } } /** * Validates a paginated Notion list envelope and every result discriminator. * * @param value - List response candidate. * @param contract - Endpoint list envelope and item contract. */ function validateNotionList( value: Record, contract: NotionOutputContract, ): boolean { if ( !Array.isArray(value.results) || typeof value.has_more !== "boolean" || (typeof value.next_cursor !== "string" && value.next_cursor !== null) ) { return false } const item = contract.item ?? (contract.listType ? { object: notionListResultObjects(contract.listType), requiredKeys: ["id"], } : undefined) return value.results.every( (result) => isPlainObject(result) && (!item || (validateNotionRequiredFields(result, item.requiredKeys) && matchesNotionDiscriminator(result.object, item.object))) && validateNotionEntity(result), ) } /** * Maps list envelope types to allowed result object discriminators. * * @param listType - Endpoint list discriminator. */ function notionListResultObjects( listType?: string | readonly string[], ): readonly string[] | undefined { if (!listType) return undefined return (typeof listType === "string" ? [listType] : listType).flatMap( (type) => type === "page_or_data_source" ? ["page", "data_source"] : [type], ) } /** * Validates every endpoint-declared required response field. * * @param value - Current top-level provider response. * @param requiredKeys - Fields declared by the endpoint contract. */ function validateNotionRequiredFields( value: Record, requiredKeys: readonly string[], ): boolean { return requiredKeys.every( (key) => value[key] !== undefined && validateNotionResponseField(key, value[key]), ) } /** * Validates one endpoint-declared top-level response field. * * @param key - Provider response field name. * @param value - Provider response field value. */ function validateNotionResponseField(key: string, value: unknown): boolean { if (key === "id" || key.endsWith("_id")) return typeof value === "string" if ( [ "created_time", "last_edited_time", "deleted_at", "created_at", "updated_at", "expires_at", "status_url", "markdown", "name", "agent_type", "url", ].includes(key) ) { return typeof value === "string" } if (["object", "type", "status"].includes(key)) { return typeof value === "string" } if (["has_more", "truncated", "deleted", "in_trash"].includes(key)) { return typeof value === "boolean" } if ( [ "poll_after_seconds", "credit_limit", "total_credits_used", "runs_completed", "total_count", ].includes(key) ) { return ( value === null || (typeof value === "number" && Number.isFinite(value)) ) } if (key === "pause_reason") { return value === null || typeof value === "string" } if (key === "next_cursor") { return value === null || typeof value === "string" } if (key === "unknown_block_ids") { return Array.isArray(value) && value.every((id) => typeof id === "string") } if (["results", "templates"].includes(key)) return Array.isArray(value) if (key === "operation" || key === "created_by") { return value === null || isPlainObject(value) } if (key === "title") return typeof value === "string" return isEncodable(value) } /** * Validates endpoint-specific top-level fields without interpreting nested * property names that have different meanings in Notion's object unions. * * @param value - Current provider response object. */ function validateNotionEntity(value: Record): boolean { switch (value.object) { case "file_upload": return ( validateNotionRequiredFields(value, [ "id", "created_time", "created_by", "last_edited_time", "in_trash", "status", ]) && isPlainObject(value.created_by) && typeof value.created_by.id === "string" && ["person", "bot", "agent"].includes(String(value.created_by.type)) && (value.expiry_time === null || typeof value.expiry_time === "string") && ["pending", "uploaded", "expired", "failed"].includes( String(value.status), ) && (value.filename === null || typeof value.filename === "string") && (value.content_type === null || typeof value.content_type === "string") && (value.content_length === null || (typeof value.content_length === "number" && Number.isFinite(value.content_length) && value.content_length >= 0)) ) case "page_markdown": return validateNotionRequiredFields(value, [ "id", "markdown", "truncated", "unknown_block_ids", ]) case "view_query": return value.deleted === undefined ? validateNotionRequiredFields(value, [ "id", "view_id", "expires_at", "total_count", "results", "next_cursor", "has_more", ]) : validateNotionRequiredFields(value, ["id", "deleted"]) default: return true } } /** * Builds OAuth and personal-token alternatives for one Notion capability. * * @param requiredScope - OAuth capability required by the action. */ function notionAccountOptions( requiredScope: IntegrationScopeRequirement, ): ActionAccountConnectionRequirements { return { connections: [ oauthConnection(requiredScope), PERSONAL_ACCESS_TOKEN_CONNECTION, ], } } const NOTION_BOOLEAN_INPUT_FIELDS = new Set([ "allow_async", "erase_content", "in_trash", "include_deleted", "include_transcript", "is_inline", "is_locked", "verbose", ]) const NOTION_NUMBER_INPUT_FIELDS = new Set([ "credit_limit", "limit", "number_of_parts", "page_size", ]) const NOTION_STRING_INPUT_FIELDS = new Set([ "content_type", "continue_from", "end_time", "external_url", "filename", "language", "markdown", "message", "mode", "name", "part_number", "prompt_context", "query", "result_type", "start_cursor", "start_time", "status", "type", ]) const NOTION_ARRAY_INPUT_FIELDS = new Set([ "actions", "attachments", "children", "content", "description", "filter_properties", "operations", "rich_text", "sorts", ]) const NOTION_OBJECT_INPUT_FIELDS = new Set([ "audio", "bookmark", "breadcrumb", "bulleted_list_item", "callout", "code", "column", "configuration", "cover", "create_database", "display_name", "divider", "embed", "equation", "file", "filter", "heading_1", "heading_2", "heading_3", "heading_4", "icon", "image", "initial_data_source", "insert_content", "link_to_page", "metadata", "numbered_list_item", "options", "paragraph", "parent", "pdf", "placement", "position", "properties", "quick_filters", "quote", "replace_content", "replace_content_range", "sort", "source", "synced_block", "tab", "table", "table_of_contents", "table_row", "template", "to_do", "toggle", "update_content", "video", ]) /** * Validates the exact primitive/container kind of each top-level request field. * * @param value - Current endpoint request input. */ function validateNotionInputFields(value: InputObject): boolean { return Object.entries(value).every(([key, field]) => { if (field === undefined) return true if (key === "title") return typeof field === "string" || Array.isArray(field) if (NOTION_BOOLEAN_INPUT_FIELDS.has(key)) return typeof field === "boolean" if (NOTION_NUMBER_INPUT_FIELDS.has(key)) { return ( (key === "credit_limit" && field === null) || (typeof field === "number" && Number.isFinite(field)) ) } if (NOTION_STRING_INPUT_FIELDS.has(key)) { return ( (key === "start_cursor" && field === null) || typeof field === "string" ) } if (NOTION_ARRAY_INPUT_FIELDS.has(key)) return Array.isArray(field) if (NOTION_OBJECT_INPUT_FIELDS.has(key)) return isPlainObject(field) return key === "id" || key.endsWith("_id") ? typeof field === "string" : true }) } /** * Checks recursive limits shared by current Notion requests. * * @param value - Request value at the current traversal position. * @param key - Provider field name associated with the value. * @param parentKey - Field containing the current value. */ function validateNotionValue( value: unknown, key?: string, parentKey?: string, ): boolean { if (value === undefined) return true if (key === "id" || key?.endsWith("_id")) { return typeof value === "string" && value.length > 0 } if (key === "type") return typeof value === "string" if (key === "content" && parentKey === "text") { return typeof value === "string" && value.length <= 2_000 } if (key === "page_size") { return ( typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ) } if (typeof value === "string") { if (key === "content" && parentKey === "text" && value.length > 2_000) return false if ((key === "email" || key === "phone_number") && value.length > 200) return false if ( (key === "url" || key === "href" || key === "external_url") && value.length > 2_000 ) return false if (key === "expression" && value.length > 1_000) return false return true } if (Array.isArray(value)) { return ( value.length <= 100 && value.every((item) => validateNotionValue(item, undefined, key)) ) } if (!isPlainObject(value)) return true return Object.entries(value).every(([nestedKey, nestedValue]) => validateNotionValue(nestedValue, nestedKey, key), ) } /** * Checks Notion's maximum JSON request body size. * * @param value - JSON-backed request input. */ function fitsNotionJsonPayload(value: InputObject): boolean { try { return new TextEncoder().encode(JSON.stringify(value)).length <= 500_000 } catch { return false } }