@friggframework/core 2.0.0--canary.703.12b980b.0 → 2.0.0--canary.707.df80bfd.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +20 -10
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -2
  19. package/database/encryption/README.md +78 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +121 -24
  23. package/database/encryption/field-encryption-service.js +69 -5
  24. package/database/encryption/integration-mapping-encryption.js +1 -1
  25. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  26. package/database/organization-where.js +18 -0
  27. package/database/prisma.js +15 -38
  28. package/database/unique-violation.js +25 -0
  29. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  30. package/errors/index.js +0 -4
  31. package/extensions/bootstrap-runner.js +43 -0
  32. package/extensions/extension-loader.js +230 -0
  33. package/extensions/index.js +44 -0
  34. package/extensions/initialize-app.js +123 -0
  35. package/extensions/prisma-composer.js +270 -0
  36. package/extensions/route-mounter.js +103 -0
  37. package/extensions/schema-composer.js +188 -0
  38. package/generated/prisma-mongodb/edge.js +33 -42
  39. package/generated/prisma-mongodb/index-browser.js +30 -39
  40. package/generated/prisma-mongodb/index.d.ts +3187 -2005
  41. package/generated/prisma-mongodb/index.js +33 -42
  42. package/generated/prisma-mongodb/package.json +1 -1
  43. package/generated/prisma-mongodb/schema.prisma +86 -59
  44. package/generated/prisma-mongodb/wasm.js +33 -42
  45. package/generated/prisma-postgresql/edge.js +33 -42
  46. package/generated/prisma-postgresql/index-browser.js +30 -39
  47. package/generated/prisma-postgresql/index.d.ts +3380 -2177
  48. package/generated/prisma-postgresql/index.js +33 -42
  49. package/generated/prisma-postgresql/package.json +1 -1
  50. package/generated/prisma-postgresql/schema.prisma +81 -57
  51. package/generated/prisma-postgresql/wasm.js +33 -42
  52. package/handlers/WEBHOOKS.md +0 -29
  53. package/handlers/app-definition-loader.js +128 -19
  54. package/handlers/app-handler-helpers.js +27 -8
  55. package/handlers/backend-utils.js +31 -25
  56. package/handlers/integration-event-dispatcher.js +3 -7
  57. package/handlers/middleware/admin-auth.js +14 -1
  58. package/handlers/routers/auth.js +46 -10
  59. package/handlers/routers/health.js +13 -4
  60. package/handlers/routers/integration-defined-routers.js +144 -92
  61. package/handlers/routers/integration-webhook-routers.js +100 -50
  62. package/handlers/workers/integration-defined-workers.js +5 -2
  63. package/index.js +66 -16
  64. package/infrastructure/scheduler/index.js +19 -6
  65. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  66. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  67. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  68. package/integrations/index.js +1 -14
  69. package/integrations/integration-base.js +2 -163
  70. package/integrations/integration-router.js +132 -102
  71. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  72. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  73. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  74. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  75. package/integrations/repositories/integration-mapping-repository.js +0 -29
  76. package/integrations/repositories/integration-not-found.js +14 -0
  77. package/integrations/repositories/integration-repository-documentdb.js +37 -9
  78. package/integrations/repositories/integration-repository-interface.js +17 -4
  79. package/integrations/repositories/integration-repository-mongo.js +64 -96
  80. package/integrations/repositories/integration-repository-postgres.js +63 -81
  81. package/integrations/repositories/process-repository-mongo.js +1 -1
  82. package/integrations/schedule-config.js +80 -0
  83. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  84. package/integrations/use-cases/create-integration.js +56 -31
  85. package/integrations/use-cases/delete-integration-for-user.js +4 -4
  86. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  87. package/integrations/use-cases/get-integration-for-user.js +2 -4
  88. package/integrations/use-cases/get-integration-instance.js +4 -4
  89. package/integrations/use-cases/get-integrations-for-user.js +79 -39
  90. package/integrations/use-cases/get-possible-integrations.js +2 -7
  91. package/integrations/use-cases/index.js +7 -1
  92. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  93. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  94. package/integrations/use-cases/update-integration.js +4 -4
  95. package/integrations/utils/map-integration-dto.js +43 -14
  96. package/management-api/create-management-api-router.js +49 -0
  97. package/management-api/error-response.js +116 -0
  98. package/management-api/management-api-config.js +151 -0
  99. package/management-api/meta-router.js +76 -0
  100. package/management-api/openapi/build-openapi.js +144 -0
  101. package/management-api/openapi/components.js +415 -0
  102. package/management-api/openapi/v1-operations.js +83 -0
  103. package/management-api/openapi/v2-operations.js +264 -0
  104. package/management-api/route-registry.js +411 -0
  105. package/management-api/test-support/http-client.js +39 -0
  106. package/management-api/use-cases/get-management-api-meta.js +57 -0
  107. package/management-api/v1-deprecation.js +120 -0
  108. package/management-api/v2/create-v2-router.js +85 -0
  109. package/management-api/v2/dependencies.js +281 -0
  110. package/management-api/v2/dto.js +143 -0
  111. package/management-api/v2/handlers/authorization.js +94 -0
  112. package/management-api/v2/handlers/credentials.js +33 -0
  113. package/management-api/v2/handlers/entities.js +77 -0
  114. package/management-api/v2/handlers/integrations.js +128 -0
  115. package/management-api/v2/handlers/proxy.js +34 -0
  116. package/management-api/v2/request.js +85 -0
  117. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  118. package/modules/domain/authorization-session.js +144 -0
  119. package/modules/entity-scope.js +15 -0
  120. package/modules/entity-type-registry.js +311 -0
  121. package/modules/index.js +14 -0
  122. package/modules/module-factory.js +12 -0
  123. package/modules/module.js +53 -2
  124. package/modules/proxy/proxy-bases.js +169 -0
  125. package/modules/proxy/proxy-errors.js +31 -0
  126. package/modules/proxy/proxy-path.js +183 -0
  127. package/modules/proxy/proxy-policy.js +277 -0
  128. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  129. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  130. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  131. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  132. package/modules/repositories/entity-not-found.js +18 -0
  133. package/modules/repositories/module-repository-documentdb.js +51 -6
  134. package/modules/repositories/module-repository-interface.js +19 -2
  135. package/modules/repositories/module-repository-mongo.js +67 -74
  136. package/modules/repositories/module-repository-postgres.js +66 -74
  137. package/modules/repositories/module-repository.js +2 -1
  138. package/modules/requester/proxy-request.js +245 -0
  139. package/modules/requester/requester.js +35 -5
  140. package/modules/scope-config.js +192 -0
  141. package/modules/scope-policy.js +112 -0
  142. package/modules/scope-resolution.js +288 -0
  143. package/modules/scope-visibility.js +247 -0
  144. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  145. package/modules/tests/doubles/ownership-matching.js +18 -0
  146. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  147. package/modules/use-cases/create-entity-from-credential.js +113 -0
  148. package/modules/use-cases/entity-operations-for-user.js +162 -0
  149. package/modules/use-cases/entity-types.js +139 -0
  150. package/modules/use-cases/execute-entity-proxy-request.js +331 -0
  151. package/modules/use-cases/get-authorization-requirements.js +100 -0
  152. package/modules/use-cases/get-entities-for-user.js +26 -13
  153. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  154. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  155. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  156. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  157. package/modules/use-cases/get-module.js +10 -4
  158. package/modules/use-cases/manage-access.js +21 -0
  159. package/modules/use-cases/multi-step-authorization.js +329 -0
  160. package/modules/use-cases/process-authorization-callback.js +53 -19
  161. package/modules/use-cases/refresh-entity-options.js +10 -4
  162. package/modules/use-cases/test-module-auth.js +10 -4
  163. package/package.json +6 -7
  164. package/prisma-mongodb/schema.prisma +86 -59
  165. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  166. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  167. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  168. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  169. package/prisma-postgresql/schema.prisma +81 -57
  170. package/providers/index.js +23 -0
  171. package/providers/resolve-provider.js +93 -0
  172. package/providers/runtime-detection.js +124 -0
  173. package/queues/index.js +25 -0
  174. package/queues/providers/index.js +11 -0
  175. package/queues/providers/netlify-background-provider.js +150 -0
  176. package/queues/providers/qstash-queue-provider.js +219 -0
  177. package/queues/providers/sqs-queue-provider.js +70 -0
  178. package/queues/queue-client-interface.js +55 -0
  179. package/queues/queue-message-auth.js +193 -0
  180. package/queues/queue-provider-factory.js +101 -0
  181. package/queues/queue-provider.js +65 -0
  182. package/queues/queuer-util.js +2 -9
  183. package/queues/sqs-queue-client.js +81 -0
  184. package/types/index.d.ts +2 -0
  185. package/types/modules/index.d.ts +214 -0
  186. package/types/user/index.d.ts +134 -0
  187. package/user/adopter-jwt-config.js +177 -0
  188. package/user/adopter-jwt-errors.js +59 -0
  189. package/user/adopter-token-verifier.js +122 -0
  190. package/user/app-user-helpers.js +97 -0
  191. package/user/create-user-authentication.js +64 -0
  192. package/user/membership.js +51 -0
  193. package/user/repositories/organization-membership-record.js +19 -0
  194. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  195. package/user/repositories/organization-membership-repository-factory.js +42 -0
  196. package/user/repositories/organization-membership-repository-interface.js +57 -0
  197. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  198. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  199. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  200. package/user/repositories/user-repository-documentdb.js +27 -2
  201. package/user/repositories/user-repository-interface.js +18 -0
  202. package/user/repositories/user-repository-mongo.js +24 -0
  203. package/user/repositories/user-repository-postgres.js +18 -0
  204. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  205. package/user/use-cases/authenticate-user.js +7 -1
  206. package/user/use-cases/ensure-organization-membership.js +92 -0
  207. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  208. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  209. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  210. package/user/user.js +64 -1
  211. package/errors/mapping-already-exists-error.js +0 -14
  212. package/handlers/workers/integration-triggers.js +0 -25
  213. package/infrastructure/scheduler/recurrence.js +0 -204
  214. package/integrations/define/DESIGN.md +0 -122
  215. package/integrations/define/config.js +0 -98
  216. package/integrations/define/define-app.js +0 -66
  217. package/integrations/define/define-extension.js +0 -154
  218. package/integrations/define/define-integration.js +0 -335
  219. package/integrations/define/examples/deal-alerts.js +0 -140
  220. package/integrations/define/extension-binding.js +0 -174
  221. package/integrations/define/index.js +0 -11
  222. package/integrations/define/json-schema-lite.js +0 -68
  223. package/integrations/define/subscriptions.js +0 -186
  224. package/integrations/define/tests/fixtures/hubspot.js +0 -109
  225. package/integrations/define/tests/fixtures/slack.js +0 -26
  226. package/integrations/define/use.js +0 -39
  227. package/integrations/definitions/README.md +0 -278
  228. package/integrations/definitions/canonical.js +0 -11
  229. package/integrations/definitions/capabilities/AUDIT.md +0 -480
  230. package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
  231. package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
  232. package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
  233. package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
  234. package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
  235. package/integrations/definitions/capabilities/github.capabilities.json +0 -585
  236. package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
  237. package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
  238. package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
  239. package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
  240. package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
  241. package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
  242. package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
  243. package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
  244. package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
  245. package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
  246. package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
  247. package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
  248. package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
  249. package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
  250. package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
  251. package/integrations/definitions/compile.js +0 -80
  252. package/integrations/definitions/config-form.js +0 -137
  253. package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
  254. package/integrations/definitions/examples/asana-slack.definition.json +0 -219
  255. package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
  256. package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
  257. package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
  258. package/integrations/definitions/examples/github-linear.definition.json +0 -322
  259. package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
  260. package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
  261. package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
  262. package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
  263. package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
  264. package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
  265. package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
  266. package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
  267. package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
  268. package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
  269. package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
  270. package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
  271. package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
  272. package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
  273. package/integrations/definitions/expressions.js +0 -229
  274. package/integrations/definitions/index.js +0 -48
  275. package/integrations/definitions/integration-runtime.js +0 -779
  276. package/integrations/definitions/interpreter.js +0 -104
  277. package/integrations/definitions/manifests.js +0 -177
  278. package/integrations/definitions/renderers/common.js +0 -67
  279. package/integrations/definitions/renderers/html.js +0 -129
  280. package/integrations/definitions/renderers/hubspot.js +0 -59
  281. package/integrations/definitions/renderers/preview.js +0 -154
  282. package/integrations/definitions/renderers/slack.js +0 -146
  283. package/integrations/definitions/repositories/definition-version-repository.js +0 -67
  284. package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
  285. package/integrations/definitions/router.js +0 -170
  286. package/integrations/definitions/runtime.js +0 -268
  287. package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
  288. package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
  289. package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
  290. package/integrations/definitions/schema/records.md +0 -105
  291. package/integrations/definitions/schema-check.js +0 -58
  292. package/integrations/definitions/scripts/check-manifests.js +0 -220
  293. package/integrations/definitions/scripts/validate.js +0 -66
  294. package/integrations/definitions/steps.js +0 -123
  295. package/integrations/definitions/sync-engine.js +0 -297
  296. package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
  297. package/integrations/definitions/transforms.js +0 -91
  298. package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
  299. package/integrations/definitions/validate.js +0 -436
  300. package/integrations/definitions/versioning.js +0 -176
  301. package/integrations/definitions/webhooks.js +0 -119
  302. package/integrations/integration-types.js +0 -73
  303. package/integrations/triggers.js +0 -244
  304. package/integrations/use-cases/run-trigger-tick.js +0 -155
  305. package/integrations/use-cases/run-trigger.js +0 -77
  306. package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
  307. package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
  308. package/prisma-postgresql/migrations/20261019120000_definition_workspace/migration.sql +0 -5
@@ -1,436 +0,0 @@
1
- /**
2
- * Validate a definition (v0.1) against the capability manifests of the modules it uses.
3
- *
4
- * Structural rules mirror schema/integration-definition.v0.1.schema.json (kept dependency-free so core needs no
5
- * JSON-schema library). Semantic rules are the ones a schema cannot express: every step names a capability the
6
- * module declares and passes its required arguments; $.steps.x refers to an earlier step; $.settings.y is declared;
7
- * trigger topics are events the module emits; UI placements are ones the host supports.
8
- *
9
- * @param {object} def
10
- * @param {Record<string, object>} manifests manifest name -> manifest (capabilities/*.capabilities.json)
11
- * @returns {{ valid: boolean, errors: Array<{path:string,message:string}>, warnings: Array<{path:string,message:string}> }}
12
- */
13
- const { BUILTINS, PAUSING, isBuiltin, splitCapability } = require('./steps');
14
- const { parseCondition, referencedPaths, templateTags, FILTERS } = require('./expressions');
15
- const { TRANSFORMS: TRANSFORM_FNS } = require('./transforms');
16
- const { validateRecurrence, parseEvery } = require('../../infrastructure/scheduler/recurrence');
17
-
18
- const ID = /^[a-z][a-z0-9-]*$/;
19
- const SEMVER = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
20
- const ROOTS = new Set(['event', 'record', 'steps', 'settings', 'install', 'vars', 'item', 'index', 'data', 'row', 'now']);
21
- const COMPONENTS = new Set(['Stack', 'Heading', 'Text', 'KeyValue', 'List', 'Table', 'Badge', 'Button', 'Empty', 'Divider']);
22
- const TRIGGERS = new Set(['webhook', 'schedule', 'userAction', 'syncRecord']);
23
- const TRANSFORMS = new Set(Object.keys(TRANSFORM_FNS));
24
- const INSTALL_KEYS = new Set(['webhookUrl', 'webhookUrls', 'portalId', 'runtime', 'integrationId', 'accounts']);
25
-
26
- /**
27
- * @param {object} [options]
28
- * @param {string[]} [options.codeMethods] the methods of the code class a definition is embedded in
29
- * (extendIntegrationClass): a code step may call them as `#method`. A published definition has none.
30
- */
31
- function validate(def, manifests = {}, { codeMethods } = {}) {
32
- const errors = [], warnings = [];
33
- const err = (path, message) => errors.push({ path, message });
34
- const warn = (path, message) => warnings.push({ path, message });
35
- /** A schedule's own timing must be valid, and must itself respect the floor it sets for installations. */
36
- const checkSchedule = (recurrence, configurable, path) => {
37
- const where = recurrence.every !== undefined ? `${path}.every` : `${path}.cron`;
38
- for (const m of validateRecurrence(recurrence)) err(where, m);
39
- if (configurable && typeof configurable === 'object' && configurable.min !== undefined) {
40
- for (const m of validateRecurrence(recurrence, { min: configurable.min })) if (!validateRecurrence(recurrence).includes(m)) err(`${path}.configurable.min`, `The default schedule breaks its own minimum: ${m}`);
41
- }
42
- };
43
-
44
- if (!def || typeof def !== 'object') return { valid: false, errors: [{ path: '', message: 'Definition must be an object' }], warnings };
45
- if (def.schemaVersion !== '0.1') err('schemaVersion', 'Must be "0.1"');
46
- if (!ID.test(def.id || '')) err('id', 'Lowercase letters, digits and dashes, starting with a letter');
47
- if (!SEMVER.test(def.version || '')) err('version', 'Must be a semantic version (1.4.0)');
48
- if (!def.display?.label) err('display.label', 'Required');
49
-
50
- // ---------- modules ----------
51
- const mods = def.modules || {};
52
- if (!Object.keys(mods).length) err('modules', 'At least one module is required');
53
- const manifestOf = {};
54
- for (const [key, m] of Object.entries(mods)) {
55
- if (!m?.module) { err(`modules.${key}`, 'Needs a module name'); continue; }
56
- if (!manifests[m.module]) err(`modules.${key}`, `No capability manifest for module "${m.module}"`);
57
- else manifestOf[key] = manifests[m.module];
58
- }
59
-
60
- // ---------- ids are unique across kinds (they become event names) ----------
61
- const seen = new Map();
62
- for (const kind of ['syncs', 'links', 'workflows', 'queries', 'ui']) {
63
- (def[kind] || []).forEach((x, i) => {
64
- if (!ID.test(x?.id || '')) err(`${kind}[${i}].id`, 'Lowercase letters, digits and dashes, starting with a letter');
65
- else if (seen.has(x.id)) err(`${kind}[${i}].id`, `Duplicate id "${x.id}" (also ${seen.get(x.id)})`);
66
- else seen.set(x.id, `${kind}[${i}]`);
67
- });
68
- }
69
- const installKeys = new Set([...Object.keys(def.settings || {}), ...INSTALL_KEYS]);
70
- let warnedWebhookUrl = false;
71
- const installStepIds = new Set((def.lifecycle?.onInstall || []).map((st) => st?.id).filter(Boolean));
72
- const syncs = Object.fromEntries((def.syncs || []).map((s) => [s.id, s]));
73
- const linkSets = Object.fromEntries((def.links || []).map((l) => [l.id, l]));
74
- const workflows = Object.fromEntries((def.workflows || []).map((w) => [w.id, w]));
75
- const queries = Object.fromEntries((def.queries || []).map((q) => [q.id, q]));
76
- const uis = Object.fromEntries((def.ui || []).map((u) => [u.id, u]));
77
-
78
- // ---------- settings ----------
79
- const checkSettings = (settings, path) => {
80
- for (const [k, s] of Object.entries(settings || {})) {
81
- const p = `${path}.${k}`;
82
- if (!s?.label) err(p, 'Setting needs a label');
83
- if (!['string', 'text', 'number', 'boolean', 'enum', 'option', 'duration'].includes(s?.type)) err(p, `Unknown setting type "${s?.type}"`);
84
- if (s?.type === 'enum' && !Array.isArray(s.enum)) err(p, 'enum settings need an enum list');
85
- if (s?.type === 'option') checkOptionSource(s.options, `${p}.options`, k, settings, path !== 'settings');
86
- }
87
- // An option list may read earlier choices; a loop (a reads b, b reads a) could never be filled in.
88
- const reads = (k) => Object.values(optionInputs(settings?.[k])).flatMap((v) => referencedPaths(v)).map((r) => r.split('.')).filter((x) => x[1] === 'settings').map((x) => x[2]);
89
- for (const k of Object.keys(settings || {})) {
90
- const seenK = new Set([k]); const stack = [...reads(k)];
91
- while (stack.length) { const n = stack.pop(); if (n === k) { err(`${path}.${k}.options`, `Option inputs form a loop through "${k}"`); break; } if (!seenK.has(n)) { seenK.add(n); stack.push(...reads(n)); } }
92
- }
93
- };
94
- const optionInputs = (s) => (s && typeof s.options === 'object' ? s.options.with || {} : {});
95
- function checkOptionSource(opts, p, self, siblings, isWorkflow) {
96
- const ref = typeof opts === 'string' ? { source: opts } : opts;
97
- if (!ref || typeof ref.source !== 'string') { err(p, 'options is "module.list" or { source: "module.list", with: { ... } }'); return; }
98
- const [mk, ok] = ref.source.split('.');
99
- if (!manifestOf[mk]) { err(p, `options "${ref.source}" names an unknown module key`); return; }
100
- const list = manifestOf[mk].options?.[ok];
101
- if (!list) { err(p, `Module "${mk}" has no option list "${ok}"`); return; }
102
- const inputs = list.inputs || {};
103
- 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`);
104
- 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}"`);
105
- for (const [name, v] of Object.entries(ref.with || {})) {
106
- if (!(name in inputs)) { err(`${p}.with.${name}`, `Option list ${ref.source} takes no input "${name}"`); continue; }
107
- for (const r of referencedPaths(v)) {
108
- const [, root, key] = r.split(/\.|\[/);
109
- if (root === 'settings' && key === self) err(`${p}.with.${name}`, 'An option list cannot read its own setting');
110
- else if (root === 'settings' && !(key in (siblings || {}))) err(`${p}.with.${name}`, `$.settings.${key} is not a setting at this level`);
111
- else if (root === 'install' && !(isWorkflow && key in (def.settings || {}))) err(`${p}.with.${name}`, `$.install.${key} is not an integration setting`);
112
- else if (root !== 'settings' && root !== 'install') err(`${p}.with.${name}`, 'Option inputs can only read $.settings (or $.install from a workflow setting)');
113
- }
114
- }
115
- }
116
- checkSettings(def.settings, 'settings');
117
-
118
- const objectFields = (key, object) => (manifestOf[key]?.objects?.[object]?.fields || []).map((f) => f.name);
119
- const splitRef = (ref) => { const [k, o] = String(ref || '').split('.'); return { k, o }; };
120
-
121
- // ---------- link sets: links between records with no sync behind them ----------
122
- (def.links || []).forEach((l, i) => {
123
- const p = `links[${i}]`;
124
- if (!Array.isArray(l.between) || l.between.length !== 2) { err(`${p}.between`, 'Two "module.object" references'); return; }
125
- l.between.forEach((ref, j) => {
126
- const r = splitRef(ref);
127
- if (!manifestOf[r.k]) err(`${p}.between[${j}]`, `Unknown module key "${r.k}"`);
128
- else if (!manifestOf[r.k].objects?.[r.o]) err(`${p}.between[${j}]`, `Module "${r.k}" has no object "${r.o}"`);
129
- });
130
- if (splitRef(l.between[0]).k === splitRef(l.between[1]).k) err(`${p}.between`, 'A link set joins two different modules');
131
- (l.identity?.match || []).forEach((m, j) => {
132
- if (!m.key) err(`${p}.identity.match[${j}].key`, 'Name the identity key (email, phone)');
133
- if (m.transform && !TRANSFORMS.has(m.transform)) err(`${p}.identity.match[${j}].transform`, `Unknown transform "${m.transform}"`);
134
- });
135
- });
136
- const checkRows = (rows, p, sf, tf, srcName, tgtName, tgtFields) => (rows || []).forEach((r, j) => {
137
- const q = `${p}[${j}]`;
138
- const fixed = r && 'value' in r;
139
- if (fixed && r.source != null) err(q, 'A row has a source or a fixed value, not both');
140
- else if (!fixed && !r?.source) err(q, 'A row needs a source or a fixed value');
141
- if (!fixed && sf?.length && !sf.includes(r.source)) err(`${q}.source`, `"${r.source}" is not a field of ${srcName}`);
142
- if (tf?.length && !tf.includes(r.target)) err(`${q}.target`, `"${r.target}" is not a field of ${tgtName}`);
143
- const tfield = tgtFields?.find((f) => f.name === r.target);
144
- if (tfield?.readOnly && r.direction !== 'to_source') err(`${q}.target`, `"${r.target}" is read-only in ${tgtName}`);
145
- if (fixed && r.direction === 'to_source') err(`${q}.direction`, 'A fixed value only writes to the target');
146
- if (r.transform && !TRANSFORMS.has(r.transform)) err(`${q}.transform`, `Unknown transform "${r.transform}"`);
147
- });
148
-
149
- // ---------- syncs ----------
150
- (def.syncs || []).forEach((s, i) => {
151
- const p = `syncs[${i}]`;
152
- if (!s.label) err(`${p}.label`, 'Required');
153
- if (!['one_way', 'two_way'].includes(s.direction)) err(`${p}.direction`, 'one_way or two_way');
154
- const src = splitRef(s.source), tgt = splitRef(s.target);
155
- for (const [side, r] of [['source', src], ['target', tgt]]) {
156
- if (!manifestOf[r.k]) err(`${p}.${side}`, `Unknown module key "${r.k}"`);
157
- else if (!manifestOf[r.k].objects?.[r.o]) err(`${p}.${side}`, `Module "${r.k}" has no object "${r.o}"`);
158
- }
159
- const sf = objectFields(src.k, src.o), tf = objectFields(tgt.k, tgt.o);
160
- const match = s.identity?.match || [];
161
- if (!match.length) err(`${p}.identity.match`, 'At least one identity key is required');
162
- match.forEach((m, j) => {
163
- if (!m.key) err(`${p}.identity.match[${j}].key`, 'Name the identity key (email, phone)');
164
- if (sf.length && !sf.includes(m.source)) err(`${p}.identity.match[${j}].source`, `"${m.source}" is not a field of ${s.source}`);
165
- if (tf.length && !tf.includes(m.target)) err(`${p}.identity.match[${j}].target`, `"${m.target}" is not a field of ${s.target}`);
166
- });
167
- checkRows(s.mapping, `${p}.mapping`, sf, tf, s.source, s.target, manifestOf[tgt.k]?.objects?.[tgt.o]?.fields);
168
- if (s.ongoing?.mode === 'webhook') {
169
- const hasEvents = (k, o) => Object.values(manifestOf[k]?.events || {}).some((e) => e.object === o);
170
- if (manifestOf[src.k] && !hasEvents(src.k, src.o)) warn(`${p}.ongoing`, `${s.source} declares no webhook events; ongoing sync will need polling`);
171
- }
172
- // What the sync engine needs from each side's manifest (objects.<obj>.sync).
173
- const specOf = (r) => manifestOf[r.k]?.objects?.[r.o]?.sync;
174
- const sides = s.direction === 'two_way' ? [[src, tgt, s.source], [tgt, src, s.target]] : [[src, tgt, s.source]];
175
- for (const [from, to, fromName] of sides) {
176
- const fs = specOf(from), ts = specOf(to);
177
- if (!manifestOf[from.k] || !manifestOf[to.k]) continue;
178
- const toName = `${to.k}.${to.o}`;
179
- if (!fs) { err(`${p}.${from === src ? 'source' : 'target'}`, `${fromName} declares no sync block (objects.${from.o}.sync), so it cannot be read`); continue; }
180
- if (!ts) { err(`${p}.${to === tgt ? 'target' : 'source'}`, `${toName} declares no sync block (objects.${to.o}.sync), so it cannot be written`); continue; }
181
- 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']]) {
182
- 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`);
183
- }
184
- if (!ts.create && !ts.update) err(`${p}.${to === tgt ? 'target' : 'source'}`, `${toName} has neither sync.create nor sync.update`);
185
- if ((s.backfill?.trigger || 'none') !== 'none' && !fs.list && from === src) err(`${p}.backfill`, `A backfill pages through ${fromName}, which has no sync.list`);
186
- // A two-way backfill also reads the target when it can list; when it cannot, it reads only the source.
187
- if (s.ongoing?.mode === 'poll' && !fs.list?.since) err(`${p}.ongoing`, `Polling ${fromName} needs sync.list with a modified-since argument`);
188
- if (s.ongoing?.mode === 'webhook') {
189
- for (const [topic, e] of Object.entries(manifestOf[from.k].events || {})) if (e.object === from.o && e.recordId && !fs.get) err(`${p}.ongoing`, `${from.k} event "${topic}" carries only an id and ${fromName} has no sync.get`);
190
- }
191
- }
192
- for (const [k, args] of Object.entries(s.args || {})) {
193
- if (k !== src.k && k !== tgt.k) err(`${p}.args.${k}`, `"${k}" is neither side of this sync`);
194
- for (const [a, v] of Object.entries(args || {})) {
195
- if (referencedPaths(v).some((r) => !r.startsWith('$.install'))) err(`${p}.args.${k}.${a}`, 'A sync argument can read only $.install (integration settings and accounts)');
196
- else checkRefs(v, `${p}.args.${k}.${a}`, null);
197
- }
198
- }
199
- if (s.ongoing?.mode === 'poll') checkSchedule({ every: s.ongoing.every || '15m' }, s.ongoing.configurable, `${p}.ongoing`);
200
- else if (s.ongoing?.configurable) err(`${p}.ongoing.configurable`, 'Only a polling sync has an interval to configure');
201
- if (s.filter) checkCondition(s.filter, `${p}.filter`);
202
- });
203
-
204
- // ---------- steps ----------
205
- function checkCondition(src, path) {
206
- try { parseCondition(src); } catch (e) { err(path, e.message); return; }
207
- checkRefs(src, path, null);
208
- }
209
- function checkFilters(value, path) {
210
- for (const t of templateTags(value)) for (const f of t.filters) if (!FILTERS[f.name]) err(path, `Unknown template filter "${f.name}" (${Object.keys(FILTERS).join(', ')})`);
211
- }
212
- function checkRefs(value, path, scope) {
213
- checkFilters(value, path);
214
- for (const ref of referencedPaths(value)) {
215
- const segs = ref.replace(/^\$\./, '').split(/\.|\[/);
216
- const root = segs[0];
217
- if (!ROOTS.has(root)) { err(path, `Unknown root "$.${root}" (use event, record, steps, settings, install, vars, item, data)`); continue; }
218
- if (!scope) { if (root === 'install') checkInstallRef(segs, path, null); continue; }
219
- if (root === 'steps' && segs[1] && !scope.steps.has(segs[1])) err(path, `$.steps.${segs[1]} is not an earlier step in this list`);
220
- if (root === 'settings' && segs[1] && !scope.settings.has(segs[1])) err(path, `$.settings.${segs[1]} is not a setting of this workflow`);
221
- if (root === 'install') checkInstallRef(segs, path, scope);
222
- if ((root === 'item' || root === 'index') && !scope.inLoop) err(path, `$.${root} is only available inside foreach`);
223
- }
224
- }
225
- // $.install.<integration setting> · $.install.accounts.<module key>.<account field the manifest declares>
226
- // $.install.runtime.installed.<onInstall step id> (not readable while install itself is running)
227
- function checkInstallRef(segs, path, scope) {
228
- const [, key, a, b] = segs;
229
- if (!key) return;
230
- if (!installKeys.has(key)) { err(path, `$.install.${key} is not an integration setting`); return; }
231
- if (key === 'accounts' && a) {
232
- if (!manifestOf[a]) err(path, `$.install.accounts.${a}: "${a}" is not a module key`);
233
- else if (b && !(manifestOf[a].account?.fields || []).some((f) => f.name === b)) err(path, `$.install.accounts.${a}.${b}: ${manifestOf[a].module} declares no account field "${b}" (${(manifestOf[a].account?.fields || []).map((f) => f.name).join(', ') || 'none'})`);
234
- }
235
- if (key === 'webhookUrls' && a && !manifestOf[a]) err(path, `$.install.webhookUrls.${a}: "${a}" is not a module key`);
236
- if (key === 'webhookUrl' && Object.keys(mods).length > 1 && !warnedWebhookUrl) {
237
- warnedWebhookUrl = true;
238
- warn(path, 'Use $.install.webhookUrls.<module key>: with more than one module, the URL should say which module a delivery comes from');
239
- }
240
- if (key === 'runtime' && a) {
241
- if (a !== 'installed') err(path, `$.install.runtime.${a}: runtime only holds "installed" (the results of onInstall steps)`);
242
- else if (scope?.installing) err(path, 'Install steps cannot read $.install.runtime.installed; refer to an earlier step with $.steps');
243
- else if (b && !installStepIds.has(b)) err(path, `$.install.runtime.installed.${b}: no onInstall step has id "${b}"`);
244
- }
245
- }
246
- function checkSteps(steps, path, scope) {
247
- if (!Array.isArray(steps)) { err(path, 'Steps must be a list'); return; }
248
- steps.forEach((st, i) => {
249
- const p = `${path}[${i}]`;
250
- if (!st?.use) { err(p, 'Step needs "use"'); return; }
251
- if (st.when) checkCondition(st.when, `${p}.when`), checkRefs(st.when, `${p}.when`, scope);
252
- if (st.onError && !['fail', 'continue', 'stop'].includes(st.onError)) err(`${p}.onError`, 'fail, continue or stop');
253
- const args = st.with || {};
254
- if (st.use === 'foreach') {
255
- checkRefs(args.over, `${p}.with.over`, scope);
256
- const inner = { ...scope, steps: new Set(scope.steps), declared: new Set(), inLoop: true, canPause: false };
257
- if (args.where) { checkCondition(args.where, `${p}.with.where`); checkRefs(args.where, `${p}.with.where`, inner); }
258
- if (!st.do && !('collect' in args)) err(p, 'foreach needs do steps, collect, or both');
259
- if (st.do) checkSteps(st.do, `${p}.do`, inner);
260
- if ('collect' in args) checkRefs(args.collect, `${p}.with.collect`, inner);
261
- if (args.max != null && !(Number.isInteger(args.max) && args.max > 0 && args.max <= 1000)) err(`${p}.with.max`, 'A whole number from 1 to 1000');
262
- } else if (st.use === 'branch') {
263
- (st.cases || []).forEach((c, j) => { checkCondition(c.when, `${p}.cases[${j}].when`); checkRefs(c.when, `${p}.cases[${j}].when`, scope); checkSteps(c.do, `${p}.cases[${j}].do`, { ...scope, steps: new Set(scope.steps), canPause: false }); });
264
- if (st.else) checkSteps(st.else, `${p}.else`, { ...scope, steps: new Set(scope.steps), canPause: false });
265
- } else if (isBuiltin(st.use)) {
266
- checkRefs(args, `${p}.with`, scope);
267
- if (PAUSING.has(st.use)) {
268
- // A pause is a checkpoint at a step index, so it lives at the top level of a workflow only.
269
- if (!scope.canPause) err(p, `${st.use} can only be a top-level step of a workflow (not inside foreach or branch, a query or a lifecycle hook)`);
270
- if (!st.id) err(p, `${st.use} needs an id: later steps read its result as $.steps.<id>`);
271
- const literalDuration = (v, q) => { if (typeof v === 'string' && !v.includes('$.') && !v.includes('{{') && parseEvery(v) === null) err(q, `"${v}" is not a duration (15m, 2h, 3d)`); };
272
- if (st.use === 'delay') {
273
- if (('for' in args) === ('until' in args)) err(`${p}.with`, 'delay takes for (a duration) or until (a time), exactly one');
274
- literalDuration(args.for, `${p}.with.for`);
275
- } else {
276
- literalDuration(args.timeout, `${p}.with.timeout`);
277
- if (args.onTimeout !== undefined && !['reject', 'approve', 'stop'].includes(args.onTimeout)) err(`${p}.with.onTimeout`, 'reject, approve or stop');
278
- if (!args.prompt) err(`${p}.with.prompt`, 'Say what is being approved');
279
- }
280
- }
281
- if (st.use.startsWith('links.')) {
282
- const isLink = 'link' in args;
283
- if (isLink === ('sync' in args)) err(`${p}.with`, `${st.use} names a link set (link) or a sync (sync), exactly one`);
284
- const set = isLink ? linkSets[args.link] : syncs[args.sync];
285
- if (!set) err(`${p}.with.${isLink ? 'link' : 'sync'}`, `Unknown ${isLink ? 'link set' : 'sync'} "${isLink ? args.link : args.sync}"`);
286
- else {
287
- const keys = new Set((set.identity?.match || []).map((m) => m.key));
288
- const mods = new Set((isLink ? set.between : [set.source, set.target]).map((r) => splitRef(r).k));
289
- const label = `${isLink ? 'link set' : 'sync'} "${set.id}"`;
290
- for (const k of Object.keys((st.use === 'links.find' ? args.key : args.keys) || {})) if (!keys.has(k)) err(`${p}.with.${st.use === 'links.find' ? 'key' : 'keys'}`, `"${k}" is not an identity key of ${label}${keys.size ? ` (${[...keys].join(', ')})` : ''}`);
291
- if (st.use === 'links.find' && !args.key && !(args.from && args.id)) err(`${p}.with`, 'links.find needs key, or from + id');
292
- if (args.from && !mods.has(args.from)) err(`${p}.with.from`, `"${args.from}" is not one of the modules ${label} links`);
293
- for (const m of Object.keys(args.ids || {})) if (!mods.has(m)) err(`${p}.with.ids.${m}`, `"${m}" is not one of the modules ${label} links`);
294
- }
295
- }
296
- if (st.use === 'merge' && !(Array.isArray(args.objects) || (typeof args.objects === 'string' && args.objects.startsWith('$.')))) err(`${p}.with.objects`, 'A list of objects (or a reference to one)');
297
- if (st.use === 'map' && Array.isArray(args.mapping)) checkRows(args.mapping, `${p}.with.mapping`);
298
- if (st.use === 'template' && args.id && !def.templates?.[args.id]) err(`${p}.with.id`, `Unknown template "${args.id}"`);
299
- if (st.use === 'map' && args.sync && !syncs[args.sync]) err(`${p}.with.sync`, `Unknown sync "${args.sync}"`);
300
- if (st.use === 'code' && /^#/.test(args.ref || '')) {
301
- if (!codeMethods) err(`${p}.with.ref`, `"${args.ref}" calls a method of a code integration; only a definition embedded in one can (extendIntegrationClass)`);
302
- else if (!codeMethods.includes(String(args.ref).slice(1))) err(`${p}.with.ref`, `The integration has no method "${String(args.ref).slice(1)}"`);
303
- } else if (st.use === 'code' && !/^[@\w/.-]+#\w+@\d+\.\d+\.\d+$/.test(args.ref || '')) err(`${p}.with.ref`, 'Code steps are "package#function@x.y.z" (or "#method" in a definition a code integration embeds)');
304
- } else {
305
- const cap = splitCapability(st.use);
306
- const man = cap && manifestOf[cap.module];
307
- if (!man) err(`${p}.use`, `"${st.use}" is not a built-in, and "${cap?.module}" is not a module key`);
308
- else {
309
- const c = man.capabilities?.[cap.capability];
310
- if (!c) err(`${p}.use`, `Module "${cap.module}" (${man.module}) has no capability "${cap.capability}"`);
311
- else {
312
- 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`);
313
- for (const [name, spec] of Object.entries(c.input || {})) if (spec.required && !(name in args)) err(`${p}.with.${name}`, `Required by ${st.use}`);
314
- for (const name of Object.keys(args)) if (c.input && !(name in c.input)) err(`${p}.with.${name}`, `${st.use} takes no argument "${name}"`);
315
- }
316
- }
317
- checkRefs(args, `${p}.with`, scope);
318
- }
319
- if (st.id) {
320
- if (scope.steps.has(st.id) && scope.declared.has(st.id)) err(`${p}.id`, `Step id "${st.id}" is used twice`);
321
- scope.steps.add(st.id); scope.declared.add(st.id);
322
- }
323
- });
324
- }
325
- const newScope = (settings) => ({ steps: new Set(), declared: new Set(), settings: new Set(Object.keys(settings || {})), install: installKeys, inLoop: false });
326
-
327
- // ---------- workflows ----------
328
- (def.workflows || []).forEach((w, i) => {
329
- const p = `workflows[${i}]`;
330
- if (!w.label) err(`${p}.label`, 'Required');
331
- checkSettings(w.settings, `${p}.settings`);
332
- const t = w.trigger || {};
333
- if (!TRIGGERS.has(t.type)) err(`${p}.trigger.type`, `One of ${[...TRIGGERS].join(', ')}`);
334
- if (t.type === 'webhook') {
335
- if (!manifestOf[t.module]) err(`${p}.trigger.module`, `Unknown module key "${t.module}"`);
336
- else if (!manifestOf[t.module].events?.[t.topic]) err(`${p}.trigger.topic`, `Module "${t.module}" emits no event "${t.topic}"`);
337
- 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`);
338
- }
339
- if (t.type === 'schedule') {
340
- if ((t.cron === undefined) === (t.every === undefined)) err(`${p}.trigger`, 'Give exactly one of cron or every');
341
- else checkSchedule(t.every !== undefined ? { every: t.every } : { cron: t.cron, ...(t.timezone ? { timezone: t.timezone } : {}) }, t.configurable, `${p}.trigger`);
342
- }
343
- if (t.type === 'userAction' && t.from && !uis[t.from]) err(`${p}.trigger.from`, `Unknown UI surface "${t.from}"`);
344
- if (t.type === 'syncRecord' && !syncs[t.sync]) err(`${p}.trigger.sync`, `Unknown sync "${t.sync}"`);
345
- if (t.filter) checkCondition(t.filter, `${p}.trigger.filter`);
346
- if (!w.steps?.length) err(`${p}.steps`, 'At least one step');
347
- checkSteps(w.steps, `${p}.steps`, { ...newScope(w.settings), canPause: true });
348
- });
349
-
350
- // ---------- queries ----------
351
- (def.queries || []).forEach((q, i) => {
352
- const scope = newScope({});
353
- checkSteps(q.steps || [], `queries[${i}].steps`, scope);
354
- checkRefs(q.returns, `queries[${i}].returns`, scope);
355
- });
356
-
357
- // ---------- ui ----------
358
- // A view sees $.data (the query's returns), $.record and $.install; $.item inside a List's title/meta, $.row inside
359
- // a Table column. Anything else is a typo that would render blank, so it is an error here, not at runtime.
360
- function checkViewRefs(value, path, ctx) {
361
- checkFilters(value, path);
362
- for (const ref of referencedPaths(value)) {
363
- const [root, key] = ref.replace(/^\$\./, '').split(/\.|\[/);
364
- const allowed = ['data', 'record', 'install', ...(ctx.item ? ['item'] : []), ...(ctx.row ? ['row'] : [])];
365
- if (!allowed.includes(root)) err(path, `$.${root} is not available here (a view can read ${allowed.map((r) => '$.' + r).join(', ')})`);
366
- else if (root === 'data' && key && ctx.dataKeys && !ctx.dataKeys.has(key)) err(path, `$.data.${key} is not returned by query "${ctx.query}" (${[...ctx.dataKeys].join(', ')})`);
367
- else if (root === 'install') checkInstallRef(ref.replace(/^\$\./, '').split(/\.|\[/), path, null);
368
- }
369
- }
370
- function checkView(node, path, ctx) {
371
- if (!node || !COMPONENTS.has(node.component)) { err(path, `Unknown component "${node?.component}" (v0.1: ${[...COMPONENTS].join(', ')})`); return; }
372
- if (node.when) { checkCondition(node.when, `${path}.when`); checkViewRefs(node.when, `${path}.when`, ctx); }
373
- const props = { ...(node.props || {}) };
374
- if (node.component === 'List') {
375
- checkViewRefs(props.items, `${path}.props.items`, ctx);
376
- for (const k of ['title', 'meta']) checkViewRefs(props[k], `${path}.props.${k}`, { ...ctx, item: true });
377
- delete props.items; delete props.title; delete props.meta;
378
- } else if (node.component === 'Table') {
379
- checkViewRefs(props.rows, `${path}.props.rows`, ctx);
380
- (props.columns || []).forEach((c, j) => checkViewRefs(c.value, `${path}.props.columns[${j}].value`, { ...ctx, row: true }));
381
- delete props.rows; delete props.columns;
382
- }
383
- const { action, ...rest } = props;
384
- checkViewRefs(rest, `${path}.props`, ctx);
385
- if (action?.url) checkViewRefs(action.url, `${path}.props.action.url`, ctx);
386
- const a = node.props?.action;
387
- if (a?.type === 'userAction') {
388
- const w = workflows[a.workflow];
389
- if (!w) err(`${path}.props.action.workflow`, `Unknown workflow "${a.workflow}"`);
390
- else if (w.trigger?.type !== 'userAction') err(`${path}.props.action.workflow`, `Workflow "${a.workflow}" is not triggered by a user action`);
391
- } else if (a && a.type !== 'open_url') err(`${path}.props.action.type`, 'userAction or open_url');
392
- (node.children || []).forEach((c, j) => checkView(c, `${path}.children[${j}]`, ctx));
393
- }
394
- (def.ui || []).forEach((u, i) => {
395
- const p = `ui[${i}]`;
396
- const host = manifestOf[u.host];
397
- if (!host) err(`${p}.host`, `Unknown module key "${u.host}"`);
398
- else if (!(host.ui?.placements || []).includes(u.placement)) err(`${p}.placement`, `"${u.placement}" is not a placement ${host.module} supports (${(host.ui?.placements || []).join(', ') || 'none'})`);
399
- const q = queries[u.data?.query];
400
- if (!q) err(`${p}.data.query`, `Unknown query "${u.data?.query}"`);
401
- const returns = q?.returns;
402
- const dataKeys = returns && typeof returns === 'object' && !Array.isArray(returns) ? new Set(Object.keys(returns)) : null;
403
- checkView(u.view, `${p}.view`, { dataKeys, query: u.data?.query });
404
- });
405
-
406
- // ---------- named templates ----------
407
- // A template renders in the context of the step that uses it, so its paths are checked for root and filter here
408
- // and its $.steps references against each workflow that renders it.
409
- const usedTemplates = new Set();
410
- const collectTemplateUse = (steps, w) => (steps || []).forEach((st) => {
411
- if (st?.use === 'template' && st.with?.id) usedTemplates.add(st.with.id);
412
- collectTemplateUse(st?.do, w); (st?.cases || []).forEach((c) => collectTemplateUse(c.do, w)); collectTemplateUse(st?.else, w);
413
- });
414
- (def.workflows || []).forEach((w) => collectTemplateUse(w.steps, w));
415
- (def.queries || []).forEach((q) => collectTemplateUse(q.steps));
416
- for (const hook of ['onInstall', 'onUninstall']) collectTemplateUse(def.lifecycle?.[hook]);
417
- for (const [id, text] of Object.entries(def.templates || {})) {
418
- if (typeof text !== 'string') { err(`templates.${id}`, 'A template is a string'); continue; }
419
- checkRefs(text, `templates.${id}`, null);
420
- if (!usedTemplates.has(id)) warn(`templates.${id}`, 'No step renders this template');
421
- }
422
-
423
- // ---------- lifecycle ----------
424
- // What install creates, uninstall should undo: an install step whose result onUninstall never reads is probably a
425
- // webhook nobody deletes. A warning, because some install steps create nothing that needs undoing.
426
- const uninstallReads = JSON.stringify(def.lifecycle?.onUninstall || []);
427
- for (const [i, st] of (def.lifecycle?.onInstall || []).entries()) {
428
- if (!st?.id) warn(`lifecycle.onInstall[${i}]`, 'This install step has no id, so its result is not kept and uninstall cannot undo it');
429
- else if (!uninstallReads.includes(`install.runtime.installed.${st.id}`)) warn(`lifecycle.onInstall[${i}]`, `onUninstall never reads $.install.runtime.installed.${st.id}, so what this step creates is never removed`);
430
- }
431
- for (const hook of ['onInstall', 'onUninstall']) if (def.lifecycle?.[hook]) checkSteps(def.lifecycle[hook], `lifecycle.${hook}`, { ...newScope({}), installing: hook === 'onInstall' });
432
-
433
- return { valid: errors.length === 0, errors, warnings };
434
- }
435
-
436
- module.exports = { validate };
@@ -1,176 +0,0 @@
1
- /**
2
- * Versioning for integrations published as definitions.
3
- *
4
- * A published version is immutable and every installation pins one exact version, so a release never changes a
5
- * running customer until that installation is upgraded. Three questions are answered here:
6
- *
7
- * diff(from, to) what changed, and how big each change is (patch / minor / major)
8
- * checkBump(from, to) is the declared version bump at least as big as the changes require?
9
- * planUpgrade(install, a, b) can this installation move from a to b, and what has to happen when it does?
10
- *
11
- * The rule of thumb: patch = behaviour inside the same contract (steps, templates, view, mapping rows);
12
- * minor = additive contract (new workflow, optional setting, new UI); major = anything an installation could be
13
- * relying on goes away or changes meaning (removed workflow, required setting, identity change, new module).
14
- */
15
- const RANK = { none: 0, patch: 1, minor: 2, major: 3 };
16
- const max = (a, b) => (RANK[a] >= RANK[b] ? a : b);
17
- const byId = (xs) => Object.fromEntries((xs || []).map((x) => [x.id, x]));
18
- const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
19
- const optionSource = (o) => (o && typeof o === 'object' ? o.source : o);
20
-
21
- function diffSettings(a = {}, b = {}, path, push) {
22
- for (const k of Object.keys(a)) if (!(k in b)) push('minor', `${path}.${k}`, 'Setting removed (saved values are dropped)');
23
- for (const [k, s] of Object.entries(b)) {
24
- if (!(k in a)) push(s.required && s.default === undefined ? 'major' : 'minor', `${path}.${k}`, s.required && s.default === undefined ? 'New required setting with no default' : 'New optional setting');
25
- else if (a[k].type !== s.type || optionSource(a[k].options) !== optionSource(s.options)) push('major', `${path}.${k}`, 'Setting type or option list changed');
26
- else if (!same(a[k].options, s.options)) push('minor', `${path}.${k}`, 'Option list inputs changed');
27
- else if (!a[k].required && s.required && s.default === undefined) push('major', `${path}.${k}`, 'Setting became required');
28
- else if (!same(a[k], s)) push('patch', `${path}.${k}`, 'Setting label, help or default changed');
29
- }
30
- }
31
-
32
- function diff(from, to) {
33
- const changes = [];
34
- const push = (level, path, message) => changes.push({ level, path, message });
35
- if (from.id !== to.id) push('major', 'id', 'Different integration');
36
-
37
- for (const k of Object.keys(from.modules || {})) if (!to.modules?.[k]) push('major', `modules.${k}`, 'Module removed');
38
- for (const [k, m] of Object.entries(to.modules || {})) {
39
- if (!from.modules?.[k]) push('major', `modules.${k}`, 'New module: installations must connect it');
40
- else if (from.modules[k].module !== m.module) push('major', `modules.${k}`, 'Module replaced');
41
- else if (from.modules[k].version !== m.version) push('patch', `modules.${k}`, 'Module version range changed');
42
- }
43
- diffSettings(from.settings, to.settings, 'settings', push);
44
-
45
- const sa = byId(from.syncs), sb = byId(to.syncs);
46
- for (const id of Object.keys(sa)) if (!sb[id]) push('major', `syncs.${id}`, 'Sync removed');
47
- for (const [id, s] of Object.entries(sb)) {
48
- const o = sa[id];
49
- if (!o) { push('minor', `syncs.${id}`, 'New sync'); continue; }
50
- if (o.source !== s.source || o.target !== s.target) push('major', `syncs.${id}`, 'Sync objects changed');
51
- if (o.direction !== s.direction) push('major', `syncs.${id}.direction`, 'Sync direction changed');
52
- if (!same(o.identity, s.identity)) push('major', `syncs.${id}.identity`, 'Identity keys changed: existing links must be rebuilt');
53
- if (!same(o.mapping, s.mapping)) push('patch', `syncs.${id}.mapping`, 'Default mapping changed (installations with their own mapping keep it)');
54
- if (o.ongoing?.configurable && !s.ongoing?.configurable) push('major', `syncs.${id}.ongoing.configurable`, 'Poll interval no longer configurable: installations lose their chosen interval');
55
- else if (!same(o.ongoing, s.ongoing) || !same(o.backfill, s.backfill) || o.filter !== s.filter) push('minor', `syncs.${id}`, 'Sync schedule or filter changed');
56
- }
57
-
58
- const la = byId(from.links), lb = byId(to.links);
59
- for (const id of Object.keys(la)) if (!lb[id]) push('major', `links.${id}`, 'Link set removed: workflows that read it lose their links');
60
- for (const [id, l] of Object.entries(lb)) {
61
- const o = la[id];
62
- if (!o) { push('minor', `links.${id}`, 'New link set'); continue; }
63
- if (!same(o.between, l.between)) push('major', `links.${id}.between`, 'Link set joins different objects');
64
- if (!same(o.identity, l.identity)) push('major', `links.${id}.identity`, 'Identity keys changed: existing links must be rebuilt');
65
- if (o.label !== l.label || o.description !== l.description) push('patch', `links.${id}`, 'Label or description changed');
66
- }
67
-
68
- const wa = byId(from.workflows), wb = byId(to.workflows);
69
- for (const id of Object.keys(wa)) if (!wb[id]) push('major', `workflows.${id}`, 'Workflow removed');
70
- for (const [id, w] of Object.entries(wb)) {
71
- const o = wa[id];
72
- if (!o) { push('minor', `workflows.${id}`, `New workflow (default ${w.default || 'on'})`); continue; }
73
- if (o.trigger?.type !== w.trigger?.type || o.trigger?.module !== w.trigger?.module) push('major', `workflows.${id}.trigger`, 'Trigger source changed');
74
- else if (o.trigger?.configurable && !w.trigger?.configurable) push('major', `workflows.${id}.trigger.configurable`, 'Schedule no longer configurable: installations lose their chosen timing');
75
- else if (!same(o.trigger, w.trigger)) push('minor', `workflows.${id}.trigger`, 'Trigger topic, schedule or filter changed');
76
- diffSettings(o.settings, w.settings, `workflows.${id}.settings`, push);
77
- if (!same(o.steps, w.steps)) push('patch', `workflows.${id}.steps`, 'Steps changed');
78
- if (o.label !== w.label || o.description !== w.description) push('patch', `workflows.${id}`, 'Label or description changed');
79
- }
80
-
81
- const qa = byId(from.queries), qb = byId(to.queries);
82
- for (const id of new Set([...Object.keys(qa), ...Object.keys(qb)])) if (!same(qa[id], qb[id])) push('patch', `queries.${id}`, qa[id] ? (qb[id] ? 'Query changed' : 'Query removed') : 'New query');
83
- const ua = byId(from.ui), ub = byId(to.ui);
84
- for (const id of Object.keys(ua)) if (!ub[id]) push('minor', `ui.${id}`, 'UI surface removed');
85
- for (const [id, u] of Object.entries(ub)) {
86
- if (!ua[id]) push('minor', `ui.${id}`, 'New UI surface');
87
- else if (ua[id].host !== u.host || ua[id].placement !== u.placement) push('minor', `ui.${id}`, 'UI surface moved');
88
- else if (!same(ua[id], u)) push('patch', `ui.${id}`, 'UI view or data changed');
89
- }
90
- if (!same(from.templates, to.templates)) push('patch', 'templates', 'Templates changed');
91
- if (!same(from.lifecycle, to.lifecycle)) push('minor', 'lifecycle', 'Install/uninstall steps changed: upgrades re-run onInstall');
92
- if (!same(from.display, to.display)) push('patch', 'display', 'Display text changed');
93
-
94
- const level = changes.reduce((l, c) => max(l, c.level), 'none');
95
- return { level, changes };
96
- }
97
-
98
- const parse = (v) => String(v).split('-')[0].split('.').map(Number);
99
- function bumpOf(fromV, toV) {
100
- const [a, b, c] = parse(fromV), [x, y, z] = parse(toV);
101
- if (x > a) return 'major';
102
- if (x === a && y > b) return 'minor';
103
- if (x === a && y === b && z > c) return 'patch';
104
- return 'none';
105
- }
106
-
107
- /** The declared bump must cover the computed one (a major change cannot ship as 1.4.1). */
108
- function checkBump(from, to) {
109
- const d = diff(from, to);
110
- const declared = bumpOf(from.version, to.version);
111
- const ok = declared !== 'none' && RANK[declared] >= RANK[d.level];
112
- return { ok, declared, required: d.level, changes: d.changes, message: ok ? null : declared === 'none' ? `${to.version} is not newer than ${from.version}` : `Changes need a ${d.level} bump; ${from.version} -> ${to.version} is ${declared}` };
113
- }
114
-
115
- /**
116
- * What it takes to move one installation between versions.
117
- * @returns {{ allowed:boolean, needs:Array<{kind:string,path:string,message:string}>, actions:Array<{kind:string,id?:string,message:string}>, config:object }}
118
- * needs things a person must supply first (a required setting, a module connection)
119
- * actions what the runtime does on upgrade (enable/disable workflows, rebuild links, re-run onInstall)
120
- * config the installation config after the upgrade (pinned to the new version)
121
- */
122
- function planUpgrade(installation, from, to) {
123
- const needs = [], actions = [];
124
- const config = JSON.parse(JSON.stringify(installation || {}));
125
- config.definition = { id: to.id, version: to.version };
126
-
127
- for (const k of Object.keys(to.modules || {})) if (!from.modules?.[k]) needs.push({ kind: 'connect', path: `modules.${k}`, message: `Connect ${to.modules[k].module}` });
128
- for (const [k, s] of Object.entries(to.settings || {})) {
129
- if (s.required && s.default === undefined && config.settings?.[k] === undefined) needs.push({ kind: 'setting', path: `settings.${k}`, message: `Choose "${s.label}"` });
130
- }
131
- const wa = byId(from.workflows), wb = byId(to.workflows);
132
- config.workflows ||= {};
133
- for (const id of Object.keys(config.workflows)) if (!wb[id]) { delete config.workflows[id]; actions.push({ kind: 'disable', id, message: `Workflow "${wa[id]?.label || id}" no longer exists` }); }
134
- for (const [id, w] of Object.entries(wb)) {
135
- const enabled = config.workflows[id]?.enabled ?? ((w.default || 'on') === 'on');
136
- if (!wa[id]) {
137
- config.workflows[id] = { enabled: (w.default || 'on') === 'on', settings: {} };
138
- actions.push({ kind: config.workflows[id].enabled ? 'enable' : 'offer', id, message: `${config.workflows[id].enabled ? 'Turns on' : 'Available, off'}: ${w.label}` });
139
- }
140
- if (!enabled) continue;
141
- for (const [k, s] of Object.entries(w.settings || {})) {
142
- if (s.required && s.default === undefined && config.workflows[id]?.settings?.[k] === undefined) needs.push({ kind: 'setting', path: `workflows.${id}.settings.${k}`, message: `"${w.label}": choose "${s.label}"` });
143
- }
144
- const keep = new Set(Object.keys(w.settings || {}));
145
- for (const k of Object.keys(config.workflows[id]?.settings || {})) if (!keep.has(k)) delete config.workflows[id].settings[k];
146
- }
147
- const sa = byId(from.syncs);
148
- for (const s of to.syncs || []) {
149
- if (sa[s.id] && !same(sa[s.id].identity, s.identity)) actions.push({ kind: 'relink', id: s.id, message: `Rebuild ${s.label} links with the new identity keys (runs a backfill)` });
150
- if (!sa[s.id]) actions.push({ kind: 'backfill', id: s.id, message: `New sync: ${s.label}` });
151
- }
152
- // A link set has no backfill to rebuild from: its links by id still resolve, but its identity index was written
153
- // with the old keys, so lookups by key miss until each pair is linked again. Said plainly rather than hidden.
154
- const la = byId(from.links), lb = byId(to.links);
155
- for (const l of to.links || []) if (la[l.id] && !same(la[l.id].identity, l.identity)) actions.push({ kind: 'reindex', id: l.id, message: `${l.label || l.id}: links by id still resolve; lookups by the new identity keys miss until each pair is linked again` });
156
- for (const id of Object.keys(la)) if (!lb[id]) actions.push({ kind: 'drop-links', id, message: `Link set "${la[id].label || id}" is gone; its stored links are no longer read` });
157
- // An installation's own timing survives an upgrade only where the new version still allows it.
158
- const keepTiming = (chosen, allowed, path, label) => {
159
- if (!chosen?.schedule) return;
160
- const problems = !allowed ? ['not configurable'] : validateRecurrence(recurrenceOf(chosen.schedule), { min: allowed === true ? undefined : allowed.min });
161
- if (!problems.length) return;
162
- delete chosen.schedule;
163
- actions.push({ kind: 'reschedule', id: path, message: `${label}: back to the definition's schedule (${problems.join('; ')})` });
164
- };
165
- for (const w of to.workflows || []) if (w.trigger?.type === 'schedule') keepTiming(config.workflows?.[w.id], w.trigger.configurable, `workflows.${w.id}`, w.label || w.id);
166
- for (const sy of to.syncs || []) keepTiming(config.syncs?.[sy.id], sy.ongoing?.mode === 'poll' && sy.ongoing.configurable, `syncs.${sy.id}`, sy.label || sy.id);
167
- if (!same(from.lifecycle?.onInstall, to.lifecycle?.onInstall)) actions.push({ kind: 'reinstall', message: 'Re-run install steps (webhook subscriptions)' });
168
- actions.push({ kind: 'drain', message: `Messages already queued under ${from.version} finish on ${from.version}; new events run on ${to.version}` });
169
-
170
- return { allowed: needs.length === 0, needs, actions, config };
171
- }
172
-
173
- module.exports = { diff, checkBump, planUpgrade, bumpOf };
174
-
175
- const { validateRecurrence } = require('../../infrastructure/scheduler/recurrence');
176
- const { recurrenceOf } = require('../triggers');