diff --git a/docs/architecture-decisions/020-capabilities.md b/docs/architecture-decisions/020-capabilities.md index 35dc5118a..a4cb5deb3 100644 --- a/docs/architecture-decisions/020-capabilities.md +++ b/docs/architecture-decisions/020-capabilities.md @@ -134,6 +134,16 @@ App level capabilities are usually computed (the union of declared integration c - [ONTOLOGY](./021-ontology.md): the ontology contains capability naming and surface-kind conventions - [AGENT-HARNESS](./025-agent-harness.md): the harness compiles and injects the capability graph at session start +## Amendment (ADR-063) + +[ADR-063](./063-integration-definitions-as-data.md) (Proposed) adds integrations that are published as data and checks them against a per-module **capability manifest**. If it is accepted, this ADR is amended as follows. It records how the two meet, and does not change the three levels or the surfaces above. + +- **The manifest is the serialised form of a module's `Definition.capabilities`.** The manifest's `capabilities` map (read and write verbs, each naming a method on the module's `Api`) is the module level of this ADR, written as a JSON file a validator and the runtime can read without loading the module. It is not a second source of truth: it should be generated from `Definition.capabilities`, or at minimum checked against the module's `Api` and its `Definition.capabilities` in the module's own tests, so the two cannot drift. +- **Key convention.** Manifest keys are `.` (`contact.list`). Names in this ADR are `..` (`crm.contact.list`), and Open question 1 leaves namespacing open. For now the domain is implied by the module: a CRM module's `contact.list` is `crm.contact.list`. The target is the full `crm.contact.list` form once a module declares its domain, so a manifest key and a capability name map to each other without a lookup beyond the module's domain. +- **Webhook events are webhook-source capabilities.** A manifest `events` entry (a topic the module can deliver) is a capability with surface `webhook-source`, like `crm.contact.watch` above. How a delivery is verified, answered and split into events comes from the webhook-source's extension, which for a one-file integration is [ADR-054](./054-one-file-integrations.md) extension contract 2 (`verify`, `handshake`, `receive`, `emits`, `normalize`), not from a second description in the manifest. +- **The endpoint is versioned.** `GET /api/capabilities` becomes `GET /api/v2/capabilities`, since [ADR-053](./053-management-api-versioning.md) puts every new Management API resource under `/api/v2/`. +- **Naming clash with `/api/meta`.** ADR-053 §4's `GET /api/meta` already returns a `capabilities` array. Its entries are server feature flags (`multiStepAuthorize`, `entityProxy`, `credentials`), not this ADR's graph. Two things called "capabilities" with different meanings on one API will confuse clients and docs. Not decided here: the choice is between renaming the meta field (for example to `features`) and giving the graph its own name. It should be settled before either ships. + ## Open questions 1. **Capability namespacing.** `crm.contact.sync.bidir` vs `crm/contact/sync.bidir` vs `crm:contact:sync:bidir`. Lean dot-notation for filesystem-safety and grep-ability. diff --git a/docs/architecture-decisions/063-integration-definitions-as-data.md b/docs/architecture-decisions/063-integration-definitions-as-data.md index 5f1e31094..c17bc400d 100644 --- a/docs/architecture-decisions/063-integration-definitions-as-data.md +++ b/docs/architecture-decisions/063-integration-definitions-as-data.md @@ -271,6 +271,15 @@ code (`schema/capability-manifest.v0.1.schema.json`; mocks for HubSpot and a pho event's example `payload` (with `headers` when the topic is in one) is an exact delivery, and validation requires it to route to its own event through the same code a live webhook uses. +A manifest names Api methods, so it can be checked against the module: `scripts/check-manifests.js` loads the Api class +and verifies each `method` (and `webhook.verify`) exists with the arguments it takes. A capability carries `args` when +the method takes positional arguments (most do) and `status: "unimplemented"` plus a `note` when the module has no such +method; a definition using one validates with a warning. The first audit (`capabilities/AUDIT.md`) found 79 of 95 +capabilities in the nine modules with source were missing or shaped wrong, and no module implements +`verifyWebhookSignature`. + +**Manifests should ship with each module.** Today they sit together in core (`capabilities/`) so that core's tests can run them, and the audit above found that copies written beside the code they describe drift from it. A manifest is the module's own statement of what is safe to call, so it belongs in the module's package, checked against that module's `Api` in the module's own tests. See the Amendment (ADR-063) in [ADR-020](./020-capabilities.md): the manifest is the serialised form of the module's `Definition.capabilities`, and should be generated from or checked against it. Moving the files is a separate change. + The manifest is the module author's promise of what is safe to call. Adding a capability is a module release, which is code that gets reviewed. Using it from a definition is a publish. This is the line between the two authoring forms. diff --git a/packages/core/integrations/data-defined/README.md b/packages/core/integrations/data-defined/README.md index fc1c7a294..41af02378 100644 --- a/packages/core/integrations/data-defined/README.md +++ b/packages/core/integrations/data-defined/README.md @@ -19,7 +19,8 @@ installation (Integration.config) ─┘ │ | `schema/capability-manifest.v0.1.schema.json` | What an API module declares it can do | | `schema/installation.v0.1.schema.json` | `Integration.config` for a data-defined installation | | `schema/records.md` | Database records: definition versions, installation, mappings, workflow runs | -| `capabilities/*.capabilities.json` | Mock manifests (HubSpot, Phone, and the apps the examples use) | +| `capabilities/*.capabilities.json` | Capability manifests. Nine are checked against the real Api classes (`capabilities/AUDIT.md`); the other twelve are still mock-shaped | +| `scripts/check-manifests.js` | Checks every manifest `method` exists on the real module's Api class, with the arguments it takes | | `examples/*.definition.json` | Example definitions; `examples/records/` has Phone ↔ HubSpot database records | | `expressions.js` | `$.ref`, `{{template \| filter}}` and conditions. No eval. | | `steps.js`, `interpreter.js`, `transforms.js` | The step catalogue and the interpreter | @@ -258,6 +259,12 @@ Exit codes: `0` valid, `1` invalid (or a warning with `--strict`), `2` could not unknown option, `ajv` not installed). Manifests are checked first and an invalid manifest fails the run, because a definition is only as trustworthy as the manifests it is checked against. +A manifest is only as real as its methods. `scripts/check-manifests.js ...` loads each module's Api class +(no network, nothing instantiated) and checks that every capability's `method` exists and is called the way it takes its +arguments (`args`: positional inputs, `"$rest"`, or a `{ "value" }` constant; no `args` means one object of all inputs). +A capability the real Api lacks is marked `"status": "unimplemented"` with a `note`: a definition that uses it validates +with a warning, not an error. `capabilities/AUDIT.md` has the table. + `ajv` comes from the monorepo root (`@friggframework/schemas` depends on it); core's runtime stays dependency-free and loads it only for validation. `tests/schemas.test.js` runs both layers over every example and manifest and drives the CLI as a child process; it fails, rather than skips, if `ajv` is missing. Core's `npm test`, and so CI's diff --git a/packages/core/integrations/data-defined/capabilities/AUDIT.md b/packages/core/integrations/data-defined/capabilities/AUDIT.md new file mode 100644 index 000000000..e938706b6 --- /dev/null +++ b/packages/core/integrations/data-defined/capabilities/AUDIT.md @@ -0,0 +1,451 @@ +# Capability manifests against the real API modules + +Audit of every `*.capabilities.json` in this directory against the Api class it names (ADR-063 phase 4). The manifests +began as bundled mocks; for each module whose source could be found, every `method` was checked to exist on the real +Api class and every call shape was read against the method's real signature. + +Re-run it (no network, nothing instantiated): + +```bash +node packages/core/integrations/data-defined/scripts/check-manifests.js --stub-missing \ + ~/phone-app/backend/src/api-modules ~/phone-app/backend/node_modules/@friggframework \ + ~/api-module-library/packages/v1-ready ~/api-module-library/packages/needs-updating +``` + +(`--stub-missing` stands in for dependencies that are not installed here, such as jsforce, stripe and @linear/sdk, so +the Api's methods can be read. Roots are searched in order and the first module that loads wins.) + +## How to read this + +- **ok**: the method exists on the Api class and the call is shaped as the method takes it. +- **unimplemented**: the real Api has no method that does this (or none that can be called as the capability is + shaped). The manifest carries `"status": "unimplemented"` and a `note`; a definition that uses the capability + validates with a **warning**, never an error. The capability name is unchanged so existing definitions keep validating. +- **unverified**: no source for the module was available; its manifest is unchanged from the mock. +- **Call shape**. A capability with no `args` is called `api.method(input)` with one object holding every input. + Nearly every real method takes positional arguments instead (`getContactById(id)`, `updateContact(id, properties)`), + so the manifest now carries `args`: an ordered list of input names, `"$rest"` (the inputs not named elsewhere, as + one object) or `{ "value": x }` (a constant, e.g. the sObject name of Salesforce's generic `get(object, id)`). + `runtime.js` `argumentsFor` spreads them. An input that is declared but never passed is refused by the manifest check, + because it would be accepted and silently dropped. +- **Webhook verification.** Every manifest names `verifyWebhookSignature`. No real module has it (only clio, which has no + manifest). Each real module's `webhook` block is marked `"status": "unimplemented"`, and a workflow that triggers on + its events validates with a warning: every delivery would fail signature verification. + +## Summary + +| Module | Source checked | Capabilities | Renamed method | Call shape fixed | Inputs changed | Unimplemented upstream | +|---|---|---|---|---|---|---| +| asana | ~/api-module-library/packages/v1-ready/asana | 6 | 3 | 1 | 2 | 3 | +| calendly | none found | 6 | - | - | - | - | +| docusign | none found | 5 | - | - | - | - | +| fathom | ~/api-module-library/packages/v1-ready/fathom | 5 | 2 | 2 | 0 | 1 | +| github | none found | 6 | - | - | - | - | +| hubspot | ~/api-module-library/packages/v1-ready/hubspot | 23 | 0 | 5 | 1 | 17 | +| jira | none found | 12 | - | - | - | - | +| klaviyo | none found | 14 | - | - | - | - | +| linear | ~/api-module-library/packages/v1-ready/linear | 9 | 0 | 0 | 0 | 9 | +| netsuite | none found | 15 | - | - | - | - | +| notion | none found | 6 | - | - | - | - | +| pipedrive | ~/phone-app/backend/node_modules/@friggframework/api-module-pipedrive | 11 | 4 | 3 | 1 | 2 | +| quickbooks | none found | 14 | - | - | - | - | +| phone | ~/phone-app/backend/src/api-modules/phone | 13 | 1 | 4 | 0 | 1 | +| salesforce | ~/api-module-library/packages/v1-ready/salesforce | 12 | 5 | 5 | 0 | 7 | +| shopify | none found | 15 | - | - | - | - | +| slack | ~/api-module-library/packages/needs-updating/slack | 7 | 2 | 1 | 0 | 1 | +| stripe | ~/api-module-library/packages/v1-ready/stripe | 9 | 1 | 0 | 0 | 8 | +| acmeerp | none found | 13 | - | - | - | - | +| webflow | none found | 5 | - | - | - | - | +| zendesk | none found | 8 | - | - | - | - | + +Modules with no source to check against: calendly, docusign, github, jira, klaviyo, netsuite, notion, quickbooks, shopify, acmeerp, webflow, zendesk. Their capabilities are still mock-shaped: +nothing in them is known to exist. + +Where a module has two candidates the first root given wins. Phone is checked against the deployed +`phone-app/backend/src/api-modules/phone` (the app's own module: `createUnifiedWebhook`, `updateContact`). The library's `v1-ready/phone` 1.0.0 lacks `updateContact` and has `createCallWebhook` and friends +instead of one webhook method. Pipedrive is checked against the built `@friggframework/api-module-pipedrive` +2.1.0-next.7 (the library copy is TypeScript source with no `dist`). + +Manifest versions in the files (for example HubSpot 1.4.0, Asana 1.5.1) are ahead of the module sources audited (HubSpot +1.1.7, Asana 1.1.5): the manifests describe modules that were never released as written. + +## Asana (`asana`) + +Checked against `~/api-module-library/packages/v1-ready/asana` (@friggframework/api-module-asana manifest 1.5.1). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `task.get` | `getTaskById` | ok | `getTaskById(id)` | Api.getTaskById(id) takes only the id; field selection (opt_fields) is not supported. (method was `getTask`; called with one object; now positional; inputs were taskGid, opt_fields) | +| `task.search` | `searchTasksInWorkspace` | unimplemented | none | Api.listTasks({ workspaceId, assigneeId }) filters by workspace and assignee only; due_on, completed, projects_any and limit have no equivalent. | +| `project.list` | `listProjects` | ok | `listProjects(params)` | (method was `getProjects`; inputs were workspaceGid, archived) | +| `workspace.list` | `listWorkspaces` | ok | `listWorkspaces()` | (method was `getWorkspaces`) | +| `webhook.create` | `createWebhook` | unimplemented | none | Api has no webhook methods. | +| `webhook.delete` | `deleteWebhook` | unimplemented | none | Api has no webhook methods. | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The asana Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## Calendly (`calendly`) + +**No module source available** (No directory named calendly or api-module-calendly). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `invitee.get` | `getInvitee` | unverified | | | +| `event.get` | `getScheduledEvent` | unverified | | | +| `event.list` | `listScheduledEvents` | unverified | | | +| `eventType.list` | `listEventTypes` | unverified | | | +| `webhook.create` | `createWebhookSubscription` | unverified | | | +| `webhook.delete` | `deleteWebhookSubscription` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## DocuSign eSignature (`docusign`) + +**No module source available** (No directory named docusign or api-module-docusign). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `envelope.get` | `getEnvelope` | unverified | | | +| `envelope.list` | `listStatusChanges` | unverified | | | +| `document.combined` | `getCombinedDocument` | unverified | | | +| `connect.create` | `createConnectConfiguration` | unverified | | | +| `connect.delete` | `deleteConnectConfiguration` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Fathom (`fathom`) + +Checked against `~/api-module-library/packages/v1-ready/fathom` (@friggframework/api-module-fathom manifest 0.4.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `meeting.list` | `listMeetings` | ok | `listMeetings(params = {})` | | +| `meeting.summary` | `getSummary` | ok | `getSummary(recordingId, params = {})` | (method was `getRecordingSummary`; called with one object; now positional) | +| `meeting.transcript` | `getTranscript` | ok | `getTranscript(recordingId, params = {})` | (method was `getRecordingTranscript`; called with one object; now positional) | +| `webhook.create` | `createWebhook` | ok | `createWebhook(data)` | | +| `webhook.delete` | `deleteWebhook` | unimplemented | none | Api has createWebhook but no deleteWebhook. | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The fathom Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## GitHub (`github`) + +**No module source available** (No directory named github or api-module-github). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `issue.get` | `getIssue` | unverified | | | +| `issue.list` | `listIssuesForRepo` | unverified | | | +| `issue.create` | `createIssue` | unverified | | | +| `issue.update` | `updateIssue` | unverified | | | +| `issue.comment` | `createIssueComment` | unverified | | | +| `repo.list` | `listInstallationRepos` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## HubSpot (`hubspot`) + +Checked against `~/api-module-library/packages/v1-ready/hubspot` (@friggframework/api-module-hubspot manifest 1.4.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `contact.get` | `getContactById` | ok | `getContactById(contactId)` | Api.getContactById(id) always requests the portal's full property list; a properties argument is not supported. (called with one object; now positional; inputs were id, properties) | +| `contact.search` | `searchContacts` | unimplemented | none | Api has no contact search (searchCompanies, searchDeals, searchCustomObjects and searchLists only). | +| `contact.list` | `listContacts` | ok | `listContacts(params)` | Api.listContacts({ after, limit, properties }) has no modified-since filter, so updatedSince is ignored and a poll re-lists every contact. | +| `contact.create` | `createContact` | ok | `createContact(body)` | (called with one object; now positional) | +| `contact.update` | `updateContact` | ok | `updateContact(contactId, properties)` | (called with one object; now positional) | +| `deal.get` | `getDealById` | ok | `getDealById(dealId)` | (called with one object; now positional) | +| `deal.listForContact` | `listDealsForContact` | unimplemented | none | Api has no per-contact deal listing; associations come from getBatchAssociations(from, to, inputs). | +| `deal.contacts` | `listContactsForDeal` | unimplemented | none | Api has no per-deal contact listing; associations come from getBatchAssociations(from, to, inputs). | +| `task.create` | `createTask` | unimplemented | none | Api has no engagement or task methods. | +| `call.create` | `createCallEngagement` | unimplemented | none | Api has no engagement or task methods. | +| `note.create` | `createNote` | unimplemented | none | Api has no engagement or task methods. | +| `owner.list` | `listOwners` | unimplemented | none | Api has getOwnerById only. | +| `pipeline.stages` | `listDealStages` | unimplemented | none | Api.getPipelineDetails(objType) returns every pipeline for an object type, not the stages of one pipeline. | +| `contact.upsertByEmail` | `upsertContactByEmail` | unimplemented | none | Api has no upsert and no contact search to build one from. | +| `deal.update` | `updateDeal` | unimplemented (a method of that name exists, unusable as the capability is shaped) | `updateDeal(params)` | Api.updateDeal({ dealId, properties }) reads this.URLs.getDealById, which is not defined (the table has dealById), so it throws before sending. Fixed in api-module-library (claude/shared-conversation-8hgmbr, 5961c9a); stays unimplemented here until a release carries it. | +| `deal.search` | `searchDeals` | unimplemented (a method of that name exists, unusable as the capability is shaped) | `searchDeals(params)` | Api.searchDeals takes HubSpot filterGroups, sorts, props, limit and after, not open/closeDate/amount filters. | +| `task.get` | `getTaskById` | unimplemented | none | Api has no engagement or task methods. | +| `task.update` | `updateTask` | unimplemented | none | Api has no engagement or task methods. | +| `task.search` | `searchTasks` | unimplemented | none | Api has no engagement or task methods. | +| `owner.get` | `getOwnerById` | ok | `getOwnerById(ownerId)` | (called with one object; now positional) | +| `owner.findByEmail` | `getOwnerByEmail` | unimplemented | none | Api has getOwnerById only. | +| `subscription.definitions` | `listSubscriptionDefinitions` | unimplemented | none | Api has no email subscription methods. | +| `subscription.unsubscribe` | `unsubscribeEmail` | unimplemented | none | Api has no email subscription methods. | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The hubspot Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## Jira Cloud (`jira`) + +**No module source available** (No directory named jira or api-module-jira). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `issue.create` | `createIssue` | unverified | | | +| `issue.get` | `getIssue` | unverified | | | +| `issue.update` | `editIssue` | unverified | | | +| `issue.addComment` | `addComment` | unverified | | | +| `issue.transition` | `transitionIssue` | unverified | | | +| `issue.search` | `searchJql` | unverified | | | +| `remoteLink.create` | `createRemoteLink` | unverified | | | +| `project.list` | `listProjects` | unverified | | | +| `issueType.list` | `listIssueTypes` | unverified | | | +| `webhook.register` | `registerDynamicWebhooks` | unverified | | | +| `webhook.refresh` | `refreshWebhooks` | unverified | | | +| `webhook.delete` | `deleteWebhooks` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Klaviyo (`klaviyo`) + +**No module source available** (No directory named klaviyo or api-module-klaviyo). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `profile.get` | `getProfile` | unverified | | | +| `profile.search` | `getProfiles` | unverified | | | +| `profile.list` | `listProfiles` | unverified | | | +| `profile.create` | `createProfile` | unverified | | | +| `profile.update` | `updateProfile` | unverified | | | +| `profile.upsert` | `createOrUpdateProfile` | unverified | | | +| `profile.lists` | `getProfileLists` | unverified | | | +| `profile.subscribe` | `subscribeProfiles` | unverified | | | +| `profile.unsubscribe` | `unsubscribeProfiles` | unverified | | | +| `list.list` | `getLists` | unverified | | | +| `list.addProfiles` | `addProfilesToList` | unverified | | | +| `event.create` | `createEvent` | unverified | | | +| `webhook.create` | `createWebhook` | unverified | | | +| `webhook.delete` | `deleteWebhook` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Linear (`linear`) + +Checked against `~/api-module-library/packages/v1-ready/linear` (@friggframework/api-module-linear manifest 1.1.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `issue.get` | `issue` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `issue.byIdentifier` | `issueByIdentifier` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `issue.search` | `searchIssues` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `issue.create` | `issueCreate` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `issue.update` | `issueUpdate` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `comment.create` | `commentCreate` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `attachment.link` | `attachmentLinkURL` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `team.list` | `teams` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `workflowState.list` | `workflowStates` | unimplemented | none | Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods. | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The linear Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## NetSuite (`netsuite`) + +**No module source available** (No directory named netsuite or api-module-netsuite). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `customer.get` | `getCustomer` | unverified | | | +| `customer.search` | `searchCustomers` | unverified | | | +| `customer.list` | `listCustomers` | unverified | | | +| `customer.create` | `createCustomer` | unverified | | | +| `customer.update` | `updateCustomer` | unverified | | | +| `salesOrder.create` | `createSalesOrder` | unverified | | | +| `salesOrder.get` | `getSalesOrder` | unverified | | | +| `salesOrder.update` | `updateSalesOrder` | unverified | | | +| `salesOrder.search` | `searchSalesOrders` | unverified | | | +| `creditMemo.create` | `createCreditMemo` | unverified | | | +| `itemFulfillment.get` | `getItemFulfillment` | unverified | | | +| `itemFulfillment.list` | `listItemFulfillments` | unverified | | | +| `item.search` | `searchItems` | unverified | | | +| `subsidiary.list` | `listSubsidiaries` | unverified | | | +| `location.list` | `listLocations` | unverified | | | + +## Notion (`notion`) + +**No module source available** (No directory named notion or api-module-notion). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `page.create` | `createPage` | unverified | | | +| `page.get` | `retrievePage` | unverified | | | +| `page.update` | `updatePage` | unverified | | | +| `block.append` | `appendBlockChildren` | unverified | | | +| `database.query` | `queryDatabase` | unverified | | | +| `database.list` | `searchDatabases` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Pipedrive (`pipedrive`) + +Checked against `~/phone-app/backend/node_modules/@friggframework/api-module-pipedrive` (@friggframework/api-module-pipedrive manifest 2.1.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `person.get` | `getPerson` | ok | `getPerson(personId, params)` | (called with one object; now positional) | +| `person.search` | `searchPersons` | ok | `searchPersons(params)` | | +| `person.list` | `listPersons` | ok | `listPersons(params)` | | +| `person.create` | `addPerson` | unimplemented | none | Api has no addPerson (persons can be listed, fetched and searched only). | +| `person.update` | `updatePerson` | unimplemented | none | Api has no updatePerson. | +| `activity.create` | `createActivity` | ok | `createActivity(params)` | (method was `addActivity`; called with one object; now positional) | +| `activity.list` | `listActivities` | ok | `listActivities(params)` | | +| `deal.listForPerson` | `listDeals` | ok | `listDeals(params)` | (method was `listPersonDeals`; inputs were personId, status) | +| `user.list` | `listUsers` | ok | `listUsers()` | (method was `getUsers`) | +| `webhook.create` | `createWebhook` | ok | `createWebhook(params)` | (method was `addWebhook`) | +| `webhook.delete` | `deleteWebhook` | ok | `deleteWebhook(webhookId)` | (called with one object; now positional) | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The pipedrive Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## QuickBooks Online (`quickbooks`) + +**No module source available** (No directory named quickbooks or api-module-quickbooks). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `customer.get` | `getCustomer` | unverified | | | +| `customer.query` | `queryCustomers` | unverified | | | +| `customer.list` | `listCustomers` | unverified | | | +| `customer.create` | `createCustomer` | unverified | | | +| `customer.update` | `updateCustomer` | unverified | | | +| `salesReceipt.create` | `createSalesReceipt` | unverified | | | +| `refundReceipt.create` | `createRefundReceipt` | unverified | | | +| `invoice.create` | `createInvoice` | unverified | | | +| `invoice.get` | `getInvoice` | unverified | | | +| `invoice.send` | `sendInvoice` | unverified | | | +| `payment.get` | `getPayment` | unverified | | | +| `deposit.create` | `createDeposit` | unverified | | | +| `item.list` | `listItems` | unverified | | | +| `account.list` | `listAccounts` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Phone (`phone`) + +Checked against `~/phone-app/backend/src/api-modules/phone` (@acme/api-module-phone manifest 2.1.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `contact.get` | `getContact` | ok | `getContact(id)` | (called with one object; now positional) | +| `contact.search` | `searchContacts` | unimplemented | none | Api has no contact search; listContacts filters by externalIds and sources only. | +| `contact.list` | `listContacts` | ok | `listContacts(params = {})` | | +| `contact.create` | `createContact` | ok | `createContact(data)` | | +| `contact.update` | `updateContact` | ok | `updateContact(id, data)` | (called with one object; now positional) | +| `call.list` | `listCalls` | ok | `listCalls(params = {})` | | +| `call.recording` | `getCallRecordings` | ok | `getCallRecordings(callId)` | (called with one object; now positional) | +| `message.send` | `sendMessage` | ok | `sendMessage(data)` | | +| `message.list` | `listMessages` | ok | `listMessages(params = {})` | | +| `phoneNumber.list` | `listPhoneNumbers` | ok | `listPhoneNumbers(params = {})` | | +| `user.list` | `listUsers` | ok | `listUsers(params = {})` | | +| `webhook.create` | `createUnifiedWebhook` | ok | `createUnifiedWebhook(data)` | (method was `createWebhook`) | +| `webhook.delete` | `deleteWebhook` | ok | `deleteWebhook(id)` | (called with one object; now positional) | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The phone Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## Salesforce (`salesforce`) + +Checked against `~/api-module-library/packages/v1-ready/salesforce` (@friggframework/api-module-salesforce manifest 3.0.2). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `lead.get` | `get` | ok | `get(object, id)` | (method was `getLead`; called with one object; now positional) | +| `lead.create` | `create` | ok | `create(object, data)` | (method was `createLead`; called with one object; now positional) | +| `lead.update` | `updateLead` | unimplemented | none | Api.update(object, data) needs the record id inside data (data.Id); the capability passes the id separately, so there is no way to call it. | +| `person.searchByEmail` | `searchPeopleByEmail` | unimplemented | none | Api.find(object, filter) searches one sObject at a time; searching Lead and Contact together would need two calls. | +| `event.create` | `create` | ok | `create(object, data)` | (method was `createEvent`; called with one object; now positional) | +| `event.update` | `updateEvent` | unimplemented | none | Api.update(object, data) needs the record id inside data (data.Id); the capability passes the id separately, so there is no way to call it. | +| `opportunity.get` | `get` | ok | `get(object, id)` | (method was `getOpportunity`; called with one object; now positional) | +| `opportunity.update` | `updateOpportunity` | unimplemented | none | Api.update(object, data) needs the record id inside data (data.Id); the capability passes the id separately, so there is no way to call it. | +| `task.create` | `create` | ok | `create(object, data)` | (method was `createTask`; called with one object; now positional) | +| `file.attach` | `createContentVersion` | unimplemented | none | Api has the generic create(object, data) only; ContentVersion field names differ from this capability's inputs. | +| `note.create` | `createContentNote` | unimplemented | none | Api has the generic create(object, data) only; ContentNote field names differ from this capability's inputs. | +| `user.list` | `listUsers` | unimplemented | none | Api has find(object, filter) but no user listing with an activeOnly filter. | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The salesforce Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## Shopify (`shopify`) + +**No module source available** (No directory named shopify or api-module-shopify). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `order.get` | `getOrder` | unverified | | | +| `order.list` | `listOrders` | unverified | | | +| `order.update` | `updateOrder` | unverified | | | +| `order.fulfillmentOrders` | `listFulfillmentOrders` | unverified | | | +| `fulfillment.create` | `createFulfillment` | unverified | | | +| `customer.get` | `getCustomer` | unverified | | | +| `customer.list` | `listCustomers` | unverified | | | +| `customer.search` | `searchCustomers` | unverified | | | +| `customer.create` | `createCustomer` | unverified | | | +| `customer.update` | `updateCustomer` | unverified | | | +| `checkout.list` | `listAbandonedCheckouts` | unverified | | | +| `variant.byInventoryItem` | `getVariantByInventoryItemId` | unverified | | | +| `location.list` | `listLocations` | unverified | | | +| `webhook.create` | `createWebhook` | unverified | | | +| `webhook.delete` | `deleteWebhook` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Slack (`slack`) + +Checked against `~/api-module-library/packages/needs-updating/slack` (@friggframework/api-module-slack manifest 2.0.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `message.post` | `postMessage` | ok | `postMessage(body)` | | +| `message.update` | `updateMessage` | ok | `updateMessage(body)` | | +| `message.postEphemeral` | `postEphemeral` | ok | `postEphemeral(body)` | | +| `message.permalink` | `getMessagePermalink` | ok | `getMessagePermalink(query)` | (method was `getPermalink`) | +| `channel.list` | `listChannels` | ok | `listChannels(query)` | (method was `listConversations`) | +| `user.list` | `listUsers` | unimplemented | none | Api has lookupUserByEmail, getUserById and getUserProfileById, but no user listing. | +| `user.lookupByEmail` | `lookupUserByEmail` | ok | `lookupUserByEmail(email)` | (called with one object; now positional) | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The slack Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## Stripe (`stripe`) + +Checked against `~/api-module-library/packages/v1-ready/stripe` (@friggframework/api-module-stripe manifest 2.3.0). + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `customer.get` | `retrieveCustomer` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `customer.list` | `listCustomers` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `customer.search` | `searchCustomers` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `customer.create` | `createCustomer` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `customer.update` | `updateCustomer` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `paymentIntent.get` | `retrievePaymentIntent` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `refund.get` | `retrieveRefund` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `payout.list` | `listPayouts` | unimplemented | none | Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed. | +| `balanceTransaction.list` | `getBalanceTransactions` | ok | `getBalanceTransactions(params)` | (method was `listBalanceTransactions`) | +| `webhook.verify` | `verifyWebhookSignature` | unimplemented | | The stripe Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one. | + +## Acme ERP (`acmeerp`) + +**No module source available** (No directory named acmeerp or api-module-acmeerp). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `customer.get` | `getCustomer` | unverified | | | +| `customer.list` | `listCustomers` | unverified | | | +| `customer.create` | `createCustomer` | unverified | | | +| `customer.update` | `updateCustomer` | unverified | | | +| `job.get` | `getJob` | unverified | | | +| `job.search` | `searchJobs` | unverified | | | +| `job.lineItems` | `listJobLineItems` | unverified | | | +| `job.update` | `updateJob` | unverified | | | +| `job.markPaid` | `recordJobPayment` | unverified | | | +| `job.addNote` | `createJobNote` | unverified | | | +| `technician.list` | `listTechnicians` | unverified | | | +| `webhook.create` | `createWebhookSubscription` | unverified | | | +| `webhook.delete` | `deleteWebhookSubscription` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Webflow (`webflow`) + +**No module source available** (No directory named webflow or api-module-webflow). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `site.list` | `listSites` | unverified | | | +| `form.list` | `listForms` | unverified | | | +| `form.submissions` | `listFormSubmissions` | unverified | | | +| `webhook.create` | `createWebhook` | unverified | | | +| `webhook.delete` | `deleteWebhook` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | + +## Zendesk Support (`zendesk`) + +**No module source available** (No directory named zendesk or api-module-zendesk). Nothing here is verified; the manifest is still a mock. + +| Capability | Method | Status | Real signature | Note | +|---|---|---|---|---| +| `ticket.get` | `showTicket` | unverified | | | +| `ticket.update` | `updateTicket` | unverified | | | +| `ticket.addComment` | `addTicketComment` | unverified | | | +| `ticket.search` | `searchTickets` | unverified | | | +| `user.get` | `showUser` | unverified | | | +| `group.list` | `listGroups` | unverified | | | +| `webhook.create` | `createWebhook` | unverified | | | +| `webhook.delete` | `deleteWebhook` | unverified | | | +| `webhook.verify` | `verifyWebhookSignature` | unverified | | | diff --git a/packages/core/integrations/data-defined/capabilities/asana.capabilities.json b/packages/core/integrations/data-defined/capabilities/asana.capabilities.json index 0af0692d4..dda1af554 100644 --- a/packages/core/integrations/data-defined/capabilities/asana.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/asana.capabilities.json @@ -151,22 +151,25 @@ "headers": { "X-Hook-Secret": "$.headers.x-hook-secret" } - } + }, + "status": "unimplemented", + "note": "The asana Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "task.get": { "kind": "read", - "method": "getTask", + "method": "getTaskById", "input": { "taskGid": { "type": "string", "required": true - }, - "opt_fields": { - "type": "string" } }, - "output": "task" + "output": "task", + "args": [ + "taskGid" + ], + "note": "Api.getTaskById(id) takes only the id; field selection (opt_fields) is not supported." }, "task.search": { "kind": "search", @@ -192,13 +195,15 @@ "type": "number" } }, - "output": "task[]" + "output": "task[]", + "status": "unimplemented", + "note": "Api.listTasks({ workspaceId, assigneeId }) filters by workspace and assignee only; due_on, completed, projects_any and limit have no equivalent." }, "project.list": { "kind": "read", - "method": "getProjects", + "method": "listProjects", "input": { - "workspaceGid": { + "workspace": { "type": "string", "required": true }, @@ -210,7 +215,7 @@ }, "workspace.list": { "kind": "read", - "method": "getWorkspaces", + "method": "listWorkspaces", "input": {}, "output": "workspace[]" }, @@ -230,7 +235,9 @@ "type": "array" } }, - "output": "webhook" + "output": "webhook", + "status": "unimplemented", + "note": "Api has no webhook methods." }, "webhook.delete": { "kind": "delete", @@ -241,7 +248,9 @@ "required": true } }, - "output": "void" + "output": "void", + "status": "unimplemented", + "note": "Api has no webhook methods." } }, "options": { @@ -255,7 +264,7 @@ "label": "{{item.name}}", "value": "$.item.gid", "inputs": { - "workspaceGid": { + "workspace": { "type": "string", "required": true } diff --git a/packages/core/integrations/data-defined/capabilities/fathom.capabilities.json b/packages/core/integrations/data-defined/capabilities/fathom.capabilities.json index ee900d93e..61598cf3a 100644 --- a/packages/core/integrations/data-defined/capabilities/fathom.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/fathom.capabilities.json @@ -142,7 +142,9 @@ "scope": "installation", "topic": "meeting.ready", "eventId": "{{body.recording_id}}", - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The fathom Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "meeting.list": { @@ -169,25 +171,31 @@ }, "meeting.summary": { "kind": "read", - "method": "getRecordingSummary", + "method": "getSummary", "input": { "recordingId": { "type": "string", "required": true } }, - "output": "summary" + "output": "summary", + "args": [ + "recordingId" + ] }, "meeting.transcript": { "kind": "read", - "method": "getRecordingTranscript", + "method": "getTranscript", "input": { "recordingId": { "type": "string", "required": true } }, - "output": "transcript" + "output": "transcript", + "args": [ + "recordingId" + ] }, "webhook.create": { "kind": "subscribe", @@ -222,7 +230,9 @@ "required": true } }, - "output": "void" + "output": "void", + "status": "unimplemented", + "note": "Api has createWebhook but no deleteWebhook." } } } diff --git a/packages/core/integrations/data-defined/capabilities/hubspot.capabilities.json b/packages/core/integrations/data-defined/capabilities/hubspot.capabilities.json index e5eb3004a..88cd771ae 100644 --- a/packages/core/integrations/data-defined/capabilities/hubspot.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/hubspot.capabilities.json @@ -387,7 +387,9 @@ "topic": "{{item.subscriptionType}}", "eventId": "{{item.eventId}}", "account": "{{item.portalId}}", - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The hubspot Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "contact.get": { @@ -397,12 +399,13 @@ "id": { "type": "string", "required": true - }, - "properties": { - "type": "array" } }, - "output": "contact" + "output": "contact", + "args": [ + "id" + ], + "note": "Api.getContactById(id) always requests the portal's full property list; a properties argument is not supported." }, "contact.search": { "kind": "search", @@ -421,7 +424,9 @@ "type": "string" } }, - "output": "contact[]" + "output": "contact[]", + "status": "unimplemented", + "note": "Api has no contact search (searchCompanies, searchDeals, searchCustomObjects and searchLists only)." }, "contact.list": { "kind": "read", @@ -437,7 +442,8 @@ "type": "string" } }, - "output": "page" + "output": "page", + "note": "Api.listContacts({ after, limit, properties }) has no modified-since filter, so updatedSince is ignored and a poll re-lists every contact." }, "contact.create": { "kind": "write", @@ -448,7 +454,10 @@ "required": true } }, - "output": "contact" + "output": "contact", + "args": [ + "properties" + ] }, "contact.update": { "kind": "write", @@ -463,7 +472,11 @@ "required": true } }, - "output": "contact" + "output": "contact", + "args": [ + "id", + "properties" + ] }, "deal.get": { "kind": "read", @@ -474,7 +487,10 @@ "required": true } }, - "output": "deal" + "output": "deal", + "args": [ + "id" + ] }, "deal.listForContact": { "kind": "read", @@ -488,7 +504,9 @@ "type": "boolean" } }, - "output": "deal[]" + "output": "deal[]", + "status": "unimplemented", + "note": "Api has no per-contact deal listing; associations come from getBatchAssociations(from, to, inputs)." }, "deal.contacts": { "kind": "read", @@ -499,7 +517,9 @@ "required": true } }, - "output": "contact[]" + "output": "contact[]", + "status": "unimplemented", + "note": "Api has no per-deal contact listing; associations come from getBatchAssociations(from, to, inputs)." }, "task.create": { "kind": "write", @@ -513,7 +533,9 @@ "type": "string" } }, - "output": "task" + "output": "task", + "status": "unimplemented", + "note": "Api has no engagement or task methods." }, "call.create": { "kind": "write", @@ -528,7 +550,9 @@ "required": true } }, - "output": "call" + "output": "call", + "status": "unimplemented", + "note": "Api has no engagement or task methods." }, "note.create": { "kind": "write", @@ -545,13 +569,17 @@ "type": "string" } }, - "output": "note" + "output": "note", + "status": "unimplemented", + "note": "Api has no engagement or task methods." }, "owner.list": { "kind": "read", "method": "listOwners", "input": {}, - "output": "owner[]" + "output": "owner[]", + "status": "unimplemented", + "note": "Api has getOwnerById only." }, "pipeline.stages": { "kind": "read", @@ -561,7 +589,9 @@ "type": "string" } }, - "output": "stage[]" + "output": "stage[]", + "status": "unimplemented", + "note": "Api.getPipelineDetails(objType) returns every pipeline for an object type, not the stages of one pipeline." }, "contact.upsertByEmail": { "kind": "write", @@ -577,7 +607,9 @@ "required": true } }, - "output": "contact" + "output": "contact", + "status": "unimplemented", + "note": "Api has no upsert and no contact search to build one from." }, "deal.update": { "kind": "write", @@ -592,7 +624,9 @@ "required": true } }, - "output": "deal" + "output": "deal", + "status": "unimplemented", + "note": "Api.updateDeal({ dealId, properties }) reads this.URLs.getDealById, which is not defined (the table has dealById), so it throws before sending. Fixed upstream (api-module-library 5961c9a); unimplemented here until a release carries it." }, "deal.search": { "kind": "search", @@ -614,7 +648,9 @@ "type": "number" } }, - "output": "deal[]" + "output": "deal[]", + "status": "unimplemented", + "note": "Api.searchDeals takes HubSpot filterGroups, sorts, props, limit and after, not open/closeDate/amount filters." }, "task.get": { "kind": "read", @@ -628,7 +664,9 @@ "type": "array" } }, - "output": "task" + "output": "task", + "status": "unimplemented", + "note": "Api has no engagement or task methods." }, "task.update": { "kind": "write", @@ -643,7 +681,9 @@ "required": true } }, - "output": "task" + "output": "task", + "status": "unimplemented", + "note": "Api has no engagement or task methods." }, "task.search": { "kind": "search", @@ -662,7 +702,9 @@ "type": "number" } }, - "output": "task[]" + "output": "task[]", + "status": "unimplemented", + "note": "Api has no engagement or task methods." }, "owner.get": { "kind": "read", @@ -673,7 +715,10 @@ "required": true } }, - "output": "owner" + "output": "owner", + "args": [ + "id" + ] }, "owner.findByEmail": { "kind": "search", @@ -684,13 +729,17 @@ "required": true } }, - "output": "owner" + "output": "owner", + "status": "unimplemented", + "note": "Api has getOwnerById only." }, "subscription.definitions": { "kind": "read", "method": "listSubscriptionDefinitions", "input": {}, - "output": "subscriptionDefinition[]" + "output": "subscriptionDefinition[]", + "status": "unimplemented", + "note": "Api has no email subscription methods." }, "subscription.unsubscribe": { "kind": "write", @@ -712,7 +761,9 @@ "type": "string" } }, - "output": "subscriptionStatus" + "output": "subscriptionStatus", + "status": "unimplemented", + "note": "Api has no email subscription methods." } }, "options": { diff --git a/packages/core/integrations/data-defined/capabilities/linear.capabilities.json b/packages/core/integrations/data-defined/capabilities/linear.capabilities.json index 1cbbd78d5..9c6faec47 100644 --- a/packages/core/integrations/data-defined/capabilities/linear.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/linear.capabilities.json @@ -182,7 +182,9 @@ "{{body.webhookId}}:{{body.webhookTimestamp}}" ], "account": "{{body.organizationId}}", - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The linear Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "issue.get": { @@ -194,7 +196,9 @@ "required": true } }, - "output": "issue" + "output": "issue", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "issue.byIdentifier": { "kind": "read", @@ -205,7 +209,9 @@ "required": true } }, - "output": "issue" + "output": "issue", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "issue.search": { "kind": "search", @@ -224,7 +230,9 @@ "type": "number" } }, - "output": "issue[]" + "output": "issue[]", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "issue.create": { "kind": "write", @@ -251,7 +259,9 @@ "type": "array" } }, - "output": "issue" + "output": "issue", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "issue.update": { "kind": "write", @@ -266,7 +276,9 @@ "required": true } }, - "output": "issue" + "output": "issue", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "comment.create": { "kind": "write", @@ -281,7 +293,9 @@ "required": true } }, - "output": "comment" + "output": "comment", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "attachment.link": { "kind": "write", @@ -299,13 +313,17 @@ "type": "string" } }, - "output": "attachment" + "output": "attachment", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "team.list": { "kind": "read", "method": "teams", "input": {}, - "output": "team[]" + "output": "team[]", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." }, "workflowState.list": { "kind": "read", @@ -316,7 +334,9 @@ "required": true } }, - "output": "workflowState[]" + "output": "workflowState[]", + "status": "unimplemented", + "note": "Api exposes getClient() (a @linear/sdk LinearClient) plus getUsers, getProjects and getUserIssues; issues, comments, attachments, teams and workflow states are reached through the SDK client, not Api methods." } }, "options": { diff --git a/packages/core/integrations/data-defined/capabilities/phone.capabilities.json b/packages/core/integrations/data-defined/capabilities/phone.capabilities.json index d1053a8c0..999eed93e 100644 --- a/packages/core/integrations/data-defined/capabilities/phone.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/phone.capabilities.json @@ -305,7 +305,9 @@ "scope": "installation", "topic": "{{body.type}}", "eventId": "{{body.id}}", - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The phone Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "contact.get": { @@ -317,7 +319,10 @@ "required": true } }, - "output": "contact" + "output": "contact", + "args": [ + "id" + ] }, "contact.search": { "kind": "search", @@ -330,7 +335,9 @@ "type": "string" } }, - "output": "contact[]" + "output": "contact[]", + "status": "unimplemented", + "note": "Api has no contact search; listContacts filters by externalIds and sources only." }, "contact.list": { "kind": "read", @@ -374,7 +381,11 @@ "type": "object" } }, - "output": "contact" + "output": "contact", + "args": [ + "id", + "$rest" + ] }, "call.list": { "kind": "read", @@ -399,7 +410,10 @@ "required": true } }, - "output": "recording[]" + "output": "recording[]", + "args": [ + "callId" + ] }, "message.send": { "kind": "write", @@ -448,7 +462,7 @@ }, "webhook.create": { "kind": "subscribe", - "method": "createWebhook", + "method": "createUnifiedWebhook", "input": { "url": { "type": "string", @@ -470,7 +484,10 @@ "required": true } }, - "output": "void" + "output": "void", + "args": [ + "id" + ] } }, "options": { diff --git a/packages/core/integrations/data-defined/capabilities/pipedrive.capabilities.json b/packages/core/integrations/data-defined/capabilities/pipedrive.capabilities.json index 522576c0a..0a7008ad6 100644 --- a/packages/core/integrations/data-defined/capabilities/pipedrive.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/pipedrive.capabilities.json @@ -241,7 +241,9 @@ "{{body.meta.id}}", "{{body.meta.entity}}:{{body.data.id}}:{{body.meta.timestamp}}" ], - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The pipedrive Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "person.get": { @@ -253,7 +255,10 @@ "required": true } }, - "output": "person" + "output": "person", + "args": [ + "id" + ] }, "person.search": { "kind": "search", @@ -297,7 +302,9 @@ "required": true } }, - "output": "person" + "output": "person", + "status": "unimplemented", + "note": "Api has no addPerson (persons can be listed, fetched and searched only)." }, "person.update": { "kind": "write", @@ -312,18 +319,23 @@ "required": true } }, - "output": "person" + "output": "person", + "status": "unimplemented", + "note": "Api has no updatePerson." }, "activity.create": { "kind": "write", - "method": "addActivity", + "method": "createActivity", "input": { "activity": { "type": "object", "required": true } }, - "output": "activity" + "output": "activity", + "args": [ + "activity" + ] }, "activity.list": { "kind": "read", @@ -343,9 +355,9 @@ }, "deal.listForPerson": { "kind": "read", - "method": "listPersonDeals", + "method": "listDeals", "input": { - "personId": { + "person_id": { "type": "string", "required": true }, @@ -357,13 +369,13 @@ }, "user.list": { "kind": "read", - "method": "getUsers", + "method": "listUsers", "input": {}, "output": "user[]" }, "webhook.create": { "kind": "subscribe", - "method": "addWebhook", + "method": "createWebhook", "input": { "subscription_url": { "type": "string", @@ -392,7 +404,10 @@ "required": true } }, - "output": "void" + "output": "void", + "args": [ + "id" + ] } }, "options": { diff --git a/packages/core/integrations/data-defined/capabilities/salesforce.capabilities.json b/packages/core/integrations/data-defined/capabilities/salesforce.capabilities.json index c66d9094d..7bc18bd01 100644 --- a/packages/core/integrations/data-defined/capabilities/salesforce.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/salesforce.capabilities.json @@ -321,30 +321,44 @@ "scope": "installation", "topic": "{{body.payload.ChangeEventHeader.entityName}}.{{body.payload.ChangeEventHeader.changeType}}", "eventId": "{{body.id}}", - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The salesforce Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "lead.get": { "kind": "read", - "method": "getLead", + "method": "get", "input": { "id": { "type": "string", "required": true } }, - "output": "lead" + "output": "lead", + "args": [ + { + "value": "Lead" + }, + "id" + ] }, "lead.create": { "kind": "write", - "method": "createLead", + "method": "create", "input": { "fields": { "type": "object", "required": true } }, - "output": "lead" + "output": "lead", + "args": [ + { + "value": "Lead" + }, + "fields" + ] }, "lead.update": { "kind": "write", @@ -359,7 +373,9 @@ "required": true } }, - "output": "void" + "output": "void", + "status": "unimplemented", + "note": "Api.update(object, data) needs the record id inside data (data.Id); the capability passes the id separately, so there is no way to call it." }, "person.searchByEmail": { "kind": "search", @@ -371,18 +387,26 @@ "required": true } }, - "output": "{ leads: lead[], contacts: contact[] }" + "output": "{ leads: lead[], contacts: contact[] }", + "status": "unimplemented", + "note": "Api.find(object, filter) searches one sObject at a time; searching Lead and Contact together would need two calls." }, "event.create": { "kind": "write", - "method": "createEvent", + "method": "create", "input": { "fields": { "type": "object", "required": true } }, - "output": "event" + "output": "event", + "args": [ + { + "value": "Event" + }, + "fields" + ] }, "event.update": { "kind": "write", @@ -397,18 +421,26 @@ "required": true } }, - "output": "void" + "output": "void", + "status": "unimplemented", + "note": "Api.update(object, data) needs the record id inside data (data.Id); the capability passes the id separately, so there is no way to call it." }, "opportunity.get": { "kind": "read", - "method": "getOpportunity", + "method": "get", "input": { "id": { "type": "string", "required": true } }, - "output": "opportunity" + "output": "opportunity", + "args": [ + { + "value": "Opportunity" + }, + "id" + ] }, "opportunity.update": { "kind": "write", @@ -423,18 +455,26 @@ "required": true } }, - "output": "void" + "output": "void", + "status": "unimplemented", + "note": "Api.update(object, data) needs the record id inside data (data.Id); the capability passes the id separately, so there is no way to call it." }, "task.create": { "kind": "write", - "method": "createTask", + "method": "create", "input": { "fields": { "type": "object", "required": true } }, - "output": "task" + "output": "task", + "args": [ + { + "value": "Task" + }, + "fields" + ] }, "file.attach": { "kind": "write", @@ -457,7 +497,9 @@ "required": true } }, - "output": "contentVersion" + "output": "contentVersion", + "status": "unimplemented", + "note": "Api has the generic create(object, data) only; ContentVersion field names differ from this capability's inputs." }, "note.create": { "kind": "write", @@ -476,7 +518,9 @@ "required": true } }, - "output": "contentNote" + "output": "contentNote", + "status": "unimplemented", + "note": "Api has the generic create(object, data) only; ContentNote field names differ from this capability's inputs." }, "user.list": { "kind": "read", @@ -486,7 +530,9 @@ "type": "boolean" } }, - "output": "user[]" + "output": "user[]", + "status": "unimplemented", + "note": "Api has find(object, filter) but no user listing with an activeOnly filter." } }, "options": { diff --git a/packages/core/integrations/data-defined/capabilities/slack.capabilities.json b/packages/core/integrations/data-defined/capabilities/slack.capabilities.json index f419c04d6..d3b260e56 100644 --- a/packages/core/integrations/data-defined/capabilities/slack.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/slack.capabilities.json @@ -206,7 +206,9 @@ "body": { "challenge": "$.body.challenge" } - } + }, + "status": "unimplemented", + "note": "The slack Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "message.post": { @@ -285,7 +287,7 @@ }, "message.permalink": { "kind": "read", - "method": "getPermalink", + "method": "getMessagePermalink", "label": "chat.getPermalink", "input": { "channel": { @@ -301,7 +303,7 @@ }, "channel.list": { "kind": "read", - "method": "listConversations", + "method": "listChannels", "label": "conversations.list", "input": { "types": { @@ -325,7 +327,9 @@ "type": "number" } }, - "output": "user[]" + "output": "user[]", + "status": "unimplemented", + "note": "Api has lookupUserByEmail, getUserById and getUserProfileById, but no user listing." }, "user.lookupByEmail": { "kind": "read", @@ -337,7 +341,10 @@ "required": true } }, - "output": "user" + "output": "user", + "args": [ + "email" + ] } }, "options": { diff --git a/packages/core/integrations/data-defined/capabilities/stripe.capabilities.json b/packages/core/integrations/data-defined/capabilities/stripe.capabilities.json index 379f710de..f7bc7b1cd 100644 --- a/packages/core/integrations/data-defined/capabilities/stripe.capabilities.json +++ b/packages/core/integrations/data-defined/capabilities/stripe.capabilities.json @@ -471,7 +471,9 @@ "topic": "{{body.type}}", "eventId": "{{body.id}}", "account": "{{body.account}}", - "verify": "verifyWebhookSignature" + "verify": "verifyWebhookSignature", + "status": "unimplemented", + "note": "The stripe Api has no verifyWebhookSignature method; webhook signatures cannot be checked until the module adds one." }, "capabilities": { "customer.get": { @@ -483,7 +485,9 @@ "required": true } }, - "output": "customer" + "output": "customer", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "customer.list": { "kind": "read", @@ -499,7 +503,9 @@ "type": "string" } }, - "output": "page" + "output": "page", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "customer.search": { "kind": "search", @@ -510,7 +516,9 @@ "required": true } }, - "output": "customer[]" + "output": "customer[]", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "customer.create": { "kind": "write", @@ -521,7 +529,9 @@ "required": true } }, - "output": "customer" + "output": "customer", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "customer.update": { "kind": "write", @@ -536,7 +546,9 @@ "required": true } }, - "output": "customer" + "output": "customer", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "paymentIntent.get": { "kind": "read", @@ -550,7 +562,9 @@ "type": "array" } }, - "output": "paymentIntent" + "output": "paymentIntent", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "refund.get": { "kind": "read", @@ -561,7 +575,9 @@ "required": true } }, - "output": "refund" + "output": "refund", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "payout.list": { "kind": "read", @@ -580,11 +596,13 @@ "type": "number" } }, - "output": "payout[]" + "output": "payout[]", + "status": "unimplemented", + "note": "Api wraps balance transactions, charges, accounts and webhook endpoints only; customers, payment intents, refunds and payouts are not exposed." }, "balanceTransaction.list": { "kind": "read", - "method": "listBalanceTransactions", + "method": "getBalanceTransactions", "input": { "payout": { "type": "string", diff --git a/packages/core/integrations/data-defined/data-defined-integration.js b/packages/core/integrations/data-defined/data-defined-integration.js index 8982a76ff..1fa50ea1f 100644 --- a/packages/core/integrations/data-defined/data-defined-integration.js +++ b/packages/core/integrations/data-defined/data-defined-integration.js @@ -18,7 +18,7 @@ */ const crypto = require('crypto'); const { compile, routeWebhook, eventName } = require('./compile'); -const { makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, runLifecycle, resolveOptions, installContext } = require('./runtime'); +const { argumentsFor, makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, runLifecycle, resolveOptions, installContext } = require('./runtime'); const { createSyncEngine } = require('./sync-engine'); const { eventsIn, handshakeFor, modulesFor } = require('./webhooks'); const { buildConfigForm, missingRequired } = require('./config-form'); @@ -252,7 +252,7 @@ function makeClass({ name, defaultId, definitions, manifests, runCode, Integrati return runCode(ref, args); }, // The shared class registers modules by module name; a definition may name them by its own keys. - callApi: (moduleKey, method, args) => this.moduleFor(moduleKey).api[method](args), + callApi: (moduleKey, method, args, order) => this.moduleFor(moduleKey).api[method](...argumentsFor(args, order)), store: this.mappingStore(), }); } diff --git a/packages/core/integrations/data-defined/examples/asana-slack.definition.json b/packages/core/integrations/data-defined/examples/asana-slack.definition.json index 55ad9d973..dc4a8af3c 100644 --- a/packages/core/integrations/data-defined/examples/asana-slack.definition.json +++ b/packages/core/integrations/data-defined/examples/asana-slack.definition.json @@ -35,7 +35,7 @@ "options": { "source": "asana.project", "with": { - "workspaceGid": "$.settings.workspace" + "workspace": "$.settings.workspace" } }, "label": "Project to announce completed tasks from", @@ -163,8 +163,7 @@ "id": "task", "use": "asana.task.get", "with": { - "taskGid": "$.record.gid", - "opt_fields": "name,completed,completed_by.name,assignee.name,permalink_url" + "taskGid": "$.record.gid" } }, { diff --git a/packages/core/integrations/data-defined/examples/klaviyo-hubspot.definition.json b/packages/core/integrations/data-defined/examples/klaviyo-hubspot.definition.json index 96dfaa318..8f176f6ac 100644 --- a/packages/core/integrations/data-defined/examples/klaviyo-hubspot.definition.json +++ b/packages/core/integrations/data-defined/examples/klaviyo-hubspot.definition.json @@ -239,10 +239,7 @@ "id": "contact", "use": "hubspot.contact.get", "with": { - "id": "$.record.objectId", - "properties": [ - "email" - ] + "id": "$.record.objectId" } }, { diff --git a/packages/core/integrations/data-defined/examples/phone-hubspot.definition.json b/packages/core/integrations/data-defined/examples/phone-hubspot.definition.json index d022b5e15..b2af035d4 100644 --- a/packages/core/integrations/data-defined/examples/phone-hubspot.definition.json +++ b/packages/core/integrations/data-defined/examples/phone-hubspot.definition.json @@ -243,11 +243,7 @@ "id": "full", "use": "hubspot.contact.get", "with": { - "id": "$.steps.contact.hubspot", - "properties": [ - "hubspot_owner_id", - "firstname" - ] + "id": "$.steps.contact.hubspot" } }, { diff --git a/packages/core/integrations/data-defined/manifests.js b/packages/core/integrations/data-defined/manifests.js index bffdab267..5612ee942 100644 --- a/packages/core/integrations/data-defined/manifests.js +++ b/packages/core/integrations/data-defined/manifests.js @@ -40,6 +40,23 @@ function validateManifest(m) { if (!KINDS.has(c?.kind)) err(`${p}.kind`, [...KINDS].join(', ')); if (!/^[A-Za-z_$][\w$]*$/.test(c?.method || '')) err(`${p}.method`, 'Name of a method on the module\'s Api class'); for (const [arg, spec] of Object.entries(c?.input || {})) if (!spec?.type) err(`${p}.input.${arg}`, 'Input needs a type'); + if (c?.status !== undefined && c.status !== 'unimplemented') err(`${p}.status`, 'Only "unimplemented" (the module\'s Api has no such method)'); + if (c?.args !== undefined) { + if (!Array.isArray(c.args)) err(`${p}.args`, 'An ordered list: input names, "$rest", or { value }'); + else { + const named = new Set(); + c.args.forEach((a, i) => { + if (typeof a === 'string' && a !== '$rest') { + if (!(a in (c.input || {}))) err(`${p}.args[${i}]`, `"${a}" is not an input of this capability`); + else if (named.has(a)) err(`${p}.args[${i}]`, `"${a}" is passed twice`); + named.add(a); + } else if (a === '$rest') { if (c.args.indexOf('$rest') !== i) err(`${p}.args[${i}]`, '"$rest" appears once'); } + else if (!(a && typeof a === 'object' && 'value' in a)) err(`${p}.args[${i}]`, 'An input name, "$rest" or { value }'); + }); + // An input that is never passed would be accepted and silently dropped. + if (!c.args.includes('$rest')) for (const k of Object.keys(c.input || {})) if (!named.has(k)) err(`${p}.args`, `Input "${k}" is declared but never passed to ${c.method}; list it in args or use "$rest"`); + } + } } // How a sync reads and writes an object (sync-engine.js). Each operation names a capability of the right kind on @@ -140,6 +157,7 @@ function validateManifest(m) { if (w.account) tpl(w.account, 'webhook.account'); const batch = w.batch == null ? [] : Array.isArray(w.batch) ? w.batch : [w.batch]; if (batch.length > 2 || batch.some((b) => typeof b !== 'string')) err('webhook.batch', 'A path to the array of events, or [outer, inner] for two levels'); + if (w.status !== undefined && w.status !== 'unimplemented') err('webhook.status', 'Only "unimplemented" (the module\'s Api has no verify method)'); if (w.verify != null && !/^[A-Za-z_$][\w$]*$/.test(w.verify)) err('webhook.verify', 'Name of an Api method that checks the signature'); if (w.handshake) { try { parseCondition(w.handshake.when); } catch (e) { err('webhook.handshake.when', e.message); } diff --git a/packages/core/integrations/data-defined/runtime.js b/packages/core/integrations/data-defined/runtime.js index 67413a7c6..ffc1133d8 100644 --- a/packages/core/integrations/data-defined/runtime.js +++ b/packages/core/integrations/data-defined/runtime.js @@ -21,6 +21,21 @@ const { TRANSFORMS } = require('./transforms'); * Backed by IntegrationMapping: get = getMapping, put = upsertMapping, claim = createMapping (insert-only; false if it existed) * @param {(ref:string, args:object) => Promise} [p.runCode] */ +/** + * The arguments an Api method is called with. A capability with no `args` takes one object holding every input; one + * with `args` (manifest `args`) takes them positionally: an input by name, "$rest" (an object of the inputs not + * named in the list) or { value } (a constant, e.g. the sObject name of Salesforce's generic create/get). + */ +function argumentsFor(input, order) { + if (!Array.isArray(order)) return [input]; + const named = new Set(order.filter((a) => typeof a === 'string' && a !== '$rest')); + return order.map((a) => { + if (a === '$rest') return Object.fromEntries(Object.entries(input || {}).filter(([k]) => !named.has(k))); + if (a && typeof a === 'object') return a.value; + return input?.[a]; + }); +} + function makePorts({ def, manifests, callApi, store, runCode }) { const manifestFor = (key) => manifests[def.modules[key]?.module]; const normalise = (set, key, value) => { @@ -39,7 +54,7 @@ function makePorts({ def, manifests, callApi, store, runCode }) { async call(moduleKey, capability, args) { const c = manifestFor(moduleKey)?.capabilities?.[capability]; if (!c) throw new Error(`${moduleKey}.${capability} is not declared by the module manifest`); - return callApi(moduleKey, c.method, args); + return callApi(moduleKey, c.method, args, c.args); }, links: { /** By one side's id: link:::. By identity key: idx:::. */ @@ -250,4 +265,4 @@ function defaultsOf(settings) { return Object.fromEntries(Object.entries(settings || {}).filter(([, s]) => s.default !== undefined).map(([k, s]) => [k, s.default])); } -module.exports = { makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, resolveView, runLifecycle, resolveOptions, installContext, recordOf, defaultsOf }; +module.exports = { argumentsFor, makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, resolveView, runLifecycle, resolveOptions, installContext, recordOf, defaultsOf }; diff --git a/packages/core/integrations/data-defined/schema/capability-manifest.v0.1.schema.json b/packages/core/integrations/data-defined/schema/capability-manifest.v0.1.schema.json index cf9e27fd7..d460f3114 100644 --- a/packages/core/integrations/data-defined/schema/capability-manifest.v0.1.schema.json +++ b/packages/core/integrations/data-defined/schema/capability-manifest.v0.1.schema.json @@ -411,6 +411,15 @@ "body": {} }, "description": "Answered at receipt, before anything is queued (Slack url_verification, Asana X-Hook-Secret)." + }, + "status": { + "enum": [ + "unimplemented" + ], + "description": "The module's Api has no `verify` method (or none that checks a signature). Every delivery fails verification until it does, so definitions that trigger on this module's events validate with a warning." + }, + "note": { + "type": "string" } } }, @@ -445,6 +454,37 @@ }, "output": { "type": "string" + }, + "args": { + "type": "array", + "description": "How the Api method takes its arguments, when it is not one object holding every input. Each entry is an input name (passed positionally), \"$rest\" (an object of every input not named elsewhere in the list) or {\"value\": x} (a constant, such as the sObject name of a generic Api). Omitted: the method is called with one object of all inputs.", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "value" + ], + "additionalProperties": false, + "properties": { + "value": {} + } + } + ] + } + }, + "status": { + "enum": [ + "unimplemented" + ], + "description": "The module's real Api class has no method that does this (or none shaped so the call can be made). Definitions that use it validate with a warning, never an error." + }, + "note": { + "type": "string", + "description": "What the real method does differently, or what is missing upstream." } } } diff --git a/packages/core/integrations/data-defined/scripts/check-manifests.js b/packages/core/integrations/data-defined/scripts/check-manifests.js new file mode 100644 index 000000000..88a27f6b7 --- /dev/null +++ b/packages/core/integrations/data-defined/scripts/check-manifests.js @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Check capability manifests against the real API modules they describe (ADR-063 phase 4: real manifests). + * + * node scripts/check-manifests.js [ ...] + * node scripts/check-manifests.js --manifests capabilities/ --json ~/api-module-library/packages + * node scripts/check-manifests.js --stub-missing --strict ~/api-module-library/packages + * + * For every manifest it finds the module (a directory named or api-module-, or whose package.json + * name ends in that, up to three levels under each given directory; the first one that loads wins), loads its Api + * class and checks, with no network and no instance, on the prototype: + * - every capability's `method` exists (a capability marked `status: "unimplemented"` must NOT have it: the + * manifest would be stale); + * - the arguments fit the method's declared parameters: `args` no longer than the parameter list, and a + * capability with no `args` (called with one object) is not a method that needs two or more positional ones; + * - `webhook.verify` names a method that exists. + * + * Exit codes: 0 every method found (unimplemented capabilities and modules with no source are reported, not failed, + * unless --strict) · 1 a mismatch · 2 could not run (bad arguments, no manifests). + * + * --manifests manifests to check (default: ../capabilities) + * --json machine-readable result on stdout + * --strict unimplemented capabilities, unloadable modules and modules not found fail too + * --stub-missing load modules whose own dependencies are not installed (jsforce, stripe, @linear/sdk ...) by + * standing in for the missing packages; enough to read the Api's methods, never to call them + */ +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); + +const FOUND = 'ok'; + +/** Parameters of a function as written: [{ name, optional }]. Reads the source, so it is a heuristic for the arity check. */ +function parametersOf(fn) { + const src = Function.prototype.toString.call(fn); + const bare = /^(?:async\s+)?([\w$]+)\s*=>/.exec(src); + if (bare) return [{ text: bare[1], optional: false }]; + const open = src.indexOf('('); + if (open < 0) return []; + let depth = 0, i = open, cur = ''; + const parts = []; + for (; i < src.length; i++) { + const c = src[i]; + if ('([{'.includes(c)) { depth++; if (depth === 1) continue; } + if (')]}'.includes(c)) { depth--; if (depth === 0) break; } + if (c === ',' && depth === 1) { parts.push(cur); cur = ''; } else cur += c; + } + if (cur.trim()) parts.push(cur); + return parts.map((p) => p.replace(/\/\*[\s\S]*?\*\//g, '').trim()).filter(Boolean).map((p) => ({ text: p.replace(/\s+/g, ' '), optional: /^\.\.\./.test(p) || p.includes('=') || /^[\w$]+\s*\?/.test(p) })); +} + +function methodOf(Api, name) { + for (let p = Api && Api.prototype; p && p !== Object.prototype; p = Object.getPrototypeOf(p)) { + const d = Object.getOwnPropertyDescriptor(p, name); + if (d) return typeof d.value === 'function' ? d.value : null; + } + return null; +} + +const signatureOf = (fn) => `(${parametersOf(fn).map((p) => p.text).join(', ')})`; + +const looksLikeModule = (dir) => ['package.json', 'index.js', 'api.js'].some((f) => fs.existsSync(path.join(dir, f))); + +/** Directories that could be the module: name match, up to `depth` levels below root. */ +function candidates(root, moduleName, depth = 3) { + const found = []; + const isMatch = (dir) => { + const base = path.basename(dir); + if (base === moduleName || base === `api-module-${moduleName}`) return true; + try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name?.endsWith(`api-module-${moduleName}`); } catch { return false; } + }; + const walk = (dir, level) => { + if (level > depth) return; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue; + const full = path.join(dir, e.name); + if (isMatch(full) && looksLikeModule(full)) found.push(full); + else walk(full, level + 1); + } + }; + if (isMatch(root) && looksLikeModule(root)) found.push(root); + walk(root, 1); + return found; +} + +/** Stand-ins for dependencies that are not installed, so an Api's methods can be read. Restored by the caller. */ +function stubMissingDependencies() { + const original = Module._load; + const stub = () => new Proxy(function stubbed() {}, { get: (t, k) => (k === '__esModule' ? false : k === 'prototype' ? t.prototype : t[k] || (t[k] = stub())), construct: () => ({}), apply: () => stub() }); + Module._load = function load(request, ...rest) { + try { return original.call(this, request, ...rest); } catch (e) { + if (e && e.code === 'MODULE_NOT_FOUND' && !request.startsWith('.') && !path.isAbsolute(request)) return stub(); + throw e; + } + }; + return () => { Module._load = original; }; +} + +function loadApi(dir) { + const attempts = [dir, path.join(dir, 'api.js'), path.join(dir, 'src', 'api.js'), path.join(dir, 'dist', 'api.js')]; + let firstError; + for (const target of attempts) { + try { + const mod = require(target); + const Api = mod.Api || mod.API || mod.Definition?.API || (typeof mod === 'function' ? mod : null); + if (typeof Api === 'function' && Api.prototype) return { Api, from: path.relative(dir, require.resolve(target)) || '.' }; + firstError = firstError || new Error('exports no Api class'); + } catch (e) { firstError = firstError || e; } // the first failure is the module's own; later ones are only missing fallbacks + } + return { error: (firstError?.message || 'cannot load').split('\n')[0] }; +} + +/** + * @param {Record} manifests module -> manifest + * @param {string[]} roots directories to look for the modules in + * @returns {Array<{module, package?, source?, state, message?, rows: Array<{capability, method, state, signature?, args?, note?}>}>} + */ +function checkManifests(manifests, roots, { stubMissing = false } = {}) { + const restore = stubMissing ? stubMissingDependencies() : null; + try { + return Object.values(manifests).map((m) => { + const result = { module: m.module, package: m.package, state: FOUND, rows: [] }; + const dirs = roots.flatMap((r) => candidates(path.resolve(r), m.module)); + if (!dirs.length) return { ...result, state: 'no-module', message: `No directory named ${m.module} or api-module-${m.module} under ${roots.join(', ')}` }; + let loaded, failures = []; + for (const dir of dirs) { + const l = loadApi(dir); + if (l.Api) { loaded = { ...l, dir }; break; } + failures.push(`${dir}: ${l.error}`); + } + if (!loaded) return { ...result, state: 'unloadable', message: failures.join('; ') }; + result.source = loaded.dir; + const { Api } = loaded; + for (const [name, c] of Object.entries(m.capabilities || {})) { + const fn = methodOf(Api, c.method); + const row = { capability: name, method: c.method }; + if (c.status === 'unimplemented') { + row.state = fn ? 'stale-unimplemented' : 'unimplemented'; + row.note = fn ? `Marked unimplemented, but ${c.method}${signatureOf(fn)} exists: ${c.note || 'no note says why it cannot be used'}` : c.note; + if (fn) row.signature = signatureOf(fn); + } else if (!fn) { + row.state = 'missing'; + } else { + row.signature = signatureOf(fn); + const params = parametersOf(fn); + const required = params.filter((p) => !p.optional).length; + if (c.args) { + row.args = c.args.map((a) => (typeof a === 'string' ? a : `=${JSON.stringify(a.value)}`)).join(', '); + row.state = c.args.length > params.length && !params.some((p) => p.text.startsWith('...')) ? 'arity' : 'ok'; + if (row.state === 'arity') row.note = `Passes ${c.args.length} arguments; ${c.method} declares ${params.length}`; + } else { + row.state = required > 1 ? 'arity' : 'ok'; + if (row.state === 'arity') row.note = `Called with one object; ${c.method} needs ${required} positional arguments (add args)`; + } + if (c.note) row.note = [row.note, c.note].filter(Boolean).join('. '); + } + result.rows.push(row); + } + if (m.webhook?.verify) { + const fn = methodOf(Api, m.webhook.verify); + const marked = m.webhook.status === 'unimplemented'; + result.rows.push({ capability: 'webhook.verify', method: m.webhook.verify, state: fn ? (marked ? 'stale-unimplemented' : 'ok') : marked ? 'unimplemented' : 'missing', ...(fn ? { signature: signatureOf(fn) } : {}), ...(m.webhook.note ? { note: m.webhook.note } : {}) }); + } + return result; + }); + } finally { if (restore) restore(); } +} + +// stale-unimplemented is soft: the method exists but the note may say why it cannot be used as the capability is shaped. +const BAD = new Set(['missing', 'arity']); +const SOFT = new Set(['unimplemented', 'stale-unimplemented', 'unloadable', 'no-module']); + +function summarise(results) { + const counts = {}; + for (const r of results) { + counts[r.state] = (counts[r.state] || 0) + (r.state === FOUND ? 0 : 1); + for (const row of r.rows) counts[row.state] = (counts[row.state] || 0) + 1; + } + const mismatches = results.flatMap((r) => r.rows.filter((x) => BAD.has(x.state))); + const soft = results.filter((r) => SOFT.has(r.state)).length + results.flatMap((r) => r.rows).filter((x) => SOFT.has(x.state)).length; + return { counts, mismatches: mismatches.length, soft }; +} + +function main(argv) { + const args = [...argv]; + const flag = (n) => { const i = args.indexOf(n); if (i < 0) return false; args.splice(i, 1); return true; }; + const option = (n) => { const i = args.indexOf(n); if (i < 0) return null; const v = args[i + 1]; args.splice(i, 2); return v; }; + const strict = flag('--strict'), asJson = flag('--json'), stubMissing = flag('--stub-missing'); + const manifestDir = option('--manifests') || path.join(__dirname, '..', 'capabilities'); + const unknown = args.find((a) => a.startsWith('--')); + if (unknown) { console.error(`Unknown option ${unknown}`); return 2; } + if (!args.length) { console.error('Usage: check-manifests.js [--manifests dir] [--json] [--strict] [--stub-missing] [...]'); return 2; } + for (const r of args) if (!fs.existsSync(r)) { console.error(`No such directory: ${r}`); return 2; } + if (!fs.existsSync(manifestDir)) { console.error(`No such directory: ${manifestDir}`); return 2; } + const manifests = {}; + for (const f of fs.readdirSync(manifestDir).filter((x) => x.endsWith('.capabilities.json'))) { + try { const m = JSON.parse(fs.readFileSync(path.join(manifestDir, f), 'utf8')); manifests[m.module] = m; } catch (e) { console.error(`${f}: not valid JSON (${e.message})`); return 2; } + } + if (!Object.keys(manifests).length) { console.error(`No *.capabilities.json in ${manifestDir}`); return 2; } + + const results = checkManifests(manifests, args, { stubMissing }); + const sum = summarise(results); + const failed = sum.mismatches > 0 || (strict && sum.soft > 0); + if (asJson) process.stdout.write(`${JSON.stringify({ valid: !failed, strict, summary: sum, results }, null, 2)}\n`); + else { + for (const r of results) { + if (r.state !== FOUND) { console.log(`${strict ? 'FAIL' : 'skip'} ${r.module.padEnd(12)} ${r.state}: ${r.message}`); continue; } + const bad = r.rows.filter((x) => BAD.has(x.state)).length; + console.log(`${bad ? 'FAIL' : 'ok '} ${r.module.padEnd(12)} ${r.rows.filter((x) => x.state === 'ok').length}/${r.rows.length} ok (${path.relative(process.cwd(), r.source) || '.'})`); + for (const x of r.rows.filter((y) => y.state !== 'ok')) console.log(` ${x.state.padEnd(20)} ${x.capability} -> ${x.method}${x.note ? ` ${x.note}` : ''}`); + } + console.log(`\n${results.length} manifests: ${sum.mismatches} mismatches, ${sum.soft} unverifiable or unimplemented${strict ? ' (strict)' : ''}`); + } + return failed ? 1 : 0; +} + +if (require.main === module) process.exit(main(process.argv.slice(2))); +module.exports = { checkManifests, summarise, parametersOf, methodOf, candidates }; diff --git a/packages/core/integrations/data-defined/tests/helpers-fake-crm.js b/packages/core/integrations/data-defined/tests/helpers-fake-crm.js index a44bfc610..2edd4989a 100644 --- a/packages/core/integrations/data-defined/tests/helpers-fake-crm.js +++ b/packages/core/integrations/data-defined/tests/helpers-fake-crm.js @@ -1,4 +1,9 @@ -/** In-memory HubSpot and Phone APIs shaped like the real ones as their manifests describe them. Test helper. */ +/** + * In-memory HubSpot and Phone APIs with the real Api classes' call shapes (the manifests' `args`): HubSpot's + * getContactById(id), createContact(properties), updateContact(id, properties); Phone's getContact(id) and + * updateContact(id, { defaultFields }). searchContacts is on both only because the sync tests need a search; neither + * real Api has one (the manifests mark contact.search unimplemented). Test helper. + */ /** A fake API: records by id, a write log, and the methods the manifest names. */ function hubspotApi() { const records = new Map(); @@ -6,7 +11,7 @@ function hubspotApi() { const api = { records, writes: [], seed(props, extra = {}) { const id = String(++n); records.set(id, { id, properties: { ...props }, updatedAt: extra.updatedAt || '2026-10-01T00:00:00Z' }); return id; }, - async getContactById({ id }) { return records.get(String(id)) || null; }, + async getContactById(id) { return records.get(String(id)) || null; }, async searchContacts({ email, phone }) { return [...records.values()].filter((r) => (email && r.properties.email === email) || (phone && r.properties.phone === phone)); }, async listContacts({ after, limit = 100, updatedSince }) { const all = [...records.values()].filter((r) => !updatedSince || Date.parse(r.updatedAt) > Date.parse(updatedSince)); @@ -14,8 +19,8 @@ function hubspotApi() { const page = all.slice(start, start + limit); return { results: page, ...(start + limit < all.length ? { paging: { next: { after: String(start + limit) } } } : {}) }; }, - async createContact({ properties }) { api.writes.push(['create', properties]); const id = api.seed(properties, { updatedAt: '2026-10-02T00:00:00Z' }); return records.get(id); }, - async updateContact({ id, properties }) { api.writes.push(['update', id, properties]); const r = records.get(String(id)); Object.assign(r.properties, properties); r.updatedAt = '2026-10-03T00:00:00Z'; return r; }, + async createContact(properties) { api.writes.push(['create', properties]); const id = api.seed(properties, { updatedAt: '2026-10-02T00:00:00Z' }); return records.get(id); }, + async updateContact(id, properties) { api.writes.push(['update', id, properties]); const r = records.get(String(id)); Object.assign(r.properties, properties); r.updatedAt = '2026-10-03T00:00:00Z'; return r; }, }; return api; } @@ -25,7 +30,7 @@ function phoneApi() { const api = { records, writes: [], searches: [], seed(fields, extra = {}) { const id = `CT${++n}`; records.set(id, { id, defaultFields: JSON.parse(JSON.stringify(fields)), ...extra }); return id; }, - async getContact({ id }) { return records.get(id) || null; }, + async getContact(id) { return records.get(id) || null; }, async searchContacts(args) { api.searches.push(args); return [...records.values()].filter((r) => (args.phone && r.defaultFields.phoneNumbers?.[0]?.value === args.phone) || (args.email && r.defaultFields.emails?.[0]?.value === args.email)); @@ -36,7 +41,7 @@ function phoneApi() { return { data: all.slice(start, start + maxResults), ...(start + maxResults < all.length ? { nextPageToken: String(start + maxResults) } : {}) }; }, async createContact({ defaultFields, externalId }) { api.writes.push(['create', defaultFields, externalId]); const id = api.seed(defaultFields, { externalId }); return records.get(id); }, - async updateContact({ id, defaultFields }) { api.writes.push(['update', id, defaultFields]); const r = records.get(id); r.defaultFields = { ...r.defaultFields, ...defaultFields }; return r; }, + async updateContact(id, { defaultFields }) { api.writes.push(['update', id, defaultFields]); const r = records.get(id); r.defaultFields = { ...r.defaultFields, ...defaultFields }; return r; }, }; return api; } diff --git a/packages/core/integrations/data-defined/tests/real-manifests.test.js b/packages/core/integrations/data-defined/tests/real-manifests.test.js new file mode 100644 index 000000000..ebf56f181 --- /dev/null +++ b/packages/core/integrations/data-defined/tests/real-manifests.test.js @@ -0,0 +1,292 @@ +/** + * Real manifests (ADR-063 phase 4): a capability names a method that really exists on the module's Api, called the + * way the method takes its arguments. Covers `args` (positional calls), `status: "unimplemented"` (a warning in the + * definition, never an error) and scripts/check-manifests.js against a small fake module directory. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const d = require('..'); +const { argumentsFor, makePorts } = require('../runtime'); +const { validateManifest } = require('../manifests'); +const { checkManifests, summarise, parametersOf } = require('../scripts/check-manifests'); + +const manifests = d.loadManifests(); +const example = (id) => JSON.parse(JSON.stringify(d.loadExamples().find((x) => x.id === id))); +const clone = (x) => JSON.parse(JSON.stringify(x)); +const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-manifests.js'); + +describe('args: how the Api method takes its arguments', () => { + it('no args: one object of every input; args: positional, "$rest" and constants', () => { + const input = { id: '7', fields: { a: 1 }, extra: true }; + expect(argumentsFor(input, undefined)).toEqual([input]); + expect(argumentsFor(input, ['id'])).toEqual(['7']); + expect(argumentsFor(input, [{ value: 'Lead' }, 'id', 'fields'])).toEqual(['Lead', '7', { a: 1 }]); + expect(argumentsFor(input, ['id', '$rest'])).toEqual(['7', { fields: { a: 1 }, extra: true }]); + expect(argumentsFor({}, ['id'])).toEqual([undefined]); + }); + + it('a capability call reaches the Api positionally when the manifest says so', async () => { + const def = example('phone-hubspot'); + const seen = []; + const ports = makePorts({ def, manifests, store: { get: async () => null, put: async () => {}, claim: async () => true }, callApi: async (module, method, args, order) => { seen.push([module, method, argumentsFor(args, order)]); return {}; } }); + await ports.call('hubspot', 'contact.update', { id: '9', properties: { firstname: 'Ada' } }); + await ports.call('phone', 'contact.update', { id: 'CT1', defaultFields: { firstName: 'Ada' } }); + await ports.call('hubspot', 'contact.list', { limit: 5 }); + expect(seen).toEqual([ + ['hubspot', 'updateContact', ['9', { firstname: 'Ada' }]], + ['phone', 'updateContact', ['CT1', { defaultFields: { firstName: 'Ada' } }]], + ['hubspot', 'listContacts', [{ limit: 5 }]], + ]); + }); + + const base = () => ({ module: 'm', label: 'M', capabilities: { 'a.get': { kind: 'read', method: 'getA', input: { id: { type: 'string' }, fields: { type: 'object' } } } } }); + const errs = (m) => validateManifest(m).errors.map((e) => `${e.path}: ${e.message}`).join('\n'); + + it('the manifest check refuses an args list that names no input, repeats one, or leaves one unpassed', () => { + const m = base(); + m.capabilities['a.get'].args = ['id', 'fields']; + expect(errs(m)).toBe(''); + m.capabilities['a.get'].args = ['id', 'nope']; + expect(errs(m)).toMatch(/args\[1\]: "nope" is not an input/); + m.capabilities['a.get'].args = ['id', 'id']; + expect(errs(m)).toMatch(/"id" is passed twice/); + // An input no argument carries is accepted by the validator and silently dropped by the call. + m.capabilities['a.get'].args = ['id']; + expect(errs(m)).toMatch(/Input "fields" is declared but never passed to getA/); + m.capabilities['a.get'].args = ['id', '$rest']; + expect(errs(m)).toBe(''); + m.capabilities['a.get'].args = [{ value: 'Lead' }, 'id', 'fields']; + expect(errs(m)).toBe(''); + m.capabilities['a.get'].args = [{ oops: 1 }, 'id', 'fields']; + expect(errs(m)).toMatch(/An input name, "\$rest" or \{ value \}/); + m.capabilities['a.get'].args = 'id'; + expect(errs(m)).toMatch(/An ordered list/); + }); + + it('the schema accepts args, status and note, and rejects an args entry that is neither a name nor { value }', () => { + const check = require('../schema-check'); + try { check.loadAjv(); } catch { return; } // ajv is installed at the monorepo root only + const m = { ...base(), package: '@x/api-module-m', objects: {} }; + Object.assign(m.capabilities['a.get'], { args: ['id', '$rest'], status: 'unimplemented', note: 'why' }); + expect(check.checkManifest(m).errors).toEqual([]); + m.capabilities['a.get'].args = [7]; + expect(check.checkManifest(m).errors.map((e) => e.layer)).toContain('schema'); + m.capabilities['a.get'].args = ['id', '$rest']; + m.capabilities['a.get'].status = 'broken'; + expect(check.checkManifest(m).valid).toBe(false); + }); + + it('status accepts only "unimplemented"', () => { + const m = base(); + m.capabilities['a.get'].status = 'unimplemented'; + expect(errs(m)).toBe(''); + m.capabilities['a.get'].status = 'later'; + expect(errs(m)).toMatch(/status: Only "unimplemented"/); + m.capabilities['a.get'].status = undefined; + m.webhook = { scope: 'app', topic: '{{body.t}}', eventId: '{{body.id}}', status: 'soon' }; + expect(errs(m)).toMatch(/webhook\.status/); + }); +}); + +describe('unimplemented capabilities are a warning in the definition, never an error', () => { + const warningsOf = (def) => d.validate(def, manifests).warnings.map((w) => `${w.path}: ${w.message}`); + const errorsOf = (def) => d.validate(def, manifests).errors; + + it('a step on an unimplemented capability warns, naming the capability and why', () => { + const def = example('phone-hubspot'); + def.workflows[0].steps.unshift({ id: 'find', use: 'hubspot.contact.search', with: { email: 'a@b.co' } }); + expect(errorsOf(def)).toEqual([]); + expect(warningsOf(def).join('\n')).toMatch(/steps\[0\]\.use: hubspot\.contact\.search is unimplemented in the hubspot Api \(Api has no contact search/); + }); + + it('an implemented capability does not', () => { + const def = example('phone-hubspot'); + def.workflows[0].steps.unshift({ id: 'get', use: 'hubspot.contact.get', with: { id: '1' } }); + expect(warningsOf(def).filter((w) => w.includes('workflows[0].steps[0]'))).toEqual([]); + }); + + it('an option list and a sync that read through one warn too', () => { + const all = d.loadExamples().flatMap((def) => warningsOf(def)).join('\n'); + expect(all).toMatch(/Option list hubspot\.owner reads owner\.list, which is unimplemented in the hubspot Api/); + const sync = example('github-linear'); + expect(warningsOf(sync).join('\n')).toMatch(/linear\.issue syncs through issue\.create, which is unimplemented in the linear Api/); + expect(errorsOf(sync)).toEqual([]); + }); + + it('a trigger on a module whose webhook verification is unimplemented warns once per trigger', () => { + const def = example('phone-hubspot'); + const w = warningsOf(def).filter((x) => /fails signature verification|fail signature verification/.test(x)); + expect(w.length).toBeGreaterThan(0); + expect(w[0]).toMatch(/verifyWebhookSignature method, so every delivery fails signature verification/); + // A manifest that does verify has no such warning. + const ok = clone(manifests); + delete ok.phone.webhook.status; + delete ok.phone.webhook.note; + const phoneOnly = d.validate(def, ok).warnings.filter((x) => /signature verification/.test(x.message) && /phone/.test(x.message)); + expect(phoneOnly).toEqual([]); + }); + + it('every bundled manifest validates, and every unimplemented one says why', () => { + for (const m of Object.values(manifests)) { + expect([m.module, validateManifest(m).errors]).toEqual([m.module, []]); + for (const [name, c] of Object.entries(m.capabilities)) if (c.status) expect([m.module, name, typeof c.note, c.note?.length > 10]).toEqual([m.module, name, 'string', true]); + } + }); +}); + +describe('scripts/check-manifests.js against a fake module directory', () => { + let root; + const write = (rel, text) => { const f = path.join(root, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); }; + const manifest = (extra = {}) => ({ + module: 'widget', label: 'Widget', package: '@x/api-module-widget', + webhook: { scope: 'app', topic: '{{body.t}}', eventId: '{{body.id}}', verify: 'verifySig', account: '{{body.a}}', ...extra.webhook }, + capabilities: { + 'thing.get': { kind: 'read', method: 'getThing', args: ['id'], input: { id: { type: 'string', required: true } } }, + 'thing.list': { kind: 'read', method: 'listThings', input: { limit: { type: 'number' } } }, + 'thing.update': { kind: 'write', method: 'updateThing', args: ['id', '$rest'], input: { id: { type: 'string' }, name: { type: 'string' } } }, + 'thing.gone': { kind: 'read', method: 'noSuchMethod', input: {} }, + 'thing.later': { kind: 'read', method: 'laterMethod', status: 'unimplemented', note: 'Not written yet', input: {} }, + 'thing.stale': { kind: 'read', method: 'listThings', status: 'unimplemented', note: 'Marked, but it exists', input: {} }, + 'thing.two': { kind: 'read', method: 'getPair', input: { a: { type: 'string' } } }, + 'thing.long': { kind: 'read', method: 'getThing', args: ['id', 'extra', 'more'], input: { id: { type: 'string' }, extra: { type: 'string' }, more: { type: 'string' } } }, + ...extra.capabilities, + }, + }); + const API = ` +class Base { listThings(params = {}) { return params; } } +class Api extends Base { + async getThing(id) { return id; } + async updateThing(id, body) { return [id, body]; } + async getPair(a, b) { return [a, b]; } + verifySig(headers, body) { return true; } +} +module.exports = { Api }; +`; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-manifests-')); + write('mods/group/api-module-widget/package.json', '{"name":"@x/api-module-widget"}'); + write('mods/group/api-module-widget/index.js', API); + write('manifests/widget.capabilities.json', JSON.stringify(manifest())); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + const rowsOf = (results, mod = 'widget') => Object.fromEntries(results.find((r) => r.module === mod).rows.map((r) => [r.capability, r])); + const load = () => JSON.parse(fs.readFileSync(path.join(root, 'manifests', 'widget.capabilities.json'), 'utf8')); + + it('finds the module under a nested directory, and a method inherited from a base class', () => { + const [r] = checkManifests({ widget: load() }, [path.join(root, 'mods')]); + expect(r.source).toBe(path.join(root, 'mods', 'group', 'api-module-widget')); + const rows = rowsOf([r]); + expect(rows['thing.get']).toMatchObject({ state: 'ok', signature: '(id)', args: 'id' }); + expect(rows['thing.list']).toMatchObject({ state: 'ok', signature: '(params = {})' }); + expect(rows['thing.update']).toMatchObject({ state: 'ok', args: 'id, $rest' }); + expect(rows['webhook.verify']).toMatchObject({ state: 'ok', signature: '(headers, body)' }); + }); + + it('reports a missing method, a marked-but-present one, and an unimplemented one that is absent', () => { + const rows = rowsOf(checkManifests({ widget: load() }, [path.join(root, 'mods')])); + expect(rows['thing.gone']).toMatchObject({ state: 'missing', method: 'noSuchMethod' }); + expect(rows['thing.later']).toMatchObject({ state: 'unimplemented', note: 'Not written yet' }); + expect(rows['thing.stale']).toMatchObject({ state: 'stale-unimplemented' }); + expect(rows['thing.stale'].note).toMatch(/Marked unimplemented, but listThings\(params = \{\}\) exists: Marked, but it exists/); + }); + + it('reports arguments that do not fit: more passed than declared, or one object for a method that needs two', () => { + const rows = rowsOf(checkManifests({ widget: load() }, [path.join(root, 'mods')])); + expect(rows['thing.long']).toMatchObject({ state: 'arity', note: 'Passes 3 arguments; getThing declares 1' }); + expect(rows['thing.two']).toMatchObject({ state: 'arity' }); + expect(rows['thing.two'].note).toMatch(/needs 2 positional arguments \(add args\)/); + }); + + it('checks webhook.verify: a missing method fails, an unimplemented one is soft', () => { + const m = load(); + m.webhook.verify = 'noVerify'; + expect(rowsOf(checkManifests({ widget: m }, [path.join(root, 'mods')]))['webhook.verify']).toMatchObject({ state: 'missing' }); + m.webhook.status = 'unimplemented'; + expect(rowsOf(checkManifests({ widget: m }, [path.join(root, 'mods')]))['webhook.verify']).toMatchObject({ state: 'unimplemented' }); + m.webhook.verify = 'verifySig'; + expect(rowsOf(checkManifests({ widget: m }, [path.join(root, 'mods')]))['webhook.verify']).toMatchObject({ state: 'stale-unimplemented' }); + }); + + it('a module that is not there, and one that will not load, are reported, not thrown', () => { + const other = { ...load(), module: 'ghost' }; + write('mods/boom/package.json', '{"name":"@x/api-module-boom"}'); + write('mods/boom/index.js', "throw new Error('Cannot start: missing dependency');"); + const boom = { ...load(), module: 'boom' }; + const results = checkManifests({ ghost: other, boom }, [path.join(root, 'mods')]); + expect(results.find((r) => r.module === 'ghost')).toMatchObject({ state: 'no-module', rows: [] }); + const b = results.find((r) => r.module === 'boom'); + expect(b.state).toBe('unloadable'); + expect(b.message).toMatch(/Cannot start: missing dependency/); + }); + + it('summarise counts mismatches apart from what is only unverified', () => { + const results = checkManifests({ widget: load(), ghost: { ...load(), module: 'ghost' } }, [path.join(root, 'mods')]); + const s = summarise(results); + expect(s.mismatches).toBe(3); // thing.gone, thing.two, thing.long + expect(s.soft).toBe(3); // thing.later, thing.stale, ghost + }); + + it('parametersOf reads defaults, rests and destructuring', () => { + expect(parametersOf(function a(x, { y, z }, w = 1, ...rest) {}).map((p) => p.optional)).toEqual([false, false, true, true]); + expect(parametersOf((a) => a)).toHaveLength(1); + expect(parametersOf((a) => a)[0]).toMatchObject({ text: 'a', optional: false }); + expect(parametersOf(new Function('x', 'return x'))).toHaveLength(1); + expect(parametersOf(function none() {})).toEqual([]); + }); + + describe('command line', () => { + const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' }); + const mods = () => path.join(root, 'mods'); + const mdir = () => path.join(root, 'manifests'); + + it('exits 1 on a mismatch and names it', () => { + const r = run('--manifests', mdir(), mods()); + expect(r.status).toBe(1); + expect(r.stdout).toMatch(/FAIL widget/); + expect(r.stdout).toMatch(/missing\s+thing\.gone -> noSuchMethod/); + }); + + it('exits 0 when every method is found, and 1 under --strict while any capability is unimplemented', () => { + const m = load(); + for (const k of ['thing.gone', 'thing.two', 'thing.long', 'thing.stale']) delete m.capabilities[k]; + fs.writeFileSync(path.join(mdir(), 'widget.capabilities.json'), JSON.stringify(m)); + expect(run('--manifests', mdir(), mods()).status).toBe(0); + expect(run('--strict', '--manifests', mdir(), mods()).status).toBe(1); + delete m.capabilities['thing.later']; + fs.writeFileSync(path.join(mdir(), 'widget.capabilities.json'), JSON.stringify(m)); + const clean = run('--strict', '--json', '--manifests', mdir(), mods()); + expect(clean.status).toBe(0); + expect(JSON.parse(clean.stdout)).toMatchObject({ valid: true, summary: { mismatches: 0, soft: 0 } }); + }); + + it('--json is machine-readable', () => { + const out = JSON.parse(run('--json', '--manifests', mdir(), mods()).stdout); + expect(out.valid).toBe(false); + expect(out.results[0].rows.find((x) => x.capability === 'thing.gone').state).toBe('missing'); + }); + + it('exits 2 when it cannot run: no directory, an unknown option, no manifests', () => { + expect(run().status).toBe(2); + expect(run('--manifests', mdir(), path.join(root, 'nowhere')).status).toBe(2); + expect(run('--bogus', mods()).status).toBe(2); + fs.mkdirSync(path.join(root, 'empty')); + expect(run('--manifests', path.join(root, 'empty'), mods()).status).toBe(2); + }); + + it('--stub-missing loads a module whose dependency is not installed; without it the module is unloadable', () => { + write('mods/needs/package.json', '{"name":"@x/api-module-needs"}'); + write('mods/needs/index.js', "const sdk = require('not-installed-sdk'); class Api { getThing(id) { return sdk.x(id); } } module.exports = { Api };"); + const m = { module: 'needs', label: 'N', capabilities: { 'thing.get': { kind: 'read', method: 'getThing', args: ['id'], input: { id: { type: 'string' } } } } }; + fs.writeFileSync(path.join(mdir(), 'needs.capabilities.json'), JSON.stringify(m)); + const plain = JSON.parse(run('--json', '--manifests', mdir(), mods()).stdout).results.find((r) => r.module === 'needs'); + expect(plain.state).toBe('unloadable'); + const stubbed = JSON.parse(run('--json', '--stub-missing', '--manifests', mdir(), mods()).stdout).results.find((r) => r.module === 'needs'); + expect(stubbed).toMatchObject({ state: 'ok' }); + expect(stubbed.rows[0]).toMatchObject({ capability: 'thing.get', state: 'ok' }); + }); + }); +}); diff --git a/packages/core/integrations/data-defined/tests/routes-and-upgrade.test.js b/packages/core/integrations/data-defined/tests/routes-and-upgrade.test.js index 641fd5dbf..aec21c135 100644 --- a/packages/core/integrations/data-defined/tests/routes-and-upgrade.test.js +++ b/packages/core/integrations/data-defined/tests/routes-and-upgrade.test.js @@ -127,7 +127,7 @@ describe('user routes: catalogue, upgrade, runs', () => { expect((await publishAs(v15)).body.changeLevel).toBe('minor'); const calls = []; const inst = install('int_2', 'user-a', { definition: { id: 'phone-hubspot', version: '1.4.0' }, settings: { fromNumber: '+15125550100' }, runtime: { installed: { hook: { id: 'WH_old' } } } }, { - phone: { api: { createWebhook: async (a) => { calls.push(['create', a.events.length]); return { id: 'WH_new' }; }, deleteWebhook: async (a) => { calls.push(['delete', a.id]); } } }, + phone: { api: { createUnifiedWebhook: async (a) => { calls.push(['create', a.events.length]); return { id: 'WH_new' }; }, deleteWebhook: async (id) => { calls.push(['delete', id]); } } }, hubspot: { api: {} }, }); const r = await request(app).post('/api/integrations/int_2/upgrade').set('authorization', 'Bearer token-a').send({ to: '1.5.0' }); @@ -161,7 +161,7 @@ describe('installing by id alone', () => { it('pins the latest published version and its checksum, and persists the pin at install', async () => { const { publishAs, install, catalog } = await setup(); await publishAs(example('phone-hubspot')); - const inst = install('int_4', 'user-a', { definition: { id: 'phone-hubspot' } }, { phone: { api: { createWebhook: async () => ({ id: 'WH1' }) } }, hubspot: { api: {} } }); + const inst = install('int_4', 'user-a', { definition: { id: 'phone-hubspot' } }, { phone: { api: { createUnifiedWebhook: async () => ({ id: 'WH1' }) } }, hubspot: { api: {} } }); await inst.initialize(); expect(inst.config.definition).toEqual({ id: 'phone-hubspot', version: '1.4.0', checksum: await catalog.checksumOf('phone-hubspot', '1.4.0') }); await inst.onCreate({}); diff --git a/packages/core/integrations/data-defined/tests/sync-class.test.js b/packages/core/integrations/data-defined/tests/sync-class.test.js index 9f0d0347b..a3140ab8a 100644 --- a/packages/core/integrations/data-defined/tests/sync-class.test.js +++ b/packages/core/integrations/data-defined/tests/sync-class.test.js @@ -38,7 +38,7 @@ function setup(defs = [defWithSyncWorkflow()]) { const hubspot = hubspotApi(), phone = phoneApi(); hubspot.verifyWebhookSignature = async () => true; phone.verifyWebhookSignature = async () => true; - phone.createWebhook = async () => ({ id: 'WH1' }); + phone.createUnifiedWebhook = async () => ({ id: 'WH1' }); phone.deleteWebhook = async () => ({}); const inst = new Cls({ id: 'int1', config: { type: 'data-defined', definition: { id: defs[0].id, version: defs[0].version }, settings: { fromNumber: '+15125550100' } } }); inst.hubspot = { api: hubspot, entity: { externalId: '12345678' } }; diff --git a/packages/core/integrations/data-defined/tests/sync-engine.test.js b/packages/core/integrations/data-defined/tests/sync-engine.test.js index 85d98bc86..c4a5c6c82 100644 --- a/packages/core/integrations/data-defined/tests/sync-engine.test.js +++ b/packages/core/integrations/data-defined/tests/sync-engine.test.js @@ -3,7 +3,7 @@ * them (list pages, search by key, create returns the record). Assertions are on what was written to each side. */ const d = require('..'); -const { makePorts, installContext } = require('../runtime'); +const { makePorts, installContext, argumentsFor } = require('../runtime'); const { createSyncEngine } = require('../sync-engine'); const manifests = d.loadManifests(); @@ -15,7 +15,7 @@ function setup({ def = example('phone-hubspot'), installation = {} } = {}) { const rows = new Map(); const store = { get: async (k) => rows.get(k) ?? null, put: async (k, v) => { rows.set(k, JSON.parse(JSON.stringify(v))); }, claim: async (k, v) => (rows.has(k) ? false : (rows.set(k, v), true)) }; const apis = { hubspot: hubspotApi(), phone: phoneApi() }; - const ports = makePorts({ def, manifests, store, callApi: (k, method, args) => apis[k][method](args) }); + const ports = makePorts({ def, manifests, store, callApi: (k, method, args, order) => apis[k][method](...argumentsFor(args, order)) }); const inst = { definition: { id: def.id, version: def.version }, ...installation }; const engine = createSyncEngine({ def, installation: inst, install: installContext(def, inst), manifests, ports, state: store }); const backfill = async (syncId = def.syncs[0].id) => { @@ -151,7 +151,7 @@ describe('one record', () => { const hs = t.apis.hubspot.seed({ firstname: 'Ida', email: 'ida@x.com' }); const get = jest.spyOn(t.apis.hubspot, 'getContactById'); const out = await t.engine.handleEvent({ syncId: 'contacts', moduleKey: 'hubspot', topic: 'contact.created', event: hubspotEvent(hs, 'contact.creation')[0] }); - expect(get).toHaveBeenCalledWith({ id: hs }); + expect(get).toHaveBeenCalledWith(hs); expect(out.status).toBe('created'); expect(t.apis.phone.writes[0][1]).toEqual({ firstName: 'Ida', emails: [{ value: 'ida@x.com' }] }); }); diff --git a/packages/core/integrations/data-defined/tests/unification.test.js b/packages/core/integrations/data-defined/tests/unification.test.js index ee6f8a1e4..8d68bc2fa 100644 --- a/packages/core/integrations/data-defined/tests/unification.test.js +++ b/packages/core/integrations/data-defined/tests/unification.test.js @@ -83,7 +83,7 @@ describe('installing by type', () => { const Host = host(docs); const Cls = await resolveIntegrationClass([Host], 'phone-hubspot'); const inst = new Cls({ id: 'int9', config: { type: 'phone-hubspot', settings: { fromNumber: '+15125550100' } } }); - inst.phone = { api: { createWebhook: async () => ({ id: 'W1' }) } }; + inst.phone = { api: { createUnifiedWebhook: async () => ({ id: 'W1' }) } }; inst.hubspot = { api: {}, entity: { externalId: '1' } }; await inst.initialize(); await inst.onCreate({}); diff --git a/packages/core/integrations/data-defined/tests/v01-gaps.test.js b/packages/core/integrations/data-defined/tests/v01-gaps.test.js index cbd11ad59..598d8a666 100644 --- a/packages/core/integrations/data-defined/tests/v01-gaps.test.js +++ b/packages/core/integrations/data-defined/tests/v01-gaps.test.js @@ -160,12 +160,12 @@ describe('option lists that take inputs', () => { const projects = [{ gid: 'p1', name: 'Launch' }, { gid: 'p2', name: 'Ops' }]; it('calls the list with inputs read from earlier choices, and returns nothing until they are chosen', async () => { - const h = harness(def, (m, method) => (method === 'getProjects' ? projects : [])); + const h = harness(def, (m, method) => (method === 'listProjects' ? projects : [])); expect(await d.resolveOptions({ def, manifests, ports: h.ports, path: 'settings.project', values: {} })).toEqual([]); expect(h.calls).toEqual([]); const opts = await d.resolveOptions({ def, manifests, ports: h.ports, path: 'settings.project', values: { workspace: 'w9' } }); expect(opts).toEqual([{ label: 'Launch', value: 'p1' }, { label: 'Ops', value: 'p2' }]); - expect(h.calls).toEqual([{ module: 'asana', method: 'getProjects', args: { workspaceGid: 'w9' } }]); + expect(h.calls).toEqual([{ module: 'asana', method: 'listProjects', args: { workspace: 'w9' } }]); }); it('a workflow setting reads integration settings as $.install', async () => { @@ -181,13 +181,13 @@ describe('option lists that take inputs', () => { x.settings.project.options = { source: 'asana.project', with: { colour: '$.settings.workspace' } }; x.settings.workspace.options = { source: 'asana.workspace', with: {} }; let e = errorsOf(x).join('\n'); - expect(e).toMatch(/settings\.project\.options\.with\.workspaceGid: Option list asana\.project needs input "workspaceGid"/); + expect(e).toMatch(/settings\.project\.options\.with\.workspace: Option list asana\.project needs input "workspace"/); expect(e).toMatch(/takes no input "colour"/); - x.settings.project.options = { source: 'asana.project', with: { workspaceGid: '$.settings.project' } }; + x.settings.project.options = { source: 'asana.project', with: { workspace: '$.settings.project' } }; expect(errorsOf(x).join('\n')).toMatch(/cannot read its own setting/); const loop = example('asana-slack'); - loop.settings.a = { type: 'option', label: 'A', options: { source: 'asana.project', with: { workspaceGid: '$.settings.b' } } }; - loop.settings.b = { type: 'option', label: 'B', options: { source: 'asana.project', with: { workspaceGid: '$.settings.a' } } }; + loop.settings.a = { type: 'option', label: 'A', options: { source: 'asana.project', with: { workspace: '$.settings.b' } } }; + loop.settings.b = { type: 'option', label: 'B', options: { source: 'asana.project', with: { workspace: '$.settings.a' } } }; expect(errorsOf(loop).join('\n')).toMatch(/Option inputs form a loop/); }); }); @@ -261,14 +261,14 @@ describe('lifecycle results and account values on $.install', () => { const def = example('pipedrive-phone'); let n = 0; const inst = make(def, (self) => ({ - phone: { api: { createWebhook: async (a) => { self.calls.push(['phone.create', a]); return { id: 'WHq' }; }, deleteWebhook: async (a) => { self.calls.push(['phone.delete', a]); } } }, - pipedrive: { api: { addWebhook: async (a) => { self.calls.push(['pd.create', a]); n += 1; return { id: `pd${n}` }; }, deleteWebhook: async (a) => { self.calls.push(['pd.delete', a]); } } }, + phone: { api: { createUnifiedWebhook: async (a) => { self.calls.push(['phone.create', a]); return { id: 'WHq' }; }, deleteWebhook: async (id) => { self.calls.push(['phone.delete', id]); } } }, + pipedrive: { api: { createWebhook: async (a) => { self.calls.push(['pd.create', a]); n += 1; return { id: `pd${n}` }; }, deleteWebhook: async (id) => { self.calls.push(['pd.delete', id]); } } }, })); await inst.onCreate({}); expect(inst.config.runtime.installed).toEqual({ phoneHook: { id: 'WHq' }, personCreated: { id: 'pd1' }, personChanged: { id: 'pd2' } }); inst.calls.length = 0; await inst.onDelete({}); - expect(inst.calls).toEqual([['phone.delete', { id: 'WHq' }], ['pd.delete', { id: 'pd1' }], ['pd.delete', { id: 'pd2' }]]); + expect(inst.calls).toEqual([['phone.delete', 'WHq'], ['pd.delete', 'pd1'], ['pd.delete', 'pd2']]); }); it('in every example, uninstall makes one call per thing install created, and never with a blank id', async () => { @@ -303,7 +303,8 @@ describe('lifecycle results and account values on $.install', () => { const w = d.validate(x, manifests).warnings.map((e) => `${e.path}: ${e.message}`).join('\n'); expect(w).toMatch(/onInstall\[0\]: onUninstall never reads \$\.install\.runtime\.installed\.phoneHook/); expect(w).toMatch(/onInstall\[3\]: This install step has no id/); - expect(d.validate(example('pipedrive-phone'), manifests).warnings).toEqual([]); + // Unimplemented-capability warnings are about the module's Api (see the unimplemented-capability tests), not install/uninstall pairing. + expect(d.validate(example('pipedrive-phone'), manifests).warnings.filter((e) => !/unimplemented|verification/.test(e.message))).toEqual([]); }); it('refuses an undeclared account field, an unknown install step, and install steps reading their own results', () => { @@ -348,7 +349,7 @@ describe('versioning of the new shapes', () => { it('changing only an option list\'s inputs is minor, not "setting type changed"', () => { const a = example('asana-slack'); const b = example('asana-slack'); b.version = '0.3.0'; - b.settings.project.options = { ...b.settings.project.options, with: { workspaceGid: '$.settings.workspace', archived: false } }; + b.settings.project.options = { ...b.settings.project.options, with: { workspace: '$.settings.workspace', archived: false } }; expect(d.diff(a, b).changes.map((x) => `${x.level} ${x.message}`)).toEqual(['minor Option list inputs changed']); }); }); diff --git a/packages/core/integrations/data-defined/tests/wiring.test.js b/packages/core/integrations/data-defined/tests/wiring.test.js index 77d317639..6b701a9b0 100644 --- a/packages/core/integrations/data-defined/tests/wiring.test.js +++ b/packages/core/integrations/data-defined/tests/wiring.test.js @@ -70,7 +70,7 @@ describe('initialize() is where a real Frigg entry point hydrates the definition const inst = shared([def], { apis: { hubspot: { api: { getContactById } }, phone: { api: {} } } })(pin(def)); await inst.initialize(); expect((await inst.send('WF_LOOK_UP', { id: '51' })).status).toBe('completed'); - expect(getContactById).toHaveBeenCalledWith({ id: '51' }); + expect(getContactById).toHaveBeenCalledWith('51'); }); }); @@ -210,7 +210,7 @@ describe('receipt and worker', () => { describe('the existing authenticated routes: config options, user actions, UI surfaces', () => { const def = example('asana-slack'); - const make = () => shared([def], { apis: { asana: { api: { getWorkspaces: async () => [{ gid: 'w1', name: 'Acme' }], getProjects: async ({ workspaceGid }) => (workspaceGid === 'w1' ? [{ gid: 'p1', name: 'Launch' }] : []) } }, slack: { api: { listChannels: async () => [{ id: 'C1', name: 'general' }], listUsers: async () => [] } } } }); + const make = () => shared([def], { apis: { asana: { api: { listWorkspaces: async () => [{ gid: 'w1', name: 'Acme' }], listProjects: async ({ workspace }) => (workspace === 'w1' ? [{ gid: 'p1', name: 'Launch' }] : []) } }, slack: { api: { listChannels: async () => [{ id: 'C1', name: 'general' }], listUsers: async () => [] } } } }); it('GET config/options: a form whose data is the config shape; a dependent list waits for its input', async () => { const inst = make()(pin(def)); @@ -234,14 +234,14 @@ describe('the existing authenticated routes: config options, user actions, UI su }); it('NEEDS_CONFIG comes from the definition\'s required settings, without calling any API', async () => { - const getWorkspaces = jest.fn(); - const inst = shared([def], { apis: { asana: { api: { getWorkspaces } }, slack: { api: {} } } })(pin(def)); + const listWorkspaces = jest.fn(); + const inst = shared([def], { apis: { asana: { api: { listWorkspaces } }, slack: { api: {} } } })(pin(def)); await inst.initialize(); expect(await inst.validateConfig()).toBe(true); inst.config = { ...inst.config, settings: Object.fromEntries(Object.entries(def.settings).filter(([, s]) => s.required).map(([k]) => [k, 'x'])) }; for (const w of def.workflows) for (const [k, s] of Object.entries(w.settings || {})) if (s.required && s.default === undefined) ((inst.config.workflows ||= {})[w.id] ||= { settings: {} }).settings[k] = 'x'; expect(await inst.validateConfig()).toBe(false); - expect(getWorkspaces).not.toHaveBeenCalled(); + expect(listWorkspaces).not.toHaveBeenCalled(); }); it('POST actions/:id runs a button workflow, renders a UI surface, and the button\'s inputs are its options form', async () => { diff --git a/packages/core/integrations/data-defined/validate.js b/packages/core/integrations/data-defined/validate.js index b0429d9dd..3333ca3c1 100644 --- a/packages/core/integrations/data-defined/validate.js +++ b/packages/core/integrations/data-defined/validate.js @@ -100,6 +100,7 @@ function validate(def, manifests = {}, { codeMethods } = {}) { const list = manifestOf[mk].options?.[ok]; if (!list) { err(p, `Module "${mk}" has no option list "${ok}"`); return; } const inputs = list.inputs || {}; + if (manifestOf[mk].capabilities?.[list.capability]?.status === 'unimplemented') warn(p, `Option list ${ref.source} reads ${list.capability}, which is unimplemented in the ${mk} Api; the dropdown will not load`); for (const [name, spec] of Object.entries(inputs)) if (spec.required && !(name in (ref.with || {}))) err(`${p}.with.${name}`, `Option list ${ref.source} needs input "${name}"`); for (const [name, v] of Object.entries(ref.with || {})) { if (!(name in inputs)) { err(`${p}.with.${name}`, `Option list ${ref.source} takes no input "${name}"`); continue; } @@ -177,6 +178,9 @@ function validate(def, manifests = {}, { codeMethods } = {}) { const toName = `${to.k}.${to.o}`; if (!fs) { err(`${p}.${from === src ? 'source' : 'target'}`, `${fromName} declares no sync block (objects.${from.o}.sync), so it cannot be read`); continue; } if (!ts) { err(`${p}.${to === tgt ? 'target' : 'source'}`, `${toName} declares no sync block (objects.${to.o}.sync), so it cannot be written`); continue; } + for (const [spec, man, nm, where] of [[fs, manifestOf[from.k], fromName, from === src ? 'source' : 'target'], [ts, manifestOf[to.k], toName, to === tgt ? 'target' : 'source']]) { + for (const op of Object.values(spec)) if (op?.capability && man.capabilities?.[op.capability]?.status === 'unimplemented') warn(`${p}.${where}`, `${nm} syncs through ${op.capability}, which is unimplemented in the ${man.module} Api`); + } if (!ts.create && !ts.update) err(`${p}.${to === tgt ? 'target' : 'source'}`, `${toName} has neither sync.create nor sync.update`); if ((s.backfill?.trigger || 'none') !== 'none' && !fs.list && from === src) err(`${p}.backfill`, `A backfill pages through ${fromName}, which has no sync.list`); // A two-way backfill also reads the target when it can list; when it cannot, it reads only the source. @@ -305,6 +309,7 @@ function validate(def, manifests = {}, { codeMethods } = {}) { const c = man.capabilities?.[cap.capability]; if (!c) err(`${p}.use`, `Module "${cap.module}" (${man.module}) has no capability "${cap.capability}"`); else { + if (c.status === 'unimplemented') warn(`${p}.use`, `${st.use} is unimplemented in the ${man.module} Api${c.note ? ` (${c.note})` : ''}; this step will fail at run time until the module adds it`); for (const [name, spec] of Object.entries(c.input || {})) if (spec.required && !(name in args)) err(`${p}.with.${name}`, `Required by ${st.use}`); for (const name of Object.keys(args)) if (c.input && !(name in c.input)) err(`${p}.with.${name}`, `${st.use} takes no argument "${name}"`); } @@ -329,6 +334,7 @@ function validate(def, manifests = {}, { codeMethods } = {}) { if (t.type === 'webhook') { if (!manifestOf[t.module]) err(`${p}.trigger.module`, `Unknown module key "${t.module}"`); else if (!manifestOf[t.module].events?.[t.topic]) err(`${p}.trigger.topic`, `Module "${t.module}" emits no event "${t.topic}"`); + else if (manifestOf[t.module].webhook?.status === 'unimplemented' && (manifestOf[t.module].events[t.topic].delivery || 'webhook') === 'webhook') warn(`${p}.trigger`, `${t.module} events arrive by webhook, but the ${manifestOf[t.module].module} Api has no ${manifestOf[t.module].webhook.verify || 'verify'} method, so every delivery fails signature verification`); } if (t.type === 'schedule') { if ((t.cron === undefined) === (t.every === undefined)) err(`${p}.trigger`, 'Give exactly one of cron or every');