import type { CustomAgentHandle } from "./notion-as-code/custom-agent.js"; import type { DataSourceHandle, DatabaseHandle, SingleSourceDatabaseHandle, } from "./notion-as-code/database.js"; import type { PageHandle } from "./notion-as-code/page.js"; /** A Notion-as-Code resource that can provide a named workflow binding. */ export type WorkflowAccessResource = | PageHandle | DatabaseHandle | SingleSourceDatabaseHandle | DataSourceHandle | CustomAgentHandle; type WorkflowAccessContentResource = Exclude; /** Resource kinds accepted by workflow access declarations. */ export type WorkflowAccessResourceType = WorkflowAccessResource["resourceType"]; /** Levels accepted by pages, databases, and data sources. */ export type ResourceAccessLevel = "view" | "comment" | "edit" | "fullAccess"; /** The only level accepted by a custom agent. */ export type WorkflowAccessAgentLevel = "call"; /** Every level accepted by at least one workflow access resource. */ export type WorkflowAccessLevel = ResourceAccessLevel | WorkflowAccessAgentLevel; /** The level set accepted by a particular resource kind. */ export type WorkflowAccessLevelFor = TType extends "customAgent" ? WorkflowAccessAgentLevel : ResourceAccessLevel; type AccessResourceFor = Extract< WorkflowAccessResource, { resourceType: TType } >; /** A named binding declaration pairing one NaC handle with its allowed level. */ export type WorkflowAccessDeclaration = { [TType in WorkflowAccessResourceType]: { readonly resource: AccessResourceFor; readonly level: WorkflowAccessLevelFor; }; }[WorkflowAccessResourceType]; /** Named binding declarations keyed by the names exposed on workflow context. */ export type WorkflowAccessDeclarations = Readonly>; /** A normalized named-binding requirement, including the required NaC resourceId. */ export type WorkflowAccessRequirement = { [TType in WorkflowAccessResourceType]: { readonly type: TType; readonly resourceId: string; readonly level: WorkflowAccessLevelFor; }; }[WorkflowAccessResourceType]; /** Normalized named-binding requirements keyed by public alias. */ export type WorkflowAccessRequirements = Readonly>; /** The resolved NaC binding exposed to a workflow handler. */ export type WorkflowAccessValue< TType extends WorkflowAccessResourceType = WorkflowAccessResourceType, > = Readonly<{ type: TType; id: string; }>; /** Resolved access values keyed by their public aliases. */ export type WorkflowAccess< TAccess extends WorkflowAccessDeclarations = WorkflowAccessDeclarations, > = Readonly<{ [TKey in keyof TAccess]: TAccess[TKey] extends { readonly resource: { readonly resourceType: infer TType }; } ? TType extends WorkflowAccessResourceType ? WorkflowAccessValue : never : never; }>; /** Resolved NaC bindings keyed by public aliases, without declaration metadata. */ export type WorkflowAccessBindings = Readonly>; /** Create type-safe access declarations for Notion-as-Code resource handles. */ export const access = { view, comment, edit, fullAccess, call, }; function view( resource: TResource, ): Readonly<{ resource: TResource; level: "view" }> { return { resource, level: "view" }; } function comment( resource: TResource, ): Readonly<{ resource: TResource; level: "comment" }> { return { resource, level: "comment" }; } function edit( resource: TResource, ): Readonly<{ resource: TResource; level: "edit" }> { return { resource, level: "edit" }; } function fullAccess( resource: TResource, ): Readonly<{ resource: TResource; level: "fullAccess" }> { return { resource, level: "fullAccess" }; } function call( resource: CustomAgentHandle, ): Readonly<{ resource: CustomAgentHandle; level: "call" }> { return { resource, level: "call" }; } const ACCESS_ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/; const RESERVED_ACCESS_ALIASES = new Set(["__proto__", "prototype", "constructor"]); const RESOURCE_TYPES: Record = { page: true, database: true, dataSource: true, customAgent: true, }; /** * Normalize public binding declarations without retaining the handle objects. * Handles contain methods and schema objects, so they must never be cloned into * the manifest or captured by the runtime transport. These declarations do not * grant or change the workflow's existing Notion-module permissions. */ export function normalizeWorkflowAccess( declarations: WorkflowAccessDeclarations | undefined, ): WorkflowAccessRequirements { const source = declarations ?? {}; assertPlainObject(source, "Workflow access must be an object keyed by alias."); const keys = Object.keys(source); if (Reflect.ownKeys(source).length !== keys.length) { throw new Error("Workflow access aliases must be enumerable strings."); } if (keys.length > 100) { throw new Error("Workflows support at most 100 access requirements."); } const normalized: Record = Object.create(null) as Record< string, WorkflowAccessRequirement >; for (const alias of keys) { if (!ACCESS_ALIAS_PATTERN.test(alias) || RESERVED_ACCESS_ALIASES.has(alias)) { throw new Error(`Invalid workflow access alias: ${alias}`); } normalized[alias] = normalizeAccessDeclaration(source[alias], `Workflow access "${alias}"`); } return Object.freeze(normalized); } /** * Check one access entry and return its resource type, ID, and level. * Workflows check the entry's alias separately; custom-agent grants have no alias. */ export function normalizeAccessDeclaration( declaration: unknown, label: string, ): WorkflowAccessRequirement { if ( !isRecord(declaration) || !Object.hasOwn(declaration, "resource") || !Object.hasOwn(declaration, "level") ) { throw new Error(`${label} must declare a resource and level.`); } const resource: unknown = declaration.resource; if ( !isRecord(resource) || !Object.hasOwn(resource, "resourceType") || !Object.hasOwn(resource, "resourceId") || typeof resource.resourceId !== "string" || resource.resourceId.length === 0 || !isWorkflowAccessResourceType(resource.resourceType) ) { throw new Error(`${label} must reference a supported NaC resource handle.`); } let requirement: WorkflowAccessRequirement; if (resource.resourceType === "customAgent") { if (declaration.level !== "call") { throw new Error(`${label} custom agents only support level "call".`); } requirement = { type: resource.resourceType, resourceId: resource.resourceId, level: declaration.level, }; } else { if (!isResourceAccessLevel(declaration.level)) { throw new Error( `${label} ${resource.resourceType} level must be view, comment, edit, or fullAccess.`, ); } requirement = { type: resource.resourceType, resourceId: resource.resourceId, level: declaration.level, }; } return Object.freeze(requirement); } /** * Resolve trusted per-invocation NaC bindings into the minimal context value. * This exposes only declared aliases and does not determine Notion API * authorization; the workflow's existing Notion-module permissions do that. * The environment is deliberately not parsed when no binding is required. */ export function hydrateWorkflowAccess( requirements: WorkflowAccessRequirements | undefined, ): WorkflowAccess; export function hydrateWorkflowAccess( requirements: WorkflowAccessRequirements | undefined, ): WorkflowAccessBindings { const expected = requirements ?? {}; const aliases = Object.keys(expected); const empty: Record = Object.create(null) as Record< string, WorkflowAccessValue >; if (aliases.length === 0) { return Object.freeze(empty); } const rawBindings = process.env.NOTION_ACCESS_BINDINGS; if (rawBindings === undefined) { throw new Error( "Workflow access requirements are not bound. Deploy and configure this requirement before running it.", ); } if (rawBindings.length === 0) { throw new Error("Invalid NOTION_ACCESS_BINDINGS metadata."); } let parsed: unknown; try { parsed = JSON.parse(rawBindings); } catch (error) { throw new Error("Invalid NOTION_ACCESS_BINDINGS metadata.", { cause: error }); } assertPlainObject(parsed, "Invalid NOTION_ACCESS_BINDINGS metadata."); const bindingKeys = Object.keys(parsed); if ( Reflect.ownKeys(parsed).length !== bindingKeys.length || bindingKeys.length !== aliases.length || bindingKeys.some((alias) => !Object.hasOwn(expected, alias)) ) { throw new Error( "Invalid NOTION_ACCESS_BINDINGS metadata: aliases do not match workflow access.", ); } const resolved: Record = Object.create(null) as Record< string, WorkflowAccessValue >; for (const alias of aliases) { const requirement = expected[alias]; if (!requirement) { throw new Error(`Workflow access "${alias}" is not declared.`); } const binding: unknown = parsed[alias]; if (!isBinding(binding)) { throw new Error(`Invalid NOTION_ACCESS_BINDINGS metadata for access "${alias}".`); } if ( binding.type !== requirement.type || binding.resourceId !== requirement.resourceId || binding.level !== requirement.level ) { throw new Error( `Workflow access "${alias}" binding does not match its declared type, resourceId, or level.`, ); } resolved[alias] = Object.freeze({ type: binding.type, id: binding.id }); } return Object.freeze(resolved); } function isWorkflowAccessResourceType(value: unknown): value is WorkflowAccessResourceType { return typeof value === "string" && Object.hasOwn(RESOURCE_TYPES, value); } function isResourceAccessLevel(value: unknown): value is ResourceAccessLevel { return value === "view" || value === "comment" || value === "edit" || value === "fullAccess"; } function isBinding(value: unknown): value is { type: WorkflowAccessResourceType; resourceId: string; level: ResourceAccessLevel | WorkflowAccessAgentLevel; id: string; } { if (!isRecord(value) || Reflect.ownKeys(value).length !== 4) return false; const keys = Object.keys(value); if ( keys.length !== 4 || !keys.includes("type") || !keys.includes("resourceId") || !keys.includes("level") || !keys.includes("id") ) { return false; } return ( isWorkflowAccessResourceType(value.type) && typeof value.resourceId === "string" && value.resourceId.length > 0 && typeof value.level === "string" && (isResourceAccessLevel(value.level) || value.level === "call") && typeof value.id === "string" && value.id.length > 0 ); } function assertPlainObject( value: unknown, message: string, ): asserts value is Record { if (!isRecord(value)) throw new Error(message); const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) throw new Error(message); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }