@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.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-encryption-service.js +9 -22
- package/database/documentdb-utils.js +2 -2
- package/database/encryption/README.md +78 -14
- package/database/encryption/documentdb-encryption-service.md +78 -1
- package/database/encryption/encryption-config.js +219 -0
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/integration-mapping-encryption.js +1 -1
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/organization-where.js +18 -0
- package/database/prisma.js +15 -38
- package/database/unique-violation.js +25 -0
- package/database/use-cases/check-encryption-health-use-case.js +43 -52
- package/errors/index.js +0 -4
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +230 -0
- package/extensions/index.js +44 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/prisma-composer.js +270 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +33 -42
- package/generated/prisma-mongodb/index-browser.js +30 -39
- package/generated/prisma-mongodb/index.d.ts +3187 -2005
- package/generated/prisma-mongodb/index.js +33 -42
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +86 -59
- package/generated/prisma-mongodb/wasm.js +33 -42
- package/generated/prisma-postgresql/edge.js +33 -42
- package/generated/prisma-postgresql/index-browser.js +30 -39
- package/generated/prisma-postgresql/index.d.ts +3380 -2177
- package/generated/prisma-postgresql/index.js +33 -42
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +81 -57
- package/generated/prisma-postgresql/wasm.js +33 -42
- package/handlers/WEBHOOKS.md +0 -29
- package/handlers/app-definition-loader.js +128 -19
- package/handlers/app-handler-helpers.js +27 -8
- package/handlers/backend-utils.js +31 -25
- package/handlers/integration-event-dispatcher.js +3 -7
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +100 -50
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +66 -16
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -14
- package/integrations/integration-base.js +2 -163
- package/integrations/integration-router.js +132 -102
- package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
- package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
- package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
- package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
- package/integrations/repositories/integration-mapping-repository.js +0 -29
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +37 -9
- package/integrations/repositories/integration-repository-interface.js +17 -4
- package/integrations/repositories/integration-repository-mongo.js +64 -96
- package/integrations/repositories/integration-repository-postgres.js +63 -81
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +56 -31
- package/integrations/use-cases/delete-integration-for-user.js +4 -4
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integration-for-user.js +2 -4
- package/integrations/use-cases/get-integration-instance.js +4 -4
- package/integrations/use-cases/get-integrations-for-user.js +79 -39
- package/integrations/use-cases/get-possible-integrations.js +2 -7
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/use-cases/update-integration.js +4 -4
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +415 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-bases.js +169 -0
- package/modules/proxy/proxy-errors.js +31 -0
- package/modules/proxy/proxy-path.js +183 -0
- package/modules/proxy/proxy-policy.js +277 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +245 -0
- package/modules/requester/requester.js +35 -5
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +331 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -7
- package/prisma-mongodb/schema.prisma +86 -59
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +81 -57
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/queuer-util.js +2 -9
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +214 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/errors/mapping-already-exists-error.js +0 -14
- package/handlers/workers/integration-triggers.js +0 -25
- package/infrastructure/scheduler/recurrence.js +0 -204
- package/integrations/define/DESIGN.md +0 -122
- package/integrations/define/config.js +0 -98
- package/integrations/define/define-app.js +0 -66
- package/integrations/define/define-extension.js +0 -154
- package/integrations/define/define-integration.js +0 -335
- package/integrations/define/examples/deal-alerts.js +0 -140
- package/integrations/define/extension-binding.js +0 -174
- package/integrations/define/index.js +0 -11
- package/integrations/define/json-schema-lite.js +0 -68
- package/integrations/define/subscriptions.js +0 -186
- package/integrations/define/tests/fixtures/hubspot.js +0 -109
- package/integrations/define/tests/fixtures/slack.js +0 -26
- package/integrations/define/use.js +0 -39
- package/integrations/definitions/README.md +0 -278
- package/integrations/definitions/canonical.js +0 -11
- package/integrations/definitions/capabilities/AUDIT.md +0 -480
- package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
- package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
- package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
- package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
- package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
- package/integrations/definitions/capabilities/github.capabilities.json +0 -585
- package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
- package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
- package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
- package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
- package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
- package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
- package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
- package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
- package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
- package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
- package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
- package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
- package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
- package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
- package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
- package/integrations/definitions/compile.js +0 -80
- package/integrations/definitions/config-form.js +0 -137
- package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
- package/integrations/definitions/examples/asana-slack.definition.json +0 -219
- package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
- package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
- package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
- package/integrations/definitions/examples/github-linear.definition.json +0 -322
- package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
- package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
- package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
- package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
- package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
- package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
- package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
- package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
- package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
- package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
- package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
- package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
- package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
- package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
- package/integrations/definitions/expressions.js +0 -229
- package/integrations/definitions/index.js +0 -48
- package/integrations/definitions/integration-runtime.js +0 -779
- package/integrations/definitions/interpreter.js +0 -104
- package/integrations/definitions/manifests.js +0 -177
- package/integrations/definitions/renderers/common.js +0 -67
- package/integrations/definitions/renderers/html.js +0 -129
- package/integrations/definitions/renderers/hubspot.js +0 -59
- package/integrations/definitions/renderers/preview.js +0 -154
- package/integrations/definitions/renderers/slack.js +0 -146
- package/integrations/definitions/repositories/definition-version-repository.js +0 -67
- package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
- package/integrations/definitions/router.js +0 -170
- package/integrations/definitions/runtime.js +0 -268
- package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
- package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
- package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
- package/integrations/definitions/schema/records.md +0 -105
- package/integrations/definitions/schema-check.js +0 -58
- package/integrations/definitions/scripts/check-manifests.js +0 -220
- package/integrations/definitions/scripts/validate.js +0 -66
- package/integrations/definitions/steps.js +0 -123
- package/integrations/definitions/sync-engine.js +0 -297
- package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
- package/integrations/definitions/transforms.js +0 -91
- package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
- package/integrations/definitions/validate.js +0 -436
- package/integrations/definitions/versioning.js +0 -176
- package/integrations/definitions/webhooks.js +0 -119
- package/integrations/integration-types.js +0 -73
- package/integrations/triggers.js +0 -244
- package/integrations/use-cases/run-trigger-tick.js +0 -155
- package/integrations/use-cases/run-trigger.js +0 -77
- package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
- package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
- package/prisma-postgresql/migrations/20261019120000_definition_workspace/migration.sql +0 -5
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `defineIntegration` (ADR-054 §1): the code front door to the integration runtime.
|
|
3
|
-
*
|
|
4
|
-
* It returns an ordinary IntegrationBase subclass with a static `Definition` and an instance `events` map: the same
|
|
5
|
-
* compile target an ADR-063 integration document reaches through `fromDefinitionDocument` (definitions/). Builders,
|
|
6
|
-
* routers, the queue worker, the ADR-055 trigger tick and the management API read that target and nothing else, so
|
|
7
|
-
* the two front doors share one runtime; neither is a serialisation of the other.
|
|
8
|
-
*
|
|
9
|
-
* const DealAlerts = defineIntegration({
|
|
10
|
-
* name: 'deal-alerts', version: '1.0.0', display: { label, description, category },
|
|
11
|
-
* modules: { crm: use(hubspot, { webhooks: { subscribe: [...] } }), chat: use(slack) },
|
|
12
|
-
* config: { channel: slack.fields.channel({ required: true }), minAmount: { type: 'number', default: 0 } },
|
|
13
|
-
* on: { 'crm.deal.changed': async ({ deal, changes }, { modules: { crm, chat }, config, log }) => { … } },
|
|
14
|
-
* triggers: { digest: { cron: '0 8 * * 1-5', timezone: 'UTC', handler: async (payload, ctx) => { … } } },
|
|
15
|
-
* actions: { SEND_TEST: { title: 'Send a test', run: async (input, ctx) => { … } } },
|
|
16
|
-
* visible: (context) => true,
|
|
17
|
-
* });
|
|
18
|
-
*
|
|
19
|
-
* Handlers are `(payload, ctx)` and are called without `this`. `ctx` has a fixed set of core-owned keys:
|
|
20
|
-
* { modules: { <key>: api }, config, log, telemetry, commands, integration }
|
|
21
|
-
* The keys inside `ctx.modules` are the author's (`crm`, `chat`); nothing else in `ctx` is.
|
|
22
|
-
*
|
|
23
|
-
* Compiles to:
|
|
24
|
-
* Definition.modules { crm: { definition: hubspot.Definition }, chat: { definition: slack.Definition } }
|
|
25
|
-
* Definition.extensions { 'crm-webhooks': <contract-1 binding> } (extension-binding.js)
|
|
26
|
-
* Definition.triggers { digest, 'crm.<ext trigger>' } (ADR-055, run by Stack B's tick)
|
|
27
|
-
* this.events SEND_TEST (USER_ACTION) + the extension's queue events (merged at initialize)
|
|
28
|
-
* getConfigOptions() { jsonSchema, uiSchema } with module field choices loaded from the account
|
|
29
|
-
*/
|
|
30
|
-
const { IntegrationBase } = require('../integration-base');
|
|
31
|
-
const { validateTrigger } = require('../triggers');
|
|
32
|
-
const { normalizeBinding } = require('./use');
|
|
33
|
-
const { validateExtension, isContract2 } = require('./define-extension');
|
|
34
|
-
const { compileBinding, resolveOptions } = require('./extension-binding');
|
|
35
|
-
const { compileConfig, resolveConfig, configOptions } = require('./config');
|
|
36
|
-
const { check, withDefaults } = require('./json-schema-lite');
|
|
37
|
-
|
|
38
|
-
const SPEC_KEYS = new Set(['name', 'version', 'display', 'usage', 'modules', 'config', 'on', 'triggers', 'actions', 'visible', 'webhooks', 'routes']);
|
|
39
|
-
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
40
|
-
const ACTION_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
41
|
-
const PLAN = Symbol.for('frigg.define.plan');
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Module keys that cannot be used: IntegrationBase attaches each module to the instance under its key
|
|
45
|
-
* (`_appendModules`: `this[key] = module`), so a key that names an instance field or method would overwrite it.
|
|
46
|
-
* Inside `ctx.modules` any name is fine; the instance is the constraint.
|
|
47
|
-
*/
|
|
48
|
-
const INSTANCE_FIELDS = ['id', 'userId', 'entities', 'config', 'status', 'version', 'messages', 'modules', 'record', 'events', 'on',
|
|
49
|
-
'defaultEvents', 'logger', 'telemetry', 'def', 'compiled', 'ports', 'integrationRepository', 'integrationMappingRepository',
|
|
50
|
-
'updateIntegrationStatus', 'updateIntegrationMessages', 'updateIntegrationConfig', 'patchIntegrationConfig', 'triggerProcessRepository',
|
|
51
|
-
'commands', 'constructor'];
|
|
52
|
-
let reservedKeys;
|
|
53
|
-
function isReservedModuleKey(key) {
|
|
54
|
-
if (!reservedKeys) {
|
|
55
|
-
reservedKeys = new Set(INSTANCE_FIELDS);
|
|
56
|
-
for (let p = IntegrationBase.prototype; p && p !== Object.prototype; p = Object.getPrototypeOf(p)) {
|
|
57
|
-
for (const n of Object.getOwnPropertyNames(p)) reservedKeys.add(n);
|
|
58
|
-
}
|
|
59
|
-
for (const n of ['handlerContext', 'resolvedConfig', 'runEmitted', 'syncInstallationSubscriptions']) reservedKeys.add(n);
|
|
60
|
-
}
|
|
61
|
-
return reservedKeys.has(key) || key.startsWith('_');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const pascal = (s) => s.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('') || 'Integration';
|
|
65
|
-
|
|
66
|
-
function fail(name, msg) {
|
|
67
|
-
throw new Error(`defineIntegration(${name ? `"${name}"` : ''}): ${msg}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Every module binding, each enabled extension validated and compiled. */
|
|
71
|
-
function compileModules(name, modules) {
|
|
72
|
-
if (!modules || typeof modules !== 'object' || !Object.keys(modules).length) fail(name, '"modules" must bind at least one API module');
|
|
73
|
-
const bindings = [];
|
|
74
|
-
const extensionBindings = [];
|
|
75
|
-
const definitionModules = {};
|
|
76
|
-
const contract1 = {};
|
|
77
|
-
for (const [key, value] of Object.entries(modules)) {
|
|
78
|
-
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) fail(name, `module key "${key}" must be an identifier (it is ctx.modules.${key})`);
|
|
79
|
-
if (isReservedModuleKey(key)) fail(name, `module key "${key}" names a member of IntegrationBase, where modules are also attached (this.${key}); choose another key`);
|
|
80
|
-
const b = normalizeBinding(key, value);
|
|
81
|
-
bindings.push(b);
|
|
82
|
-
definitionModules[key] = { definition: b.module.Definition };
|
|
83
|
-
for (const [extKey, options] of Object.entries(b.extensions)) {
|
|
84
|
-
if (options === false || options === undefined) continue;
|
|
85
|
-
const ext = b.module.extensions?.[extKey];
|
|
86
|
-
if (!ext) fail(name, `modules.${key}: ${b.moduleName} has no extension "${extKey}" (it has ${Object.keys(b.module.extensions || {}).join(', ') || 'none'})`);
|
|
87
|
-
validateExtension(ext, { where: `defineIntegration("${name}") modules.${key}` });
|
|
88
|
-
if (!isContract2(ext)) {
|
|
89
|
-
// A contract-1 bundle binds as it does today, under the derived key; its event names stay global.
|
|
90
|
-
if (options !== true && Object.keys(options || {}).length) fail(name, `modules.${key}: extension "${extKey}" is contract 1 and takes no options`);
|
|
91
|
-
contract1[`${key}-${extKey}`] = { extension: ext };
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const raw = options === true ? {} : options;
|
|
95
|
-
// Function values depend on instance config (ADR-054 §4): present now, checked once resolved.
|
|
96
|
-
const problems = check(ext.options, withDefaults(ext.options, raw));
|
|
97
|
-
if (problems.length) fail(name, `use(${b.moduleName}, { ${extKey} }): ${problems.join('; ')}`);
|
|
98
|
-
extensionBindings.push({ moduleKey: key, extKey, ext, module: b.module, moduleName: b.moduleName, options: raw });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return { bindings, extensionBindings, definitionModules, contract1 };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function compileTriggers(name, triggers = {}, extensionBindings) {
|
|
105
|
-
if (typeof triggers !== 'object' || Array.isArray(triggers)) fail(name, '"triggers" must be an object keyed by trigger name');
|
|
106
|
-
const out = {};
|
|
107
|
-
const add = (triggerName, t, handlerOf) => {
|
|
108
|
-
if (out[triggerName]) fail(name, `trigger "${triggerName}" is declared twice`);
|
|
109
|
-
if (typeof t?.handler !== 'function') fail(name, `trigger "${triggerName}": "handler" must be a function (payload, ctx)`);
|
|
110
|
-
const problems = validateTrigger(triggerName, t);
|
|
111
|
-
if (problems.length) fail(name, problems.join('; '));
|
|
112
|
-
out[triggerName] = { ...t, handler: handlerOf(t.handler) };
|
|
113
|
-
};
|
|
114
|
-
// Called by IntegrationBase#onTriggerRun with `this` = the hydrated installation; the author's handler gets ctx.
|
|
115
|
-
const wrap = (fn) => function runTrigger(payload) { return fn(payload, this.handlerContext()); };
|
|
116
|
-
for (const [n, t] of Object.entries(triggers)) {
|
|
117
|
-
if (n.includes('.')) fail(name, `trigger "${n}": dotted names are reserved for module extensions (<moduleKey>.<name>)`);
|
|
118
|
-
add(n, t, wrap);
|
|
119
|
-
}
|
|
120
|
-
for (const b of extensionBindings) {
|
|
121
|
-
for (const [n, t] of Object.entries(b.ext.triggers || {})) {
|
|
122
|
-
// An extension trigger gets the extension ctx (its own api and options), not the integration's.
|
|
123
|
-
add(`${b.moduleKey}.${n}`, t, (fn) => function runExtensionTrigger(payload) {
|
|
124
|
-
const config = this.resolvedConfig();
|
|
125
|
-
return fn(payload, this.handlerContext({ api: this[b.moduleKey]?.api, options: resolveOptions(b, config) }));
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return out;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function compileActions(name, actions = {}) {
|
|
133
|
-
if (typeof actions !== 'object' || Array.isArray(actions)) fail(name, '"actions" must be an object keyed by action id');
|
|
134
|
-
const out = {};
|
|
135
|
-
for (const [id, a] of Object.entries(actions)) {
|
|
136
|
-
if (!ACTION_KEY.test(id)) fail(name, `action "${id}": ids are UPPER_SNAKE_CASE (they are USER_ACTION event names)`);
|
|
137
|
-
if (typeof a?.run !== 'function') fail(name, `action "${id}": "run" must be a function (input, ctx)`);
|
|
138
|
-
if (typeof a.title !== 'string' || !a.title) fail(name, `action "${id}": needs a "title"`);
|
|
139
|
-
out[id] = a;
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function defineIntegration(spec = {}) {
|
|
145
|
-
const { name } = spec;
|
|
146
|
-
if (typeof name !== 'string' || !NAME.test(name)) fail(name, '"name" must be lower-kebab-case (it names routes, functions and queues)');
|
|
147
|
-
for (const key of Object.keys(spec)) if (!SPEC_KEYS.has(key)) fail(name, `unknown key "${key}"`);
|
|
148
|
-
if (spec.version !== undefined && typeof spec.version !== 'string') fail(name, '"version" must be a string');
|
|
149
|
-
if (spec.visible !== undefined && typeof spec.visible !== 'function') fail(name, '"visible" must be a function (context) => boolean');
|
|
150
|
-
|
|
151
|
-
const { bindings, extensionBindings, definitionModules, contract1 } = compileModules(name, spec.modules);
|
|
152
|
-
const extensions = { ...contract1 };
|
|
153
|
-
const emitted = new Map(); // 'crm.deal.changed' -> { binding, name }
|
|
154
|
-
const internalEvents = new Set();
|
|
155
|
-
for (const b of extensionBindings) {
|
|
156
|
-
const c = compileBinding(b);
|
|
157
|
-
if (extensions[c.key]) fail(name, `two bindings derive the key "${c.key}"`);
|
|
158
|
-
extensions[c.key] = c.compiled;
|
|
159
|
-
for (const ev of Object.keys(c.compiled.extension.events)) internalEvents.add(ev);
|
|
160
|
-
for (const n of Object.keys(b.ext.emits || {})) emitted.set(`${b.moduleKey}.${n}`, { binding: b, name: n });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const on = spec.on || {};
|
|
164
|
-
if (typeof on !== 'object' || Array.isArray(on)) fail(name, '"on" must be an object keyed by event name');
|
|
165
|
-
for (const [event, handler] of Object.entries(on)) {
|
|
166
|
-
if (typeof handler !== 'function') fail(name, `on["${event}"]: handlers are functions (payload, ctx); method names are not accepted`);
|
|
167
|
-
if (!emitted.has(event)) {
|
|
168
|
-
const known = [...emitted.keys()];
|
|
169
|
-
fail(name, `on["${event}"]: no bound extension emits it${known.length ? ` (bound extensions emit ${known.join(', ')})` : ' (no bound extension emits events)'}`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const actions = compileActions(name, spec.actions);
|
|
173
|
-
for (const id of Object.keys(actions)) if (internalEvents.has(id)) fail(name, `action "${id}" collides with an extension event`);
|
|
174
|
-
const triggers = compileTriggers(name, spec.triggers, extensionBindings);
|
|
175
|
-
const configPlan = compileConfig(spec.config, bindings, `defineIntegration("${name}")`);
|
|
176
|
-
|
|
177
|
-
const plan = Object.freeze({ name, bindings, extensionBindings, on: { ...on }, actions, configPlan, emitted });
|
|
178
|
-
|
|
179
|
-
const Definition = {
|
|
180
|
-
name,
|
|
181
|
-
version: spec.version || '1.0.0',
|
|
182
|
-
modules: definitionModules,
|
|
183
|
-
display: { label: name, description: '', category: '', ...(spec.display || {}) },
|
|
184
|
-
...(spec.usage ? { usage: spec.usage } : {}),
|
|
185
|
-
...(Object.keys(extensions).length ? { extensions } : {}),
|
|
186
|
-
...(Object.keys(triggers).length ? { triggers } : {}),
|
|
187
|
-
...(spec.webhooks !== undefined ? { webhooks: spec.webhooks } : {}),
|
|
188
|
-
...(spec.routes ? { routes: spec.routes } : {}),
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
class DefinedIntegration extends IntegrationBase {
|
|
192
|
-
static Definition = Definition;
|
|
193
|
-
|
|
194
|
-
static [PLAN] = plan;
|
|
195
|
-
|
|
196
|
-
constructor(params) {
|
|
197
|
-
super(params);
|
|
198
|
-
for (const [id, a] of Object.entries(actions)) {
|
|
199
|
-
this.events[id] = {
|
|
200
|
-
type: 'USER_ACTION',
|
|
201
|
-
userActionType: a.userActionType || 'DATA_ACTION',
|
|
202
|
-
title: a.title,
|
|
203
|
-
...(a.description ? { description: a.description } : {}),
|
|
204
|
-
handler: (input) => a.run(input ?? {}, this.handlerContext()),
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** The stored config with the form's defaults filled in: what handlers see as ctx.config. */
|
|
210
|
-
resolvedConfig() {
|
|
211
|
-
return resolveConfig(plan.configPlan, this.config || {});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* The context every handler gets (ADR-054 §1). Built per dispatch, frozen. `integration` is a read-only
|
|
216
|
-
* record (ADR-054 open question 6, lean: read-only); `commands` is created on first use. `extra` adds the
|
|
217
|
-
* extension keys (`api`, `options`) for an extension's own handlers.
|
|
218
|
-
*/
|
|
219
|
-
handlerContext(extra = {}) {
|
|
220
|
-
const self = this;
|
|
221
|
-
const modules = {};
|
|
222
|
-
for (const b of plan.bindings) {
|
|
223
|
-
Object.defineProperty(modules, b.key, { enumerable: true, get: () => self[b.key]?.api });
|
|
224
|
-
}
|
|
225
|
-
let commands;
|
|
226
|
-
const integration = Object.freeze({
|
|
227
|
-
id: this.id ?? null,
|
|
228
|
-
name: plan.name,
|
|
229
|
-
status: this.status ?? null,
|
|
230
|
-
userId: this.userId ?? null,
|
|
231
|
-
entities: this.entities ?? null,
|
|
232
|
-
version: this.version ?? Definition.version,
|
|
233
|
-
});
|
|
234
|
-
const ctx = {
|
|
235
|
-
modules: Object.freeze(modules),
|
|
236
|
-
config: Object.freeze(this.resolvedConfig()),
|
|
237
|
-
log: this.logger,
|
|
238
|
-
telemetry: this.telemetry,
|
|
239
|
-
integration,
|
|
240
|
-
...extra,
|
|
241
|
-
};
|
|
242
|
-
Object.defineProperty(ctx, 'commands', {
|
|
243
|
-
enumerable: true,
|
|
244
|
-
get() {
|
|
245
|
-
if (!commands) {
|
|
246
|
-
const { createFriggCommands } = require('../../application');
|
|
247
|
-
commands = createFriggCommands({ integrationClass: self.constructor });
|
|
248
|
-
}
|
|
249
|
-
return commands;
|
|
250
|
-
},
|
|
251
|
-
});
|
|
252
|
-
return Object.freeze(ctx);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Run one emitted event: the `on` handler, else the extension's default handler, else ack and count it.
|
|
257
|
-
* @returns {Promise<'handled'|'unhandled'|'undeclared'>}
|
|
258
|
-
*/
|
|
259
|
-
async runEmitted(binding, eventName, payload) {
|
|
260
|
-
const full = `${binding.moduleKey}.${eventName}`;
|
|
261
|
-
if (!binding.ext.emits || !Object.prototype.hasOwnProperty.call(binding.ext.emits, eventName)) {
|
|
262
|
-
this.logger.warn('Extension produced an event it does not declare', { eventName: 'frigg.extension.event_undeclared', event: full });
|
|
263
|
-
return 'undeclared';
|
|
264
|
-
}
|
|
265
|
-
const handler = plan.on[full] || binding.ext.emits[eventName].handler;
|
|
266
|
-
if (!handler) {
|
|
267
|
-
try { this.telemetry?.event?.('frigg.extension.event_unhandled', { event: full }); } catch { /* telemetry never breaks dispatch */ }
|
|
268
|
-
this.logger.info('Event has no handler', { eventName: 'frigg.extension.event_unhandled', event: full });
|
|
269
|
-
return 'unhandled';
|
|
270
|
-
}
|
|
271
|
-
await handler(payload, this.handlerContext());
|
|
272
|
-
return 'handled';
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/*
|
|
276
|
-
* Lifecycle invoker for app-scope subscriptions (ADR-054 §2, amended): after the base hook, an installation
|
|
277
|
-
* whose binding options are functions of its config gets the subscriptions it needs, add-only. A failure is
|
|
278
|
-
* a warning on the installation, never a failed create or update.
|
|
279
|
-
* TODO(ADR-054 §5): move these call sites into the lifecycle dispatcher when it lands.
|
|
280
|
-
*/
|
|
281
|
-
async onCreate(params) {
|
|
282
|
-
await super.onCreate(params);
|
|
283
|
-
await this.syncInstallationSubscriptions('create');
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async onUpdate(params) {
|
|
287
|
-
await super.onUpdate(params);
|
|
288
|
-
await this.syncInstallationSubscriptions('update');
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async onDelete(params) {
|
|
292
|
-
await super.onDelete(params);
|
|
293
|
-
await this.syncInstallationSubscriptions('delete');
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async syncInstallationSubscriptions(event) {
|
|
297
|
-
const { ensureInstallationSubscriptions, hasFunctionOption } = require('./subscriptions');
|
|
298
|
-
const bindings = plan.extensionBindings.filter((b) => b.ext.subscriptions?.scope === 'app' && hasFunctionOption(b));
|
|
299
|
-
if (!bindings.length) return [];
|
|
300
|
-
if (event === 'delete') {
|
|
301
|
-
// Add-only: nothing to create for an installation that is going away, and removing needs the whole
|
|
302
|
-
// app's union, so its subscriptions stay until a reconcile runs with prune.
|
|
303
|
-
this.logger.info('Subscriptions kept on delete; a pruning reconcile removes the ones no installation needs', { eventName: 'frigg.subscriptions.kept_on_delete', extensions: bindings.map((b) => b.ext.name) });
|
|
304
|
-
return [];
|
|
305
|
-
}
|
|
306
|
-
if (this.status && this.status !== 'ENABLED') return [];
|
|
307
|
-
try {
|
|
308
|
-
return await ensureInstallationSubscriptions(this);
|
|
309
|
-
} catch (error) {
|
|
310
|
-
this.logger.warn('Webhook subscriptions not created', { eventName: 'frigg.subscriptions.ensure_failed', event, error });
|
|
311
|
-
try {
|
|
312
|
-
await this.updateIntegrationMessages?.execute(this.id, 'warnings', 'Webhook subscriptions not created', 'The provider subscriptions this installation needs could not be created. They are retried on the next config save or reconcile.', Date.now());
|
|
313
|
-
} catch { /* a report, never a failure */ }
|
|
314
|
-
return [];
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async getConfigOptions() {
|
|
319
|
-
return configOptions(plan.configPlan, (key) => this[key]?.api, this.config || {}, this.logger);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async refreshConfigOptions(params = {}) {
|
|
323
|
-
const values = params?.data ?? params?.formData ?? params ?? {};
|
|
324
|
-
return configOptions(plan.configPlan, (key) => this[key]?.api, { ...(this.config || {}), ...values }, this.logger);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
Object.defineProperty(DefinedIntegration, 'name', { value: pascal(name) });
|
|
328
|
-
if (spec.visible) DefinedIntegration.visible = spec.visible;
|
|
329
|
-
return DefinedIntegration;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** The compiled plan of a class made by defineIntegration, or null for any other class. */
|
|
333
|
-
const planOf = (Cls) => (Cls && Object.prototype.hasOwnProperty.call(Cls, PLAN) ? Cls[PLAN] : null);
|
|
334
|
-
|
|
335
|
-
module.exports = { defineIntegration, planOf };
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// =====================================================================================
|
|
2
|
-
// (a) ONE-WAY, EVENT-DRIVEN: a HubSpot deal enters a chosen stage -> post to Slack.
|
|
3
|
-
// This is the WHOLE app: backend/index.js (ADR-054 Appendix A, unchanged except these notes).
|
|
4
|
-
// App code: ~70 lines. Nothing else in the repo but package.json and infrastructure.js.
|
|
5
|
-
// Runs end to end in tests/example-deal-alerts.test.js (HubSpot and Slack nock'd).
|
|
6
|
-
// =====================================================================================
|
|
7
|
-
const { defineApp, defineIntegration, use } = require('@friggframework/core');
|
|
8
|
-
const hubspot = require('@friggframework/api-module-hubspot');
|
|
9
|
-
const slack = require('@friggframework/api-module-slack');
|
|
10
|
-
|
|
11
|
-
const DealAlerts = defineIntegration({
|
|
12
|
-
name: 'deal-alerts',
|
|
13
|
-
version: '1.0.0',
|
|
14
|
-
display: {
|
|
15
|
-
label: 'Deal alerts in Slack',
|
|
16
|
-
description: 'Posts to a Slack channel when a HubSpot deal reaches a chosen stage.',
|
|
17
|
-
category: 'Notifications',
|
|
18
|
-
},
|
|
19
|
-
|
|
20
|
-
// The module key (crm, chat) is the namespace for everything a module's extensions
|
|
21
|
-
// contribute: routes (/api/deal-alerts-integration/crm-webhooks/...), events
|
|
22
|
-
// ('crm.deal.changed'), config fields, and admin scripts.
|
|
23
|
-
modules: {
|
|
24
|
-
crm: use(hubspot, {
|
|
25
|
-
webhooks: {
|
|
26
|
-
subscribe: ['deal.creation', 'deal.propertyChange:dealstage'],
|
|
27
|
-
hydrate: ['dealname', 'amount', 'dealstage', 'pipeline', 'hubspot_owner_id'],
|
|
28
|
-
},
|
|
29
|
-
}),
|
|
30
|
-
chat: use(slack),
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
// Per-install form shown to the end user. Module fields load their options from
|
|
34
|
-
// the connected account (Slack channels, HubSpot pipelines and stages).
|
|
35
|
-
config: {
|
|
36
|
-
channel: slack.fields.channel({ title: 'Post to channel', required: true }),
|
|
37
|
-
stages: hubspot.fields.dealStages({
|
|
38
|
-
title: 'Alert when a deal enters',
|
|
39
|
-
multiple: true,
|
|
40
|
-
default: ['closedwon'],
|
|
41
|
-
}),
|
|
42
|
-
minAmount: { type: 'number', title: 'Only deals worth at least', default: 0 },
|
|
43
|
-
mentionOwner: { type: 'boolean', title: 'Mention the deal owner', default: true },
|
|
44
|
-
},
|
|
45
|
-
|
|
46
|
-
// Handlers take (payload, ctx). ctx.modules holds the APIs under the keys chosen
|
|
47
|
-
// above (crm, chat); every other ctx key is core's and fixed.
|
|
48
|
-
on: {
|
|
49
|
-
// Emitted by hubspot.extensions.webhooks after it verified the v3 signature,
|
|
50
|
-
// resolved portalId -> this integration, and batch-read the deal (hydrate).
|
|
51
|
-
async 'crm.deal.changed'({ deal, changes }, { modules: { crm, chat }, config, log }) {
|
|
52
|
-
const stage = changes.dealstage?.to ?? deal.properties.dealstage;
|
|
53
|
-
if (!config.stages.includes(stage)) return;
|
|
54
|
-
if (Number(deal.properties.amount ?? 0) < config.minAmount) return;
|
|
55
|
-
|
|
56
|
-
let mention = '';
|
|
57
|
-
if (config.mentionOwner && deal.properties.hubspot_owner_id) {
|
|
58
|
-
const owner = await crm.getOwnerById(deal.properties.hubspot_owner_id);
|
|
59
|
-
const user = owner?.email && (await chat.lookupUserByEmail(owner.email).catch(() => null));
|
|
60
|
-
if (user?.user?.id) mention = ` <@${user.user.id}>`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
await chat.postMessage({
|
|
64
|
-
channel: config.channel,
|
|
65
|
-
text: `Deal *${deal.properties.dealname}* moved to ${stage}${mention}`,
|
|
66
|
-
blocks: [
|
|
67
|
-
{ type: 'section', text: { type: 'mrkdwn',
|
|
68
|
-
text: `*${deal.properties.dealname}* ${stage}\nAmount: ${deal.properties.amount ?? 'n/a'}${mention}` } },
|
|
69
|
-
{ type: 'actions', elements: [{ type: 'button', text: { type: 'plain_text', text: 'Open in HubSpot' },
|
|
70
|
-
url: crm.urls.record('deal', deal.id) }] },
|
|
71
|
-
],
|
|
72
|
-
});
|
|
73
|
-
log.info({ dealId: deal.id, stage }, 'deal alert posted');
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
actions: {
|
|
78
|
-
SEND_TEST_ALERT: {
|
|
79
|
-
title: 'Send a test message',
|
|
80
|
-
async run(_input, { modules: { chat }, config }) {
|
|
81
|
-
await chat.postMessage({ channel: config.channel, text: 'Deal alerts are connected.' });
|
|
82
|
-
return { ok: true };
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
module.exports = defineApp({
|
|
89
|
-
name: 'deal-alerts',
|
|
90
|
-
integrations: [DealAlerts],
|
|
91
|
-
database: { postgres: { enable: true } },
|
|
92
|
-
encryption: { fieldLevelEncryptionMethod: 'kms' },
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// =====================================================================================
|
|
96
|
-
// WHAT EACH IMPORT CONTRIBUTES (none of it is written in this file)
|
|
97
|
-
//
|
|
98
|
-
// @friggframework/core
|
|
99
|
-
// defineIntegration -> an IntegrationBase subclass with a static Definition, so every
|
|
100
|
-
// existing builder, router, worker and the management API work unchanged.
|
|
101
|
-
// modules -> Definition.modules; use(hubspot, { webhooks }) -> Definition.extensions['crm-webhooks'],
|
|
102
|
-
// the contract-1 binding next already deploys (route /crm-webhooks/webhooks, function
|
|
103
|
-
// deal-alerts__crmwebhooks). The same compile target as an ADR-063 integration document.
|
|
104
|
-
// on/actions -> this.events (LIFE_CYCLE_EVENT / USER_ACTION), handler(payload, ctx),
|
|
105
|
-
// ctx = { modules: { crm, chat }, config, log, telemetry, commands, integration }.
|
|
106
|
-
// No `this`. The keys inside ctx.modules are this file's; the rest are core's.
|
|
107
|
-
// config -> getConfigOptions() jsonSchema/uiSchema + validateConfig() (NEEDS_CONFIG).
|
|
108
|
-
// Queue worker, DLQ, retries, delivery.isLastAttempt, structured logs, telemetry,
|
|
109
|
-
// usage counters, encryption, token refresh: already on next.
|
|
110
|
-
//
|
|
111
|
-
// @friggframework/api-module-hubspot
|
|
112
|
-
// Definition: OAuth2, scopes, entity identity (portalId = externalId).
|
|
113
|
-
// Api: getOwnerById, batch reads, urls.record(); static rateLimit (ADR-049, descriptive).
|
|
114
|
-
// fields.dealStages: config field with options loaded from the connected portal.
|
|
115
|
-
// extensions.webhooks (contract 2):
|
|
116
|
-
// route POST /webhooks, useDatabase: false, verify: HubSpot v3 HMAC + 5 min skew
|
|
117
|
-
// resolve: portalId -> integration (the RESOLVE hop the extension does by hand today)
|
|
118
|
-
// subscriptions: scope 'app' (HubSpot v3 subscriptions are per developer app);
|
|
119
|
-
// desired set = union of every installed binding's `subscribe`; reconcileAppSubscriptions()
|
|
120
|
-
// creates what is missing (add-only; `prune` removes what no binding needs). Runs after
|
|
121
|
-
// `frigg deploy`, on installation create/update when options depend on config, and from
|
|
122
|
-
// the admin script. One HubSpot developer app per stage.
|
|
123
|
-
// emits: deal.created | deal.changed {deal, changes} | deal.deleted (+ contact.*, company.*)
|
|
124
|
-
// hydrate: batch-reads the object with the listed properties before dispatch.
|
|
125
|
-
// env: HUBSPOT_CLIENT_SECRET, HUBSPOT_APP_ID, HUBSPOT_DEVELOPER_API_KEY (ADR-027 scoping).
|
|
126
|
-
// scripts: hubspot.reconcile-subscriptions (prune, dryRun), appended by defineApp;
|
|
127
|
-
// hubspot.replay-webhook is a follow-up.
|
|
128
|
-
//
|
|
129
|
-
// @friggframework/api-module-slack
|
|
130
|
-
// Definition: OAuth2 bot install, team_id = externalId.
|
|
131
|
-
// Api: postMessage, lookupUserByEmail; static rateLimit (tiered, per-method).
|
|
132
|
-
// fields.channel: config field with options from conversations.list.
|
|
133
|
-
// (extensions.events — Slack Events API receiver with url_verification handshake — is
|
|
134
|
-
// available but not enabled here, so no route is deployed for it.)
|
|
135
|
-
//
|
|
136
|
-
// Provisioned by devtools from the Definition (no infrastructure code in the app):
|
|
137
|
-
// deal-alerts (HTTP catch-all), deal-alerts__crmwebhooks (receiver, no Prisma layer),
|
|
138
|
-
// deal-alerts QueueWorker + queue + DLQ, and the admin-script functions that run
|
|
139
|
-
// hubspot.reconcile-subscriptions.
|
|
140
|
-
// =====================================================================================
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A contract-2 extension enabled on a module binding, compiled into the contract-1 binding that `next` already runs
|
|
3
|
-
* (ADR-054 §3). Nothing downstream changes: devtools gives the binding its own function, the defined-routes router
|
|
4
|
-
* mounts it under the binding key, `_mergeExtensions` merges its events, and the queue worker dispatches them.
|
|
5
|
-
*
|
|
6
|
-
* For `modules: { crm: use(hubspot, { webhooks }) }`:
|
|
7
|
-
*
|
|
8
|
-
* binding key crm-webhooks route /api/{integration}-integration/crm-webhooks/webhooks
|
|
9
|
-
* function {integration}__crmwebhooks (devtools, from Definition.extensions as today)
|
|
10
|
-
* queue events CRM__WEBHOOKS__RECEIVED (receipt, no DB) -> CRM__WEBHOOKS__RESOLVE (worker, dry)
|
|
11
|
-
* -> CRM__WEBHOOKS__DISPATCH (worker, the installation hydrated)
|
|
12
|
-
* CRM__WEBHOOKS__DEAL_CHANGED, … one per emitted event (replays, tests)
|
|
13
|
-
* integration events crm.deal.changed, … (the `on` map)
|
|
14
|
-
* triggers crm.<name> (merged into Definition.triggers)
|
|
15
|
-
*
|
|
16
|
-
* Every name derives from the module key, so two modules that both emit `contact.changed`, or the same module bound
|
|
17
|
-
* twice, never collide; no bare name reaches the global event map.
|
|
18
|
-
*/
|
|
19
|
-
const { check, withDefaults } = require('./json-schema-lite');
|
|
20
|
-
|
|
21
|
-
const upper = (s) => String(s).replace(/[^A-Za-z0-9]+/g, '_').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
|
|
22
|
-
const bindingKeyOf = (moduleKey, extKey) => `${moduleKey}-${extKey}`;
|
|
23
|
-
const eventPrefixOf = (moduleKey, extKey) => `${upper(moduleKey)}__${upper(extKey)}__`;
|
|
24
|
-
|
|
25
|
-
function envFor(ext) {
|
|
26
|
-
return Object.fromEntries((ext.env || []).map((k) => [k, process.env[k]]));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The binding's options for one installation: function values resolved with its config, defaults applied, then
|
|
31
|
-
* checked against the extension's schema.
|
|
32
|
-
*/
|
|
33
|
-
function resolveOptions(binding, config = {}) {
|
|
34
|
-
const raw = binding.options === true ? {} : binding.options || {};
|
|
35
|
-
const resolved = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === 'function' ? v(config) : v]));
|
|
36
|
-
const out = withDefaults(binding.ext.options, resolved);
|
|
37
|
-
const problems = check(binding.ext.options, out);
|
|
38
|
-
if (problems.length) throw new Error(`use(${binding.moduleName}, { ${binding.extKey} }) for this installation: ${problems.join('; ')}`);
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The Api a verify method or an app-scope subscription runs on: the module's Api class, without a credential. */
|
|
43
|
-
function appApiOf(binding) {
|
|
44
|
-
const Api = binding.module.Definition?.API || binding.module.Api;
|
|
45
|
-
return Api ? new Api({}) : null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function runVerify(binding, input, ctx) {
|
|
49
|
-
const { ext } = binding;
|
|
50
|
-
let out;
|
|
51
|
-
if (typeof ext.verify === 'string') {
|
|
52
|
-
const api = appApiOf(binding);
|
|
53
|
-
if (!api || typeof api[ext.verify] !== 'function') return { valid: false, reason: `the module Api has no ${ext.verify}()` };
|
|
54
|
-
out = await api[ext.verify](input);
|
|
55
|
-
} else {
|
|
56
|
-
out = await ext.verify(input, ctx);
|
|
57
|
-
}
|
|
58
|
-
if (typeof out === 'boolean') return { valid: out, reason: out ? undefined : 'signature check failed' };
|
|
59
|
-
return { valid: Boolean(out?.valid), reason: out?.reason };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** The installations an external account belongs to, among this integration type: the entity reverse lookup. */
|
|
63
|
-
async function defaultLookup(IntegrationClass, externalId, moduleName) {
|
|
64
|
-
const { createIntegrationCommands } = require('../../application/commands/integration-commands');
|
|
65
|
-
const commands = createIntegrationCommands({ integrationClass: IntegrationClass });
|
|
66
|
-
const ids = await commands.listIntegrationsByEntityExternalId(String(externalId), moduleName);
|
|
67
|
-
const out = [];
|
|
68
|
-
for (const integrationId of ids || []) {
|
|
69
|
-
const found = await commands.loadIntegrationContextById(integrationId);
|
|
70
|
-
const type = found?.context?.record?.config?.type;
|
|
71
|
-
if (type === undefined || type === IntegrationClass.Definition.name) out.push(integrationId);
|
|
72
|
-
}
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* @param {object} binding { moduleKey, extKey, ext, module, moduleName, options }
|
|
78
|
-
* @param {object} plan the integration's compiled plan (on handlers), read at call time
|
|
79
|
-
* @returns {{ key: string, compiled: object, emitted: string[], prefix: string }}
|
|
80
|
-
*/
|
|
81
|
-
function compileBinding(binding) {
|
|
82
|
-
const { moduleKey, extKey, ext } = binding;
|
|
83
|
-
const key = bindingKeyOf(moduleKey, extKey);
|
|
84
|
-
const prefix = eventPrefixOf(moduleKey, extKey);
|
|
85
|
-
const RECEIVED = `${prefix}RECEIVED`;
|
|
86
|
-
const RESOLVE = `${prefix}RESOLVE`;
|
|
87
|
-
const DISPATCH = `${prefix}DISPATCH`;
|
|
88
|
-
const emits = ext.emits || {};
|
|
89
|
-
const emitted = Object.keys(emits).map((n) => `${moduleKey}.${n}`);
|
|
90
|
-
const env = () => envFor(ext);
|
|
91
|
-
const where = `${moduleKey}-${extKey}`;
|
|
92
|
-
|
|
93
|
-
/** Receipt: handshake, verification, split. No database: the binding's function has no Prisma layer. */
|
|
94
|
-
async function received({ req, res }) {
|
|
95
|
-
const { webhookRequestFacts } = require('../integration-base');
|
|
96
|
-
const input = { headers: req.headers || {}, body: req.body, query: req.query || {}, params: req.params || {}, ...webhookRequestFacts(req) };
|
|
97
|
-
const ctx = { env: env(), options: binding.options, log: this.logger };
|
|
98
|
-
if (ext.handshake) {
|
|
99
|
-
const h = await ext.handshake(input, ctx);
|
|
100
|
-
if (h) {
|
|
101
|
-
for (const [k, v] of Object.entries(h.headers || {})) res.set(k, String(v));
|
|
102
|
-
res.status(h.status || 200);
|
|
103
|
-
return h.body === undefined ? {} : h.body;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (ext.verify) {
|
|
107
|
-
const v = await runVerify(binding, input, ctx);
|
|
108
|
-
if (!v.valid) {
|
|
109
|
-
this.logger.warn('Webhook rejected', { eventName: 'frigg.extension.webhook_rejected', binding: where, reason: v.reason });
|
|
110
|
-
res.status(401);
|
|
111
|
-
return { error: 'invalid signature' };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const items = (await ext.receive(input, ctx)) || [];
|
|
115
|
-
let queued = 0;
|
|
116
|
-
for (const item of items) {
|
|
117
|
-
if (item?.externalId === undefined || item?.externalId === null) {
|
|
118
|
-
this.logger.warn('Webhook item without an account id skipped', { eventName: 'frigg.extension.webhook_item_skipped', binding: where });
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
await this.queueWebhook({ event: RESOLVE, externalId: String(item.externalId), body: item.body });
|
|
122
|
-
queued += 1;
|
|
123
|
-
}
|
|
124
|
-
return { received: items.length, queued };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Worker, dry instance: which installations of this integration the account belongs to; one message each. */
|
|
128
|
-
async function resolve({ data = {} } = {}) {
|
|
129
|
-
const Cls = this.constructor;
|
|
130
|
-
const lookup = (externalId, moduleName) => (Cls.lookupInstallations || defaultLookup)(Cls, externalId, moduleName);
|
|
131
|
-
const ids = typeof ext.resolve === 'function'
|
|
132
|
-
? await ext.resolve({ externalId: data.externalId, body: data.body }, { lookup, env: env(), log: this.logger })
|
|
133
|
-
: await lookup(data.externalId, ext.resolve.module);
|
|
134
|
-
if (!ids?.length) {
|
|
135
|
-
this.logger.info('Webhook for an account with no installation', { eventName: 'frigg.extension.webhook_unresolved', binding: where });
|
|
136
|
-
return { resolved: 0 };
|
|
137
|
-
}
|
|
138
|
-
for (const integrationId of ids) await this.queueWebhook({ event: DISPATCH, integrationId, body: data.body });
|
|
139
|
-
return { resolved: ids.length };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Worker, the installation hydrated: normalize into named events and run the integration's handlers. */
|
|
143
|
-
async function dispatch({ data = {} } = {}) {
|
|
144
|
-
const config = this.resolvedConfig();
|
|
145
|
-
const ctx = { api: this[moduleKey]?.api, options: resolveOptions(binding, config), config, env: env(), log: this.logger };
|
|
146
|
-
const events = ext.normalize ? await ext.normalize([data.body], ctx) : [data.body];
|
|
147
|
-
const results = [];
|
|
148
|
-
for (const e of events || []) results.push(await this.runEmitted(binding, e?.name, e?.payload));
|
|
149
|
-
return { dispatched: results.filter((r) => r === 'handled').length, events: results.length };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const events = {
|
|
153
|
-
[RECEIVED]: { type: 'LIFE_CYCLE_EVENT', handler: received },
|
|
154
|
-
[RESOLVE]: { type: 'LIFE_CYCLE_EVENT', handler: resolve },
|
|
155
|
-
[DISPATCH]: { type: 'LIFE_CYCLE_EVENT', handler: dispatch },
|
|
156
|
-
};
|
|
157
|
-
for (const name of Object.keys(emits)) {
|
|
158
|
-
events[`${prefix}${upper(name)}`] = {
|
|
159
|
-
type: 'LIFE_CYCLE_EVENT',
|
|
160
|
-
handler: async function emittedEvent({ data = {} } = {}) { return this.runEmitted(binding, name, data.payload); },
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
const compiled = {
|
|
164
|
-
extension: {
|
|
165
|
-
name: ext.name,
|
|
166
|
-
useDatabase: ext.useDatabase ?? false,
|
|
167
|
-
routes: (ext.routes || []).map((r) => ({ path: r.path, method: r.method, event: RECEIVED })),
|
|
168
|
-
events,
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
return { key, compiled, emitted, prefix };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
module.exports = { compileBinding, resolveOptions, appApiOf, runVerify, defaultLookup, bindingKeyOf, eventPrefixOf, envFor, upper };
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One-file integrations (ADR-054, the stable event-driven path): `defineIntegration`, `defineApp`, `use`, and the
|
|
3
|
-
* contract-2 extension helper. See DESIGN.md in this folder for how it shares the ADR-063 runtime.
|
|
4
|
-
*/
|
|
5
|
-
const { defineIntegration, planOf } = require('./define-integration');
|
|
6
|
-
const { defineApp } = require('./define-app');
|
|
7
|
-
const { use } = require('./use');
|
|
8
|
-
const { defineExtension, validateExtension, isContract2 } = require('./define-extension');
|
|
9
|
-
const { reconcileAppSubscriptions, ensureInstallationSubscriptions, createReconcileSubscriptionsScript } = require('./subscriptions');
|
|
10
|
-
|
|
11
|
-
module.exports = { defineIntegration, defineApp, use, defineExtension, validateExtension, isContract2, reconcileAppSubscriptions, ensureInstallationSubscriptions, createReconcileSubscriptionsScript, planOf };
|