@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,110 @@
1
+ /**
2
+ * The configure form for a data-defined installation, generated from its definition: what GET_CONFIG_OPTIONS and
3
+ * REFRESH_CONFIG_OPTIONS return on Frigg's existing, authenticated /api/integrations/:id/config/options routes.
4
+ *
5
+ * The form's data has exactly the shape of Integration.config, so what the management UI PATCHes back is the config:
6
+ * { settings: { … }, syncs: { <id>: { enabled } }, workflows: { <id>: { enabled, settings: { … } } }, ui: { <id>: { enabled } } }
7
+ *
8
+ * Option settings become enums filled by `options(path, values)` (resolveOptions underneath). A list whose inputs are
9
+ * not chosen yet comes back empty with a help line saying what to choose first; REFRESH_CONFIG_OPTIONS, called with
10
+ * the form's current values, fills it.
11
+ */
12
+ const SETTING_SCHEMA = {
13
+ string: () => ({ type: 'string' }),
14
+ text: () => ({ type: 'string' }),
15
+ number: () => ({ type: 'number' }),
16
+ boolean: () => ({ type: 'boolean' }),
17
+ duration: () => ({ type: 'string', pattern: '^\\d+(m|h|d)$' }),
18
+ enum: (s) => ({ type: 'string', enum: s.enum }),
19
+ };
20
+
21
+ const dependsOn = (s) => (s && typeof s.options === 'object' ? Object.values(s.options.with || {}).map((v) => String(v).replace(/^\$\.(settings|install)\./, '')) : []);
22
+
23
+ async function settingsSchema(settings, pathPrefix, values, options, integrationSettings) {
24
+ const properties = {}, ui = {}, required = [];
25
+ for (const [key, s] of Object.entries(settings || {})) {
26
+ let schema;
27
+ if (s.type === 'option') {
28
+ const choices = await options(`${pathPrefix}.${key}`, values, integrationSettings);
29
+ schema = choices.length
30
+ ? { type: 'string', oneOf: choices.map((c) => ({ const: String(c.value), title: c.label })) }
31
+ : { type: 'string', enum: [] };
32
+ if (!choices.length && dependsOn(s).length) ui[key] = { 'ui:help': `Choose ${dependsOn(s).join(' and ')} first.` };
33
+ } else schema = (SETTING_SCHEMA[s.type] || SETTING_SCHEMA.string)(s);
34
+ properties[key] = { ...schema, title: s.label, ...(s.default !== undefined ? { default: s.default } : {}) };
35
+ if (s.help) ui[key] = { ...(ui[key] || {}), 'ui:help': s.help };
36
+ if (s.required && s.default === undefined) required.push(key);
37
+ }
38
+ return { schema: { type: 'object', properties, ...(required.length ? { required } : {}) }, ui };
39
+ }
40
+
41
+ const toggle = (title, description, enabledByDefault) => ({ type: 'boolean', title, ...(description ? { description } : {}), default: enabledByDefault });
42
+ const isOn = (item, chosen) => (typeof chosen?.enabled === 'boolean' ? chosen.enabled : (item.default || 'on') === 'on');
43
+
44
+ /**
45
+ * @param {object} def
46
+ * @param {object} config the installation's current config (or the form's current values on refresh)
47
+ * @param {(path: string, values: object, integrationSettings: object) => Promise<Array<{label,value}>>} options
48
+ * @returns {Promise<{ jsonSchema: object, uiSchema: object, data: object }>}
49
+ */
50
+ async function buildConfigForm(def, config = {}, options = async () => []) {
51
+ const jsonSchema = { type: 'object', title: def.display?.label, properties: {} };
52
+ const uiSchema = {};
53
+ const data = { settings: { ...(config.settings || {}) }, syncs: {}, workflows: {}, ui: {} };
54
+
55
+ if (Object.keys(def.settings || {}).length) {
56
+ const s = await settingsSchema(def.settings, 'settings', config.settings || {}, options, config.settings || {});
57
+ jsonSchema.properties.settings = { title: 'Settings', ...s.schema };
58
+ uiSchema.settings = s.ui;
59
+ }
60
+
61
+ if ((def.syncs || []).length) {
62
+ jsonSchema.properties.syncs = { type: 'object', title: 'Syncs', properties: {} };
63
+ for (const s of def.syncs) {
64
+ jsonSchema.properties.syncs.properties[s.id] = { type: 'object', title: s.label, properties: { enabled: toggle('On', s.description, (s.default || 'on') === 'on') } };
65
+ data.syncs[s.id] = { enabled: isOn(s, config.syncs?.[s.id]) };
66
+ }
67
+ }
68
+
69
+ if ((def.workflows || []).length) {
70
+ jsonSchema.properties.workflows = { type: 'object', title: 'Workflows', properties: {} };
71
+ uiSchema.workflows = {};
72
+ for (const w of def.workflows) {
73
+ const chosen = config.workflows?.[w.id] || {};
74
+ const node = { type: 'object', title: w.label, properties: { enabled: toggle('On', w.description, (w.default || 'on') === 'on') } };
75
+ data.workflows[w.id] = { enabled: isOn(w, chosen), settings: { ...(chosen.settings || {}) } };
76
+ if (Object.keys(w.settings || {}).length) {
77
+ // A workflow setting's option inputs may read integration settings, which it sees as $.install.
78
+ const s = await settingsSchema(w.settings, `workflows.${w.id}.settings`, chosen.settings || {}, options, config.settings || {});
79
+ node.properties.settings = { title: 'Settings', ...s.schema };
80
+ uiSchema.workflows[w.id] = { settings: s.ui };
81
+ // Settings only matter while the workflow is on: required only then.
82
+ node.dependencies = { enabled: { oneOf: [{ properties: { enabled: { const: false } } }, { properties: { enabled: { const: true } }, required: ['settings'] }] } };
83
+ }
84
+ jsonSchema.properties.workflows.properties[w.id] = node;
85
+ }
86
+ }
87
+
88
+ if ((def.ui || []).length) {
89
+ jsonSchema.properties.ui = { type: 'object', title: 'Cards and panels', properties: {} };
90
+ for (const u of def.ui) {
91
+ jsonSchema.properties.ui.properties[u.id] = { type: 'object', title: u.label || u.id, properties: { enabled: toggle('Show', `${u.host} · ${u.placement}`, (u.default || 'on') === 'on') } };
92
+ data.ui[u.id] = { enabled: isOn(u, config.ui?.[u.id]) };
93
+ }
94
+ }
95
+
96
+ return { jsonSchema, uiSchema, data };
97
+ }
98
+
99
+ /** Is anything required still missing? (NEEDS_CONFIG) */
100
+ function missingRequired(def, config = {}) {
101
+ const missing = [];
102
+ for (const [k, s] of Object.entries(def.settings || {})) if (s.required && s.default === undefined && (config.settings?.[k] == null || config.settings[k] === '')) missing.push(`settings.${k}`);
103
+ for (const w of def.workflows || []) {
104
+ if (!isOn(w, config.workflows?.[w.id])) continue;
105
+ for (const [k, s] of Object.entries(w.settings || {})) if (s.required && s.default === undefined && (config.workflows?.[w.id]?.settings?.[k] == null || config.workflows[w.id].settings[k] === '')) missing.push(`workflows.${w.id}.settings.${k}`);
106
+ }
107
+ return missing;
108
+ }
109
+
110
+ module.exports = { buildConfigForm, missingRequired };
@@ -0,0 +1,366 @@
1
+ /**
2
+ * DataDefinedIntegration: one IntegrationBase subclass that runs any published definition.
3
+ *
4
+ * Frigg registers integration classes at startup (appDefinition.integrations). For data-defined integrations the app
5
+ * registers one generated class per definition id:
6
+ *
7
+ * const { defineIntegrationClass } = require('@friggframework/core/integrations/data-defined');
8
+ * integrations: [ defineIntegrationClass({ id: 'phone-hubspot', definitions, manifests, modules: { phone: PhoneModule, hubspot: HubSpotModule } }) ]
9
+ *
10
+ * The class's static Definition (name, modules) is what the infrastructure needs, and it is the same for every
11
+ * version, so publishing a new version deploys nothing. Each installation loads the exact version it pins
12
+ * (record.config.definition.version) when it is hydrated, compiles it with that installation's choices, and gets an
13
+ * ordinary `this.events` map. Everything after that is the existing Frigg machinery: the event dispatcher, the
14
+ * queue worker, the scheduler and the mapping repository.
15
+ *
16
+ * Ports used: definitions.get(id, version) -> definition doc (immutable; cache freely)
17
+ * runCode(ref, args) -> registered code steps (optional)
18
+ */
19
+ const { compile, routeWebhook } = require('./compile');
20
+ const { makePorts, runWorkflow, runQuery, renderSurface, runLifecycle, resolveOptions } = require('./runtime');
21
+ const { eventsIn, handshakeFor, modulesFor } = require('./webhooks');
22
+ const { buildConfigForm, missingRequired } = require('./config-form');
23
+
24
+ /**
25
+ * Shape A — one class per definition id (defineIntegrationClass): the class name and modules come from the
26
+ * definition. Publishing a new VERSION deploys nothing; a new definition ID is a new class (a deploy, and its own
27
+ * functions and queue). Right for a product team with a handful of integrations.
28
+ *
29
+ * Shape B — one class for every definition (defineSharedIntegrationClass): registered once as "data-defined", its
30
+ * static modules are every API module the app has installed, and each installation names its definition id and
31
+ * version in config. A new integration is a row, not a deploy, and every installation shares one set of functions
32
+ * and one queue. Right for a multi-tenant product, where definitions accumulate without bound.
33
+ */
34
+ function defineIntegrationClass({ id, definitions, manifests, modules, runCode, IntegrationBase, lookup, runs }) {
35
+ const latest = definitions.latest(id);
36
+ if (!latest || typeof latest.then === 'function') throw new Error(`defineIntegrationClass needs the definitions of "${id}" synchronously at registration (bundled files); a database store goes with defineSharedIntegrationClass`);
37
+ return makeClass({
38
+ name: latest.id, defaultId: id, definitions, manifests, runCode, IntegrationBase, lookup, runs,
39
+ modules: Object.fromEntries(Object.keys(latest.modules).map((key) => [key, { definition: modules[key] }])),
40
+ display: { label: latest.display.label, description: latest.display.description, category: latest.display.category },
41
+ version: latest.version,
42
+ });
43
+ }
44
+
45
+ function defineSharedIntegrationClass({ name = 'data-defined', definitions, manifests, modules, runCode, IntegrationBase, lookup, runs }) {
46
+ return makeClass({
47
+ name, defaultId: null, definitions, manifests, runCode, IntegrationBase, lookup, runs,
48
+ modules: Object.fromEntries(Object.entries(modules).map(([key, definition]) => [key, { definition }])),
49
+ display: { label: 'Data-defined integration', description: 'Runs any published integration definition.', category: 'Custom' },
50
+ version: '0.1.0',
51
+ });
52
+ }
53
+
54
+ /**
55
+ * App-level webhooks (one URL for every account: HubSpot, Stripe Connect, GitHub Apps) arrive with no integration id.
56
+ * The default lookup finds the installations of THIS class connected to that account, using Frigg's commands: the
57
+ * entity reverse lookup, then each candidate's record, because one account can be used by integrations of other
58
+ * types too, and the worker would hydrate those as their own class.
59
+ */
60
+ function friggLookup(typeName) {
61
+ return async (externalId, moduleName) => {
62
+ const { createIntegrationCommands } = require('../../application/commands/integration-commands');
63
+ const commands = createIntegrationCommands();
64
+ const ids = await commands.listIntegrationsByEntityExternalId(externalId, moduleName);
65
+ const out = [];
66
+ for (const integrationId of ids) {
67
+ const found = await commands.loadIntegrationContextById(integrationId);
68
+ if (found?.context?.record?.config?.type === typeName) out.push(integrationId);
69
+ }
70
+ return out;
71
+ };
72
+ }
73
+
74
+ function makeClass({ name, defaultId, definitions, manifests, runCode, IntegrationBase, modules, display, version, lookup, runs }) {
75
+ const Base = IntegrationBase || require('../integration-base').IntegrationBase;
76
+ const id = name;
77
+ const findInstallations = lookup || friggLookup(name);
78
+ // Where workflow runs are kept: the WorkflowRun table by default (loaded lazily, so tests and tools without a
79
+ // database can pass their own store or `false` to keep none).
80
+ let runStore = runs;
81
+ const runRepository = () => (runStore === undefined ? (runStore = require('./repositories/workflow-run-repository').createWorkflowRunRepository()) : runStore);
82
+ const publicBase = () => `${process.env.FRIGG_PUBLIC_URL || ''}/api/${id}-integration/webhooks`;
83
+
84
+ class DataDefinedIntegration extends Base {
85
+ static Definition = { name, version, modules, webhooks: { enabled: true }, display }; // version is display only; installations pin their own
86
+
87
+ /** How the routers find data-defined classes and the store and manifests they run on. */
88
+ static DataDefined = { definitions, manifests, shared: !defaultId, runs: () => runRepository() };
89
+
90
+ /**
91
+ * Frigg's use cases construct an integration from its record and call initialize() before anything else
92
+ * (create, update, delete, webhooks, user actions, config options). So this is where the pinned definition is
93
+ * loaded: every real entry point hydrates through here. A dry instance (no record, e.g. an app-level webhook
94
+ * before its installation is known) has no pin and loads nothing.
95
+ */
96
+ async initialize() {
97
+ if (this.config?.definition || defaultId) await this.loadDefinition();
98
+ return super.initialize();
99
+ }
100
+
101
+ async loadDefinition() {
102
+ let pin = this.config?.definition || (defaultId ? { id: defaultId } : null);
103
+ if (!pin?.id) throw new Error('This installation does not name a definition (config.definition.id)');
104
+ if (!pin.version) {
105
+ // Installed by id alone: pin the latest published version now (onCreate persists the pin).
106
+ const latest = await definitions.latest(pin.id);
107
+ if (!latest) throw new Error(`No published version of "${pin.id}"`);
108
+ pin = { id: pin.id, version: latest.version, checksum: await definitions.checksumOf?.(pin.id, latest.version) || undefined };
109
+ if (this.config?.definition) this.config = { ...this.config, definition: pin };
110
+ }
111
+ this.def = await definitions.get(pin.id, pin.version, { checksum: pin.checksum });
112
+ if (!this.def) throw new Error(`Definition ${pin.id}@${pin.version} is not published`);
113
+ this.compiled = compile(this.def, this.config || {});
114
+ this.ports = this.portsFor(this.def);
115
+ // Replace, not merge: a workflow switched off since the last compile must lose its event.
116
+ for (const n of this.compiledEventNames || []) delete this.events[n];
117
+ this.compiledEventNames = Object.keys(this.compiled.events);
118
+ for (const [n, e] of Object.entries(this.compiled.events)) {
119
+ this.events[n] = { ...e, handler: (payload) => this.dispatch(e.ref, payload) };
120
+ }
121
+ this.registerEventHandlers();
122
+ return this.def;
123
+ }
124
+
125
+ /** Ports for a definition: Api calls through the manifest, and links/dedupe over IntegrationMapping. */
126
+ portsFor(def) {
127
+ return makePorts({
128
+ def, manifests, runCode,
129
+ // The shared class registers modules by module name; a definition may name them by its own keys.
130
+ callApi: (moduleKey, method, args) => this.moduleFor(moduleKey).api[method](args),
131
+ store: {
132
+ get: async (key) => (await this.getMapping(key))?.mapping || null,
133
+ put: (key, value) => this.upsertMapping(key, value),
134
+ claim: async (key, value) => { try { await this.createMapping(key, value); return true; } catch (e) { if (e.name === 'MappingAlreadyExistsError') return false; throw e; } },
135
+ release: (key) => this.deleteMapping(key),
136
+ },
137
+ });
138
+ }
139
+
140
+ moduleFor(key) {
141
+ const m = this[key] || this[this.def?.modules?.[key]?.module];
142
+ if (!m?.api) throw new Error(`Module "${key}" is not connected to this installation`);
143
+ return m;
144
+ }
145
+
146
+ async dispatch(ref, payload = {}) {
147
+ const msg = payload.data || payload;
148
+ // A message queued under an older version runs on that version (an upgrade drains, it never re-interprets).
149
+ const def = msg.definitionVersion && msg.definitionVersion !== this.def.version
150
+ ? await definitions.get(this.def.id, msg.definitionVersion) || this.def
151
+ : this.def;
152
+ const installation = this.installation();
153
+ if (ref.kind === 'workflow') return this.recordRun(await runWorkflow({ def, installation, workflowId: ref.id, event: msg.body ?? msg, eventId: msg.eventId, record: msg.record, ports: this.ports, manifests }));
154
+ if (ref.kind === 'query') return runQuery({ def, installation, queryId: ref.id, record: msg.record, ports: this.ports });
155
+ if (ref.kind === 'ui') return renderSurface({ def, installation, surfaceId: ref.id, record: payload.req?.body?.record || msg.record, ports: this.ports });
156
+ if (ref.kind === 'sync') return this.runSync(ref, msg);
157
+ throw new Error(`Unknown event ref ${JSON.stringify(ref)}`);
158
+ }
159
+
160
+ /**
161
+ * Receipt (no database): answer a provider handshake synchronously (Slack url_verification, Asana
162
+ * X-Hook-Secret), otherwise queue the delivery as Frigg always does. The URL names the module (?module=).
163
+ */
164
+ async onWebhookReceived({ req, res }) {
165
+ const manifest = manifests[req.query?.module];
166
+ const h = manifest && handshakeFor(manifest, { body: req.body, headers: req.headers, query: req.query });
167
+ if (h) {
168
+ for (const [k, v] of Object.entries(h.headers || {})) res.set(k, String(v));
169
+ return typeof h.body === 'object' ? res.status(h.status).json(h.body) : res.status(h.status).send(h.body);
170
+ }
171
+ return super.onWebhookReceived({ req, res });
172
+ }
173
+
174
+ /**
175
+ * Worker side of a raw delivery. Without an installation (an app-level webhook), find the installations
176
+ * connected to each account the delivery is about and queue it for each. With one: verify it, split it into
177
+ * module events, and queue one message per enabled workflow listening for each event.
178
+ */
179
+ async onWebhook({ data = {} }) {
180
+ if (data.module && data.topic) return this.routeEvents(data.module, [{ topic: data.topic, event: data.body, eventId: data.eventId || data.body?.id || null }]);
181
+ const delivery = { body: data.body, headers: data.headers || {}, query: data.query || {} };
182
+ if (!this.def) return this.fanOutAppWebhook(delivery);
183
+
184
+ const keys = modulesFor(this.def, manifests, delivery);
185
+ if (keys.length !== 1) { this.logWebhook('webhook.unrouted', { module: delivery.query.module || null, candidates: keys }); return { routed: 0 }; }
186
+ const key = keys[0];
187
+ const manifest = manifests[this.def.modules[key].module];
188
+ if (manifest.webhook?.verify) {
189
+ let ok = false;
190
+ try { ok = await this.moduleFor(key).api[manifest.webhook.verify]({ headers: delivery.headers, body: delivery.body, query: delivery.query }); } catch { ok = false; }
191
+ if (ok !== true) { this.logWebhook('webhook.rejected', { module: key }); return { routed: 0, rejected: true }; }
192
+ }
193
+ const own = this.accountOf(key);
194
+ const events = eventsIn(manifest, delivery).filter((e) => manifest.webhook?.scope !== 'app' || !own || !e.account || String(e.account) === String(own));
195
+ return this.routeEvents(key, events);
196
+ }
197
+
198
+ async routeEvents(moduleKey, events) {
199
+ let routed = 0;
200
+ for (const e of events) {
201
+ for (const n of routeWebhook(this.compiled, moduleKey, e.topic)) {
202
+ await this.queueWebhook({ event: n, integrationId: this.id, module: moduleKey, topic: e.topic, body: e.event, eventId: e.eventId, definitionVersion: this.def.version });
203
+ routed += 1;
204
+ }
205
+ }
206
+ return { routed };
207
+ }
208
+
209
+ async fanOutAppWebhook(delivery) {
210
+ const moduleName = delivery.query.module;
211
+ const manifest = manifests[moduleName];
212
+ if (!manifest?.webhook?.account) { this.logWebhook('webhook.unrouted', { module: moduleName || null, reason: 'no installation and no account to look it up by' }); return { queued: 0 }; }
213
+ const accounts = new Set(eventsIn(manifest, delivery).map((e) => e.account).filter(Boolean));
214
+ let queued = 0;
215
+ for (const account of accounts) {
216
+ for (const integrationId of await findInstallations(account, moduleName)) {
217
+ await this.queueWebhook({ integrationId, body: delivery.body, headers: delivery.headers, query: delivery.query });
218
+ queued += 1;
219
+ }
220
+ }
221
+ return { queued };
222
+ }
223
+
224
+ /** The connected account's id for a module (its entity externalId): what app-level events are matched on. */
225
+ accountOf(key) {
226
+ const m = this[key] || this[this.def?.modules?.[key]?.module] || this.modules?.[this.def?.modules?.[key]?.module];
227
+ return m?.entity?.externalId ?? null;
228
+ }
229
+
230
+ logWebhook(eventName, fields) {
231
+ try { require('../../logs').getLogger('frigg.data-defined').info('Webhook not routed', { eventName: `frigg.data-defined.${eventName}`, integrationId: this.id || null, ...fields }); } catch { /* logging must never break delivery */ }
232
+ }
233
+
234
+ async onCreate(params) {
235
+ if (!this.def) await this.loadDefinition();
236
+ const out = await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onInstall', ports: this.ports });
237
+ // Every onInstall step with an id keeps its result, so onUninstall can undo exactly what install did
238
+ // ($.install.runtime.installed.<step id>). Saved even when empty, so a re-install replaces stale ids.
239
+ const installed = Object.fromEntries((this.def.lifecycle?.onInstall || []).filter((st) => st.id).map((st) => [st.id, out.steps?.[st.id] ?? null]));
240
+ await this.patchConfig({ ...(this.config?.definition ? { definition: this.config.definition } : {}), runtime: { ...(this.config?.runtime || {}), installed } });
241
+ return super.onCreate(params);
242
+ }
243
+
244
+ async onUpdate(params) {
245
+ await super.onUpdate(params); // merges config (enabled workflows, settings, mapping overrides)
246
+ return this.loadDefinition(); // recompile: enabling a workflow adds its event, disabling removes it
247
+ }
248
+
249
+ async onDelete(params) {
250
+ if (!this.def) await this.loadDefinition();
251
+ await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onUninstall', ports: this.ports });
252
+ return super.onDelete(params);
253
+ }
254
+
255
+ /** The configure form (GET /api/integrations/:id/config/options), generated from the definition. */
256
+ async getConfigOptions() {
257
+ return buildConfigForm(this.def, this.config || {}, (path, values, integrationSettings) => this.optionsFor(path, values, integrationSettings));
258
+ }
259
+
260
+ /** The same form re-filled from the values chosen so far (POST …/config/options/refresh): dependent dropdowns. */
261
+ async refreshConfigOptions(params = {}) {
262
+ const values = params.config || params.formData || params.data || params;
263
+ return buildConfigForm(this.def, { ...(this.config || {}), ...values }, (path, v, integrationSettings) => this.optionsFor(path, v, integrationSettings));
264
+ }
265
+
266
+ async optionsFor(path, values, integrationSettings) {
267
+ try { return await resolveOptions({ def: this.def, manifests, ports: this.ports, path, values, integrationSettings }); } catch { return []; }
268
+ }
269
+
270
+ /** Required settings come from the definition; checked without calling any API (the base class renders the form). */
271
+ async validateConfig() {
272
+ return missingRequired(this.def, this.config || {}).length > 0;
273
+ }
274
+
275
+ /** The input form for a button workflow (POST …/actions/:id/options): the inputs of the UI buttons that run it. */
276
+ async getActionOptions(actionId) {
277
+ const ref = this.compiled?.events?.[actionId]?.ref;
278
+ const inputs = {};
279
+ const visit = (node) => { if (!node) return; const a = node.props?.action; if (a?.type === 'userAction' && a.workflow === ref?.id) Object.assign(inputs, a.input || {}); (node.children || []).forEach(visit); };
280
+ if (ref?.kind === 'workflow') (this.def.ui || []).forEach((u) => visit(u.view));
281
+ const properties = Object.fromEntries(Object.entries(inputs).map(([k, f]) => [k, { type: f.type === 'number' ? 'number' : 'string', title: f.label || k }]));
282
+ return { jsonSchema: Object.keys(properties).length ? { type: 'object', properties: { input: { type: 'object', properties } } } : {}, uiSchema: {} };
283
+ }
284
+
285
+ installation() {
286
+ const base = publicBase();
287
+ const webhookUrls = Object.fromEntries(Object.entries(this.def?.modules || {}).map(([key, m]) => [key, manifests[m.module]?.webhook?.scope === 'app'
288
+ ? `${base}?module=${encodeURIComponent(m.module)}`
289
+ : `${base}/${this.id}?module=${encodeURIComponent(m.module)}`]));
290
+ return { ...(this.config || {}), integrationId: this.id, accounts: this.accounts(), webhookUrl: `${base}/${this.id}`, webhookUrls };
291
+ }
292
+
293
+ /** $.install.accounts: per module key, the account fields its manifest declares, read from the module's Api
294
+ * (where apiPropertiesToPersist land after auth). An undeclared property is never exposed. */
295
+ accounts() {
296
+ return Object.fromEntries(Object.entries(this.def?.modules || {}).map(([key, m]) => {
297
+ const fields = manifests[m.module]?.account?.fields || [];
298
+ let api = {};
299
+ try { api = this.moduleFor(key).api || {}; } catch { api = {}; }
300
+ return [key, Object.fromEntries(fields.map((f) => [f.name, api[f.name] ?? null]))];
301
+ }));
302
+ }
303
+
304
+ /**
305
+ * Move this installation to another published version (default: the latest). With apply false, only the plan:
306
+ * what a person must supply first and what will happen. Applying refuses while anything is needed, and when
307
+ * install steps changed, undoes the old ones before running the new ones (it never leaves the old webhooks).
308
+ */
309
+ async upgradeTo(toVersion, { apply = false } = {}) {
310
+ const { PlanInstallationUpgrade } = require('./use-cases/definition-use-cases');
311
+ const plan = await new PlanInstallationUpgrade({ catalog: definitions }).execute({ config: this.config, toVersion });
312
+ if (!apply || !plan.allowed) return { ...plan, applied: false };
313
+ const reinstall = plan.actions.some((a) => a.kind === 'reinstall');
314
+ let config = plan.config;
315
+ if (reinstall) {
316
+ await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onUninstall', ports: this.ports });
317
+ const next = await definitions.get(plan.config.definition.id, plan.config.definition.version);
318
+ const out = await runLifecycle({ def: next, installation: { ...this.installation(), ...config }, hook: 'onInstall', ports: this.portsFor(next) });
319
+ config = { ...config, runtime: { installed: Object.fromEntries((next.lifecycle?.onInstall || []).filter((st) => st.id).map((st) => [st.id, out.steps?.[st.id] ?? null])) } };
320
+ }
321
+ await this.patchConfig(config);
322
+ this.config = { ...this.config, ...config };
323
+ await this.loadDefinition();
324
+ return { ...plan, config, applied: true };
325
+ }
326
+
327
+ async recordRun(run) {
328
+ this.telemetry?.count?.('workflow.run', 1, { workflow: run.workflowId, status: run.status });
329
+ // Run history must never decide the run's outcome: a failed write is logged and the run stands.
330
+ try {
331
+ const store = runRepository();
332
+ if (store) await store.createRun({ ...run, integrationId: this.id, definitionId: run.definition?.id, definitionVersion: run.definition?.version, triggerType: run.trigger?.type, eventId: run.trigger?.eventId });
333
+ } catch (e) {
334
+ try { require('../../logs').getLogger('frigg.data-defined').warn('Workflow run not recorded', { eventName: 'frigg.data-defined.run.not_recorded', integrationId: this.id, workflowId: run.workflowId, cause: e?.message }); } catch { /* ignore */ }
335
+ }
336
+ if (run.status === 'failed') { const e = new Error(run.error); e.run = run; throw e; } // the queue retries
337
+ return run;
338
+ }
339
+
340
+ async runSync(ref) {
341
+ // The sync engine (backfill pages, ongoing deltas, two-way master rules) reuses Frigg's existing SYNC_PAGE
342
+ // pattern; it is specified in README.md and not built yet.
343
+ throw new Error(`Sync ${ref.id} (${ref.phase}) is not implemented yet`);
344
+ }
345
+ }
346
+ Object.defineProperty(DataDefinedIntegration, 'name', { value: `DataDefined_${id.replace(/-/g, '_')}` });
347
+ return DataDefinedIntegration;
348
+ }
349
+
350
+ /** In-memory definition store for tests and local dev. Production: the IntegrationDefinitionVersion table. */
351
+ function memoryDefinitions(docs) {
352
+ const byKey = new Map(docs.map((d) => [`${d.id}@${d.version}`, d]));
353
+ const cmp = (a, b) => a.split('.').map(Number).reduce((r, n, i) => r || n - Number(b.split('.')[i]), 0);
354
+ return {
355
+ get: async (defId, version) => byKey.get(`${defId}@${version}`) || null,
356
+ checksumOf: async (defId, version) => { const doc = byKey.get(`${defId}@${version}`); return doc ? require('./canonical').checksum(doc) : null; },
357
+ list: async () => {
358
+ const latestById = new Map();
359
+ for (const doc of docs) if (!latestById.has(doc.id) || cmp(doc.version, latestById.get(doc.id).version) > 0) latestById.set(doc.id, doc);
360
+ return [...latestById.values()].map((doc) => ({ id: doc.id, version: doc.version, checksum: require('./canonical').checksum(doc), display: doc.display, modules: Object.fromEntries(Object.entries(doc.modules || {}).map(([k, m]) => [k, m.module])), counts: { syncs: (doc.syncs || []).length, links: (doc.links || []).length, workflows: (doc.workflows || []).length, ui: (doc.ui || []).length } }));
361
+ },
362
+ latest: (id) => docs.filter((d) => d.id === id).sort((a, b) => cmp(b.version, a.version))[0] || null,
363
+ };
364
+ }
365
+
366
+ module.exports = { defineIntegrationClass, defineSharedIntegrationClass, memoryDefinitions };