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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +20 -10
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -2
  19. package/database/encryption/README.md +78 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +121 -24
  23. package/database/encryption/field-encryption-service.js +69 -5
  24. package/database/encryption/integration-mapping-encryption.js +1 -1
  25. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  26. package/database/organization-where.js +18 -0
  27. package/database/prisma.js +15 -38
  28. package/database/unique-violation.js +25 -0
  29. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  30. package/errors/index.js +0 -4
  31. package/extensions/bootstrap-runner.js +43 -0
  32. package/extensions/extension-loader.js +230 -0
  33. package/extensions/index.js +44 -0
  34. package/extensions/initialize-app.js +123 -0
  35. package/extensions/prisma-composer.js +270 -0
  36. package/extensions/route-mounter.js +103 -0
  37. package/extensions/schema-composer.js +188 -0
  38. package/generated/prisma-mongodb/edge.js +33 -42
  39. package/generated/prisma-mongodb/index-browser.js +30 -39
  40. package/generated/prisma-mongodb/index.d.ts +3187 -2005
  41. package/generated/prisma-mongodb/index.js +33 -42
  42. package/generated/prisma-mongodb/package.json +1 -1
  43. package/generated/prisma-mongodb/schema.prisma +86 -59
  44. package/generated/prisma-mongodb/wasm.js +33 -42
  45. package/generated/prisma-postgresql/edge.js +33 -42
  46. package/generated/prisma-postgresql/index-browser.js +30 -39
  47. package/generated/prisma-postgresql/index.d.ts +3380 -2177
  48. package/generated/prisma-postgresql/index.js +33 -42
  49. package/generated/prisma-postgresql/package.json +1 -1
  50. package/generated/prisma-postgresql/schema.prisma +81 -57
  51. package/generated/prisma-postgresql/wasm.js +33 -42
  52. package/handlers/WEBHOOKS.md +0 -29
  53. package/handlers/app-definition-loader.js +128 -19
  54. package/handlers/app-handler-helpers.js +27 -8
  55. package/handlers/backend-utils.js +31 -25
  56. package/handlers/integration-event-dispatcher.js +3 -7
  57. package/handlers/middleware/admin-auth.js +14 -1
  58. package/handlers/routers/auth.js +46 -10
  59. package/handlers/routers/health.js +13 -4
  60. package/handlers/routers/integration-defined-routers.js +144 -92
  61. package/handlers/routers/integration-webhook-routers.js +100 -50
  62. package/handlers/workers/integration-defined-workers.js +5 -2
  63. package/index.js +66 -16
  64. package/infrastructure/scheduler/index.js +19 -6
  65. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  66. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  67. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  68. package/integrations/index.js +1 -14
  69. package/integrations/integration-base.js +2 -163
  70. package/integrations/integration-router.js +132 -102
  71. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  72. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  73. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  74. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  75. package/integrations/repositories/integration-mapping-repository.js +0 -29
  76. package/integrations/repositories/integration-not-found.js +14 -0
  77. package/integrations/repositories/integration-repository-documentdb.js +37 -9
  78. package/integrations/repositories/integration-repository-interface.js +17 -4
  79. package/integrations/repositories/integration-repository-mongo.js +64 -96
  80. package/integrations/repositories/integration-repository-postgres.js +63 -81
  81. package/integrations/repositories/process-repository-mongo.js +1 -1
  82. package/integrations/schedule-config.js +80 -0
  83. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  84. package/integrations/use-cases/create-integration.js +56 -31
  85. package/integrations/use-cases/delete-integration-for-user.js +4 -4
  86. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  87. package/integrations/use-cases/get-integration-for-user.js +2 -4
  88. package/integrations/use-cases/get-integration-instance.js +4 -4
  89. package/integrations/use-cases/get-integrations-for-user.js +79 -39
  90. package/integrations/use-cases/get-possible-integrations.js +2 -7
  91. package/integrations/use-cases/index.js +7 -1
  92. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  93. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  94. package/integrations/use-cases/update-integration.js +4 -4
  95. package/integrations/utils/map-integration-dto.js +43 -14
  96. package/management-api/create-management-api-router.js +49 -0
  97. package/management-api/error-response.js +116 -0
  98. package/management-api/management-api-config.js +151 -0
  99. package/management-api/meta-router.js +76 -0
  100. package/management-api/openapi/build-openapi.js +144 -0
  101. package/management-api/openapi/components.js +415 -0
  102. package/management-api/openapi/v1-operations.js +83 -0
  103. package/management-api/openapi/v2-operations.js +264 -0
  104. package/management-api/route-registry.js +411 -0
  105. package/management-api/test-support/http-client.js +39 -0
  106. package/management-api/use-cases/get-management-api-meta.js +57 -0
  107. package/management-api/v1-deprecation.js +120 -0
  108. package/management-api/v2/create-v2-router.js +85 -0
  109. package/management-api/v2/dependencies.js +281 -0
  110. package/management-api/v2/dto.js +143 -0
  111. package/management-api/v2/handlers/authorization.js +94 -0
  112. package/management-api/v2/handlers/credentials.js +33 -0
  113. package/management-api/v2/handlers/entities.js +77 -0
  114. package/management-api/v2/handlers/integrations.js +128 -0
  115. package/management-api/v2/handlers/proxy.js +34 -0
  116. package/management-api/v2/request.js +85 -0
  117. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  118. package/modules/domain/authorization-session.js +144 -0
  119. package/modules/entity-scope.js +15 -0
  120. package/modules/entity-type-registry.js +311 -0
  121. package/modules/index.js +14 -0
  122. package/modules/module-factory.js +12 -0
  123. package/modules/module.js +53 -2
  124. package/modules/proxy/proxy-bases.js +169 -0
  125. package/modules/proxy/proxy-errors.js +31 -0
  126. package/modules/proxy/proxy-path.js +183 -0
  127. package/modules/proxy/proxy-policy.js +277 -0
  128. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  129. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  130. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  131. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  132. package/modules/repositories/entity-not-found.js +18 -0
  133. package/modules/repositories/module-repository-documentdb.js +51 -6
  134. package/modules/repositories/module-repository-interface.js +19 -2
  135. package/modules/repositories/module-repository-mongo.js +67 -74
  136. package/modules/repositories/module-repository-postgres.js +66 -74
  137. package/modules/repositories/module-repository.js +2 -1
  138. package/modules/requester/proxy-request.js +245 -0
  139. package/modules/requester/requester.js +35 -5
  140. package/modules/scope-config.js +192 -0
  141. package/modules/scope-policy.js +112 -0
  142. package/modules/scope-resolution.js +288 -0
  143. package/modules/scope-visibility.js +247 -0
  144. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  145. package/modules/tests/doubles/ownership-matching.js +18 -0
  146. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  147. package/modules/use-cases/create-entity-from-credential.js +113 -0
  148. package/modules/use-cases/entity-operations-for-user.js +162 -0
  149. package/modules/use-cases/entity-types.js +139 -0
  150. package/modules/use-cases/execute-entity-proxy-request.js +331 -0
  151. package/modules/use-cases/get-authorization-requirements.js +100 -0
  152. package/modules/use-cases/get-entities-for-user.js +26 -13
  153. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  154. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  155. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  156. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  157. package/modules/use-cases/get-module.js +10 -4
  158. package/modules/use-cases/manage-access.js +21 -0
  159. package/modules/use-cases/multi-step-authorization.js +329 -0
  160. package/modules/use-cases/process-authorization-callback.js +53 -19
  161. package/modules/use-cases/refresh-entity-options.js +10 -4
  162. package/modules/use-cases/test-module-auth.js +10 -4
  163. package/package.json +6 -7
  164. package/prisma-mongodb/schema.prisma +86 -59
  165. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  166. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  167. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  168. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  169. package/prisma-postgresql/schema.prisma +81 -57
  170. package/providers/index.js +23 -0
  171. package/providers/resolve-provider.js +93 -0
  172. package/providers/runtime-detection.js +124 -0
  173. package/queues/index.js +25 -0
  174. package/queues/providers/index.js +11 -0
  175. package/queues/providers/netlify-background-provider.js +150 -0
  176. package/queues/providers/qstash-queue-provider.js +219 -0
  177. package/queues/providers/sqs-queue-provider.js +70 -0
  178. package/queues/queue-client-interface.js +55 -0
  179. package/queues/queue-message-auth.js +193 -0
  180. package/queues/queue-provider-factory.js +101 -0
  181. package/queues/queue-provider.js +65 -0
  182. package/queues/queuer-util.js +2 -9
  183. package/queues/sqs-queue-client.js +81 -0
  184. package/types/index.d.ts +2 -0
  185. package/types/modules/index.d.ts +214 -0
  186. package/types/user/index.d.ts +134 -0
  187. package/user/adopter-jwt-config.js +177 -0
  188. package/user/adopter-jwt-errors.js +59 -0
  189. package/user/adopter-token-verifier.js +122 -0
  190. package/user/app-user-helpers.js +97 -0
  191. package/user/create-user-authentication.js +64 -0
  192. package/user/membership.js +51 -0
  193. package/user/repositories/organization-membership-record.js +19 -0
  194. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  195. package/user/repositories/organization-membership-repository-factory.js +42 -0
  196. package/user/repositories/organization-membership-repository-interface.js +57 -0
  197. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  198. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  199. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  200. package/user/repositories/user-repository-documentdb.js +27 -2
  201. package/user/repositories/user-repository-interface.js +18 -0
  202. package/user/repositories/user-repository-mongo.js +24 -0
  203. package/user/repositories/user-repository-postgres.js +18 -0
  204. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  205. package/user/use-cases/authenticate-user.js +7 -1
  206. package/user/use-cases/ensure-organization-membership.js +92 -0
  207. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  208. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  209. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  210. package/user/user.js +64 -1
  211. package/errors/mapping-already-exists-error.js +0 -14
  212. package/handlers/workers/integration-triggers.js +0 -25
  213. package/infrastructure/scheduler/recurrence.js +0 -204
  214. package/integrations/define/DESIGN.md +0 -122
  215. package/integrations/define/config.js +0 -98
  216. package/integrations/define/define-app.js +0 -66
  217. package/integrations/define/define-extension.js +0 -154
  218. package/integrations/define/define-integration.js +0 -335
  219. package/integrations/define/examples/deal-alerts.js +0 -140
  220. package/integrations/define/extension-binding.js +0 -174
  221. package/integrations/define/index.js +0 -11
  222. package/integrations/define/json-schema-lite.js +0 -68
  223. package/integrations/define/subscriptions.js +0 -186
  224. package/integrations/define/tests/fixtures/hubspot.js +0 -109
  225. package/integrations/define/tests/fixtures/slack.js +0 -26
  226. package/integrations/define/use.js +0 -39
  227. package/integrations/definitions/README.md +0 -278
  228. package/integrations/definitions/canonical.js +0 -11
  229. package/integrations/definitions/capabilities/AUDIT.md +0 -480
  230. package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
  231. package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
  232. package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
  233. package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
  234. package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
  235. package/integrations/definitions/capabilities/github.capabilities.json +0 -585
  236. package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
  237. package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
  238. package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
  239. package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
  240. package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
  241. package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
  242. package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
  243. package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
  244. package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
  245. package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
  246. package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
  247. package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
  248. package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
  249. package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
  250. package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
  251. package/integrations/definitions/compile.js +0 -80
  252. package/integrations/definitions/config-form.js +0 -137
  253. package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
  254. package/integrations/definitions/examples/asana-slack.definition.json +0 -219
  255. package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
  256. package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
  257. package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
  258. package/integrations/definitions/examples/github-linear.definition.json +0 -322
  259. package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
  260. package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
  261. package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
  262. package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
  263. package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
  264. package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
  265. package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
  266. package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
  267. package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
  268. package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
  269. package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
  270. package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
  271. package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
  272. package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
  273. package/integrations/definitions/expressions.js +0 -229
  274. package/integrations/definitions/index.js +0 -48
  275. package/integrations/definitions/integration-runtime.js +0 -779
  276. package/integrations/definitions/interpreter.js +0 -104
  277. package/integrations/definitions/manifests.js +0 -177
  278. package/integrations/definitions/renderers/common.js +0 -67
  279. package/integrations/definitions/renderers/html.js +0 -129
  280. package/integrations/definitions/renderers/hubspot.js +0 -59
  281. package/integrations/definitions/renderers/preview.js +0 -154
  282. package/integrations/definitions/renderers/slack.js +0 -146
  283. package/integrations/definitions/repositories/definition-version-repository.js +0 -67
  284. package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
  285. package/integrations/definitions/router.js +0 -170
  286. package/integrations/definitions/runtime.js +0 -268
  287. package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
  288. package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
  289. package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
  290. package/integrations/definitions/schema/records.md +0 -105
  291. package/integrations/definitions/schema-check.js +0 -58
  292. package/integrations/definitions/scripts/check-manifests.js +0 -220
  293. package/integrations/definitions/scripts/validate.js +0 -66
  294. package/integrations/definitions/steps.js +0 -123
  295. package/integrations/definitions/sync-engine.js +0 -297
  296. package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
  297. package/integrations/definitions/transforms.js +0 -91
  298. package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
  299. package/integrations/definitions/validate.js +0 -436
  300. package/integrations/definitions/versioning.js +0 -176
  301. package/integrations/definitions/webhooks.js +0 -119
  302. package/integrations/integration-types.js +0 -73
  303. package/integrations/triggers.js +0 -244
  304. package/integrations/use-cases/run-trigger-tick.js +0 -155
  305. package/integrations/use-cases/run-trigger.js +0 -77
  306. package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
  307. package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
  308. package/prisma-postgresql/migrations/20261019120000_definition_workspace/migration.sql +0 -5
@@ -1,779 +0,0 @@
1
- /**
2
- * IntegrationRuntime: one IntegrationBase subclass that runs any published definition.
3
- *
4
- * Frigg registers integration classes at startup (appDefinition.integrations). For integrations published as definitions the app
5
- * registers one generated class per definition id:
6
- *
7
- * const { defineIntegrationClass } = require('@friggframework/core/integrations/definitions');
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 crypto = require('crypto');
20
- const { compile, routeWebhook, eventName } = require('./compile');
21
- const { argumentsFor, makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, runLifecycle, resolveOptions, installContext } = require('./runtime');
22
- const { createSyncEngine } = require('./sync-engine');
23
- const { eventsIn, handshakeFor, modulesFor } = require('./webhooks');
24
- const { buildConfigForm, missingRequired } = require('./config-form');
25
-
26
- /** The tick every trigger of a published definition runs on: a chosen interval is rounded up to whole multiples of it. */
27
- const INSTANCE_TICK = '5m';
28
-
29
- /** A compiled definition's triggers as IntegrationBase trigger declarations: each runs its compiled event. */
30
- function triggersOf(def, config) {
31
- return compile(def, config).triggers.map((t) => ({
32
- ...t,
33
- tick: INSTANCE_TICK,
34
- handler(payload) {
35
- const event = this.events?.[t.name];
36
- if (!event) throw new Error(`Trigger "${t.name}" has no compiled event in this installation`);
37
- return event.handler({ data: { integrationId: this.id, ...payload } });
38
- },
39
- }));
40
- }
41
-
42
- /**
43
- * Shape A — one class per definition id (defineIntegrationClass): the class name and modules come from the
44
- * definition. Publishing a new VERSION deploys nothing; a new definition ID is a new class (a deploy, and its own
45
- * functions and queue). Right for a product team with a handful of integrations.
46
- *
47
- * Shape B — one class for every definition (defineSharedIntegrationClass): registered once as "integration-runtime", its
48
- * static modules are every API module the app has installed, and each installation names its definition id and
49
- * version in config. A new integration is a row, not a deploy, and every installation shares one set of functions
50
- * and one queue. Right for a multi-tenant product, where definitions accumulate without bound.
51
- */
52
- function defineIntegrationClass({ id, definitions, manifests, modules, runCode, IntegrationBase, lookup, runs }) {
53
- const latest = definitions.latest(id);
54
- 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`);
55
- return makeClass({
56
- name: latest.id, defaultId: id, definitions, manifests, runCode, IntegrationBase, lookup, runs,
57
- modules: Object.fromEntries(Object.keys(latest.modules).map((key) => [key, { definition: modules[key] }])),
58
- display: { label: latest.display.label, description: latest.display.description, category: latest.display.category },
59
- version: latest.version,
60
- });
61
- }
62
-
63
- function defineSharedIntegrationClass({ name = 'integration-runtime', definitions, manifests, modules, runCode, IntegrationBase, lookup, runs }) {
64
- return makeClass({
65
- name, defaultId: null, definitions, manifests, runCode, IntegrationBase, lookup, runs,
66
- modules: Object.fromEntries(Object.entries(modules).map(([key, definition]) => [key, { definition }])),
67
- display: { label: 'Integration runtime', description: 'Runs any published integration definition.', category: 'Custom' },
68
- version: '0.1.0',
69
- });
70
- }
71
-
72
- /**
73
- * App-level webhooks (one URL for every account: HubSpot, Stripe Connect, GitHub Apps) arrive with no integration id.
74
- * The default lookup finds the installations of THIS class connected to that account, using Frigg's commands: the
75
- * entity reverse lookup, then each candidate's record, because one account can be used by integrations of other
76
- * types too, and the worker would hydrate those as their own class.
77
- */
78
- /**
79
- * A code integration that also embeds a definition: the class keeps its own name, modules, handlers and lifecycle,
80
- * and gains what a published integration has — workflows (with delay and approval pauses), syncs, the generated
81
- * configure form, UI surfaces, versions and run history — through the same records and routes. Code is the escape
82
- * hatch inside it: a `code` step whose ref is `#<method>` calls that method on the integration.
83
- *
84
- * class HubspotSlack extends IntegrationBase { static Definition = { name: 'hubspot-slack', modules, … }; … }
85
- * module.exports = extendIntegrationClass(HubspotSlack, { definition: require('./hubspot-slack.definition.json'), manifests });
86
- *
87
- * What the definition does not declare stays the code's: with no webhook-triggered workflow or sync the class's own
88
- * webhook handling runs; with no settings, its own configure form; its own triggers run beside the compiled ones.
89
- */
90
- function extendIntegrationClass(CodeClass, { definition, manifests, runCode, runs, lookup }) {
91
- const name = CodeClass?.Definition?.name;
92
- if (!name) throw new Error('extendIntegrationClass needs an IntegrationBase subclass with a static Definition.name');
93
- if (!definition || definition.id !== name) throw new Error(`The definition embedded in "${name}" must have the id "${name}" (it has "${definition?.id}")`);
94
- const { validate } = require('./validate');
95
- const codeMethods = [];
96
- for (let proto = CodeClass.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
97
- for (const k of Object.getOwnPropertyNames(proto)) if (k !== 'constructor' && typeof Object.getOwnPropertyDescriptor(proto, k).value === 'function') codeMethods.push(k);
98
- }
99
- const problems = (validate(definition, manifests, { codeMethods }).errors || []);
100
- if (problems.length) throw new Error(`The definition embedded in "${name}" is not valid: ${problems.map((e) => `${e.path || ''} ${e.message}`.trim()).join('; ')}`);
101
- return makeClass({
102
- name, defaultId: name, definitions: memoryDefinitions([definition]), manifests, runCode, IntegrationBase: CodeClass, lookup, runs,
103
- modules: CodeClass.Definition.modules, display: CodeClass.Definition.display, version: CodeClass.Definition.version, embedded: true,
104
- });
105
- }
106
-
107
- /** Whether a definition has recurring work of its own (a schedule-triggered workflow or a polling sync). */
108
- const hasRecurring = (def) => Boolean((def?.workflows || []).some((w) => w.trigger?.type === 'schedule') || (def?.syncs || []).some((sy) => sy.ongoing?.mode === 'poll'));
109
- /** Whether a definition receives webhooks itself (a webhook-triggered workflow or a webhook-driven sync). */
110
- const ownsWebhooks = (def) => Boolean((def?.workflows || []).some((w) => w.trigger?.type === 'webhook') || (def?.syncs || []).some((sy) => sy.ongoing?.mode === 'webhook'));
111
- /** Whether a definition generates the configure form (it declares settings, at its level or a workflow's). */
112
- const ownsConfigForm = (def) => Boolean(Object.keys(def?.settings || {}).length || (def?.workflows || []).some((w) => Object.keys(w.settings || {}).length));
113
-
114
- function friggLookup(accepts) {
115
- return async (externalId, moduleName) => {
116
- const { createIntegrationCommands } = require('../../application/commands/integration-commands');
117
- const commands = createIntegrationCommands();
118
- const ids = await commands.listIntegrationsByEntityExternalId(externalId, moduleName);
119
- const out = [];
120
- for (const integrationId of ids) {
121
- const found = await commands.loadIntegrationContextById(integrationId);
122
- if (await accepts(found?.context?.record?.config?.type)) out.push(integrationId);
123
- }
124
- return out;
125
- };
126
- }
127
-
128
- function makeClass({ name, defaultId, definitions, manifests, runCode, IntegrationBase, modules, display, version, lookup, runs, embedded = false }) {
129
- const Base = IntegrationBase || require('../integration-base').IntegrationBase;
130
- const id = name;
131
- // Where workflow runs are kept: the WorkflowRun table by default (loaded lazily, so tests and tools without a
132
- // database can pass their own store or `false` to keep none).
133
- let runStore = runs;
134
- // App-level webhooks fan out to the installations of every type this deployment runs.
135
- const findInstallations = (externalId, moduleName, Cls) => (lookup || friggLookup(async (type) => type === id || type === Cls.Definition.name || Boolean(await IntegrationRuntime.IntegrationProvider?.resolve(type))))(externalId, moduleName);
136
- const runRepository = () => (runStore === undefined ? (runStore = require('./repositories/workflow-run-repository').createWorkflowRunRepository()) : runStore);
137
- const baseFor = (typeName) => `${process.env.FRIGG_PUBLIC_URL || ''}/api/${typeName}-integration/webhooks`;
138
- const pinnedDefinitions = new Map(); // `${id}@${version}` -> definition, for the trigger tick
139
- // A code class embedding a definition provisions the per-installation tick only when its definition has
140
- // schedule-triggered workflows or polling syncs; its own Definition.triggers are provisioned as they are.
141
- const embeddedRecurring = embedded && hasRecurring(definitions.latest(defaultId));
142
- // A code class's own Definition.triggers (IntegrationBase.triggersFor), kept beside the compiled ones.
143
- const codeTriggers = async (Cls, installation) => (embedded && typeof Base.triggersFor === 'function' ? Base.triggersFor.call(Cls, installation) : []);
144
-
145
- class IntegrationRuntime extends Base {
146
- // version is display only; installations pin their own. A code class keeps every part of its Definition.
147
- // instanceTriggers: schedule-triggered workflows and polling syncs are this class's recurring triggers,
148
- // resolved per installation (triggersFor) on one 5m tick.
149
- static Definition = embedded
150
- ? { ...Base.Definition, webhooks: Base.Definition.webhooks ?? { enabled: true }, ...(embeddedRecurring ? { instanceTriggers: { tick: INSTANCE_TICK } } : {}) }
151
- : { name, version, modules, webhooks: { enabled: true }, display, instanceTriggers: { tick: INSTANCE_TICK } };
152
-
153
- /**
154
- * The recurring triggers of one installation (ADR-055): its pinned definition compiled with its config, so a
155
- * switched-off workflow has none and a configurable one carries the installation's choice. Each runs its
156
- * compiled event. A code class embedding a definition keeps its own Definition.triggers beside them. Read by
157
- * the shared tick from the record alone (no modules, no credentials).
158
- */
159
- static async triggersFor(installation = {}) {
160
- const config = installation.config || {};
161
- const own = await codeTriggers(this, installation);
162
- const fixedId = this.definitionId;
163
- let pin = config.definition || (fixedId ? { id: fixedId } : null);
164
- if (!pin?.id) return own;
165
- if (embedded) pin = { id: pin.id }; // the definition ships with the code: the deployed version runs
166
- let def;
167
- if (pin.version) {
168
- // Published versions are immutable, so a tick over many installations reads each one once.
169
- const key = `${pin.id}@${pin.version}`;
170
- if (!pinnedDefinitions.has(key)) {
171
- if (pinnedDefinitions.size >= 100) pinnedDefinitions.clear();
172
- pinnedDefinitions.set(key, await definitions.get(pin.id, pin.version, { checksum: pin.checksum }));
173
- }
174
- def = pinnedDefinitions.get(key);
175
- } else {
176
- def = await definitions.latest(pin.id);
177
- }
178
- return def ? [...own, ...triggersOf(def, config)] : own;
179
- }
180
-
181
- /**
182
- * The installation types one tick of this class covers (RunTriggerTick): its own, plus, for the shared class,
183
- * every published definition it provides, because each of those is an integration type of its own that runs
184
- * on this deployment.
185
- */
186
- static async triggerTypes() {
187
- const own = this.Definition.name;
188
- if (!this.IntegrationProvider) return [own];
189
- const provided = (await this.IntegrationProvider.list()).map((c) => c.Definition.name);
190
- return [...new Set([own, ...provided])];
191
- }
192
-
193
- /** How the routers find integration runtime classes and the store and manifests they run on. */
194
- static IntegrationRuntime = { definitions, manifests, shared: !defaultId, runs: () => runRepository() };
195
-
196
- /** Shape A: the one definition this class runs. A class generated for a published definition sets its own. */
197
- static definitionId = defaultId;
198
-
199
- /**
200
- * Frigg's use cases construct an integration from its record and call initialize() before anything else
201
- * (create, update, delete, webhooks, user actions, config options). So this is where the pinned definition is
202
- * loaded: every real entry point hydrates through here. A dry instance (no record, e.g. an app-level webhook
203
- * before its installation is known) has no pin and loads nothing.
204
- */
205
- async initialize() {
206
- if (this.config?.definition || this.constructor.definitionId) await this.loadDefinition();
207
- return super.initialize();
208
- }
209
-
210
- async loadDefinition() {
211
- const fixedId = this.constructor.definitionId;
212
- let pin = this.config?.definition || (fixedId ? { id: fixedId } : null);
213
- if (fixedId && pin.id !== fixedId) throw new Error(`This installation pins "${pin.id}", but its type is "${fixedId}"`);
214
- if (!pin?.id) throw new Error('This installation does not name a definition (config.definition.id)');
215
- // A definition embedded in code ships with the code: an installation runs the version deployed now.
216
- if (embedded) pin = { id: pin.id };
217
- if (!pin.version) {
218
- // Installed by id alone: pin the latest published version now (onCreate persists the pin).
219
- const latest = await definitions.latest(pin.id);
220
- if (!latest) throw new Error(`No published version of "${pin.id}"`);
221
- pin = { id: pin.id, version: latest.version, checksum: await definitions.checksumOf?.(pin.id, latest.version) || undefined };
222
- // Persisted by onCreate, so this installation stays on this version until it is upgraded.
223
- this.config = { ...(this.config || {}), definition: pin };
224
- }
225
- this.def = await definitions.get(pin.id, pin.version, { checksum: pin.checksum });
226
- if (!this.def) throw new Error(`Definition ${pin.id}@${pin.version} is not published`);
227
- this.compiled = compile(this.def, this.config || {});
228
- this.ports = this.portsFor(this.def);
229
- // Replace, not merge: a workflow switched off since the last compile must lose its event.
230
- for (const n of this.compiledEventNames || []) delete this.events[n];
231
- this.compiledEventNames = Object.keys(this.compiled.events);
232
- for (const [n, e] of Object.entries(this.compiled.events)) {
233
- this.events[n] = { ...e, handler: (payload) => this.dispatch(e.ref, payload) };
234
- }
235
- this.registerEventHandlers();
236
- return this.def;
237
- }
238
-
239
- /** Ports for a definition: Api calls through the manifest, and links/dedupe over IntegrationMapping. */
240
- portsFor(def) {
241
- return makePorts({
242
- def, manifests,
243
- // `#method` is a method of this integration (a code class embedding a definition); anything else is a
244
- // registered code step.
245
- runCode: (ref, args) => {
246
- if (typeof ref === 'string' && ref.startsWith('#')) {
247
- const fn = this[ref.slice(1)];
248
- if (typeof fn !== 'function') throw new Error(`code: this integration has no method "${ref.slice(1)}"`);
249
- return fn.call(this, args);
250
- }
251
- if (!runCode) throw new Error('code: this runtime has no code steps registered');
252
- return runCode(ref, args);
253
- },
254
- // The shared class registers modules by module name; a definition may name them by its own keys.
255
- callApi: (moduleKey, method, args, order) => this.moduleFor(moduleKey).api[method](...argumentsFor(args, order)),
256
- store: this.mappingStore(),
257
- });
258
- }
259
-
260
- /** Key-value rows in IntegrationMapping: links, dedupe claims and sync progress all live here. */
261
- mappingStore() {
262
- return {
263
- get: async (key) => (await this.getMapping(key))?.mapping || null,
264
- put: (key, value) => this.upsertMapping(key, value),
265
- claim: async (key, value) => { try { await this.createMapping(key, value); return true; } catch (e) { if (e.name === 'MappingAlreadyExistsError') return false; throw e; } },
266
- release: (key) => this.deleteMapping(key),
267
- };
268
- }
269
-
270
- moduleFor(key) {
271
- const m = this[key] || this[this.def?.modules?.[key]?.module];
272
- if (!m?.api) throw new Error(`Module "${key}" is not connected to this installation`);
273
- return m;
274
- }
275
-
276
- async dispatch(ref, payload = {}) {
277
- const msg = payload.data || payload;
278
- if (msg.resume) return this.resumeRun(msg.resume, msg); // a paused run carries on (its own version)
279
- // A message queued under an older version runs on that version (an upgrade drains, it never re-interprets).
280
- const def = msg.definitionVersion && msg.definitionVersion !== this.def.version
281
- ? await definitions.get(this.def.id, msg.definitionVersion) || this.def
282
- : this.def;
283
- const installation = this.installation();
284
- // A trigger run may be delivered twice (a redelivery, two ticks in one slot); the trigger and the time it
285
- // was scheduled for make its dedupe id.
286
- const eventId = msg.eventId || (msg.trigger && msg.scheduledFor ? `tick:${msg.trigger}:${msg.scheduledFor}` : undefined);
287
- if (ref.kind === 'workflow') return this.recordRun(await runWorkflow({ def, installation, workflowId: ref.id, event: msg.body ?? msg, eventId, record: msg.record, ports: this.ports, manifests }));
288
- if (ref.kind === 'query') return runQuery({ def, installation, queryId: ref.id, record: msg.record, ports: this.ports });
289
- if (ref.kind === 'ui') return renderSurface({ def, installation, surfaceId: ref.id, record: payload.req?.body?.record || msg.record, ports: this.ports });
290
- if (ref.kind === 'sync') return this.runSync(ref, msg);
291
- throw new Error(`Unknown event ref ${JSON.stringify(ref)}`);
292
- }
293
-
294
- /**
295
- * Receipt (no database): answer a provider handshake synchronously (Slack url_verification, Asana
296
- * X-Hook-Secret), otherwise queue the delivery as Frigg always does. The URL names the module (?module=).
297
- */
298
- async onWebhookReceived({ req, res }) {
299
- if (embedded && !ownsWebhooks(this.def)) return super.onWebhookReceived({ req, res });
300
- const manifest = manifests[req.query?.module];
301
- const h = manifest && handshakeFor(manifest, { body: req.body, headers: req.headers, query: req.query });
302
- if (h) {
303
- for (const [k, v] of Object.entries(h.headers || {})) res.set(k, String(v));
304
- return typeof h.body === 'object' ? res.status(h.status).json(h.body) : res.status(h.status).send(h.body);
305
- }
306
- return super.onWebhookReceived({ req, res });
307
- }
308
-
309
- /**
310
- * Worker side of a raw delivery. Without an installation (an app-level webhook), find the installations
311
- * connected to each account the delivery is about and queue it for each. With one: verify it, split it into
312
- * module events, and queue one message per enabled workflow listening for each event.
313
- */
314
- async onWebhook(params) {
315
- if (embedded && !ownsWebhooks(this.def)) return super.onWebhook(params);
316
- const { data = {} } = params || {};
317
- if (data.module && data.topic) return this.routeEvents(data.module, [{ topic: data.topic, event: data.body, eventId: data.eventId || data.body?.id || null }]);
318
- const delivery = { body: data.body, headers: data.headers || {}, query: data.query || {}, rawBody: data.rawBody, method: data.method, url: data.url };
319
- // No installation yet (an app-level webhook): find the ones it is for.
320
- if (!this.id || !this.def) return this.fanOutAppWebhook(delivery);
321
-
322
- const keys = modulesFor(this.def, manifests, delivery);
323
- if (keys.length !== 1) { this.logWebhook('webhook.unrouted', { module: delivery.query.module || null, candidates: keys }); return { routed: 0 }; }
324
- const key = keys[0];
325
- const manifest = manifests[this.def.modules[key].module];
326
- if (manifest.webhook?.verify) {
327
- let ok = false;
328
- // The raw body, method and URL travel from receipt (IntegrationBase.onWebhookReceived): vendors sign bytes.
329
- try { ok = await this.moduleFor(key).api[manifest.webhook.verify]({ headers: delivery.headers, body: delivery.body, query: delivery.query, rawBody: delivery.rawBody, method: delivery.method, url: delivery.url }); } catch { ok = false; }
330
- if (ok !== true) { this.logWebhook('webhook.rejected', { module: key }); return { routed: 0, rejected: true }; }
331
- }
332
- const own = this.accountOf(key);
333
- const events = eventsIn(manifest, delivery).filter((e) => manifest.webhook?.scope !== 'app' || !own || !e.account || String(e.account) === String(own));
334
- return this.routeEvents(key, events);
335
- }
336
-
337
- async routeEvents(moduleKey, events) {
338
- let routed = 0;
339
- for (const e of events) {
340
- for (const n of routeWebhook(this.compiled, moduleKey, e.topic)) {
341
- await this.queueWebhook({ event: n, integrationId: this.id, module: moduleKey, topic: e.topic, body: e.event, eventId: e.eventId, definitionVersion: this.def.version });
342
- routed += 1;
343
- }
344
- }
345
- return { routed };
346
- }
347
-
348
- async fanOutAppWebhook(delivery) {
349
- const moduleName = delivery.query.module;
350
- const manifest = manifests[moduleName];
351
- 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 }; }
352
- const accounts = new Set(eventsIn(manifest, delivery).map((e) => e.account).filter(Boolean));
353
- let queued = 0;
354
- for (const account of accounts) {
355
- for (const integrationId of await findInstallations(account, moduleName, this.constructor)) {
356
- await this.queueWebhook({ integrationId, body: delivery.body, headers: delivery.headers, query: delivery.query, rawBody: delivery.rawBody, method: delivery.method, url: delivery.url });
357
- queued += 1;
358
- }
359
- }
360
- return { queued };
361
- }
362
-
363
- /** The connected account's id for a module (its entity externalId): what app-level events are matched on. */
364
- accountOf(key) {
365
- const m = this[key] || this[this.def?.modules?.[key]?.module] || this.modules?.[this.def?.modules?.[key]?.module];
366
- return m?.entity?.externalId ?? null;
367
- }
368
-
369
- logWebhook(eventName, fields) {
370
- try { require('../../logs').getLogger('frigg.integrations.runtime').info('Webhook not routed', { eventName: `frigg.integrations.runtime.${eventName}`, integrationId: this.id || null, ...fields }); } catch { /* logging must never break delivery */ }
371
- }
372
-
373
- async onCreate(params) {
374
- if (!this.def) await this.loadDefinition();
375
- const out = await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onInstall', ports: this.ports });
376
- // Every onInstall step with an id keeps its result, so onUninstall can undo exactly what install did
377
- // ($.install.runtime.installed.<step id>). Saved even when empty, so a re-install replaces stale ids.
378
- const installed = Object.fromEntries((this.def.lifecycle?.onInstall || []).filter((st) => st.id).map((st) => [st.id, out.steps?.[st.id] ?? null]));
379
- await this.patchConfig({ definition: this.config.definition, runtime: { ...(this.config?.runtime || {}), installed } });
380
- const result = await super.onCreate(params);
381
- // Syncs that copy what already exists at install time start their backfill now (through the queue).
382
- for (const sy of this.def.syncs || []) {
383
- if (sy.backfill?.trigger === 'onInstall' && this.compiled.events[eventName('SYNC', sy.id, 'PAGE')]) await this.startBackfill(sy.id, 'install');
384
- }
385
- return result;
386
- }
387
-
388
- async onUpdate(params) {
389
- await super.onUpdate(params); // merges config (enabled workflows, settings, mapping overrides)
390
- return this.loadDefinition(); // recompile: enabling a workflow adds its event, disabling removes it
391
- }
392
-
393
- async onDelete(params) {
394
- if (!this.def) await this.loadDefinition();
395
- await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onUninstall', ports: this.ports });
396
- return super.onDelete(params);
397
- }
398
-
399
- /**
400
- * This installation's triggers, from the definition it already loaded and its current config, beside a code
401
- * class's own Definition.triggers.
402
- */
403
- async triggerDeclarations() {
404
- if (!this.def) return this.constructor.triggersFor({ config: this.config || {}, record: this.record });
405
- const own = await codeTriggers(this.constructor, { config: this.config || {}, record: this.record });
406
- return [...own, ...triggersOf(this.def, this.config || {})];
407
- }
408
-
409
- /**
410
- * The queue worker's seam (IntegrationEventDispatcher.dispatchJob): a code integration's own queued events are
411
- * runs too, recorded in the same history as its workflows. Compiled events record their own runs (with steps).
412
- * As with workflows, the history never decides the outcome: a failed write is logged and the handler's result
413
- * or error stands.
414
- */
415
- async runEvent(event, invoke) {
416
- if (!embedded || (this.compiledEventNames || []).includes(event)) return invoke();
417
- const startedAt = new Date().toISOString();
418
- const write = async (status, error) => {
419
- try {
420
- const store = runRepository();
421
- if (store) await store.createRun({ integrationId: this.id, definitionId: name, definitionVersion: this.def?.version, workflowId: event, triggerType: 'code', status, error: error ? String(error.message || error) : null, retryable: Boolean(error), trace: [], startedAt, endedAt: new Date().toISOString() });
422
- } catch (e) {
423
- try { require('../../logs').getLogger('frigg.integrations.runtime').warn('Workflow run not recorded', { eventName: 'frigg.integrations.runtime.run.not_recorded', integrationId: this.id, workflowId: event, cause: e?.message }); } catch { /* ignore */ }
424
- }
425
- };
426
- let out;
427
- try { out = await invoke(); } catch (e) { await write('failed', e); throw e; }
428
- await write('completed');
429
- return out;
430
- }
431
-
432
- /** The configure form (GET /api/integrations/:id/config/options), generated from the definition. */
433
- async getConfigOptions() {
434
- if (embedded && !ownsConfigForm(this.def)) return super.getConfigOptions();
435
- return buildConfigForm(this.def, this.config || {}, (path, values, integrationSettings) => this.optionsFor(path, values, integrationSettings));
436
- }
437
-
438
- /** The same form re-filled from the values chosen so far (POST …/config/options/refresh): dependent dropdowns. */
439
- async refreshConfigOptions(params = {}) {
440
- if (embedded && !ownsConfigForm(this.def)) return super.refreshConfigOptions(params);
441
- const values = params.config || params.formData || params.data || params;
442
- return buildConfigForm(this.def, { ...(this.config || {}), ...values }, (path, v, integrationSettings) => this.optionsFor(path, v, integrationSettings));
443
- }
444
-
445
- async optionsFor(path, values, integrationSettings) {
446
- try { return await resolveOptions({ def: this.def, manifests, ports: this.ports, path, values, integrationSettings }); } catch { return []; }
447
- }
448
-
449
- /** Required settings come from the definition; checked without calling any API (the base class renders the form). */
450
- async validateConfig() {
451
- if (embedded && !ownsConfigForm(this.def)) return super.validateConfig();
452
- return missingRequired(this.def, this.config || {}).length > 0;
453
- }
454
-
455
- /** The input form for a button workflow (POST …/actions/:id/options): the inputs of the UI buttons that run it. */
456
- async getActionOptions(actionId) {
457
- const ref = this.compiled?.events?.[actionId]?.ref;
458
- const inputs = {};
459
- 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); };
460
- if (ref?.kind === 'workflow') (this.def.ui || []).forEach((u) => visit(u.view));
461
- const properties = Object.fromEntries(Object.entries(inputs).map(([k, f]) => [k, { type: f.type === 'number' ? 'number' : 'string', title: f.label || k }]));
462
- return { jsonSchema: Object.keys(properties).length ? { type: 'object', properties: { input: { type: 'object', properties } } } : {}, uiSchema: {} };
463
- }
464
-
465
- installation() {
466
- // An installation's webhooks arrive at its own type's URL. App-level webhooks (one URL registered with the
467
- // vendor for every account) arrive at the deployment's URL, which finds the installations they are for.
468
- const base = baseFor(this.constructor.Definition.name);
469
- const appBase = baseFor(this.constructor.getDeploymentName?.() || id);
470
- const webhookUrls = Object.fromEntries(Object.entries(this.def?.modules || {}).map(([key, m]) => [key, manifests[m.module]?.webhook?.scope === 'app'
471
- ? `${appBase}?module=${encodeURIComponent(m.module)}`
472
- : `${base}/${this.id}?module=${encodeURIComponent(m.module)}`]));
473
- return { ...(this.config || {}), integrationId: this.id, accounts: this.accounts(), webhookUrl: `${base}/${this.id}`, webhookUrls };
474
- }
475
-
476
- /** $.install.accounts: per module key, the account fields its manifest declares, read from the module's Api
477
- * (where apiPropertiesToPersist land after auth). An undeclared property is never exposed. */
478
- accounts() {
479
- return Object.fromEntries(Object.entries(this.def?.modules || {}).map(([key, m]) => {
480
- const fields = manifests[m.module]?.account?.fields || [];
481
- let api = {};
482
- try { api = this.moduleFor(key).api || {}; } catch { api = {}; }
483
- return [key, Object.fromEntries(fields.map((f) => [f.name, api[f.name] ?? null]))];
484
- }));
485
- }
486
-
487
- /**
488
- * Move this installation to another published version (default: the latest). With apply false, only the plan:
489
- * what a person must supply first and what will happen. Applying refuses while anything is needed, and when
490
- * install steps changed, undoes the old ones before running the new ones (it never leaves the old webhooks).
491
- */
492
- async upgradeTo(toVersion, { apply = false } = {}) {
493
- // An embedded definition's version is the code's: a deploy moves every installation, nothing else does.
494
- if (embedded) return { allowed: false, applied: false, needs: [], actions: [], from: this.def?.version ?? null, to: this.def?.version ?? null, reason: 'This integration moves to a new version when its code is deployed' };
495
- const { PlanInstallationUpgrade } = require('./use-cases/definition-use-cases');
496
- const plan = await new PlanInstallationUpgrade({ catalog: definitions }).execute({ config: this.config, toVersion });
497
- if (!apply || !plan.allowed) return { ...plan, applied: false };
498
- const reinstall = plan.actions.some((a) => a.kind === 'reinstall');
499
- let config = plan.config;
500
- if (reinstall) {
501
- await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onUninstall', ports: this.ports });
502
- const next = await definitions.get(plan.config.definition.id, plan.config.definition.version);
503
- const out = await runLifecycle({ def: next, installation: { ...this.installation(), ...config }, hook: 'onInstall', ports: this.portsFor(next) });
504
- config = { ...config, runtime: { ...(this.config?.runtime || {}), installed: Object.fromEntries((next.lifecycle?.onInstall || []).filter((st) => st.id).map((st) => [st.id, out.steps?.[st.id] ?? null])) } };
505
- }
506
- await this.patchConfig(config);
507
- this.config = { ...this.config, ...config };
508
- await this.loadDefinition();
509
- // Triggers need nothing here: the next tick reads the new pin and config.
510
- // New identity keys: a backfill rewrites every link with them. A new sync starts its own backfill
511
- // only if its definition says so (onInstall); otherwise it waits for its button.
512
- for (const a of plan.actions.filter((x) => x.kind === 'relink')) await this.startBackfill(a.id, 'relink');
513
- return { ...plan, config, applied: true };
514
- }
515
-
516
- /**
517
- * Keep the run's history, and act on its outcome: a failed run throws (the queue retries), a paused run saves
518
- * its checkpoint and books its resume. A paused run is one row from first step to last: `rowId` updates it.
519
- */
520
- async recordRun(run, { rowId } = {}) {
521
- this.telemetry?.count?.('workflow.run', 1, { workflow: run.workflowId, status: run.status });
522
- if (run.status === 'waiting') await this.pauseRun(run, rowId); // before the history write: a pause must not be lost
523
- // Run history must never decide the run's outcome: a failed write is logged and the run stands.
524
- let row = null;
525
- try {
526
- const store = runRepository();
527
- const { checkpoint, ...stored } = run;
528
- if (store && rowId && store.updateRun) row = await store.updateRun(rowId, { status: run.status, reason: run.reason, error: run.error, retryable: Boolean(run.retryable), trace: run.trace, waitingFor: run.waitingFor ?? null, resumeAt: run.resumeAt ?? null, endedAt: run.endedAt ?? null });
529
- else if (store) row = await store.createRun({ ...stored, runKey: run.id, integrationId: this.id, definitionId: run.definition?.id, definitionVersion: run.definition?.version, triggerType: run.trigger?.type, eventId: run.trigger?.eventId });
530
- } catch (e) {
531
- try { require('../../logs').getLogger('frigg.integrations.runtime').warn('Workflow run not recorded', { eventName: 'frigg.integrations.runtime.run.not_recorded', integrationId: this.id, workflowId: run.workflowId, cause: e?.message }); } catch { /* ignore */ }
532
- }
533
- if (run.status === 'waiting' && row?.id && !rowId) await this.mappingStore().put(`wait:${run.id}`, { ...(await this.mappingStore().get(`wait:${run.id}`)), rowId: row.id });
534
- if (run.status === 'failed') { const e = new Error(run.error); e.run = run; throw e; } // the queue retries
535
- const { checkpoint, ...out } = run;
536
- return out;
537
- }
538
-
539
- /**
540
- * Save a paused run's checkpoint (IntegrationMapping, encrypted at rest: it holds step results) and book its
541
- * resume: the delay's end, or an approval's timeout. If the checkpoint cannot be saved, the event's dedupe
542
- * claim is given back and the error thrown, so the queue runs the event again rather than losing it.
543
- */
544
- async pauseRun(run, rowId) {
545
- const cp = run.checkpoint;
546
- const store = this.mappingStore();
547
- try {
548
- await store.put(`wait:${run.id}`, { ...cp, runId: run.id, definitionVersion: run.definition.version, rowId: rowId ?? null });
549
- } catch (e) {
550
- if (cp.eventId != null) await this.ports.dedupe.release?.(`evt:${run.definition.id}:${cp.workflowId}:${cp.eventId}`);
551
- throw e;
552
- }
553
- if (!cp.wait.until) return;
554
- const name = this.constructor.Definition.name;
555
- const commands = typeof this.schedulerCommands?.scheduleJob === 'function' ? this.schedulerCommands : require('../../application/commands/scheduler-commands').createSchedulerCommands({ integrationName: name });
556
- const booked = await commands.scheduleJob({
557
- jobId: `${name}-${this.id}-${run.id}-${cp.index}`.replace(/[^0-9A-Za-z_.-]/g, '-').slice(0, 64),
558
- scheduledAt: new Date(cp.wait.until),
559
- event: eventName('WF', cp.workflowId),
560
- payload: { integrationId: this.id, resume: run.id, reason: cp.wait.kind === 'delay' ? 'delay' : 'timeout', index: cp.index },
561
- queueUrl: typeof this.scheduleQueueUrl === 'function' ? this.scheduleQueueUrl() : process.env[`${name.toUpperCase().replace(/-/g, '_')}_QUEUE_URL`],
562
- });
563
- if (booked?.error || booked?.warning) {
564
- try { require('../../logs').getLogger('frigg.integrations.runtime').error('Run resume not booked', { eventName: 'frigg.integrations.runtime.run.resume_not_booked', integrationId: this.id, runId: run.id, kind: cp.wait.kind, reason: booked.reason || booked.warning }); } catch { /* ignore */ }
565
- }
566
- }
567
-
568
- /**
569
- * Carry on a paused run: its delay ended, its approval timed out, or someone decided. The first of those to
570
- * claim the pause wins; the others are no-ops. The run continues on the definition version it started on.
571
- */
572
- async resumeRun(runId, { reason, decision, by, note, index } = {}) {
573
- const store = this.mappingStore();
574
- const cp = await store.get(`wait:${runId}`);
575
- if (!cp) return { status: 'gone', runId };
576
- if (index !== undefined && Number(index) !== cp.index) return { status: 'ignored', runId, reason: 'a resume for an earlier pause' };
577
- if (cp.wait.kind === 'delay' && reason !== 'delay') return { status: 'ignored', runId, reason: 'a delay resumes only when it ends' };
578
- if (cp.wait.kind === 'approval' && !['decision', 'timeout'].includes(reason)) return { status: 'ignored', runId };
579
- const claimKey = `resume:${runId}:${cp.index}`;
580
- if (!(await store.claim(claimKey, { at: new Date().toISOString(), reason }))) return { status: 'duplicate', runId };
581
-
582
- const at = new Date().toISOString();
583
- let result, stop;
584
- if (cp.wait.kind === 'delay') result = { resumedAt: at, until: cp.wait.until, waited: true };
585
- else if (reason === 'decision') result = { decision, by: by ?? null, note: note ?? null, at };
586
- else if (cp.wait.onTimeout === 'stop') stop = 'approval timed out';
587
- else result = { decision: cp.wait.onTimeout === 'approve' ? 'approved' : 'rejected', by: 'timeout', note: null, at };
588
-
589
- const def = cp.definitionVersion !== this.def.version ? (await definitions.get(this.def.id, cp.definitionVersion)) || this.def : this.def;
590
- const run = await resumeWorkflow({ def, installation: this.installation(), checkpoint: cp, result, stop, ports: def === this.def ? this.ports : this.portsFor(def), runId });
591
- if (run.status === 'failed') await store.release(claimKey); // the queue's retry may resume again
592
- else if (run.status !== 'waiting') await store.release(`wait:${runId}`);
593
- return this.recordRun(run, { rowId: cp.rowId });
594
- }
595
-
596
- /**
597
- * An approval decision (POST /api/integrations/:id/runs/:runId/decision). Checked here, carried out through
598
- * the queue (the steps after an approval may take longer than an HTTP request should).
599
- */
600
- async decide(runId, { decision, userId, note, isOwner = true } = {}) {
601
- const { DefinitionError } = require('./use-cases/definition-use-cases');
602
- const store = this.mappingStore();
603
- const cp = await store.get(`wait:${runId}`);
604
- // Someone who neither owns the installation nor is named on this run learns nothing about it.
605
- if (!isOwner && !(cp?.wait?.approvers || []).includes(String(userId))) throw new DefinitionError(`Integration ${this.id} not found`, { status: 404, code: 'NOT_FOUND' });
606
- if (!['approve', 'reject'].includes(decision)) throw new DefinitionError('decision is approve or reject', { status: 400, code: 'BAD_DECISION' });
607
- if (!cp) throw new DefinitionError(`Run ${runId} is not waiting`, { status: 404, code: 'NOT_WAITING' });
608
- if (cp.wait.kind !== 'approval') throw new DefinitionError(`Run ${runId} is waiting on a ${cp.wait.kind}, not an approval`, { status: 409, code: 'NOT_AN_APPROVAL' });
609
- if (cp.wait.approvers?.length && !cp.wait.approvers.includes(String(userId))) throw new DefinitionError('You are not an approver of this run', { status: 403, code: 'NOT_AN_APPROVER' });
610
- if (await store.get(`resume:${runId}:${cp.index}`)) throw new DefinitionError(`Run ${runId} has already been decided`, { status: 409, code: 'ALREADY_DECIDED' });
611
- await this.queueWebhook({ event: eventName('WF', cp.workflowId), integrationId: this.id, resume: runId, reason: 'decision', index: cp.index, decision: decision === 'approve' ? 'approved' : 'rejected', by: String(userId), note: note ?? null });
612
- return { accepted: true, runId, decision };
613
- }
614
-
615
- /** The sync engine over this installation's ports and mapping store (sync-engine.js). */
616
- syncEngine(def = this.def) {
617
- const installation = this.installation();
618
- return createSyncEngine({ def, installation, install: installContext(def, installation), manifests, ports: def === this.def ? this.ports : this.portsFor(def), state: this.mappingStore() });
619
- }
620
-
621
- /**
622
- * SYNC_<ID>_BACKFILL (a button), SYNC_<ID>_PAGE (the queue, one page per message) and SYNC_<ID>_ONGOING
623
- * (a webhook event about a synced object, or a poll tick). Each page queues the next, so a long backfill is
624
- * many short messages; the progress row says where it is (GET /api/integrations/:id/syncs).
625
- */
626
- async runSync(ref, msg = {}) {
627
- const engine = this.syncEngine();
628
- if (ref.phase === 'backfill') return this.startBackfill(ref.id, msg.reason || 'user');
629
- if (ref.phase === 'page') {
630
- const out = await engine.continueBackfill({ syncId: ref.id, runId: msg.runId, role: msg.role, cursor: msg.cursor ?? null });
631
- if (out.next) await this.queueWebhook({ event: eventName('SYNC', ref.id, 'PAGE'), integrationId: this.id, ...out.next, definitionVersion: this.def.version });
632
- await this.fireSyncWorkflows(ref.id, out.results || []);
633
- return { progress: out.progress, stale: out.stale || false };
634
- }
635
- if (ref.phase === 'ongoing') {
636
- if (msg.module && msg.topic) {
637
- const result = await engine.handleEvent({ syncId: ref.id, moduleKey: msg.module, topic: msg.topic, event: msg.body });
638
- await this.fireSyncWorkflows(ref.id, [result]);
639
- return result;
640
- }
641
- return engine.poll({ syncId: ref.id });
642
- }
643
- throw new Error(`Unknown sync phase "${ref.phase}"`);
644
- }
645
-
646
- /** Start a backfill (or a relink after an identity change) and queue its first page. */
647
- async startBackfill(syncId, reason = 'user') {
648
- const runId = `bf_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
649
- const out = await this.syncEngine().startBackfill({ syncId, runId, reason });
650
- if (out.started) await this.queueWebhook({ event: eventName('SYNC', syncId, 'PAGE'), integrationId: this.id, ...out.next, definitionVersion: this.def.version });
651
- return { started: out.started, progress: out.progress };
652
- }
653
-
654
- /**
655
- * syncRecord workflows: queue one run per synced record whose action (created, updated, linked) a switched-on
656
- * workflow listens for. A create or link happens once per record, so it carries a dedupe id; updates do not.
657
- */
658
- async fireSyncWorkflows(syncId, results) {
659
- for (const w of this.def.workflows || []) {
660
- const t = w.trigger || {};
661
- const n = eventName('WF', w.id);
662
- if (t.type !== 'syncRecord' || t.sync !== syncId || !this.compiled.events[n]) continue;
663
- for (const r of results) {
664
- if (!r?.action || !(t.on || []).includes(r.action)) continue;
665
- const ids = Object.entries(r.ids || {}).map(([k, v]) => `${k}:${v}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join('|');
666
- await this.queueWebhook({ event: n, integrationId: this.id, body: { sync: syncId, action: r.action, ids: r.ids }, ...(r.action !== 'updated' ? { eventId: `${syncId}:${r.action}:${ids}` } : {}), definitionVersion: this.def.version });
667
- }
668
- }
669
- }
670
-
671
- /** Every switched-on sync's progress: the current or last backfill, and the poll marks. */
672
- async syncStatus() {
673
- const engine = this.syncEngine();
674
- const out = [];
675
- for (const sy of this.def.syncs || []) {
676
- if (!this.compiled.events[eventName('SYNC', sy.id, 'PAGE')]) continue;
677
- out.push({ id: sy.id, label: sy.label, source: sy.source, target: sy.target, direction: sy.direction, ongoing: sy.ongoing?.mode || 'none', ...(await engine.status(sy.id)) });
678
- }
679
- return out;
680
- }
681
- }
682
- Object.defineProperty(IntegrationRuntime, 'name', { value: `IntegrationRuntime_${id.replace(/-/g, '_')}` });
683
- if (!defaultId) IntegrationRuntime.IntegrationProvider = definitionProvider(IntegrationRuntime, definitions);
684
- return IntegrationRuntime;
685
- }
686
-
687
- /**
688
- * The shared class as a provider of integration types (integration-types.js): every published definition is an
689
- * integration type named by its id, run by a class of its own. The class differs from code only in where it gets its
690
- * behaviour; it runs on the shared deployment (queue, functions) of the class that provides it.
691
- */
692
- function definitionProvider(Host, definitions) {
693
- const cache = new Map(); // id -> { version, cls }
694
- const classFor = (entry) => {
695
- const hit = cache.get(entry.id);
696
- if (hit && hit.version === entry.version && hit.workspaceId === (entry.workspaceId ?? null)) return hit.cls;
697
- const hostModules = Host.Definition.modules;
698
- const modules = {};
699
- for (const [key, m] of Object.entries(entry.modules || {})) {
700
- const moduleName = typeof m === 'string' ? m : m?.module;
701
- if (!hostModules[moduleName]) return null; // an app module this deployment does not have: not installable here
702
- modules[key] = hostModules[moduleName];
703
- }
704
- const display = entry.display || {};
705
- class PublishedIntegration extends Host {
706
- static Definition = { name: entry.id, version: entry.version, modules, webhooks: { enabled: true }, display: { label: display.label, description: display.description, category: display.category } };
707
- static definitionId = entry.id;
708
- static Host = Host;
709
- /** The workspace that built it (null: the app's own catalogue). */
710
- static workspaceId = entry.workspaceId ?? null;
711
-
712
- /** The install option (GET /api/integrations): the base class reports its first module's name as the type,
713
- * which is not this integration's name. */
714
- static getOptionDetails() {
715
- return {
716
- type: entry.id, hasUserConfig: true, isMany: false, requiresNewEntity: false,
717
- // Built by this workspace: its page shows the editor views (rule: otherwise identical).
718
- ...(entry.workspaceId ? { editable: true } : {}),
719
- display: { name: display.label, description: display.description, detailsUrl: display.detailsUrl, icon: display.icon },
720
- };
721
- }
722
- }
723
- Object.defineProperty(PublishedIntegration, 'IntegrationProvider', { value: undefined });
724
- Object.defineProperty(PublishedIntegration, 'name', { value: `Integration_${entry.id.replace(/[^0-9A-Za-z]/g, '_')}` });
725
- cache.set(entry.id, { version: entry.version, workspaceId: entry.workspaceId ?? null, cls: PublishedIntegration });
726
- return PublishedIntegration;
727
- };
728
- const visible = (cls, context) => Boolean(cls) && (!context?.workspaceId || cls.workspaceId == null || cls.workspaceId === String(context.workspaceId));
729
- return {
730
- async resolve(type, context) {
731
- if (!type || type === Host.Definition.name) return null;
732
- let cls = cache.get(type)?.cls;
733
- if (!cls) {
734
- const latest = await definitions.latest(type);
735
- if (!latest) return null;
736
- cls = classFor({ ...latest, workspaceId: (await definitions.ownerOf?.(type)) ?? null });
737
- }
738
- return visible(cls, context) ? cls : null;
739
- },
740
- async list(context) {
741
- const entries = (await definitions.list?.(context)) || [];
742
- return entries.map(classFor).filter((cls) => visible(cls, context));
743
- },
744
- };
745
- }
746
-
747
- /** In-memory definition store for tests and local dev. Production: the IntegrationDefinitionVersion table. */
748
- function memoryDefinitions(docs) {
749
- const byKey = new Map(docs.map((d) => [`${d.id}@${d.version}`, d]));
750
- const cmp = (a, b) => a.split('.').map(Number).reduce((r, n, i) => r || n - Number(b.split('.')[i]), 0);
751
- return {
752
- get: async (defId, version) => byKey.get(`${defId}@${version}`) || null,
753
- checksumOf: async (defId, version) => { const doc = byKey.get(`${defId}@${version}`); return doc ? require('./canonical').checksum(doc) : null; },
754
- list: async () => {
755
- const latestById = new Map();
756
- for (const doc of docs) if (!latestById.has(doc.id) || cmp(doc.version, latestById.get(doc.id).version) > 0) latestById.set(doc.id, doc);
757
- 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 } }));
758
- },
759
- latest: (id) => docs.filter((d) => d.id === id).sort((a, b) => cmp(b.version, a.version))[0] || null,
760
- };
761
- }
762
-
763
- /*
764
- * Names (ADR-054 amendment, "One runtime, two front doors"): `defineIntegration` is the code front door, so the
765
- * document front door is named for documents. The first names stay as aliases until Stack B merges, then go.
766
- * fromDefinitionDocument (was defineIntegrationClass) one class per integration document (Shape A)
767
- * definitionDocumentHost (was defineSharedIntegrationClass) one class hosting every published document (Shape B)
768
- * embedDefinitionDocument (was extendIntegrationClass) a code class (hand-written or defineIntegration) plus a document
769
- */
770
- const fromDefinitionDocument = defineIntegrationClass;
771
- const definitionDocumentHost = defineSharedIntegrationClass;
772
- const embedDefinitionDocument = extendIntegrationClass;
773
-
774
- module.exports = {
775
- fromDefinitionDocument, definitionDocumentHost, embedDefinitionDocument, memoryDefinitions,
776
- /** @deprecated use fromDefinitionDocument */ defineIntegrationClass,
777
- /** @deprecated use definitionDocumentHost */ defineSharedIntegrationClass,
778
- /** @deprecated use embedDefinitionDocument */ extendIntegrationClass,
779
- };