import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import { Ajv } from "ajv"; import { APPS_MANIFEST_SCHEMA } from "../manifest.js"; import { isRecordedProvisioning, type RecordedProvisioning, } from "../notion-as-code/provisioning.js"; import type { AppManifest, DatabaseManifestEntry, PacerManifestEntry, SecretDeclaration, } from "../manifest.js"; import { clearRegisteredPacers, registeredPacers } from "../pacer.js"; import type { DiscoveredCapability } from "./discover.js"; const SDK_VERSION = (() => { const require = createRequire(import.meta.url); const packageJson: unknown = require("../../package.json"); if ( typeof packageJson !== "object" || packageJson === null || !("version" in packageJson) || typeof packageJson.version !== "string" ) { throw new Error("Failed to read SDK version from package.json"); } return packageJson.version; })(); const MANIFEST_SCHEMA = { type: "object", properties: { $schema: { type: "string", enum: [APPS_MANIFEST_SCHEMA] }, sdkVersion: { type: "string" }, databases: { type: "array", items: { type: "object", properties: { key: { type: "string" }, config: { type: "object", properties: { initialTitle: { type: "string" }, schema: { type: "object" }, }, required: ["schema"], additionalProperties: false, }, }, required: ["key", "config"], additionalProperties: false, }, }, xldbs: { type: "array", maxItems: 0 }, pacers: { type: "array", items: { type: "object", properties: { key: { type: "string" }, config: { type: "object", properties: { allowedRequests: { type: "number", exclusiveMinimum: 0 }, intervalMs: { type: "number", exclusiveMinimum: 0 }, }, required: ["allowedRequests", "intervalMs"], additionalProperties: false, }, }, required: ["key", "config"], additionalProperties: false, }, }, secrets: { type: "array", items: { type: "object", properties: { name: { type: "string" }, title: { type: "string" }, description: { type: "string" }, required: { type: "boolean" }, }, required: ["name", "title", "description", "required"], additionalProperties: false, }, }, capabilities: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["workflow", "sync", "custom_block"] }, key: { type: "string" }, config: {}, }, required: ["type", "key", "config"], additionalProperties: false, }, }, }, required: ["$schema", "sdkVersion", "databases", "xldbs", "pacers", "capabilities"], additionalProperties: false, } as const; type TaggedCapability = { _tag: string; config: Record }; type DatabaseObject = TaggedCapability & { _tag: "database"; key: string }; function asTagged(value: unknown): TaggedCapability | undefined { if ( typeof value === "object" && value !== null && "_tag" in value && typeof value._tag === "string" && "config" in value && typeof value.config === "object" && value.config !== null ) { return value as TaggedCapability; } return undefined; } function asDatabase(value: unknown): DatabaseObject | undefined { const tagged = asTagged(value); if (tagged?._tag === "database" && "key" in tagged && typeof tagged.key === "string") { return tagged as DatabaseObject; } return undefined; } function schemaProperties(database: DatabaseObject): Record { const schema = database.config.schema; if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { throw new Error(`Database "${database.key}" must have a schema object`); } return schema as Record; } function isRelationProperty( value: unknown, ): value is Record & { database: unknown } { return ( typeof value === "object" && value !== null && "type" in value && value.type === "relation" && "database" in value ); } class DatabaseCollector { #keys = new Map(); #byKey = new Map(); #order: string[] = []; collect(value: unknown, referencedFrom: string): string { const known = this.#keys.get(value); if (known !== undefined) return known; const database = asDatabase(value); if (!database) { throw new Error( `${referencedFrom}: expected a database created with createDatabase(...)`, ); } const duplicate = this.#byKey.get(database.key); if (duplicate && duplicate !== database) { throw new Error(`Two distinct databases use the key "${database.key}"`); } this.#keys.set(database, database.key); this.#byKey.set(database.key, database); this.#order.push(database.key); for (const [propertyName, property] of Object.entries(schemaProperties(database))) { if (isRelationProperty(property)) { this.collect( property.database, `relation property "${propertyName}" of database "${database.key}"`, ); } } return database.key; } get(key: string): DatabaseObject | undefined { return this.#byKey.get(key); } entries(): DatabaseManifestEntry[] { return this.#order.map((key) => { const database = this.#byKey.get(key)!; const properties: Record = {}; for (const [propertyName, property] of Object.entries(schemaProperties(database))) { if (isRelationProperty(property)) { const { database: target, ...rest } = property; properties[propertyName] = { ...rest, relatedDatabaseKey: this.#keys.get(target), }; } else { properties[propertyName] = property; } } return { key, config: { ...(typeof database.config.initialTitle === "string" ? { initialTitle: database.config.initialTitle } : {}), schema: { properties, ...(database.config.databaseIcon === undefined ? {} : { databaseIcon: database.config.databaseIcon }), ...(database.config.subItems === undefined ? {} : { subItems: database.config.subItems }), }, }, }; }); } } /** Extract the app manifest from a built bundle. */ export async function extractManifest( bundlePath: string, capabilities: readonly DiscoveredCapability[], blockConfigs: Record = {}, provisioning?: RecordedProvisioning, secrets: SecretDeclaration[] = [], ): Promise { clearRegisteredPacers(); const bundle = await importBundle(bundlePath); const registry = getCapabilityRegistry(bundle); const databases = new DatabaseCollector(); const primaryKeyByDatabase = new Map(); const capabilityEntries = capabilities.map((capability) => { const tagged = asTagged( capability.runtime === "build" ? blockConfigs[capability.key] : registry[capability.type]?.[capability.key], ); if (!tagged || tagged._tag !== capability.tag) { throw new Error( `${capability.sourcePath}: default export is not a ${capability.type} — ` + `files under this directory must default-export ${capability.type === "custom_block" ? "customBlock" : capability.type}(...)`, ); } let config: Record = { ...tagged.config }; if (capability.type === "sync") { const databaseKey = databases.collect(config.database, capability.sourcePath); const database = databases.get(databaseKey)!; const primaryKey = config.primaryKeyProperty; const property = typeof primaryKey === "string" ? schemaProperties(database)[primaryKey] : undefined; if ( typeof primaryKey !== "string" || typeof property !== "object" || property === null || !("type" in property) || (property.type !== "title" && property.type !== "text") ) { throw new Error( `${capability.sourcePath}: primaryKey "${String(primaryKey)}" must name a title or rich-text property`, ); } const existingPrimaryKey = primaryKeyByDatabase.get(databaseKey); if (existingPrimaryKey !== undefined && existingPrimaryKey !== primaryKey) { throw new Error( `Syncs for database "${databaseKey}" disagree on its primary key: ` + `"${existingPrimaryKey}" and "${primaryKey}"`, ); } primaryKeyByDatabase.set(databaseKey, primaryKey); const { database: _database, ...rest } = config; config = { ...rest, databaseKey }; } if (capability.type === "workflow") { validateWorkflowAccessReferences(config.access, provisioning, capability.sourcePath); } assertJsonSafe(config, `${capability.sourcePath} config`); return { type: capability.type, key: capability.key, config }; }); const pacerEntries: PacerManifestEntry[] = registeredPacers().map((pacer) => ({ key: pacer.name, config: pacer.config, })); for (const pacer of pacerEntries) assertJsonSafe(pacer.config, `pacer "${pacer.key}" config`); const databaseEntries = databases.entries(); for (const database of databaseEntries) { assertJsonSafe(database.config, `database "${database.key}" config`); } const manifest: unknown = JSON.parse( JSON.stringify({ $schema: APPS_MANIFEST_SCHEMA, sdkVersion: SDK_VERSION, databases: databaseEntries, xldbs: [], pacers: pacerEntries, ...(secrets.length === 0 ? {} : { secrets }), capabilities: capabilityEntries, } satisfies AppManifest), ); const ajv = new Ajv(); if (!ajv.validate(MANIFEST_SCHEMA, manifest)) { throw new Error(`Generated manifest is invalid: ${ajv.errorsText(ajv.errors)}`); } return manifest as AppManifest; } const WORKFLOW_ACCESS_TYPES: Record = { page: true, database: true, dataSource: true, customAgent: true, }; const WORKFLOW_ACCESS_LEVELS: Record = { view: true, comment: true, edit: true, fullAccess: true, }; function validateWorkflowAccessReferences( access: unknown, provisioning: RecordedProvisioning | undefined, capabilityPath: string, ): void { if (access === undefined) return; if ( typeof access !== "object" || access === null || Array.isArray(access) || (provisioning === undefined && Object.keys(access).length > 0) ) { throw new Error( `${capabilityPath}: workflow access must reference resources recorded by the current build`, ); } if (provisioning === undefined) { return; } const accessObject = access as Record; const aliases = Object.keys(accessObject); if (Reflect.ownKeys(accessObject).length !== aliases.length) { throw new Error(`${capabilityPath}: workflow access aliases must be enumerable strings`); } for (const alias of aliases) { if ( !/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(alias) || alias === "__proto__" || alias === "prototype" || alias === "constructor" ) { throw new Error(`${capabilityPath}: invalid workflow access alias "${alias}"`); } const requirement = accessObject[alias]; if ( typeof requirement !== "object" || requirement === null || Array.isArray(requirement) || Reflect.ownKeys(requirement).length !== 3 || !Object.hasOwn(requirement, "type") || !Object.hasOwn(requirement, "resourceId") || !Object.hasOwn(requirement, "level") ) { throw new Error(`${capabilityPath}: invalid workflow access requirement "${alias}"`); } const requirementObject = requirement as Record; const type = requirementObject.type; const resourceId = requirementObject.resourceId; const level = requirementObject.level; if ( typeof type !== "string" || !Object.hasOwn(WORKFLOW_ACCESS_TYPES, type) || typeof resourceId !== "string" || resourceId.length === 0 || typeof level !== "string" || (type === "customAgent" ? level !== "call" : !Object.hasOwn(WORKFLOW_ACCESS_LEVELS, level)) ) { throw new Error(`${capabilityPath}: invalid workflow access requirement "${alias}"`); } if ( !provisioning.intents.some((intent) => { switch (type) { case "page": return intent.type === "page" && intent.resourceId === resourceId; case "database": return intent.type === "database" && intent.resourceId === resourceId; case "customAgent": return intent.type === "custom_agent" && intent.resourceId === resourceId; case "dataSource": return ( intent.type === "database" && (intent.dataSources ?? []).some( (dataSource) => dataSource.resourceId === resourceId, ) ); } }) ) { throw new Error( `${capabilityPath}: workflow access "${alias}" references ${type} "${resourceId}" that is not declared by the current build`, ); } } } /** Import the metadata bundle and return the provisioning declarations it recorded. */ export async function extractProvisioning(bundlePath: string): Promise { const bundle = await importBundle(bundlePath); if ( typeof bundle !== "object" || bundle === null || !("default" in bundle) || !isRecordedProvisioning(bundle.default) ) { throw new Error("Metadata bundle did not export a Notion-as-Code provisioning record"); } return bundle.default; } let importNonce = 0; async function importBundle(bundlePath: string): Promise { try { return await import(`${pathToFileURL(bundlePath).href}?import=${importNonce++}`); } catch (error) { throw new Error( `Failed to import built app bundle. Capability modules must be importable without ` + `secrets or network access. Caused by: ${error instanceof Error ? error.message : String(error)}`, ); } } function getCapabilityRegistry( bundle: unknown, ): Record | undefined> { if ( typeof bundle !== "object" || bundle === null || !("capabilities" in bundle) || typeof bundle.capabilities !== "object" || bundle.capabilities === null ) { throw new Error("Built app bundle does not export a capability registry"); } return bundle.capabilities as Record | undefined>; } /** * Throw if `value` contains anything `JSON.stringify` would silently drop or * rewrite, so invalid manifest configuration fails during the build. */ export function assertJsonSafe(value: unknown, path: string): void { const reject = (reason: string): never => { throw new Error(`${path}: not JSON-serializable (${reason})`); }; switch (typeof value) { case "function": case "symbol": case "bigint": reject(typeof value); break; case "number": if (!Number.isFinite(value)) reject(String(value)); return; case "string": case "boolean": case "undefined": return; } if (value === null) return; if (Array.isArray(value)) { value.forEach((element, index) => { if (element === undefined) reject(`undefined at [${index}]`); assertJsonSafe(element, `${path}[${index}]`); }); return; } const object = value as { constructor?: { name?: string } }; const prototype = Object.getPrototypeOf(object); if (prototype !== null && prototype !== Object.prototype) { reject(object.constructor?.name ?? "non-plain object"); } for (const [key, property] of Object.entries(object as Record)) { if (property !== undefined) assertJsonSafe(property, `${path}.${key}`); } } /** Read the isolated declaration bundle; browser declarations never enter the Worker. */ export async function extractBlockConfigs(bundlePath: string): Promise> { return getCapabilityRegistry(await importBundle(bundlePath)).custom_block ?? {}; }