@friggframework/core 2.0.0--canary.690.08cd07e.0 → 2.0.0--canary.693.d8859c2.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 (261) hide show
  1. package/CLAUDE.md +0 -28
  2. package/README.md +0 -105
  3. package/application/commands/integration-commands.js +10 -20
  4. package/application/commands/scheduler-commands.js +7 -11
  5. package/application/commands/user-commands.js +5 -17
  6. package/application/index.js +2 -6
  7. package/core/Worker.js +7 -31
  8. package/core/create-handler.js +0 -9
  9. package/credential/repositories/credential-repository-documentdb.js +0 -34
  10. package/credential/repositories/credential-repository-interface.js +0 -15
  11. package/credential/repositories/credential-repository-mongo.js +0 -23
  12. package/credential/repositories/credential-repository-postgres.js +0 -28
  13. package/database/config.js +24 -48
  14. package/database/documentdb-utils.js +0 -2
  15. package/database/encryption/encryption-schema-registry.js +12 -93
  16. package/database/prisma.js +2 -4
  17. package/errors/index.js +4 -0
  18. package/errors/mapping-already-exists-error.js +14 -0
  19. package/generated/prisma-mongodb/edge.js +38 -33
  20. package/generated/prisma-mongodb/index-browser.js +35 -30
  21. package/generated/prisma-mongodb/index.d.ts +1880 -3191
  22. package/generated/prisma-mongodb/index.js +38 -33
  23. package/generated/prisma-mongodb/package.json +1 -1
  24. package/generated/prisma-mongodb/schema.prisma +50 -86
  25. package/generated/prisma-mongodb/wasm.js +38 -33
  26. package/generated/prisma-postgresql/edge.js +38 -33
  27. package/generated/prisma-postgresql/index-browser.js +35 -30
  28. package/generated/prisma-postgresql/index.d.ts +2050 -3390
  29. package/generated/prisma-postgresql/index.js +38 -33
  30. package/generated/prisma-postgresql/package.json +1 -1
  31. package/generated/prisma-postgresql/schema.prisma +48 -81
  32. package/generated/prisma-postgresql/wasm.js +38 -33
  33. package/handlers/WEBHOOKS.md +29 -0
  34. package/handlers/app-definition-loader.js +19 -128
  35. package/handlers/app-handler-helpers.js +3 -25
  36. package/handlers/backend-utils.js +25 -31
  37. package/handlers/middleware/admin-auth.js +1 -14
  38. package/handlers/routers/auth.js +10 -46
  39. package/handlers/routers/health.js +4 -13
  40. package/handlers/routers/integration-defined-routers.js +92 -144
  41. package/handlers/routers/integration-webhook-routers.js +64 -102
  42. package/handlers/workers/integration-defined-workers.js +2 -5
  43. package/index.js +3 -65
  44. package/infrastructure/scheduler/index.js +6 -19
  45. package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
  46. package/integrations/data-defined/README.md +169 -0
  47. package/integrations/data-defined/canonical.js +11 -0
  48. package/integrations/data-defined/capabilities/acmeerp.capabilities.json +608 -0
  49. package/integrations/data-defined/capabilities/asana.capabilities.json +265 -0
  50. package/integrations/data-defined/capabilities/calendly.capabilities.json +385 -0
  51. package/integrations/data-defined/capabilities/docusign.capabilities.json +314 -0
  52. package/integrations/data-defined/capabilities/fathom.capabilities.json +228 -0
  53. package/integrations/data-defined/capabilities/github.capabilities.json +563 -0
  54. package/integrations/data-defined/capabilities/hubspot.capabilities.json +715 -0
  55. package/integrations/data-defined/capabilities/jira.capabilities.json +376 -0
  56. package/integrations/data-defined/capabilities/klaviyo.capabilities.json +475 -0
  57. package/integrations/data-defined/capabilities/linear.capabilities.json +324 -0
  58. package/integrations/data-defined/capabilities/netsuite.capabilities.json +491 -0
  59. package/integrations/data-defined/capabilities/notion.capabilities.json +208 -0
  60. package/integrations/data-defined/capabilities/phone.capabilities.json +470 -0
  61. package/integrations/data-defined/capabilities/pipedrive.capabilities.json +388 -0
  62. package/integrations/data-defined/capabilities/quickbooks.capabilities.json +615 -0
  63. package/integrations/data-defined/capabilities/salesforce.capabilities.json +507 -0
  64. package/integrations/data-defined/capabilities/shopify.capabilities.json +1082 -0
  65. package/integrations/data-defined/capabilities/slack.capabilities.json +363 -0
  66. package/integrations/data-defined/capabilities/stripe.capabilities.json +580 -0
  67. package/integrations/data-defined/capabilities/webflow.capabilities.json +196 -0
  68. package/integrations/data-defined/capabilities/zendesk.capabilities.json +409 -0
  69. package/integrations/data-defined/compile.js +77 -0
  70. package/integrations/data-defined/config-form.js +110 -0
  71. package/integrations/data-defined/data-defined-integration.js +366 -0
  72. package/integrations/data-defined/examples/acme-erp-quickbooks.definition.json +467 -0
  73. package/integrations/data-defined/examples/asana-slack.definition.json +220 -0
  74. package/integrations/data-defined/examples/calendly-salesforce.definition.json +459 -0
  75. package/integrations/data-defined/examples/docusign-salesforce.definition.json +305 -0
  76. package/integrations/data-defined/examples/fathom-hubspot-notion.definition.json +575 -0
  77. package/integrations/data-defined/examples/github-linear.definition.json +316 -0
  78. package/integrations/data-defined/examples/hubspot-slack.definition.json +409 -0
  79. package/integrations/data-defined/examples/klaviyo-hubspot.definition.json +339 -0
  80. package/integrations/data-defined/examples/phone-hubspot.definition.json +637 -0
  81. package/integrations/data-defined/examples/pipedrive-hubspot.definition.json +136 -0
  82. package/integrations/data-defined/examples/pipedrive-phone.definition.json +340 -0
  83. package/integrations/data-defined/examples/records/phone-hubspot.definition-version.json +652 -0
  84. package/integrations/data-defined/examples/records/phone-hubspot.integration.json +86 -0
  85. package/integrations/data-defined/examples/records/phone-hubspot.mappings.json +39 -0
  86. package/integrations/data-defined/examples/records/phone-hubspot.workflow-run.json +62 -0
  87. package/integrations/data-defined/examples/shopify-klaviyo.definition.json +488 -0
  88. package/integrations/data-defined/examples/shopify-netsuite.definition.json +622 -0
  89. package/integrations/data-defined/examples/stripe-quickbooks.definition.json +476 -0
  90. package/integrations/data-defined/examples/webflow-hubspot-slack.definition.json +262 -0
  91. package/integrations/data-defined/examples/zendesk-jira.definition.json +604 -0
  92. package/integrations/data-defined/expressions.js +225 -0
  93. package/integrations/data-defined/index.js +39 -0
  94. package/integrations/data-defined/interpreter.js +89 -0
  95. package/integrations/data-defined/manifests.js +125 -0
  96. package/integrations/data-defined/repositories/definition-version-repository.js +67 -0
  97. package/integrations/data-defined/repositories/workflow-run-repository.js +59 -0
  98. package/integrations/data-defined/router.js +97 -0
  99. package/integrations/data-defined/runtime.js +208 -0
  100. package/integrations/data-defined/schema/capability-manifest.v0.1.schema.json +377 -0
  101. package/integrations/data-defined/schema/installation.v0.1.schema.json +83 -0
  102. package/integrations/data-defined/schema/integration-definition.v0.1.schema.json +848 -0
  103. package/integrations/data-defined/schema/records.md +98 -0
  104. package/integrations/data-defined/schema-check.js +58 -0
  105. package/integrations/data-defined/scripts/validate.js +66 -0
  106. package/integrations/data-defined/steps.js +89 -0
  107. package/integrations/data-defined/transforms.js +91 -0
  108. package/integrations/data-defined/use-cases/definition-use-cases.js +187 -0
  109. package/integrations/data-defined/validate.js +370 -0
  110. package/integrations/data-defined/versioning.js +161 -0
  111. package/integrations/data-defined/webhooks.js +119 -0
  112. package/integrations/index.js +0 -1
  113. package/integrations/integration-base.js +29 -0
  114. package/integrations/integration-router.js +101 -131
  115. package/integrations/repositories/integration-mapping-repository-documentdb.js +50 -0
  116. package/integrations/repositories/integration-mapping-repository-interface.js +15 -0
  117. package/integrations/repositories/integration-mapping-repository-mongo.js +29 -0
  118. package/integrations/repositories/integration-mapping-repository-postgres.js +30 -0
  119. package/integrations/repositories/integration-mapping-repository.js +29 -0
  120. package/integrations/repositories/integration-repository-documentdb.js +3 -35
  121. package/integrations/repositories/integration-repository-interface.js +1 -17
  122. package/integrations/repositories/integration-repository-mongo.js +77 -60
  123. package/integrations/repositories/integration-repository-postgres.js +76 -62
  124. package/integrations/repositories/process-repository-mongo.js +1 -1
  125. package/integrations/tests/doubles/test-integration-repository.js +1 -12
  126. package/integrations/use-cases/create-integration.js +20 -55
  127. package/integrations/use-cases/get-integrations-for-user.js +40 -80
  128. package/integrations/use-cases/index.js +1 -7
  129. package/integrations/utils/map-integration-dto.js +14 -43
  130. package/modules/index.js +0 -14
  131. package/modules/module-factory.js +0 -12
  132. package/modules/module.js +2 -53
  133. package/modules/repositories/module-repository-documentdb.js +6 -51
  134. package/modules/repositories/module-repository-interface.js +2 -19
  135. package/modules/repositories/module-repository-mongo.js +74 -67
  136. package/modules/repositories/module-repository-postgres.js +74 -66
  137. package/modules/repositories/module-repository.js +1 -2
  138. package/modules/requester/requester.js +5 -35
  139. package/modules/use-cases/get-entities-for-user.js +13 -26
  140. package/modules/use-cases/get-entity-options-by-id.js +4 -10
  141. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  142. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  143. package/modules/use-cases/get-module.js +4 -10
  144. package/modules/use-cases/process-authorization-callback.js +19 -53
  145. package/modules/use-cases/refresh-entity-options.js +4 -10
  146. package/modules/use-cases/test-module-auth.js +4 -10
  147. package/package.json +6 -6
  148. package/prisma-mongodb/schema.prisma +50 -86
  149. package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +66 -0
  150. package/prisma-postgresql/schema.prisma +48 -81
  151. package/queues/index.js +0 -25
  152. package/types/index.d.ts +0 -2
  153. package/user/repositories/user-repository-documentdb.js +2 -27
  154. package/user/repositories/user-repository-interface.js +0 -18
  155. package/user/repositories/user-repository-mongo.js +0 -24
  156. package/user/repositories/user-repository-postgres.js +0 -18
  157. package/user/use-cases/authenticate-user.js +1 -7
  158. package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
  159. package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
  160. package/user/user.js +1 -64
  161. package/credential/use-cases/credential-operations-for-user.js +0 -202
  162. package/credential/use-cases/reauthorize-credential-for-user.js +0 -73
  163. package/database/organization-where.js +0 -18
  164. package/database/unique-violation.js +0 -25
  165. package/extensions/bootstrap-runner.js +0 -43
  166. package/extensions/extension-loader.js +0 -187
  167. package/extensions/index.js +0 -41
  168. package/extensions/initialize-app.js +0 -123
  169. package/extensions/route-mounter.js +0 -103
  170. package/extensions/schema-composer.js +0 -188
  171. package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -216
  172. package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
  173. package/integrations/repositories/integration-not-found.js +0 -14
  174. package/integrations/schedule-config.js +0 -80
  175. package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
  176. package/integrations/use-cases/integration-operations-for-user.js +0 -256
  177. package/integrations/use-cases/resolve-integration-ownership.js +0 -177
  178. package/management-api/create-management-api-router.js +0 -49
  179. package/management-api/error-response.js +0 -116
  180. package/management-api/management-api-config.js +0 -151
  181. package/management-api/meta-router.js +0 -76
  182. package/management-api/openapi/build-openapi.js +0 -144
  183. package/management-api/openapi/components.js +0 -415
  184. package/management-api/openapi/v1-operations.js +0 -83
  185. package/management-api/openapi/v2-operations.js +0 -264
  186. package/management-api/route-registry.js +0 -411
  187. package/management-api/test-support/http-client.js +0 -39
  188. package/management-api/use-cases/get-management-api-meta.js +0 -57
  189. package/management-api/v1-deprecation.js +0 -120
  190. package/management-api/v2/create-v2-router.js +0 -85
  191. package/management-api/v2/dependencies.js +0 -281
  192. package/management-api/v2/dto.js +0 -143
  193. package/management-api/v2/handlers/authorization.js +0 -94
  194. package/management-api/v2/handlers/credentials.js +0 -33
  195. package/management-api/v2/handlers/entities.js +0 -77
  196. package/management-api/v2/handlers/integrations.js +0 -128
  197. package/management-api/v2/handlers/proxy.js +0 -34
  198. package/management-api/v2/request.js +0 -85
  199. package/management-api/v2/test-support/fake-dependencies.js +0 -103
  200. package/modules/domain/authorization-session.js +0 -144
  201. package/modules/entity-scope.js +0 -15
  202. package/modules/entity-type-registry.js +0 -311
  203. package/modules/proxy/proxy-bases.js +0 -169
  204. package/modules/proxy/proxy-errors.js +0 -31
  205. package/modules/proxy/proxy-path.js +0 -183
  206. package/modules/proxy/proxy-policy.js +0 -277
  207. package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
  208. package/modules/repositories/authorization-session-repository-factory.js +0 -31
  209. package/modules/repositories/authorization-session-repository-interface.js +0 -47
  210. package/modules/repositories/authorization-session-repository-prisma.js +0 -87
  211. package/modules/repositories/entity-not-found.js +0 -18
  212. package/modules/requester/proxy-request.js +0 -245
  213. package/modules/scope-config.js +0 -192
  214. package/modules/scope-policy.js +0 -112
  215. package/modules/scope-resolution.js +0 -288
  216. package/modules/scope-visibility.js +0 -247
  217. package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
  218. package/modules/tests/doubles/ownership-matching.js +0 -18
  219. package/modules/use-cases/authorize-entity-for-user.js +0 -61
  220. package/modules/use-cases/create-entity-from-credential.js +0 -113
  221. package/modules/use-cases/entity-operations-for-user.js +0 -162
  222. package/modules/use-cases/entity-types.js +0 -139
  223. package/modules/use-cases/execute-entity-proxy-request.js +0 -331
  224. package/modules/use-cases/get-authorization-requirements.js +0 -100
  225. package/modules/use-cases/get-entity-module-for-user.js +0 -113
  226. package/modules/use-cases/manage-access.js +0 -21
  227. package/modules/use-cases/multi-step-authorization.js +0 -329
  228. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
  229. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
  230. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
  231. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
  232. package/providers/index.js +0 -23
  233. package/providers/resolve-provider.js +0 -93
  234. package/providers/runtime-detection.js +0 -124
  235. package/queues/providers/index.js +0 -11
  236. package/queues/providers/netlify-background-provider.js +0 -150
  237. package/queues/providers/qstash-queue-provider.js +0 -219
  238. package/queues/providers/sqs-queue-provider.js +0 -70
  239. package/queues/queue-client-interface.js +0 -55
  240. package/queues/queue-message-auth.js +0 -193
  241. package/queues/queue-provider-factory.js +0 -101
  242. package/queues/queue-provider.js +0 -65
  243. package/queues/sqs-queue-client.js +0 -81
  244. package/types/modules/index.d.ts +0 -214
  245. package/types/user/index.d.ts +0 -134
  246. package/user/adopter-jwt-config.js +0 -177
  247. package/user/adopter-jwt-errors.js +0 -59
  248. package/user/adopter-token-verifier.js +0 -122
  249. package/user/app-user-helpers.js +0 -97
  250. package/user/create-user-authentication.js +0 -64
  251. package/user/membership.js +0 -51
  252. package/user/repositories/organization-membership-record.js +0 -19
  253. package/user/repositories/organization-membership-repository-documentdb.js +0 -106
  254. package/user/repositories/organization-membership-repository-factory.js +0 -42
  255. package/user/repositories/organization-membership-repository-interface.js +0 -57
  256. package/user/repositories/organization-membership-repository-mongo.js +0 -13
  257. package/user/repositories/organization-membership-repository-postgres.js +0 -29
  258. package/user/repositories/organization-membership-repository-prisma.js +0 -81
  259. package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
  260. package/user/use-cases/ensure-organization-membership.js +0 -92
  261. package/user/use-cases/find-individual-users-by-organization.js +0 -54
@@ -0,0 +1,370 @@
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, isBuiltin, splitCapability } = require('./steps');
14
+ const { parseCondition, referencedPaths, templateTags, FILTERS } = require('./expressions');
15
+ const { TRANSFORMS: TRANSFORM_FNS } = require('./transforms');
16
+
17
+ const ID = /^[a-z][a-z0-9-]*$/;
18
+ const SEMVER = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
19
+ const ROOTS = new Set(['event', 'record', 'steps', 'settings', 'install', 'vars', 'item', 'index', 'data', 'row', 'now']);
20
+ const COMPONENTS = new Set(['Stack', 'Heading', 'Text', 'KeyValue', 'List', 'Table', 'Badge', 'Button', 'Empty', 'Divider']);
21
+ const TRIGGERS = new Set(['webhook', 'schedule', 'userAction', 'syncRecord']);
22
+ const TRANSFORMS = new Set(Object.keys(TRANSFORM_FNS));
23
+ const INSTALL_KEYS = new Set(['webhookUrl', 'webhookUrls', 'portalId', 'runtime', 'integrationId', 'accounts']);
24
+
25
+ function validate(def, manifests = {}) {
26
+ const errors = [], warnings = [];
27
+ const err = (path, message) => errors.push({ path, message });
28
+ const warn = (path, message) => warnings.push({ path, message });
29
+
30
+ if (!def || typeof def !== 'object') return { valid: false, errors: [{ path: '', message: 'Definition must be an object' }], warnings };
31
+ if (def.schemaVersion !== '0.1') err('schemaVersion', 'Must be "0.1"');
32
+ if (!ID.test(def.id || '')) err('id', 'Lowercase letters, digits and dashes, starting with a letter');
33
+ if (!SEMVER.test(def.version || '')) err('version', 'Must be a semantic version (1.4.0)');
34
+ if (!def.display?.label) err('display.label', 'Required');
35
+
36
+ // ---------- modules ----------
37
+ const mods = def.modules || {};
38
+ if (!Object.keys(mods).length) err('modules', 'At least one module is required');
39
+ const manifestOf = {};
40
+ for (const [key, m] of Object.entries(mods)) {
41
+ if (!m?.module) { err(`modules.${key}`, 'Needs a module name'); continue; }
42
+ if (!manifests[m.module]) err(`modules.${key}`, `No capability manifest for module "${m.module}"`);
43
+ else manifestOf[key] = manifests[m.module];
44
+ }
45
+
46
+ // ---------- ids are unique across kinds (they become event names) ----------
47
+ const seen = new Map();
48
+ for (const kind of ['syncs', 'links', 'workflows', 'queries', 'ui']) {
49
+ (def[kind] || []).forEach((x, i) => {
50
+ if (!ID.test(x?.id || '')) err(`${kind}[${i}].id`, 'Lowercase letters, digits and dashes, starting with a letter');
51
+ else if (seen.has(x.id)) err(`${kind}[${i}].id`, `Duplicate id "${x.id}" (also ${seen.get(x.id)})`);
52
+ else seen.set(x.id, `${kind}[${i}]`);
53
+ });
54
+ }
55
+ const installKeys = new Set([...Object.keys(def.settings || {}), ...INSTALL_KEYS]);
56
+ let warnedWebhookUrl = false;
57
+ const installStepIds = new Set((def.lifecycle?.onInstall || []).map((st) => st?.id).filter(Boolean));
58
+ const syncs = Object.fromEntries((def.syncs || []).map((s) => [s.id, s]));
59
+ const linkSets = Object.fromEntries((def.links || []).map((l) => [l.id, l]));
60
+ const workflows = Object.fromEntries((def.workflows || []).map((w) => [w.id, w]));
61
+ const queries = Object.fromEntries((def.queries || []).map((q) => [q.id, q]));
62
+ const uis = Object.fromEntries((def.ui || []).map((u) => [u.id, u]));
63
+
64
+ // ---------- settings ----------
65
+ const checkSettings = (settings, path) => {
66
+ for (const [k, s] of Object.entries(settings || {})) {
67
+ const p = `${path}.${k}`;
68
+ if (!s?.label) err(p, 'Setting needs a label');
69
+ if (!['string', 'text', 'number', 'boolean', 'enum', 'option', 'duration'].includes(s?.type)) err(p, `Unknown setting type "${s?.type}"`);
70
+ if (s?.type === 'enum' && !Array.isArray(s.enum)) err(p, 'enum settings need an enum list');
71
+ if (s?.type === 'option') checkOptionSource(s.options, `${p}.options`, k, settings, path !== 'settings');
72
+ }
73
+ // An option list may read earlier choices; a loop (a reads b, b reads a) could never be filled in.
74
+ 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]);
75
+ for (const k of Object.keys(settings || {})) {
76
+ const seenK = new Set([k]); const stack = [...reads(k)];
77
+ 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)); } }
78
+ }
79
+ };
80
+ const optionInputs = (s) => (s && typeof s.options === 'object' ? s.options.with || {} : {});
81
+ function checkOptionSource(opts, p, self, siblings, isWorkflow) {
82
+ const ref = typeof opts === 'string' ? { source: opts } : opts;
83
+ if (!ref || typeof ref.source !== 'string') { err(p, 'options is "module.list" or { source: "module.list", with: { ... } }'); return; }
84
+ const [mk, ok] = ref.source.split('.');
85
+ if (!manifestOf[mk]) { err(p, `options "${ref.source}" names an unknown module key`); return; }
86
+ const list = manifestOf[mk].options?.[ok];
87
+ if (!list) { err(p, `Module "${mk}" has no option list "${ok}"`); return; }
88
+ const inputs = list.inputs || {};
89
+ 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}"`);
90
+ for (const [name, v] of Object.entries(ref.with || {})) {
91
+ if (!(name in inputs)) { err(`${p}.with.${name}`, `Option list ${ref.source} takes no input "${name}"`); continue; }
92
+ for (const r of referencedPaths(v)) {
93
+ const [, root, key] = r.split(/\.|\[/);
94
+ if (root === 'settings' && key === self) err(`${p}.with.${name}`, 'An option list cannot read its own setting');
95
+ else if (root === 'settings' && !(key in (siblings || {}))) err(`${p}.with.${name}`, `$.settings.${key} is not a setting at this level`);
96
+ else if (root === 'install' && !(isWorkflow && key in (def.settings || {}))) err(`${p}.with.${name}`, `$.install.${key} is not an integration setting`);
97
+ else if (root !== 'settings' && root !== 'install') err(`${p}.with.${name}`, 'Option inputs can only read $.settings (or $.install from a workflow setting)');
98
+ }
99
+ }
100
+ }
101
+ checkSettings(def.settings, 'settings');
102
+
103
+ const objectFields = (key, object) => (manifestOf[key]?.objects?.[object]?.fields || []).map((f) => f.name);
104
+ const splitRef = (ref) => { const [k, o] = String(ref || '').split('.'); return { k, o }; };
105
+
106
+ // ---------- link sets: links between records with no sync behind them ----------
107
+ (def.links || []).forEach((l, i) => {
108
+ const p = `links[${i}]`;
109
+ if (!Array.isArray(l.between) || l.between.length !== 2) { err(`${p}.between`, 'Two "module.object" references'); return; }
110
+ l.between.forEach((ref, j) => {
111
+ const r = splitRef(ref);
112
+ if (!manifestOf[r.k]) err(`${p}.between[${j}]`, `Unknown module key "${r.k}"`);
113
+ else if (!manifestOf[r.k].objects?.[r.o]) err(`${p}.between[${j}]`, `Module "${r.k}" has no object "${r.o}"`);
114
+ });
115
+ if (splitRef(l.between[0]).k === splitRef(l.between[1]).k) err(`${p}.between`, 'A link set joins two different modules');
116
+ (l.identity?.match || []).forEach((m, j) => {
117
+ if (!m.key) err(`${p}.identity.match[${j}].key`, 'Name the identity key (email, phone)');
118
+ if (m.transform && !TRANSFORMS.has(m.transform)) err(`${p}.identity.match[${j}].transform`, `Unknown transform "${m.transform}"`);
119
+ });
120
+ });
121
+ const checkRows = (rows, p, sf, tf, srcName, tgtName, tgtFields) => (rows || []).forEach((r, j) => {
122
+ const q = `${p}[${j}]`;
123
+ const fixed = r && 'value' in r;
124
+ if (fixed && r.source != null) err(q, 'A row has a source or a fixed value, not both');
125
+ else if (!fixed && !r?.source) err(q, 'A row needs a source or a fixed value');
126
+ if (!fixed && sf?.length && !sf.includes(r.source)) err(`${q}.source`, `"${r.source}" is not a field of ${srcName}`);
127
+ if (tf?.length && !tf.includes(r.target)) err(`${q}.target`, `"${r.target}" is not a field of ${tgtName}`);
128
+ const tfield = tgtFields?.find((f) => f.name === r.target);
129
+ if (tfield?.readOnly && r.direction !== 'to_source') err(`${q}.target`, `"${r.target}" is read-only in ${tgtName}`);
130
+ if (fixed && r.direction === 'to_source') err(`${q}.direction`, 'A fixed value only writes to the target');
131
+ if (r.transform && !TRANSFORMS.has(r.transform)) err(`${q}.transform`, `Unknown transform "${r.transform}"`);
132
+ });
133
+
134
+ // ---------- syncs ----------
135
+ (def.syncs || []).forEach((s, i) => {
136
+ const p = `syncs[${i}]`;
137
+ if (!s.label) err(`${p}.label`, 'Required');
138
+ if (!['one_way', 'two_way'].includes(s.direction)) err(`${p}.direction`, 'one_way or two_way');
139
+ const src = splitRef(s.source), tgt = splitRef(s.target);
140
+ for (const [side, r] of [['source', src], ['target', tgt]]) {
141
+ if (!manifestOf[r.k]) err(`${p}.${side}`, `Unknown module key "${r.k}"`);
142
+ else if (!manifestOf[r.k].objects?.[r.o]) err(`${p}.${side}`, `Module "${r.k}" has no object "${r.o}"`);
143
+ }
144
+ const sf = objectFields(src.k, src.o), tf = objectFields(tgt.k, tgt.o);
145
+ const match = s.identity?.match || [];
146
+ if (!match.length) err(`${p}.identity.match`, 'At least one identity key is required');
147
+ match.forEach((m, j) => {
148
+ if (!m.key) err(`${p}.identity.match[${j}].key`, 'Name the identity key (email, phone)');
149
+ if (sf.length && !sf.includes(m.source)) err(`${p}.identity.match[${j}].source`, `"${m.source}" is not a field of ${s.source}`);
150
+ if (tf.length && !tf.includes(m.target)) err(`${p}.identity.match[${j}].target`, `"${m.target}" is not a field of ${s.target}`);
151
+ });
152
+ checkRows(s.mapping, `${p}.mapping`, sf, tf, s.source, s.target, manifestOf[tgt.k]?.objects?.[tgt.o]?.fields);
153
+ if (s.ongoing?.mode === 'webhook') {
154
+ const hasEvents = (k, o) => Object.values(manifestOf[k]?.events || {}).some((e) => e.object === o);
155
+ if (manifestOf[src.k] && !hasEvents(src.k, src.o)) warn(`${p}.ongoing`, `${s.source} declares no webhook events; ongoing sync will need polling`);
156
+ }
157
+ if (s.filter) checkCondition(s.filter, `${p}.filter`);
158
+ });
159
+
160
+ // ---------- steps ----------
161
+ function checkCondition(src, path) {
162
+ try { parseCondition(src); } catch (e) { err(path, e.message); return; }
163
+ checkRefs(src, path, null);
164
+ }
165
+ function checkFilters(value, path) {
166
+ 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(', ')})`);
167
+ }
168
+ function checkRefs(value, path, scope) {
169
+ checkFilters(value, path);
170
+ for (const ref of referencedPaths(value)) {
171
+ const segs = ref.replace(/^\$\./, '').split(/\.|\[/);
172
+ const root = segs[0];
173
+ if (!ROOTS.has(root)) { err(path, `Unknown root "$.${root}" (use event, record, steps, settings, install, vars, item, data)`); continue; }
174
+ if (!scope) { if (root === 'install') checkInstallRef(segs, path, null); continue; }
175
+ if (root === 'steps' && segs[1] && !scope.steps.has(segs[1])) err(path, `$.steps.${segs[1]} is not an earlier step in this list`);
176
+ if (root === 'settings' && segs[1] && !scope.settings.has(segs[1])) err(path, `$.settings.${segs[1]} is not a setting of this workflow`);
177
+ if (root === 'install') checkInstallRef(segs, path, scope);
178
+ if ((root === 'item' || root === 'index') && !scope.inLoop) err(path, `$.${root} is only available inside foreach`);
179
+ }
180
+ }
181
+ // $.install.<integration setting> · $.install.accounts.<module key>.<account field the manifest declares>
182
+ // $.install.runtime.installed.<onInstall step id> (not readable while install itself is running)
183
+ function checkInstallRef(segs, path, scope) {
184
+ const [, key, a, b] = segs;
185
+ if (!key) return;
186
+ if (!installKeys.has(key)) { err(path, `$.install.${key} is not an integration setting`); return; }
187
+ if (key === 'accounts' && a) {
188
+ if (!manifestOf[a]) err(path, `$.install.accounts.${a}: "${a}" is not a module key`);
189
+ 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'})`);
190
+ }
191
+ if (key === 'webhookUrls' && a && !manifestOf[a]) err(path, `$.install.webhookUrls.${a}: "${a}" is not a module key`);
192
+ if (key === 'webhookUrl' && Object.keys(mods).length > 1 && !warnedWebhookUrl) {
193
+ warnedWebhookUrl = true;
194
+ warn(path, 'Use $.install.webhookUrls.<module key>: with more than one module, the URL should say which module a delivery comes from');
195
+ }
196
+ if (key === 'runtime' && a) {
197
+ if (a !== 'installed') err(path, `$.install.runtime.${a}: runtime only holds "installed" (the results of onInstall steps)`);
198
+ else if (scope?.installing) err(path, 'Install steps cannot read $.install.runtime.installed; refer to an earlier step with $.steps');
199
+ else if (b && !installStepIds.has(b)) err(path, `$.install.runtime.installed.${b}: no onInstall step has id "${b}"`);
200
+ }
201
+ }
202
+ function checkSteps(steps, path, scope) {
203
+ if (!Array.isArray(steps)) { err(path, 'Steps must be a list'); return; }
204
+ steps.forEach((st, i) => {
205
+ const p = `${path}[${i}]`;
206
+ if (!st?.use) { err(p, 'Step needs "use"'); return; }
207
+ if (st.when) checkCondition(st.when, `${p}.when`), checkRefs(st.when, `${p}.when`, scope);
208
+ if (st.onError && !['fail', 'continue', 'stop'].includes(st.onError)) err(`${p}.onError`, 'fail, continue or stop');
209
+ const args = st.with || {};
210
+ if (st.use === 'foreach') {
211
+ checkRefs(args.over, `${p}.with.over`, scope);
212
+ const inner = { ...scope, steps: new Set(scope.steps), declared: new Set(), inLoop: true };
213
+ if (args.where) { checkCondition(args.where, `${p}.with.where`); checkRefs(args.where, `${p}.with.where`, inner); }
214
+ if (!st.do && !('collect' in args)) err(p, 'foreach needs do steps, collect, or both');
215
+ if (st.do) checkSteps(st.do, `${p}.do`, inner);
216
+ if ('collect' in args) checkRefs(args.collect, `${p}.with.collect`, inner);
217
+ 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');
218
+ } else if (st.use === 'branch') {
219
+ (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) }); });
220
+ if (st.else) checkSteps(st.else, `${p}.else`, { ...scope, steps: new Set(scope.steps) });
221
+ } else if (isBuiltin(st.use)) {
222
+ checkRefs(args, `${p}.with`, scope);
223
+ if (st.use.startsWith('links.')) {
224
+ const isLink = 'link' in args;
225
+ if (isLink === ('sync' in args)) err(`${p}.with`, `${st.use} names a link set (link) or a sync (sync), exactly one`);
226
+ const set = isLink ? linkSets[args.link] : syncs[args.sync];
227
+ if (!set) err(`${p}.with.${isLink ? 'link' : 'sync'}`, `Unknown ${isLink ? 'link set' : 'sync'} "${isLink ? args.link : args.sync}"`);
228
+ else {
229
+ const keys = new Set((set.identity?.match || []).map((m) => m.key));
230
+ const mods = new Set((isLink ? set.between : [set.source, set.target]).map((r) => splitRef(r).k));
231
+ const label = `${isLink ? 'link set' : 'sync'} "${set.id}"`;
232
+ 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(', ')})` : ''}`);
233
+ if (st.use === 'links.find' && !args.key && !(args.from && args.id)) err(`${p}.with`, 'links.find needs key, or from + id');
234
+ if (args.from && !mods.has(args.from)) err(`${p}.with.from`, `"${args.from}" is not one of the modules ${label} links`);
235
+ 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`);
236
+ }
237
+ }
238
+ 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)');
239
+ if (st.use === 'map' && Array.isArray(args.mapping)) checkRows(args.mapping, `${p}.with.mapping`);
240
+ if (st.use === 'template' && args.id && !def.templates?.[args.id]) err(`${p}.with.id`, `Unknown template "${args.id}"`);
241
+ if (st.use === 'map' && args.sync && !syncs[args.sync]) err(`${p}.with.sync`, `Unknown sync "${args.sync}"`);
242
+ if (st.use === 'code' && !/^[@\w/.-]+#\w+@\d+\.\d+\.\d+$/.test(args.ref || '')) err(`${p}.with.ref`, 'Code steps are "package#function@x.y.z"');
243
+ } else {
244
+ const cap = splitCapability(st.use);
245
+ const man = cap && manifestOf[cap.module];
246
+ if (!man) err(`${p}.use`, `"${st.use}" is not a built-in, and "${cap?.module}" is not a module key`);
247
+ else {
248
+ const c = man.capabilities?.[cap.capability];
249
+ if (!c) err(`${p}.use`, `Module "${cap.module}" (${man.module}) has no capability "${cap.capability}"`);
250
+ else {
251
+ for (const [name, spec] of Object.entries(c.input || {})) if (spec.required && !(name in args)) err(`${p}.with.${name}`, `Required by ${st.use}`);
252
+ for (const name of Object.keys(args)) if (c.input && !(name in c.input)) err(`${p}.with.${name}`, `${st.use} takes no argument "${name}"`);
253
+ }
254
+ }
255
+ checkRefs(args, `${p}.with`, scope);
256
+ }
257
+ if (st.id) {
258
+ if (scope.steps.has(st.id) && scope.declared.has(st.id)) err(`${p}.id`, `Step id "${st.id}" is used twice`);
259
+ scope.steps.add(st.id); scope.declared.add(st.id);
260
+ }
261
+ });
262
+ }
263
+ const newScope = (settings) => ({ steps: new Set(), declared: new Set(), settings: new Set(Object.keys(settings || {})), install: installKeys, inLoop: false });
264
+
265
+ // ---------- workflows ----------
266
+ (def.workflows || []).forEach((w, i) => {
267
+ const p = `workflows[${i}]`;
268
+ if (!w.label) err(`${p}.label`, 'Required');
269
+ checkSettings(w.settings, `${p}.settings`);
270
+ const t = w.trigger || {};
271
+ if (!TRIGGERS.has(t.type)) err(`${p}.trigger.type`, `One of ${[...TRIGGERS].join(', ')}`);
272
+ if (t.type === 'webhook') {
273
+ if (!manifestOf[t.module]) err(`${p}.trigger.module`, `Unknown module key "${t.module}"`);
274
+ else if (!manifestOf[t.module].events?.[t.topic]) err(`${p}.trigger.topic`, `Module "${t.module}" emits no event "${t.topic}"`);
275
+ }
276
+ if (t.type === 'schedule' && String(t.cron || '').trim().split(/\s+/).length !== 5) err(`${p}.trigger.cron`, 'Five-field cron expression');
277
+ if (t.type === 'userAction' && t.from && !uis[t.from]) err(`${p}.trigger.from`, `Unknown UI surface "${t.from}"`);
278
+ if (t.type === 'syncRecord' && !syncs[t.sync]) err(`${p}.trigger.sync`, `Unknown sync "${t.sync}"`);
279
+ if (t.filter) checkCondition(t.filter, `${p}.trigger.filter`);
280
+ if (!w.steps?.length) err(`${p}.steps`, 'At least one step');
281
+ checkSteps(w.steps, `${p}.steps`, newScope(w.settings));
282
+ });
283
+
284
+ // ---------- queries ----------
285
+ (def.queries || []).forEach((q, i) => {
286
+ const scope = newScope({});
287
+ checkSteps(q.steps || [], `queries[${i}].steps`, scope);
288
+ checkRefs(q.returns, `queries[${i}].returns`, scope);
289
+ });
290
+
291
+ // ---------- ui ----------
292
+ // A view sees $.data (the query's returns), $.record and $.install; $.item inside a List's title/meta, $.row inside
293
+ // a Table column. Anything else is a typo that would render blank, so it is an error here, not at runtime.
294
+ function checkViewRefs(value, path, ctx) {
295
+ checkFilters(value, path);
296
+ for (const ref of referencedPaths(value)) {
297
+ const [root, key] = ref.replace(/^\$\./, '').split(/\.|\[/);
298
+ const allowed = ['data', 'record', 'install', ...(ctx.item ? ['item'] : []), ...(ctx.row ? ['row'] : [])];
299
+ if (!allowed.includes(root)) err(path, `$.${root} is not available here (a view can read ${allowed.map((r) => '$.' + r).join(', ')})`);
300
+ 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(', ')})`);
301
+ else if (root === 'install') checkInstallRef(ref.replace(/^\$\./, '').split(/\.|\[/), path, null);
302
+ }
303
+ }
304
+ function checkView(node, path, ctx) {
305
+ if (!node || !COMPONENTS.has(node.component)) { err(path, `Unknown component "${node?.component}" (v0.1: ${[...COMPONENTS].join(', ')})`); return; }
306
+ if (node.when) { checkCondition(node.when, `${path}.when`); checkViewRefs(node.when, `${path}.when`, ctx); }
307
+ const props = { ...(node.props || {}) };
308
+ if (node.component === 'List') {
309
+ checkViewRefs(props.items, `${path}.props.items`, ctx);
310
+ for (const k of ['title', 'meta']) checkViewRefs(props[k], `${path}.props.${k}`, { ...ctx, item: true });
311
+ delete props.items; delete props.title; delete props.meta;
312
+ } else if (node.component === 'Table') {
313
+ checkViewRefs(props.rows, `${path}.props.rows`, ctx);
314
+ (props.columns || []).forEach((c, j) => checkViewRefs(c.value, `${path}.props.columns[${j}].value`, { ...ctx, row: true }));
315
+ delete props.rows; delete props.columns;
316
+ }
317
+ const { action, ...rest } = props;
318
+ checkViewRefs(rest, `${path}.props`, ctx);
319
+ if (action?.url) checkViewRefs(action.url, `${path}.props.action.url`, ctx);
320
+ const a = node.props?.action;
321
+ if (a?.type === 'userAction') {
322
+ const w = workflows[a.workflow];
323
+ if (!w) err(`${path}.props.action.workflow`, `Unknown workflow "${a.workflow}"`);
324
+ else if (w.trigger?.type !== 'userAction') err(`${path}.props.action.workflow`, `Workflow "${a.workflow}" is not triggered by a user action`);
325
+ } else if (a && a.type !== 'open_url') err(`${path}.props.action.type`, 'userAction or open_url');
326
+ (node.children || []).forEach((c, j) => checkView(c, `${path}.children[${j}]`, ctx));
327
+ }
328
+ (def.ui || []).forEach((u, i) => {
329
+ const p = `ui[${i}]`;
330
+ const host = manifestOf[u.host];
331
+ if (!host) err(`${p}.host`, `Unknown module key "${u.host}"`);
332
+ 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'})`);
333
+ const q = queries[u.data?.query];
334
+ if (!q) err(`${p}.data.query`, `Unknown query "${u.data?.query}"`);
335
+ const returns = q?.returns;
336
+ const dataKeys = returns && typeof returns === 'object' && !Array.isArray(returns) ? new Set(Object.keys(returns)) : null;
337
+ checkView(u.view, `${p}.view`, { dataKeys, query: u.data?.query });
338
+ });
339
+
340
+ // ---------- named templates ----------
341
+ // A template renders in the context of the step that uses it, so its paths are checked for root and filter here
342
+ // and its $.steps references against each workflow that renders it.
343
+ const usedTemplates = new Set();
344
+ const collectTemplateUse = (steps, w) => (steps || []).forEach((st) => {
345
+ if (st?.use === 'template' && st.with?.id) usedTemplates.add(st.with.id);
346
+ collectTemplateUse(st?.do, w); (st?.cases || []).forEach((c) => collectTemplateUse(c.do, w)); collectTemplateUse(st?.else, w);
347
+ });
348
+ (def.workflows || []).forEach((w) => collectTemplateUse(w.steps, w));
349
+ (def.queries || []).forEach((q) => collectTemplateUse(q.steps));
350
+ for (const hook of ['onInstall', 'onUninstall']) collectTemplateUse(def.lifecycle?.[hook]);
351
+ for (const [id, text] of Object.entries(def.templates || {})) {
352
+ if (typeof text !== 'string') { err(`templates.${id}`, 'A template is a string'); continue; }
353
+ checkRefs(text, `templates.${id}`, null);
354
+ if (!usedTemplates.has(id)) warn(`templates.${id}`, 'No step renders this template');
355
+ }
356
+
357
+ // ---------- lifecycle ----------
358
+ // What install creates, uninstall should undo: an install step whose result onUninstall never reads is probably a
359
+ // webhook nobody deletes. A warning, because some install steps create nothing that needs undoing.
360
+ const uninstallReads = JSON.stringify(def.lifecycle?.onUninstall || []);
361
+ for (const [i, st] of (def.lifecycle?.onInstall || []).entries()) {
362
+ if (!st?.id) warn(`lifecycle.onInstall[${i}]`, 'This install step has no id, so its result is not kept and uninstall cannot undo it');
363
+ 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`);
364
+ }
365
+ for (const hook of ['onInstall', 'onUninstall']) if (def.lifecycle?.[hook]) checkSteps(def.lifecycle[hook], `lifecycle.${hook}`, { ...newScope({}), installing: hook === 'onInstall' });
366
+
367
+ return { valid: errors.length === 0, errors, warnings };
368
+ }
369
+
370
+ module.exports = { validate };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Versioning for data-defined integrations.
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 (!same(o.ongoing, s.ongoing) || !same(o.backfill, s.backfill) || o.filter !== s.filter) push('minor', `syncs.${id}`, 'Sync schedule or filter changed');
55
+ }
56
+
57
+ const la = byId(from.links), lb = byId(to.links);
58
+ for (const id of Object.keys(la)) if (!lb[id]) push('major', `links.${id}`, 'Link set removed: workflows that read it lose their links');
59
+ for (const [id, l] of Object.entries(lb)) {
60
+ const o = la[id];
61
+ if (!o) { push('minor', `links.${id}`, 'New link set'); continue; }
62
+ if (!same(o.between, l.between)) push('major', `links.${id}.between`, 'Link set joins different objects');
63
+ if (!same(o.identity, l.identity)) push('major', `links.${id}.identity`, 'Identity keys changed: existing links must be rebuilt');
64
+ if (o.label !== l.label || o.description !== l.description) push('patch', `links.${id}`, 'Label or description changed');
65
+ }
66
+
67
+ const wa = byId(from.workflows), wb = byId(to.workflows);
68
+ for (const id of Object.keys(wa)) if (!wb[id]) push('major', `workflows.${id}`, 'Workflow removed');
69
+ for (const [id, w] of Object.entries(wb)) {
70
+ const o = wa[id];
71
+ if (!o) { push('minor', `workflows.${id}`, `New workflow (default ${w.default || 'on'})`); continue; }
72
+ if (o.trigger?.type !== w.trigger?.type || o.trigger?.module !== w.trigger?.module) push('major', `workflows.${id}.trigger`, 'Trigger source changed');
73
+ else if (!same(o.trigger, w.trigger)) push('minor', `workflows.${id}.trigger`, 'Trigger topic, schedule or filter changed');
74
+ diffSettings(o.settings, w.settings, `workflows.${id}.settings`, push);
75
+ if (!same(o.steps, w.steps)) push('patch', `workflows.${id}.steps`, 'Steps changed');
76
+ if (o.label !== w.label || o.description !== w.description) push('patch', `workflows.${id}`, 'Label or description changed');
77
+ }
78
+
79
+ const qa = byId(from.queries), qb = byId(to.queries);
80
+ 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');
81
+ const ua = byId(from.ui), ub = byId(to.ui);
82
+ for (const id of Object.keys(ua)) if (!ub[id]) push('minor', `ui.${id}`, 'UI surface removed');
83
+ for (const [id, u] of Object.entries(ub)) {
84
+ if (!ua[id]) push('minor', `ui.${id}`, 'New UI surface');
85
+ else if (ua[id].host !== u.host || ua[id].placement !== u.placement) push('minor', `ui.${id}`, 'UI surface moved');
86
+ else if (!same(ua[id], u)) push('patch', `ui.${id}`, 'UI view or data changed');
87
+ }
88
+ if (!same(from.templates, to.templates)) push('patch', 'templates', 'Templates changed');
89
+ if (!same(from.lifecycle, to.lifecycle)) push('minor', 'lifecycle', 'Install/uninstall steps changed: upgrades re-run onInstall');
90
+ if (!same(from.display, to.display)) push('patch', 'display', 'Display text changed');
91
+
92
+ const level = changes.reduce((l, c) => max(l, c.level), 'none');
93
+ return { level, changes };
94
+ }
95
+
96
+ const parse = (v) => String(v).split('-')[0].split('.').map(Number);
97
+ function bumpOf(fromV, toV) {
98
+ const [a, b, c] = parse(fromV), [x, y, z] = parse(toV);
99
+ if (x > a) return 'major';
100
+ if (x === a && y > b) return 'minor';
101
+ if (x === a && y === b && z > c) return 'patch';
102
+ return 'none';
103
+ }
104
+
105
+ /** The declared bump must cover the computed one (a major change cannot ship as 1.4.1). */
106
+ function checkBump(from, to) {
107
+ const d = diff(from, to);
108
+ const declared = bumpOf(from.version, to.version);
109
+ const ok = declared !== 'none' && RANK[declared] >= RANK[d.level];
110
+ 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}` };
111
+ }
112
+
113
+ /**
114
+ * What it takes to move one installation between versions.
115
+ * @returns {{ allowed:boolean, needs:Array<{kind:string,path:string,message:string}>, actions:Array<{kind:string,id?:string,message:string}>, config:object }}
116
+ * needs things a person must supply first (a required setting, a module connection)
117
+ * actions what the runtime does on upgrade (enable/disable workflows, rebuild links, re-run onInstall)
118
+ * config the installation config after the upgrade (pinned to the new version)
119
+ */
120
+ function planUpgrade(installation, from, to) {
121
+ const needs = [], actions = [];
122
+ const config = JSON.parse(JSON.stringify(installation || {}));
123
+ config.definition = { id: to.id, version: to.version };
124
+
125
+ 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}` });
126
+ for (const [k, s] of Object.entries(to.settings || {})) {
127
+ if (s.required && s.default === undefined && config.settings?.[k] === undefined) needs.push({ kind: 'setting', path: `settings.${k}`, message: `Choose "${s.label}"` });
128
+ }
129
+ const wa = byId(from.workflows), wb = byId(to.workflows);
130
+ config.workflows ||= {};
131
+ 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` }); }
132
+ for (const [id, w] of Object.entries(wb)) {
133
+ const enabled = config.workflows[id]?.enabled ?? ((w.default || 'on') === 'on');
134
+ if (!wa[id]) {
135
+ config.workflows[id] = { enabled: (w.default || 'on') === 'on', settings: {} };
136
+ actions.push({ kind: config.workflows[id].enabled ? 'enable' : 'offer', id, message: `${config.workflows[id].enabled ? 'Turns on' : 'Available, off'}: ${w.label}` });
137
+ }
138
+ if (!enabled) continue;
139
+ for (const [k, s] of Object.entries(w.settings || {})) {
140
+ 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}"` });
141
+ }
142
+ const keep = new Set(Object.keys(w.settings || {}));
143
+ for (const k of Object.keys(config.workflows[id]?.settings || {})) if (!keep.has(k)) delete config.workflows[id].settings[k];
144
+ }
145
+ const sa = byId(from.syncs);
146
+ for (const s of to.syncs || []) {
147
+ 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)` });
148
+ if (!sa[s.id]) actions.push({ kind: 'backfill', id: s.id, message: `New sync: ${s.label}` });
149
+ }
150
+ // A link set has no backfill to rebuild from: its links by id still resolve, but its identity index was written
151
+ // with the old keys, so lookups by key miss until each pair is linked again. Said plainly rather than hidden.
152
+ const la = byId(from.links), lb = byId(to.links);
153
+ 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` });
154
+ 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` });
155
+ if (!same(from.lifecycle?.onInstall, to.lifecycle?.onInstall)) actions.push({ kind: 'reinstall', message: 'Re-run install steps (webhook subscriptions)' });
156
+ actions.push({ kind: 'drain', message: `Messages already queued under ${from.version} finish on ${from.version}; new events run on ${to.version}` });
157
+
158
+ return { allowed: needs.length === 0, needs, actions, config };
159
+ }
160
+
161
+ module.exports = { diff, checkBump, planUpgrade, bumpOf };