From 51d88ff627621446a0b4e14a4ea3d76cb9109976 Mon Sep 17 00:00:00 2001 From: Andrej Guran Date: Tue, 18 Aug 2026 14:56:54 +0000 Subject: [PATCH] Add declarative integration manifest support --- CHANGELOG.md | 3 + src/bundle.test.ts | 35 ++ src/index.ts | 102 ++--- vendor/bundler/src/index.ts | 24 +- vendor/contracts/src/manifest.test.ts | 562 +++++++++++++++++++++++++- vendor/contracts/src/manifest.ts | 398 +++++++++++++++++- 6 files changed, 1047 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12588c7..97a3568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ concurrency policy, and system-only consumer Functions in local bundles. - Preserve queue-free schema-2 archive compatibility while including declared queues in deterministic artifacts and development capability validation. +- Validate declarative brokered integration slots locally, including + project-contained Asana access and app-owned HubSpot CRM capabilities, while + keeping integration-free archives byte-compatible with older releases. - Add `jobs list|get` for retained production depth, created/retried/succeeded/ failed rollups, inclusive creation-time filtering, cursor pagination, and metadata-only job inspection without payloads or idempotency keys. diff --git a/src/bundle.test.ts b/src/bundle.test.ts index a298d6d..d2dac3a 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -158,6 +158,9 @@ functions: expect(await readArchivedManifest(root, first.archive)).not.toHaveProperty( "queues", ); + expect(await readArchivedManifest(root, first.archive)).not.toHaveProperty( + "integrations", + ); }); it("validates and archives declared background queues", async () => { @@ -200,6 +203,38 @@ queues: expect(archived.queues).toEqual(bundle.manifest.queues); }); + it("validates and archives declared HubSpot CRM integrations", async () => { + const root = await temporaryDirectory(); + await mkdir(path.join(root, "frontend")); + await writeFile(path.join(root, "frontend", "index.html"), "hello"); + await writeManifest( + root, + ` +frontend: + directory: frontend +integrations: + crm: + provider: hubspot-crm + account: app + cardinality: one + capabilities: + - crm.contacts.read + - crm.contacts.write +`, + ); + + const bundle = await buildBundle(root); + const archived = await readArchivedManifest(root, bundle.archive); + + expect(bundle.manifest.integrations.crm).toEqual({ + provider: "hubspot-crm", + account: "app", + cardinality: "one", + capabilities: ["crm.contacts.read", "crm.contacts.write"], + }); + expect(archived.integrations).toEqual(bundle.manifest.integrations); + }); + it("never archives local .opencloud development metadata", async () => { const root = await temporaryDirectory(); await mkdir(path.join(root, ".opencloud")); diff --git a/src/index.ts b/src/index.ts index c29baac..e9c0071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,11 @@ import { openBrowser, revokeAccountCredential, } from "./account-auth.js"; -import { buildBundle, OPEN_CLOUD_E2E_TEST_PATH } from "./bundle.js"; +import { + buildBundle, + OPEN_CLOUD_E2E_TEST_PATH, + serializeBundleManifest, +} from "./bundle.js"; import { CredentialStore } from "./credential-store.js"; import { doctorDiagnostics } from "./doctor.js"; import { devDataRequest, type DevDataAction } from "./dev-data.js"; @@ -115,7 +119,9 @@ function client(): OpenCloudClient { const apiUrl = options.apiUrl ?? binding?.apiUrl ?? legacy?.apiUrl; if (options.token) { if (!apiUrl) { - throw new Error("Pass --api-url with --token outside a connected workspace."); + throw new Error( + "Pass --api-url with --token outside a connected workspace.", + ); } return new OpenCloudClient({ apiUrl, token: options.token }); } @@ -338,7 +344,7 @@ async function synchronizeValidatedDraft( for (const file of bundle.files) { const content = file === "opencloud.json" - ? Buffer.from(`${JSON.stringify(bundle.manifest, null, 2)}\n`) + ? Buffer.from(serializeBundleManifest(bundle.manifest)) : await readFile(path.join(sourceRoot, ...file.split("/"))); local.set(file, { content, @@ -547,9 +553,10 @@ program process.stderr.write( `Open this URL to approve the CLI:\n${authorization.verificationUriComplete}\n`, ); - const browserOpened = options.browser !== false - ? openBrowser(authorization.verificationUriComplete) - : false; + const browserOpened = + options.browser !== false + ? openBrowser(authorization.verificationUriComplete) + : false; const account = await completeDeviceAuthorization(authorization); const stored = await credentialStore.saveAccount(account); output({ @@ -652,7 +659,7 @@ async function logout(): Promise { currentWorkspaceCredentialRemoved: Boolean(binding), workspaceBindingRetained: binding ? workspaceFile() : null, legacyOnboardingSessionRemoved: legacyOnboardingSessionRemoved - ? legacyOnboardingSession?.state ?? true + ? (legacyOnboardingSession?.state ?? true) : false, next: binding ? "The non-secret app binding remains. Run opencloud login to reconnect it later." @@ -660,8 +667,14 @@ async function logout(): Promise { }); } -auth.command("logout").description("Revoke and clear the CLI login").action(logout); -program.command("logout").description("Revoke and clear the CLI login").action(logout); +auth + .command("logout") + .description("Revoke and clear the CLI login") + .action(logout); +program + .command("logout") + .description("Revoke and clear the CLI login") + .action(logout); program .command("onboard") @@ -772,7 +785,9 @@ program program .command("doctor") - .description("Print redacted CLI, identity, endpoint, and platform diagnostics") + .description( + "Print redacted CLI, identity, endpoint, and platform diagnostics", + ) .action(async () => { const options = program.opts<{ apiUrl?: string; token?: string }>(); const file = sessionFile(); @@ -813,7 +828,7 @@ program : stored ? "session-file" : "none", - sessionState: binding ? "connected" : stored?.state ?? null, + sessionState: binding ? "connected" : (stored?.state ?? null), appId: binding?.appId ?? (stored?.state === "ready" ? stored.appId : null), credentialExpiresAt: @@ -845,7 +860,9 @@ app .option("--idempotency-key ") .action(async (options) => { output( - await (await managementClient()).call( + await ( + await managementClient() + ).call( "createApp", { body: { @@ -866,17 +883,13 @@ app app .command("list") .description("List apps available to the signed-in account") - .action(async () => - output(await (await managementClient()).get("/v1/apps")), - ); + .action(async () => output(await (await managementClient()).get("/v1/apps"))); app .command("get") .argument("") .action(async (appId) => - output( - await (await managementClient()).get(`/v1/apps/${appId}`), - ), + output(await (await managementClient()).get(`/v1/apps/${appId}`)), ); app @@ -924,11 +937,7 @@ app version?: string; sdkVersion?: string; }; - if ( - !deployment.id || - !deployment.version || - !deployment.sdkVersion - ) { + if (!deployment.id || !deployment.version || !deployment.sdkVersion) { throw new Error( "The active deployment does not expose an OpenCloud SDK pin", ); @@ -980,16 +989,18 @@ email .option("--limit ", "maximum records", "100") .option("--alias ", "filter by a manifest-declared alias") .addOption( - new Option("--direction ", "filter by message direction").choices([ - "inbound", - "outbound", - ]), + new Option( + "--direction ", + "filter by message direction", + ).choices(["inbound", "outbound"]), ) .option("--from ", "messages created at or after this ISO timestamp") .option("--to ", "messages created at or before this ISO timestamp") .action(async (appId, options) => { output( - await (await managementClient()).call("getAppEmail", { + await ( + await managementClient() + ).call("getAppEmail", { appId: String(appId), query: emailHistoryQuery(options), }), @@ -1003,7 +1014,9 @@ email .argument("") .action(async (appId, messageId) => { output( - await (await managementClient()).call("getAppEmailMessage", { + await ( + await managementClient() + ).call("getAppEmailMessage", { appId: String(appId), messageId: String(messageId), }), @@ -1141,8 +1154,7 @@ dev const state = await requireDevState(callerPath(directory)); const body = devDataRequest(String(table), action as DevDataAction, { id: options.id === undefined ? undefined : String(options.id), - values: - options.values === undefined ? undefined : String(options.values), + values: options.values === undefined ? undefined : String(options.values), }); output( await client().call("mutateDevData", { @@ -1203,15 +1215,17 @@ devEmail new Option("--text ", "plain-text body").conflicts("textFile"), ) .addOption( - new Option("--text-file ", "read the plain-text body from a file").conflicts( - "text", - ), + new Option( + "--text-file ", + "read the plain-text body from a file", + ).conflicts("text"), ) .addOption(new Option("--html ", "HTML body").conflicts("htmlFile")) .addOption( - new Option("--html-file ", "read the HTML body from a file").conflicts( - "html", - ), + new Option( + "--html-file ", + "read the HTML body from a file", + ).conflicts("html"), ) .option("--reply-to
", "reserved .test reply-to address") .option( @@ -1300,7 +1314,9 @@ dev dev .command("receipts") - .description("List exact-revision verification evidence, even after dev stops") + .description( + "List exact-revision verification evidence, even after dev stops", + ) .argument("[directory]", "app source directory", ".") .option("--limit ", "maximum records", "50") .action(async (directory, options) => { @@ -1754,7 +1770,7 @@ program for (const file of bundle.files) { const content = file === "opencloud.json" - ? Buffer.from(`${JSON.stringify(bundle.manifest, null, 2)}\n`) + ? Buffer.from(serializeBundleManifest(bundle.manifest)) : await readFile(path.join(sourceRoot, ...file.split("/"))); changes.push({ path: file, @@ -1997,9 +2013,7 @@ jobs .action(async (appId, options) => { const query = backgroundJobsQuery(options); output( - await client().get( - `/v1/apps/${encodeURIComponent(appId)}/jobs?${query}`, - ), + await client().get(`/v1/apps/${encodeURIComponent(appId)}/jobs?${query}`), ); }); @@ -2017,9 +2031,7 @@ const secret = program secret .command("rotate") - .description( - "Rotate a manifest-generated secret without returning its value", - ) + .description("Rotate a manifest-generated secret without returning its value") .argument("") .argument("") .option("--bytes ", "random byte count", "32") diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index cb54269..dc36870 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -47,6 +47,7 @@ interface AuthorManifest { email?: unknown; health?: unknown; secrets?: Record; + integrations?: Record; } export interface BuiltBundle { @@ -79,6 +80,19 @@ export interface BundleOptions { version?: string; } +export function serializeBundleManifest(manifest: OpenCloudManifest): string { + const archiveManifest: Partial = { ...manifest }; + // Queue-free schema-2 apps keep the archive shape accepted by older + // platform releases while declared queues remain canonical bundle input. + if (manifest.queues.length === 0) delete archiveManifest.queues; + // Integration-free apps likewise retain the archive shape accepted by + // platform releases that predate declarative provider bindings. + if (Object.keys(manifest.integrations).length === 0) { + delete archiveManifest.integrations; + } + return `${JSON.stringify(archiveManifest, null, 2)}\n`; +} + interface BundleSelection { files: Map; directories: Set; @@ -183,13 +197,9 @@ export async function buildBundle( await copyFile(sourceFile, destination); await chmod(destination, 0o644); } - const archiveManifest: Partial = { ...manifest }; - // Queue-free schema-2 apps keep the archive shape accepted by older - // platform releases while declared queues remain canonical bundle input. - if (manifest.queues.length === 0) delete archiveManifest.queues; await writeFile( path.join(staging, "opencloud.json"), - `${JSON.stringify(archiveManifest, null, 2)}\n`, + serializeBundleManifest(manifest), { flag: "wx", mode: 0o644 }, ); @@ -227,9 +237,7 @@ export async function buildBundle( } } -export function assertE2eTestOutsideFrontend( - frontendDirectory: string, -): void { +export function assertE2eTestOutsideFrontend(frontendDirectory: string): void { const relative = path.posix.relative( frontendDirectory, OPEN_CLOUD_E2E_TEST_PATH, diff --git a/vendor/contracts/src/manifest.test.ts b/vendor/contracts/src/manifest.test.ts index 879acea..6d212bc 100644 --- a/vendor/contracts/src/manifest.test.ts +++ b/vendor/contracts/src/manifest.test.ts @@ -28,6 +28,12 @@ describe("OpenCloud manifest", () => { expect(parseManifest(valid)).not.toHaveProperty("files"); }); + it("preserves the canonical shape when email is not declared", () => { + const { email, ...withoutEmail } = valid; + expect(email).toEqual({ addresses: [] }); + expect(parseManifest(withoutEmail)).not.toHaveProperty("email"); + }); + it("enables files only when declared and defaults to user isolation", () => { expect( parseManifest({ @@ -87,7 +93,11 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, functions: [ - { name: "old", entrypoint: "functions/old/index.ts", verifyJwt: true }, + { + name: "old", + entrypoint: "functions/old/index.ts", + verifyJwt: true, + }, ], }), ).toThrow(/verifyJwt with access/); @@ -101,7 +111,11 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, functions: [ - { name: "tick", entrypoint: "functions/tick/index.ts", acess: "user" }, + { + name: "tick", + entrypoint: "functions/tick/index.ts", + acess: "user", + }, ], }), ).toThrow(/Unrecognized key/); @@ -220,7 +234,11 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, functions: [ - { name: "tick", entrypoint: "functions/tick/index.ts", access: "user" }, + { + name: "tick", + entrypoint: "functions/tick/index.ts", + access: "user", + }, ], cron: [ { @@ -236,7 +254,11 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, functions: [ - { name: "tick", entrypoint: "functions/tick/index.ts", access: "system" }, + { + name: "tick", + entrypoint: "functions/tick/index.ts", + access: "system", + }, ], cron: [ { @@ -351,6 +373,524 @@ describe("OpenCloud manifest", () => { }); }); + it("declares Google Calendar integration slots with one or many bindings", () => { + expect( + parseManifest({ + ...valid, + integrations: { + calendar: { + provider: "google-calendar", + account: "app", + capabilities: ["calendar.events.create"], + }, + team_calendars: { + provider: "google-calendar", + account: "calling_user", + cardinality: "many", + capabilities: ["calendar.events.read"], + }, + }, + }).integrations, + ).toEqual({ + calendar: { + provider: "google-calendar", + account: "app", + cardinality: "one", + capabilities: ["calendar.events.create"], + }, + team_calendars: { + provider: "google-calendar", + account: "calling_user", + cardinality: "many", + capabilities: ["calendar.events.read"], + }, + }); + }); + + it("declares provider-scoped Google Workspace integrations", () => { + expect( + parseManifest({ + ...valid, + integrations: { + drive: { + provider: "google-drive", + account: "app", + capabilities: ["drive.files.read", "drive.files.write"], + }, + sheets: { + provider: "google-sheets", + account: "calling_user", + cardinality: "many", + capabilities: [ + "sheets.spreadsheets.read", + "sheets.spreadsheets.write", + ], + }, + docs: { + provider: "google-docs", + account: "calling_user", + capabilities: ["docs.documents.read", "docs.documents.write"], + }, + slides: { + provider: "google-slides", + account: "calling_user", + capabilities: [ + "slides.presentations.read", + "slides.presentations.write", + ], + }, + }, + }).integrations, + ).toMatchObject({ + drive: { provider: "google-drive", cardinality: "one" }, + sheets: { provider: "google-sheets", cardinality: "many" }, + docs: { provider: "google-docs", cardinality: "one" }, + slides: { provider: "google-slides", cardinality: "one" }, + }); + }); + + it("rejects capabilities declared under the wrong Google provider", () => { + expect(() => + parseManifest({ + ...valid, + integrations: { + drive: { + provider: "google-drive", + account: "app", + capabilities: ["docs.documents.read"], + }, + }, + }), + ).toThrow(/not supported by google-drive/); + }); + + it("declares app-owned HubSpot CRM access and bounds associations", () => { + expect( + parseManifest({ + ...valid, + integrations: { + crm: { + provider: "hubspot-crm", + account: "app", + capabilities: [ + "crm.contacts.read", + "crm.contacts.write", + "crm.notes.write", + "crm.associations.write", + ], + }, + }, + }).integrations.crm, + ).toEqual({ + provider: "hubspot-crm", + account: "app", + cardinality: "one", + capabilities: [ + "crm.contacts.read", + "crm.contacts.write", + "crm.notes.write", + "crm.associations.write", + ], + }); + + expect(() => + parseManifest({ + ...valid, + integrations: { + crm: { + provider: "hubspot-crm", + account: "calling_user", + capabilities: ["crm.contacts.read"], + }, + }, + }), + ).toThrow(/hubspot-crm integrations must use the app account/); + + expect(() => + parseManifest({ + ...valid, + integrations: { + crm: { + provider: "hubspot-crm", + account: "app", + capabilities: ["crm.associations.write"], + }, + }, + }), + ).toThrow(/requires at least one CRM record write capability/); + }); + + it("declares provider-scoped GoCardless Bank Account Data access", () => { + expect( + parseManifest({ + ...valid, + integrations: { + bank: { + provider: "gocardless-bank-account-data", + account: "calling_user", + cardinality: "many", + capabilities: [ + "bank.accounts.read", + "bank.balances.read", + "bank.transactions.read", + ], + }, + }, + }).integrations.bank, + ).toEqual({ + provider: "gocardless-bank-account-data", + account: "calling_user", + cardinality: "many", + capabilities: [ + "bank.accounts.read", + "bank.balances.read", + "bank.transactions.read", + ], + }); + expect(() => + parseManifest({ + ...valid, + integrations: { + bank: { + provider: "gocardless-bank-account-data", + account: "calling_user", + capabilities: ["calendar.events.read"], + }, + }, + }), + ).toThrow(/not supported by gocardless-bank-account-data/); + }); + + it("declares app-owned Wise payment reconciliation", () => { + expect( + parseManifest({ + ...valid, + integrations: { + payments: { + provider: "wise-balance-webhook", + account: "app", + capabilities: ["payments.received.reconcile"], + }, + }, + }).integrations.payments, + ).toEqual({ + provider: "wise-balance-webhook", + account: "app", + cardinality: "one", + capabilities: ["payments.received.reconcile"], + }); + expect(() => + parseManifest({ + ...valid, + integrations: { + payments: { + provider: "wise-balance-webhook", + account: "calling_user", + capabilities: ["payments.received.reconcile"], + }, + }, + }), + ).toThrow(/must use the app account/); + }); + + it("declares app-owned Slack messaging with an exact system handler", () => { + const parsed = parseManifest({ + ...valid, + functions: [ + { + name: "receive-slack-message", + entrypoint: "functions/receive-slack-message/index.ts", + access: "system", + }, + ], + integrations: { + team_chat: { + provider: "slack", + account: "app", + cardinality: "many", + capabilities: ["slack.messages.send", "slack.messages.receive"], + events: { + message: { function: "receive-slack-message" }, + }, + }, + }, + }); + + expect(parsed.integrations.team_chat).toEqual({ + provider: "slack", + account: "app", + cardinality: "many", + capabilities: ["slack.messages.send", "slack.messages.receive"], + events: { + message: { function: "receive-slack-message" }, + }, + }); + }); + + it("rejects ambiguous or non-system Slack message handlers", () => { + expect(() => + parseManifest({ + ...valid, + integrations: { + chat: { + provider: "slack", + account: "app", + capabilities: ["slack.messages.receive"], + }, + }, + }), + ).toThrow(/requires an events.message system Function/); + expect(() => + parseManifest({ + ...valid, + functions: [ + { + name: "receive-chat", + entrypoint: "functions/receive-chat/index.ts", + access: "user", + }, + ], + integrations: { + chat: { + provider: "slack", + account: "calling_user", + capabilities: ["slack.messages.receive"], + events: { message: { function: "receive-chat" } }, + }, + }, + }), + ).toThrow(/must use the app account|must declare access: system/); + }); + + it("declares app-owned Telegram messaging with an exact system handler", () => { + const parsed = parseManifest({ + ...valid, + functions: [ + { + name: "receive-telegram-message", + entrypoint: "functions/receive-telegram-message/index.ts", + access: "system", + }, + ], + integrations: { + team_chat: { + provider: "telegram", + account: "app", + capabilities: ["telegram.messages.send", "telegram.messages.receive"], + events: { + message: { function: "receive-telegram-message" }, + }, + }, + }, + }); + + expect(parsed.integrations.team_chat).toEqual({ + provider: "telegram", + account: "app", + cardinality: "one", + capabilities: ["telegram.messages.send", "telegram.messages.receive"], + events: { + message: { function: "receive-telegram-message" }, + }, + }); + }); + + it("rejects ambiguous or non-system Telegram message handlers", () => { + expect(() => + parseManifest({ + ...valid, + integrations: { + chat: { + provider: "telegram", + account: "app", + capabilities: ["telegram.messages.receive"], + }, + }, + }), + ).toThrow(/requires an events.message system Function/); + expect(() => + parseManifest({ + ...valid, + functions: [ + { + name: "receive-chat", + entrypoint: "functions/receive-chat/index.ts", + access: "user", + }, + ], + integrations: { + chat: { + provider: "telegram", + account: "calling_user", + capabilities: ["telegram.messages.receive"], + events: { message: { function: "receive-chat" } }, + }, + }, + }), + ).toThrow(/must use the app account|must declare access: system/); + }); + + it("declares project-contained Asana access and a system event handler", () => { + const manifest = parseManifest({ + ...valid, + functions: [ + { + name: "asana-events", + entrypoint: "functions/asana-events/index.ts", + access: "system", + }, + ], + integrations: { + work: { + provider: "asana", + account: "app", + capabilities: [ + "asana.tasks.read", + "asana.tasks.update", + "asana.assignees.write", + "asana.sections.read", + "asana.sections.move_tasks", + "asana.custom_fields.read", + "asana.custom_field_values.write", + "asana.attachments.read", + "asana.attachments.write", + "asana.events.receive", + ], + events: { function: "asana-events" }, + }, + }, + }); + + expect(manifest.integrations.work).toMatchObject({ + provider: "asana", + account: "app", + cardinality: "one", + events: { function: "asana-events" }, + }); + }); + + it("requires Asana events to be app-owned, readable, and handled by a system Function", () => { + const integration = { + provider: "asana", + account: "app", + capabilities: ["asana.tasks.read", "asana.events.receive"], + events: { function: "asana-events" }, + }; + const functions = [ + { + name: "asana-events", + entrypoint: "functions/asana-events/index.ts", + access: "system", + }, + ]; + + expect(() => + parseManifest({ + ...valid, + functions, + integrations: { + work: { ...integration, account: "calling_user" }, + }, + }), + ).toThrow(/must use the app account/); + expect(() => + parseManifest({ + ...valid, + functions, + integrations: { + work: { + ...integration, + capabilities: ["asana.events.receive"], + }, + }, + }), + ).toThrow(/requires asana.tasks.read/); + expect(() => + parseManifest({ + ...valid, + functions: [{ ...functions[0], access: "user" }], + integrations: { work: integration }, + }), + ).toThrow(/must declare access: system/); + expect(() => + parseManifest({ + ...valid, + functions, + integrations: { + work: { + ...integration, + capabilities: ["asana.tasks.read"], + }, + }, + }), + ).toThrow(/requires asana.events.receive/); + }); + + it("requires task read access for Asana mutations that return current task state", () => { + for (const capability of [ + "asana.tasks.create", + "asana.tasks.update", + "asana.assignees.write", + "asana.sections.move_tasks", + "asana.custom_field_values.write", + ]) { + expect(() => + parseManifest({ + ...valid, + integrations: { + work: { + provider: "asana", + account: "app", + capabilities: [capability], + }, + }, + }), + ).toThrow(/requires asana.tasks.read/); + } + }); + + it("rejects unknown providers, capabilities, and duplicate capabilities", () => { + expect(() => + parseManifest({ + ...valid, + integrations: { + calendar: { + provider: "raw-google-api", + account: "app", + cardinality: "one", + capabilities: ["calendar.events.read"], + }, + }, + }), + ).toThrow(); + expect(() => + parseManifest({ + ...valid, + integrations: { + calendar: { + provider: "google-calendar", + account: "app", + cardinality: "one", + capabilities: ["calendar.events.delete"], + }, + }, + }), + ).toThrow(); + expect(() => + parseManifest({ + ...valid, + integrations: { + calendar: { + provider: "google-calendar", + account: "app", + cardinality: "one", + capabilities: ["calendar.events.read", "calendar.events.read"], + }, + }, + }), + ).toThrow(/must be unique/); + }); + it("rejects legacy requiredSecrets with direct migration guidance", () => { expect(() => parseManifest({ ...valid, requiredSecrets: ["SIGNING_SECRET"] }), @@ -386,8 +926,8 @@ describe("OpenCloud manifest", () => { ], }, }); - expect(manifest.email.addresses).toHaveLength(2); - expect(manifest.email.addresses[0]?.function).toBe("receive-support"); + expect(manifest.email?.addresses).toHaveLength(2); + expect(manifest.email?.addresses[0]?.function).toBe("receive-support"); }); it("rejects duplicate email aliases and unknown inbound handlers", () => { @@ -416,9 +956,7 @@ describe("OpenCloud manifest", () => { }, ], email: { - addresses: [ - { name: "support", function: "receive-support" }, - ], + addresses: [{ name: "support", function: "receive-support" }], }, }), ).toThrow(/must declare access: system/); @@ -445,7 +983,11 @@ describe("OpenCloud manifest", () => { parseManifest({ ...valid, functions: [ - { name: "tick", entrypoint: "functions/tick/index.ts", access: "system" }, + { + name: "tick", + entrypoint: "functions/tick/index.ts", + access: "system", + }, ], cron: [ { diff --git a/vendor/contracts/src/manifest.ts b/vendor/contracts/src/manifest.ts index 4524997..a6069c4 100644 --- a/vendor/contracts/src/manifest.ts +++ b/vendor/contracts/src/manifest.ts @@ -5,8 +5,10 @@ const relativePath = z .string() .min(1) .max(240) - .refine((value) => !value.startsWith("/") && !value.includes("\\"), - "path must be relative and use forward slashes") + .refine( + (value) => !value.startsWith("/") && !value.includes("\\"), + "path must be relative and use forward slashes", + ) .refine( (value) => value.split("/").every((part) => part !== ".." && part !== ""), "path must not traverse outside the bundle", @@ -54,7 +56,12 @@ export const queueSchema = z maxAttempts: z.number().int().min(1).max(10).default(3), retryDelaySeconds: z.number().int().min(1).max(3_600).default(5), retryBackoff: z.boolean().default(true), - timeoutSeconds: z.number().int().min(1).max(15 * 60).default(15 * 60), + timeoutSeconds: z + .number() + .int() + .min(1) + .max(15 * 60) + .default(15 * 60), }) .strict(); @@ -62,6 +69,320 @@ export const filesAccessSchema = z.enum(["user", "app"]); export const secretModeSchema = z.enum(["generated", "required", "optional"]); +export const integrationAccountSchema = z.enum(["app", "calling_user"]); + +export const integrationCardinalitySchema = z.enum(["one", "many"]); + +export const integrationCapabilitySchema = z.enum([ + "calendar.events.read", + "calendar.events.create", + "drive.files.read", + "drive.files.write", + "sheets.spreadsheets.read", + "sheets.spreadsheets.write", + "docs.documents.read", + "docs.documents.write", + "slides.presentations.read", + "slides.presentations.write", + "bank.accounts.read", + "bank.balances.read", + "bank.transactions.read", + "payments.received.reconcile", + "transfers.sent.read", + "slack.messages.send", + "slack.messages.receive", + "telegram.messages.send", + "telegram.messages.receive", + "asana.tasks.read", + "asana.tasks.create", + "asana.tasks.update", + "asana.assignees.read", + "asana.assignees.write", + "asana.sections.read", + "asana.sections.move_tasks", + "asana.custom_fields.read", + "asana.custom_field_values.write", + "asana.attachments.read", + "asana.attachments.write", + "asana.stories.read", + "asana.comments.write", + "asana.events.receive", + "crm.contacts.read", + "crm.contacts.write", + "crm.companies.read", + "crm.companies.write", + "crm.deals.read", + "crm.deals.write", + "crm.owners.read", + "crm.pipelines.read", + "crm.notes.write", + "crm.associations.write", +]); + +export const integrationProviderSchema = z.enum([ + "google-calendar", + "google-drive", + "google-sheets", + "google-docs", + "google-slides", + "gocardless-bank-account-data", + "wise-balance-webhook", + "slack", + "telegram", + "asana", + "hubspot-crm", +]); + +const capabilitiesForProvider: Record< + z.infer, + ReadonlySet> +> = { + "google-calendar": new Set([ + "calendar.events.read", + "calendar.events.create", + ]), + "google-drive": new Set(["drive.files.read", "drive.files.write"]), + "google-sheets": new Set([ + "sheets.spreadsheets.read", + "sheets.spreadsheets.write", + ]), + "google-docs": new Set(["docs.documents.read", "docs.documents.write"]), + "google-slides": new Set([ + "slides.presentations.read", + "slides.presentations.write", + ]), + "gocardless-bank-account-data": new Set([ + "bank.accounts.read", + "bank.balances.read", + "bank.transactions.read", + ]), + "wise-balance-webhook": new Set([ + "payments.received.reconcile", + "transfers.sent.read", + ]), + slack: new Set(["slack.messages.send", "slack.messages.receive"]), + telegram: new Set(["telegram.messages.send", "telegram.messages.receive"]), + asana: new Set([ + "asana.tasks.read", + "asana.tasks.create", + "asana.tasks.update", + "asana.assignees.read", + "asana.assignees.write", + "asana.sections.read", + "asana.sections.move_tasks", + "asana.custom_fields.read", + "asana.custom_field_values.write", + "asana.attachments.read", + "asana.attachments.write", + "asana.stories.read", + "asana.comments.write", + "asana.events.receive", + ]), + "hubspot-crm": new Set([ + "crm.contacts.read", + "crm.contacts.write", + "crm.companies.read", + "crm.companies.write", + "crm.deals.read", + "crm.deals.write", + "crm.owners.read", + "crm.pipelines.read", + "crm.notes.write", + "crm.associations.write", + ]), +}; + +const integrationEventFunctionSchema = z + .object({ + function: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + }) + .strict(); + +export const integrationEventsSchema = z + .object({ + message: integrationEventFunctionSchema.optional(), + function: z + .string() + .regex(/^[a-z][a-z0-9-]{0,62}$/) + .optional(), + }) + .strict(); + +export const integrationDefinitionSchema = z + .object({ + provider: integrationProviderSchema, + account: integrationAccountSchema, + cardinality: integrationCardinalitySchema.default("one"), + capabilities: z + .array(integrationCapabilitySchema) + .min(1) + .max(20) + .refine( + (capabilities) => new Set(capabilities).size === capabilities.length, + "integration capabilities must be unique", + ), + events: integrationEventsSchema.optional(), + }) + .strict() + .superRefine((definition, context) => { + const allowed = capabilitiesForProvider[definition.provider]; + definition.capabilities.forEach((capability, index) => { + if (!allowed.has(capability)) { + context.addIssue({ + code: "custom", + path: ["capabilities", index], + message: `${capability} is not supported by ${definition.provider}`, + }); + } + }); + if ( + definition.provider === "wise-balance-webhook" && + definition.account !== "app" + ) { + context.addIssue({ + code: "custom", + path: ["account"], + message: "wise-balance-webhook integrations must use the app account", + }); + } + if (["slack", "telegram", "hubspot-crm"].includes(definition.provider)) { + if (definition.account !== "app") { + context.addIssue({ + code: "custom", + path: ["account"], + message: `${definition.provider} integrations must use the app account`, + }); + } + } + if ( + definition.provider === "hubspot-crm" && + definition.capabilities.includes("crm.associations.write") && + !definition.capabilities.some((capability) => + [ + "crm.contacts.write", + "crm.companies.write", + "crm.deals.write", + "crm.notes.write", + ].includes(capability), + ) + ) { + context.addIssue({ + code: "custom", + path: ["capabilities"], + message: + "crm.associations.write requires at least one CRM record write capability", + }); + } + if (["slack", "telegram"].includes(definition.provider)) { + const receivesMessages = definition.capabilities.includes( + definition.provider === "slack" + ? "slack.messages.receive" + : "telegram.messages.receive", + ); + if (receivesMessages && !definition.events?.message) { + context.addIssue({ + code: "custom", + path: ["events", "message"], + message: `${definition.provider}.messages.receive requires an events.message system Function`, + }); + } else if (!receivesMessages && definition.events?.message) { + context.addIssue({ + code: "custom", + path: ["events", "message"], + message: `events.message requires the ${definition.provider}.messages.receive capability`, + }); + } + if (definition.events?.function) { + context.addIssue({ + code: "custom", + path: ["events", "function"], + message: "events.function is supported only by asana integrations", + }); + } + } else if (definition.provider === "asana") { + const receivesAsanaEvents = definition.capabilities.includes( + "asana.events.receive", + ); + const taskStateMutation = [ + "asana.tasks.create", + "asana.tasks.update", + "asana.assignees.write", + "asana.sections.move_tasks", + "asana.custom_field_values.write", + ].find((capability) => + definition.capabilities.includes( + capability as z.infer, + ), + ); + if ( + taskStateMutation && + !definition.capabilities.includes("asana.tasks.read") + ) { + context.addIssue({ + code: "custom", + path: ["capabilities"], + message: `${taskStateMutation} requires asana.tasks.read because task mutations return normalized current task state`, + }); + } + if (receivesAsanaEvents && definition.account !== "app") { + context.addIssue({ + code: "custom", + path: ["account"], + message: "asana event integrations must use the app account", + }); + } + if (receivesAsanaEvents && !definition.events?.function) { + context.addIssue({ + code: "custom", + path: ["events", "function"], + message: "asana.events.receive requires an events.function handler", + }); + } + if (definition.events?.function && !receivesAsanaEvents) { + context.addIssue({ + code: "custom", + path: ["capabilities"], + message: "events.function requires asana.events.receive", + }); + } + if ( + receivesAsanaEvents && + !definition.capabilities.includes("asana.tasks.read") + ) { + context.addIssue({ + code: "custom", + path: ["capabilities"], + message: + "asana.events.receive requires asana.tasks.read so OpenCloud can deliver current task state", + }); + } + if (definition.events?.message) { + context.addIssue({ + code: "custom", + path: ["events", "message"], + message: + "events.message is supported only by slack and telegram integrations", + }); + } + } else if (definition.events) { + context.addIssue({ + code: "custom", + path: ["events"], + message: + "integration events are currently supported only by slack, telegram, and asana", + }); + } + }); + +const integrationNameSchema = z + .string() + .min(1) + .max(63) + .regex( + /^[a-z][a-z0-9_]*$/, + "integration names must use lowercase snake_case", + ); + const secretNameSchema = z .string() .regex(/^[A-Z][A-Z0-9_]{0,127}$/) @@ -76,9 +397,15 @@ export const emailAddressSchema = z .string() .min(1) .max(30) - .regex(/^[a-z][a-z0-9-]*$/, "email address names must be lowercase aliases"), + .regex( + /^[a-z][a-z0-9-]*$/, + "email address names must be lowercase aliases", + ), displayName: z.string().trim().min(1).max(120).optional(), - function: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/).optional(), + function: z + .string() + .regex(/^[a-z][a-z0-9-]{0,62}$/) + .optional(), }) .strict(); @@ -178,7 +505,7 @@ export const openCloudManifestSchema = z addresses: z.array(emailAddressSchema).max(25).default([]), }) .strict() - .default({ addresses: [] }), + .optional(), health: z .object({ path: z.string().startsWith("/").max(200).default("/") }) .strict() @@ -189,6 +516,12 @@ export const openCloudManifestSchema = z message: "apps may declare at most 100 secrets", }) .default({}), + integrations: z + .record(integrationNameSchema, integrationDefinitionSchema) + .refine((integrations) => Object.keys(integrations).length <= 20, { + message: "apps may declare at most 20 integrations", + }) + .default({}), observability: z .object({ metrics: z.array(customMetricDefinitionSchema).max(20).default([]), @@ -228,10 +561,16 @@ export const openCloudManifestSchema = z manifest.functions.map((definition) => definition.name), "functions", ); - assertUnique(manifest.cron.map((cron) => cron.name), "cron"); - assertUnique(manifest.queues.map((queue) => queue.name), "queues"); assertUnique( - manifest.email.addresses.map((address) => address.name), + manifest.cron.map((cron) => cron.name), + "cron", + ); + assertUnique( + manifest.queues.map((queue) => queue.name), + "queues", + ); + assertUnique( + (manifest.email?.addresses ?? []).map((address) => address.name), "email", ); assertUnique( @@ -268,8 +607,7 @@ export const openCloudManifestSchema = z context.addIssue({ code: "custom", path: ["cron", index, "function"], - message: - `cron function ${cron.function} must declare access: system`, + message: `cron function ${cron.function} must declare access: system`, }); } try { @@ -300,7 +638,7 @@ export const openCloudManifestSchema = z }); } }); - manifest.email.addresses.forEach((address, index) => { + (manifest.email?.addresses ?? []).forEach((address, index) => { if (!address.function) return; const target = manifest.functions.find( (definition) => definition.name === address.function, @@ -315,11 +653,37 @@ export const openCloudManifestSchema = z context.addIssue({ code: "custom", path: ["email", "addresses", index, "function"], - message: - `email function ${address.function} must declare access: system`, + message: `email function ${address.function} must declare access: system`, }); } }); + Object.entries(manifest.integrations).forEach( + ([integrationName, integration]) => { + const messageHandler = integration.events?.message?.function; + const asanaHandler = integration.events?.function; + const handler = messageHandler ?? asanaHandler; + if (!handler) return; + const target = manifest.functions.find( + (definition) => definition.name === handler, + ); + const handlerPath = messageHandler + ? ["integrations", integrationName, "events", "message", "function"] + : ["integrations", integrationName, "events", "function"]; + if (!target) { + context.addIssue({ + code: "custom", + path: handlerPath, + message: `${integration.provider} event references unknown function: ${handler}`, + }); + } else if (target.access !== "system") { + context.addIssue({ + code: "custom", + path: handlerPath, + message: `${integration.provider} event function ${handler} must declare access: system`, + }); + } + }, + ); }); export type OpenCloudManifest = z.infer; @@ -327,6 +691,12 @@ export type OpenCloudMigration = z.infer; export type FilesAccess = z.infer; export type FunctionAccess = z.infer; export type SecretMode = z.infer; +export type IntegrationAccount = z.infer; +export type IntegrationCardinality = z.infer< + typeof integrationCardinalitySchema +>; +export type IntegrationCapability = z.infer; +export type IntegrationDefinition = z.infer; export type SdkVersion = z.infer; export type OpenCloudEmailAddress = z.infer; export type OpenCloudQueue = z.infer;