@friggframework/core 2.0.0-next.87 → 2.0.0-next.89
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/application/commands/integration-commands.js +53 -0
- package/core/create-handler.js +7 -0
- package/handlers/backend-utils.js +6 -0
- package/handlers/routers/health.js +3 -1
- package/handlers/routers/integration-defined-routers.js +77 -5
- package/handlers/workers/integration-defined-workers.js +7 -0
- package/integrations/EXTENSIONS.md +240 -0
- package/integrations/extension.js +254 -0
- package/integrations/index.js +8 -0
- package/integrations/integration-base.js +113 -2
- package/integrations/use-cases/find-integration-by-entity-external-id.js +74 -0
- package/integrations/use-cases/list-integrations-by-entity-external-id.js +46 -0
- package/modules/repositories/module-repository-documentdb.js +15 -0
- package/modules/repositories/module-repository-interface.js +16 -0
- package/modules/repositories/module-repository-mongo.js +28 -0
- package/modules/repositories/module-repository-postgres.js +28 -0
- package/modules/repositories/module-repository.js +24 -0
- package/package.json +5 -5
|
@@ -11,6 +11,12 @@ const {
|
|
|
11
11
|
const {
|
|
12
12
|
FindIntegrationContextByExternalEntityIdUseCase,
|
|
13
13
|
} = require('../../integrations/use-cases/find-integration-context-by-external-entity-id');
|
|
14
|
+
const {
|
|
15
|
+
FindIntegrationByEntityExternalIdUseCase,
|
|
16
|
+
} = require('../../integrations/use-cases/find-integration-by-entity-external-id');
|
|
17
|
+
const {
|
|
18
|
+
ListIntegrationsByEntityExternalIdUseCase,
|
|
19
|
+
} = require('../../integrations/use-cases/list-integrations-by-entity-external-id');
|
|
14
20
|
const {
|
|
15
21
|
GetIntegrationsForUser,
|
|
16
22
|
} = require('../../integrations/use-cases/get-integrations-for-user');
|
|
@@ -70,6 +76,18 @@ function createIntegrationCommands({ integrationClass }) {
|
|
|
70
76
|
loadIntegrationContextUseCase: loadIntegrationContextUseCase,
|
|
71
77
|
});
|
|
72
78
|
|
|
79
|
+
const findIntegrationByEntityExternalIdUseCase =
|
|
80
|
+
new FindIntegrationByEntityExternalIdUseCase({
|
|
81
|
+
integrationRepository,
|
|
82
|
+
moduleRepository,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const listIntegrationsByEntityExternalIdUseCase =
|
|
86
|
+
new ListIntegrationsByEntityExternalIdUseCase({
|
|
87
|
+
integrationRepository,
|
|
88
|
+
moduleRepository,
|
|
89
|
+
});
|
|
90
|
+
|
|
73
91
|
const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
|
|
74
92
|
integrationRepository,
|
|
75
93
|
integrationClasses: [integrationClass],
|
|
@@ -103,6 +121,41 @@ function createIntegrationCommands({ integrationClass }) {
|
|
|
103
121
|
}
|
|
104
122
|
},
|
|
105
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Resolve an externalId (e.g. HubSpot portalId, Slack team_id) to a
|
|
126
|
+
* single integration ID. Throws on ambiguous resolution at either the
|
|
127
|
+
* entity or integration layer — cross-tenant routing is refused.
|
|
128
|
+
*
|
|
129
|
+
* @param {string|number} externalId - Provider's stable identifier.
|
|
130
|
+
* @param {string} [moduleName] - Disambiguates when multiple modules in
|
|
131
|
+
* the same app could carry colliding externalIds.
|
|
132
|
+
* @returns {Promise<string|null>} Integration ID, or null on no match.
|
|
133
|
+
* @throws {Error} On ambiguous resolution (multiple entities or
|
|
134
|
+
* multiple owning integrations).
|
|
135
|
+
*/
|
|
136
|
+
async findIntegrationByEntityExternalId(externalId, moduleName) {
|
|
137
|
+
return findIntegrationByEntityExternalIdUseCase.execute({
|
|
138
|
+
externalId,
|
|
139
|
+
moduleName,
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* List all integration IDs whose module entities match an externalId.
|
|
145
|
+
* Use when one externalId is expected to map to multiple integrations
|
|
146
|
+
* (intentional fan-out). Does not throw on ambiguity.
|
|
147
|
+
*
|
|
148
|
+
* @param {string|number} externalId - Provider's stable identifier.
|
|
149
|
+
* @param {string} [moduleName] - Disambiguates across modules.
|
|
150
|
+
* @returns {Promise<Array<string>>} Array of integration IDs (possibly empty).
|
|
151
|
+
*/
|
|
152
|
+
async listIntegrationsByEntityExternalId(externalId, moduleName) {
|
|
153
|
+
return listIntegrationsByEntityExternalIdUseCase.execute({
|
|
154
|
+
externalId,
|
|
155
|
+
moduleName,
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
|
|
106
159
|
async loadIntegrationContextById(integrationId) {
|
|
107
160
|
try {
|
|
108
161
|
const context = await loadIntegrationContextUseCase.execute({
|
package/core/create-handler.js
CHANGED
|
@@ -49,6 +49,7 @@ const createHandler = (optionByName = {}) => {
|
|
|
49
49
|
eventName = 'Event',
|
|
50
50
|
isUserFacingResponse = true,
|
|
51
51
|
method,
|
|
52
|
+
shouldUseDatabase = true,
|
|
52
53
|
} = optionByName;
|
|
53
54
|
|
|
54
55
|
if (!method) {
|
|
@@ -79,6 +80,12 @@ const createHandler = (optionByName = {}) => {
|
|
|
79
80
|
// If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
|
|
80
81
|
await secretsToEnv();
|
|
81
82
|
|
|
83
|
+
// Lazy-required so DB-free handlers never load the Prisma client.
|
|
84
|
+
if (shouldUseDatabase) {
|
|
85
|
+
const { connectPrisma } = require('../database/prisma');
|
|
86
|
+
await connectPrisma();
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
// Helps reuse the database connection. Lowers response times.
|
|
83
90
|
context.callbackWaitsForEmptyEventLoop = false;
|
|
84
91
|
|
|
@@ -31,6 +31,9 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
|
|
|
31
31
|
router[method.toLowerCase()](path, async (req, res, next) => {
|
|
32
32
|
try {
|
|
33
33
|
const integrationInstance = new IntegrationClass();
|
|
34
|
+
// initialize() registers dynamic user actions AND merges any Tier 3
|
|
35
|
+
// Integration Extension events into instance.events before dispatch.
|
|
36
|
+
await integrationInstance.initialize();
|
|
34
37
|
const dispatcher = new IntegrationEventDispatcher(
|
|
35
38
|
integrationInstance
|
|
36
39
|
);
|
|
@@ -212,6 +215,9 @@ const createQueueWorker = (integrationClass) => {
|
|
|
212
215
|
logCtx
|
|
213
216
|
);
|
|
214
217
|
integrationInstance = new integrationClass();
|
|
218
|
+
// Merge Tier 3 Integration Extension events into instance.events
|
|
219
|
+
// so extension-contributed queue events can be dispatched.
|
|
220
|
+
await integrationInstance.initialize();
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
const dispatcher = new IntegrationEventDispatcher(
|
|
@@ -511,6 +511,8 @@ router.get('/health/ready', async (_req, res) => {
|
|
|
511
511
|
});
|
|
512
512
|
});
|
|
513
513
|
|
|
514
|
-
|
|
514
|
+
// DB-free: /health/ready probes the DB itself and degrades to 503. Eager-connect
|
|
515
|
+
// here would turn a DB outage into a 500, killing otherwise-healthy containers.
|
|
516
|
+
const handler = createAppHandler('HTTP Event: Health', router, false);
|
|
515
517
|
|
|
516
518
|
module.exports = { handler, router };
|
|
@@ -2,24 +2,50 @@ const { createAppHandler } = require('./../app-handler-helpers');
|
|
|
2
2
|
const {
|
|
3
3
|
loadAppDefinition,
|
|
4
4
|
} = require('../app-definition-loader');
|
|
5
|
-
const
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const { Router } = express;
|
|
6
7
|
const { loadRouterFromObject } = require('../backend-utils');
|
|
8
|
+
const { getExtensionRoutes } = require('../../integrations/extension');
|
|
7
9
|
|
|
8
10
|
const handlers = {};
|
|
9
11
|
const { integrations: integrationClasses } = loadAppDefinition();
|
|
10
12
|
|
|
13
|
+
const routeKey = (method, path) => `${(method || 'ANY').toUpperCase()} ${path}`;
|
|
14
|
+
|
|
15
|
+
// Serverless function keys must be alphanumeric; binding keys are developer-chosen.
|
|
16
|
+
const sanitizeBindingKey = (name) => String(name).replace(/[^A-Za-z0-9]/g, '');
|
|
17
|
+
|
|
11
18
|
//todo: this should be in a use case class
|
|
12
19
|
for (const IntegrationClass of integrationClasses) {
|
|
13
20
|
const router = Router();
|
|
14
21
|
const basePath = `/api/${IntegrationClass.Definition.name}-integration`;
|
|
22
|
+
// Track (method, path) tuples to fail fast on conflicts between Definition.routes,
|
|
23
|
+
// extension routes, or two extensions claiming the same path.
|
|
24
|
+
const claimedRoutes = new Map();
|
|
25
|
+
const claim = (method, path, source) => {
|
|
26
|
+
const key = routeKey(method, path);
|
|
27
|
+
if (claimedRoutes.has(key)) {
|
|
28
|
+
const prev = claimedRoutes.get(key);
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Integration "${IntegrationClass.Definition.name}" route conflict: ` +
|
|
31
|
+
`${key} declared by ${prev} and ${source}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
claimedRoutes.set(key, source);
|
|
35
|
+
};
|
|
15
36
|
|
|
16
37
|
console.log(`\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:`);
|
|
17
38
|
|
|
18
|
-
|
|
39
|
+
const routes = IntegrationClass.Definition.routes || [];
|
|
40
|
+
for (const routeDef of routes) {
|
|
19
41
|
if (typeof routeDef === 'function') {
|
|
20
42
|
router.use(basePath, routeDef(IntegrationClass));
|
|
21
43
|
console.log(`│ ANY ${basePath}/* (function handler)`);
|
|
44
|
+
} else if (routeDef instanceof express.Router) {
|
|
45
|
+
router.use(basePath, routeDef);
|
|
46
|
+
console.log(`│ ANY ${basePath}/* (express router)`);
|
|
22
47
|
} else if (typeof routeDef === 'object') {
|
|
48
|
+
claim(routeDef.method, routeDef.path, 'Definition.routes');
|
|
23
49
|
router.use(
|
|
24
50
|
basePath,
|
|
25
51
|
loadRouterFromObject(IntegrationClass, routeDef)
|
|
@@ -27,11 +53,34 @@ for (const IntegrationClass of integrationClasses) {
|
|
|
27
53
|
const method = (routeDef.method || 'ANY').toUpperCase();
|
|
28
54
|
const fullPath = `${basePath}${routeDef.path}`;
|
|
29
55
|
console.log(`│ ${method} ${fullPath}`);
|
|
30
|
-
} else if (routeDef instanceof express.Router) {
|
|
31
|
-
router.use(basePath, routeDef);
|
|
32
|
-
console.log(`│ ANY ${basePath}/* (express router)`);
|
|
33
56
|
}
|
|
34
57
|
}
|
|
58
|
+
|
|
59
|
+
// Each extension binding gets its own namespaced handler (/{bindingName}),
|
|
60
|
+
// so two modules' extensions can share a path like /webhooks without colliding.
|
|
61
|
+
const bindingGroups = new Map();
|
|
62
|
+
for (const extRoute of getExtensionRoutes(IntegrationClass)) {
|
|
63
|
+
const namespacedPath = `/${extRoute.bindingName}${extRoute.path}`;
|
|
64
|
+
claim(
|
|
65
|
+
extRoute.method,
|
|
66
|
+
namespacedPath,
|
|
67
|
+
`extension "${extRoute.extensionName}" (binding "${extRoute.bindingName}")`
|
|
68
|
+
);
|
|
69
|
+
if (!bindingGroups.has(extRoute.bindingName)) {
|
|
70
|
+
bindingGroups.set(extRoute.bindingName, {
|
|
71
|
+
router: Router(),
|
|
72
|
+
useDatabase: extRoute.useDatabase,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const group = bindingGroups.get(extRoute.bindingName);
|
|
76
|
+
group.router.use(
|
|
77
|
+
`${basePath}/${extRoute.bindingName}`,
|
|
78
|
+
loadRouterFromObject(IntegrationClass, extRoute)
|
|
79
|
+
);
|
|
80
|
+
console.log(
|
|
81
|
+
`│ ${extRoute.method.toUpperCase()} ${basePath}/${extRoute.bindingName}${extRoute.path} (extension: ${extRoute.extensionName}, useDatabase: ${extRoute.useDatabase})`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
35
84
|
console.log('│');
|
|
36
85
|
|
|
37
86
|
handlers[`${IntegrationClass.Definition.name}`] = {
|
|
@@ -40,6 +89,29 @@ for (const IntegrationClass of integrationClasses) {
|
|
|
40
89
|
router
|
|
41
90
|
),
|
|
42
91
|
};
|
|
92
|
+
|
|
93
|
+
for (const [bindingName, group] of bindingGroups) {
|
|
94
|
+
// Wire contract: integration-builder.js (devtools) derives the identical
|
|
95
|
+
// function key for the serverless config. Keep both in sync.
|
|
96
|
+
const fnKey = `${IntegrationClass.Definition.name}__${sanitizeBindingKey(
|
|
97
|
+
bindingName
|
|
98
|
+
)}`;
|
|
99
|
+
// Distinct binding keys can sanitize to the same fnKey — fail loud rather than overwrite.
|
|
100
|
+
if (Object.prototype.hasOwnProperty.call(handlers, fnKey)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Integration "${IntegrationClass.Definition.name}" extension handler conflict: ` +
|
|
103
|
+
`binding "${bindingName}" sanitizes to "${fnKey}", which is already taken. ` +
|
|
104
|
+
`Use binding keys that are distinct after stripping non-alphanumeric characters.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
handlers[fnKey] = {
|
|
108
|
+
handler: createAppHandler(
|
|
109
|
+
`HTTP Event: ${IntegrationClass.Definition.name} extension ${bindingName}`,
|
|
110
|
+
group.router,
|
|
111
|
+
group.useDatabase
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
43
115
|
}
|
|
44
116
|
|
|
45
117
|
module.exports = { handlers };
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
const { createHandler } = require('@friggframework/core');
|
|
2
2
|
const { loadAppDefinition } = require('../app-definition-loader');
|
|
3
3
|
const { createQueueWorker } = require('../backend-utils');
|
|
4
|
+
// TODO(Phase 2): mount extension-declared workers in addition to the per-integration
|
|
5
|
+
// default queue worker. Today, getExtensionWorkers(IntegrationClass) returns the
|
|
6
|
+
// declared workers but they are not yet bound to dedicated SQS sources. Extension-
|
|
7
|
+
// contributed *events* still flow through the default queue worker below because
|
|
8
|
+
// _mergeExtensions() registers them in instance.events, so end-to-end webhook
|
|
9
|
+
// delivery for Tier 3 extensions works without this Phase 2 work.
|
|
10
|
+
// const { getExtensionWorkers } = require('../../integrations/extension');
|
|
4
11
|
|
|
5
12
|
const handlers = {};
|
|
6
13
|
const { integrations: integrationClasses } = loadAppDefinition();
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Integration Extensions Quick Start
|
|
2
|
+
|
|
3
|
+
Tier 3 **Integration Extensions** let an API module ship reusable handler bundles — receiver routes, event handlers, queues, workers — that an integration consumes declaratively via `Definition.extensions`. See [ADR-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) for the full taxonomy.
|
|
4
|
+
|
|
5
|
+
## When to use this vs `Definition.webhooks: true`
|
|
6
|
+
|
|
7
|
+
| Use `webhooks: true` ([WEBHOOK-QUICKSTART](./WEBHOOK-QUICKSTART.md)) | Use `extensions: {...}` |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Per-account webhooks scoped to one integration record | App-level webhooks fanned out to many account records by external ID lookup |
|
|
10
|
+
| You'll write the receiver, signature check, and queue dispatch yourself | The API module ships the receiver, signature check, and queue dispatch already |
|
|
11
|
+
| One pattern, one endpoint | Multiple bundles (webhooks + CRM cards + timeline) declared together |
|
|
12
|
+
|
|
13
|
+
## Step 1: Bind the extension on your Integration's Definition
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
const hubspot = require('@friggframework/api-module-hubspot');
|
|
17
|
+
|
|
18
|
+
class HubSpotIntegration extends IntegrationBase {
|
|
19
|
+
static Definition = {
|
|
20
|
+
name: 'hubspot',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
modules: { hubspot: { definition: hubspot.Definition } },
|
|
23
|
+
extensions: {
|
|
24
|
+
hubspotWebhooks: {
|
|
25
|
+
extension: hubspot.extensions.webhooks,
|
|
26
|
+
handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' },
|
|
27
|
+
// optional: override the extension's declared useDatabase
|
|
28
|
+
// useDatabase: true,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async onHubSpotEvent({ data }) {
|
|
34
|
+
// pure business logic — signature verification, portalId lookup,
|
|
35
|
+
// and queue dispatch are all done by the extension's default handlers
|
|
36
|
+
const { subscriptionType, objectId } = data.body;
|
|
37
|
+
if (subscriptionType === 'contact.creation') {
|
|
38
|
+
await this.upsertContact(objectId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The binding key (`hubspotWebhooks`) is your local name. It is also the **URL namespace** for the extension's routes (see below), so pick something readable — `hubspot` yields a cleaner URL than `hubspotWebhooks`. The extension reference (`hubspot.extensions.webhooks`) is whatever the API module exports.
|
|
45
|
+
|
|
46
|
+
## Step 2: Deploy
|
|
47
|
+
|
|
48
|
+
Each extension binding is mounted under its **binding key**, on its own dedicated handler/Lambda function. This means two modules' extensions (e.g. a HubSpot and a Clockwork webhooks extension on the same integration) never collide — each lives at a distinct namespaced path. Boot logs show:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
│ Configuring routes for hubspot Integration:
|
|
52
|
+
│ POST /api/hubspot-integration/hubspotWebhooks/webhooks (extension: hubspot-webhooks, useDatabase: false)
|
|
53
|
+
│
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
So the full URL is `/api/{integration-name}-integration/{bindingKey}{route.path}`. Register that URL with the upstream provider (e.g. paste it into your HubSpot app's webhook settings). Hit it and the bound method (`onHubSpotEvent`) fires on the resolved per-account integration instance.
|
|
57
|
+
|
|
58
|
+
> **⚠️ Breaking:** extension routes used to mount un-namespaced (`/api/{x}-integration/webhooks`). They are now namespaced under the binding key. Any provider webhook already registered against the old path must be re-pointed at the new `/{bindingKey}` URL — and for signature schemes that sign the full URL (e.g. HubSpot v3), the old registration will also fail verification until updated.
|
|
59
|
+
|
|
60
|
+
## `useDatabase` — does the receiver open a DB connection?
|
|
61
|
+
|
|
62
|
+
Each extension declares whether its route handler should open a database connection:
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
// in the extension bundle (api-module side)
|
|
66
|
+
module.exports = {
|
|
67
|
+
name: 'hubspot-webhooks',
|
|
68
|
+
useDatabase: false, // default — the receiver is DB-free
|
|
69
|
+
routes: [ /* ... */ ],
|
|
70
|
+
events: { /* ... */ },
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Default is `false`** — a webhook receiver that only verifies a signature and enqueues should not pay for a DB connection (faster cold start; at build time its Lambda doesn't get the Prisma layer).
|
|
75
|
+
- Set `useDatabase: true` at the **extension level** if the receiver itself needs the DB. A binding may override it locally (`extensions: { x: { extension, useDatabase: true } }`), though that's rarely needed.
|
|
76
|
+
- Resolution order: `binding.useDatabase ?? extension.useDatabase ?? false`.
|
|
77
|
+
- Scope note: `false` is the default **for extension routes**. `createHandler` itself still defaults `shouldUseDatabase: true` for the integration's own catch-all handler and the legacy `Definition.webhooks: true` path — those connect as before. The `false` default applies only to the per-binding extension handler.
|
|
78
|
+
|
|
79
|
+
If `useDatabase` is `false`, the receiver must not touch the database. Work that needs the DB (e.g. resolving `portalId → integrationId`) belongs in the queue worker that processes the dispatched event, not in the receiver.
|
|
80
|
+
|
|
81
|
+
## How handler binding works
|
|
82
|
+
|
|
83
|
+
Each binding can map extension event names to method names on your integration:
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
extensions: {
|
|
87
|
+
hubspotWebhooks: {
|
|
88
|
+
extension: hubspot.extensions.webhooks,
|
|
89
|
+
handlers: {
|
|
90
|
+
HUBSPOT_WEBHOOK: 'onHubSpotEvent', // method on `this`
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Resolution priority per event:
|
|
97
|
+
|
|
98
|
+
1. `binding.handlers[eventName]` → resolves to `this[methodName]`, bound to the instance
|
|
99
|
+
2. Extension's own default handler from `extension.events[eventName].handler`, bound to the instance
|
|
100
|
+
3. Otherwise → `initialize()` throws with a message naming the integration, binding, and event
|
|
101
|
+
|
|
102
|
+
Strings (not function refs) are intentional: it dodges the `this`-in-static-Definition bootstrapping problem and centralizes binding inside the framework. The framework resolves the method against the live integration instance at startup.
|
|
103
|
+
|
|
104
|
+
## Event-name conflicts (and binding the same extension twice)
|
|
105
|
+
|
|
106
|
+
If two bindings in your integration's `extensions` map declare the same event name, the framework **throws at `initialize()`** with a clear conflict error — no silent first/last-writer pick, no surprise routing. The fix is to use distinct event names per binding.
|
|
107
|
+
|
|
108
|
+
This means **binding the same extension twice only works if the extension itself defines disjoint event sets per use-case** (rare). For the common "two webhooks, two handlers" pattern, an API module should ship two distinct extensions instead — for example `hubspot.extensions.webhooks` and `hubspot.extensions.sandboxWebhooks`, each with its own event names.
|
|
109
|
+
|
|
110
|
+
Subclass overrides via `this.events[eventName]` (set in the constructor) take precedence over extension-declared events. If a binding tried to wire a handler that's now shadowed, the framework logs a warning naming the integration, binding, and ignored method.
|
|
111
|
+
|
|
112
|
+
**Routes do not collide across bindings** — each binding's routes are namespaced under its binding key (`/{bindingKey}{route.path}`), so two extensions can both declare `POST /webhooks` and live at distinct URLs. A route conflict only throws at boot if a *single* binding declares two routes with the same `method + path` (or a `Definition.routes` entry exactly matches an extension's namespaced path). Note this is independent of event-name conflicts above: namespacing disambiguates URLs, but two bindings still must use distinct **event** names since events are merged into one `this.events` map.
|
|
113
|
+
|
|
114
|
+
## Authoring an extension (for API module authors)
|
|
115
|
+
|
|
116
|
+
An extension bundle is a plain object exported from your api-module:
|
|
117
|
+
|
|
118
|
+
```javascript
|
|
119
|
+
// @friggframework/api-module-hubspot/extensions/webhooks/index.js
|
|
120
|
+
module.exports = {
|
|
121
|
+
name: 'hubspot-webhooks',
|
|
122
|
+
routes: [
|
|
123
|
+
{ path: '/webhooks', method: 'POST', event: 'HUBSPOT_WEBHOOK_RECEIVED' },
|
|
124
|
+
],
|
|
125
|
+
events: {
|
|
126
|
+
HUBSPOT_WEBHOOK_RECEIVED: {
|
|
127
|
+
type: 'LIFE_CYCLE_EVENT',
|
|
128
|
+
handler: async function ({ req, res }) {
|
|
129
|
+
// verify signature, look up integration by portalId, queue
|
|
130
|
+
await verifyHubSpotSignature(req);
|
|
131
|
+
for (const evt of req.body) {
|
|
132
|
+
// Reverse-lookup is exposed via friggCommands, the
|
|
133
|
+
// canonical access pattern for cross-cutting lookups.
|
|
134
|
+
// The HubSpot-named wrapper lives in the api-module's
|
|
135
|
+
// extension package.
|
|
136
|
+
const integrationId =
|
|
137
|
+
await this.commands.findIntegrationByEntityExternalId(
|
|
138
|
+
evt.portalId,
|
|
139
|
+
'hubspot'
|
|
140
|
+
);
|
|
141
|
+
if (!integrationId) continue;
|
|
142
|
+
await this.queueWebhook({
|
|
143
|
+
integrationId,
|
|
144
|
+
body: evt,
|
|
145
|
+
event: 'HUBSPOT_WEBHOOK',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
res.status(200).json({ received: req.body.length });
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
HUBSPOT_WEBHOOK: {
|
|
152
|
+
type: 'LIFE_CYCLE_EVENT',
|
|
153
|
+
handler: async function ({ data }) {
|
|
154
|
+
// default no-op; integrations override via binding.handlers
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Then expose it on your api-module's index:
|
|
162
|
+
|
|
163
|
+
```javascript
|
|
164
|
+
// @friggframework/api-module-hubspot/index.js
|
|
165
|
+
module.exports = {
|
|
166
|
+
Definition: require('./api-module-definition'),
|
|
167
|
+
extensions: {
|
|
168
|
+
webhooks: require('./extensions/webhooks'),
|
|
169
|
+
crmCards: require('./extensions/crm-cards'),
|
|
170
|
+
timeline: require('./extensions/timeline'),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Contract enforced by the framework
|
|
176
|
+
|
|
177
|
+
At `initialize()`, the framework validates each binding:
|
|
178
|
+
|
|
179
|
+
- `extension` must be an object with a `name`
|
|
180
|
+
- `extension.events` must be an object keyed by event name (if present)
|
|
181
|
+
- `extension.routes` must be an array (if present)
|
|
182
|
+
- Every route's `event` must exist in `extension.events`
|
|
183
|
+
- Every route's `method` must be a known HTTP verb
|
|
184
|
+
- For each event, either `binding.handlers[eventName]` resolves to an instance method, OR `extension.events[eventName].handler` is a function
|
|
185
|
+
|
|
186
|
+
Validation failures throw at boot with a message identifying the integration, binding, and field.
|
|
187
|
+
|
|
188
|
+
## Reverse-lookup helpers
|
|
189
|
+
|
|
190
|
+
For app-level webhooks (HubSpot, Slack, Asana, Microsoft Teams, etc.) where one URL serves many accounts, extension default handlers need to resolve the inbound external ID (HubSpot `portalId`, Slack `team_id`, Asana `workspace_id`, Teams `tenant_id`) to a Frigg integration record. Two helpers are exposed via [`createFriggCommands`](../application/index.js) — the canonical access pattern for cross-cutting lookups:
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
// inside an integration class
|
|
194
|
+
this.commands = createFriggCommands({ integrationClass: MyIntegration });
|
|
195
|
+
|
|
196
|
+
// Throws on ambiguous resolution. Use when one externalId is expected
|
|
197
|
+
// to map to exactly one integration.
|
|
198
|
+
const integrationId = await this.commands.findIntegrationByEntityExternalId(
|
|
199
|
+
externalId,
|
|
200
|
+
'hubspot' // optional moduleName
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Returns array. Use when one externalId may legitimately fan out to
|
|
204
|
+
// multiple integrations (e.g. one upstream account broadcasting to
|
|
205
|
+
// several Frigg integration records).
|
|
206
|
+
const integrationIds = await this.commands.listIntegrationsByEntityExternalId(
|
|
207
|
+
externalId,
|
|
208
|
+
'hubspot'
|
|
209
|
+
);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`findIntegrationByEntityExternalId` throws if:
|
|
213
|
+
- the (externalId, moduleName) tuple matches more than one Entity row, OR
|
|
214
|
+
- the matched entity is owned by more than one Integration record
|
|
215
|
+
|
|
216
|
+
A silent first-match at either layer is a cross-tenant routing risk; the command refuses to pick. The second argument (moduleName) disambiguates when multiple modules in the same app could carry colliding external IDs — pass it whenever an api-module knows its own moduleName.
|
|
217
|
+
|
|
218
|
+
### Where platform-named wrappers belong
|
|
219
|
+
|
|
220
|
+
The commands are intentionally platform-neutral. Platform-vocabulary wrappers (`findIntegrationByPortalId`, `findIntegrationByTeamId`, `findIntegrationByWorkspaceId`, etc.) belong **inside the api-module's own extension**, not in core:
|
|
221
|
+
|
|
222
|
+
```javascript
|
|
223
|
+
// inside @friggframework/api-module-hubspot/extensions/webhooks
|
|
224
|
+
async function findIntegrationByPortalId(integration, portalId) {
|
|
225
|
+
// thin wrapper — reads as self-documenting HubSpot code,
|
|
226
|
+
// delegates to the platform-neutral command
|
|
227
|
+
return integration.commands.findIntegrationByEntityExternalId(
|
|
228
|
+
portalId,
|
|
229
|
+
'hubspot'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
This keeps core platform-neutral and reusable while keeping the api-module code self-documenting for the platform's developers. The same rule applies to any helper that can be named in a single platform's vocabulary.
|
|
235
|
+
|
|
236
|
+
## See also
|
|
237
|
+
|
|
238
|
+
- [ADR-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) — the three-tier taxonomy (Core Plugins / Application Extensions / Integration Extensions)
|
|
239
|
+
- [WEBHOOK-QUICKSTART](./WEBHOOK-QUICKSTART.md) — per-account `Definition.webhooks: true` pattern
|
|
240
|
+
- `extension.js` — the validation + flattening helpers (`validateExtensionBinding`, `getExtensionRoutes`, `getExtensionWorkers`)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 3 — Integration Extensions
|
|
3
|
+
*
|
|
4
|
+
* An Integration Extension is a reusable bundle exported by an API module (or a
|
|
5
|
+
* shared extensions library) that contributes routes, events, queues, and workers
|
|
6
|
+
* to a consumer integration. The integration binds the bundle declaratively via
|
|
7
|
+
* `static Definition.extensions` and the framework merges its contributions into
|
|
8
|
+
* the integration's effective definition at instantiation time.
|
|
9
|
+
*
|
|
10
|
+
* Extension bundle shape:
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* name: string, // required, unique within the API module
|
|
14
|
+
* routes?: Array<{ // optional, mounted alongside Definition.routes
|
|
15
|
+
* path: string,
|
|
16
|
+
* method: 'GET' | 'POST' | 'PUT' | 'DELETE' | ...,
|
|
17
|
+
* event: string // must exist in `events` below
|
|
18
|
+
* }>,
|
|
19
|
+
* events?: { // optional, merged into instance.events
|
|
20
|
+
* [eventName]: {
|
|
21
|
+
* type?: string, // e.g. 'LIFE_CYCLE_EVENT'
|
|
22
|
+
* handler: Function // default handler; integration may override per-binding
|
|
23
|
+
* }
|
|
24
|
+
* },
|
|
25
|
+
* queues?: Array<Object>, // reserved — Phase 2
|
|
26
|
+
* workers?: Array<Object> // reserved — Phase 2
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* Integration binding shape (on the integration's static Definition.extensions):
|
|
30
|
+
*
|
|
31
|
+
* extensions: {
|
|
32
|
+
* hubspotWebhooks: { // local binding name (developer's choice)
|
|
33
|
+
* extension: hubspot.extensions.webhooks,
|
|
34
|
+
* handlers: { // optional override map
|
|
35
|
+
* HUBSPOT_WEBHOOK: 'onHubSpotEvent' // event → method name on the integration
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* }
|
|
39
|
+
*
|
|
40
|
+
* The same extension may be bound multiple times under different local names; the
|
|
41
|
+
* binding key is the developer-controlled local handle, not a global registry key.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const KNOWN_HTTP_METHODS = new Set([
|
|
45
|
+
'get',
|
|
46
|
+
'post',
|
|
47
|
+
'put',
|
|
48
|
+
'patch',
|
|
49
|
+
'delete',
|
|
50
|
+
'options',
|
|
51
|
+
'head',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate the shape of an extension bundle and its binding.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} extension - The extension bundle to validate.
|
|
58
|
+
* @param {string} bindingName - The local binding key (for error context).
|
|
59
|
+
* @param {string} integrationName - The integration's Definition.name (for error context).
|
|
60
|
+
* @param {Object} [binding] - Optional full binding object; if provided, also validates binding.handlers.
|
|
61
|
+
* @throws {Error} If the extension is missing required fields or is internally inconsistent.
|
|
62
|
+
*/
|
|
63
|
+
function validateExtensionBinding(extension, bindingName, integrationName, binding) {
|
|
64
|
+
const ctx = `Integration "${integrationName}" extension binding "${bindingName}"`;
|
|
65
|
+
|
|
66
|
+
if (!extension || typeof extension !== 'object') {
|
|
67
|
+
throw new Error(`${ctx}: extension must be an object`);
|
|
68
|
+
}
|
|
69
|
+
if (!extension.name || typeof extension.name !== 'string') {
|
|
70
|
+
throw new Error(`${ctx}: extension is missing required "name" field`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
extension.useDatabase !== undefined &&
|
|
75
|
+
typeof extension.useDatabase !== 'boolean'
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${ctx}: extension "${extension.name}" "useDatabase" must be a boolean`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
binding &&
|
|
83
|
+
binding.useDatabase !== undefined &&
|
|
84
|
+
typeof binding.useDatabase !== 'boolean'
|
|
85
|
+
) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${ctx}: binding "useDatabase" must be a boolean`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const events = extension.events || {};
|
|
92
|
+
if (typeof events !== 'object' || Array.isArray(events)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${ctx}: extension "${extension.name}" "events" must be an object keyed by event name`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate each event's shape — handler, when present, must be a function.
|
|
99
|
+
for (const [eventName, eventDef] of Object.entries(events)) {
|
|
100
|
+
if (!eventDef || typeof eventDef !== 'object') {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${ctx}: extension "${extension.name}" event "${eventName}" must be an object`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
eventDef.handler !== undefined &&
|
|
107
|
+
typeof eventDef.handler !== 'function'
|
|
108
|
+
) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${ctx}: extension "${extension.name}" event "${eventName}" "handler" must be a function`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const routes = extension.routes || [];
|
|
116
|
+
if (!Array.isArray(routes)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${ctx}: extension "${extension.name}" "routes" must be an array`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const route of routes) {
|
|
123
|
+
if (!route || typeof route !== 'object') {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${ctx}: extension "${extension.name}" has a malformed route entry`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (typeof route.path !== 'string' || route.path.length === 0) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${ctx}: extension "${extension.name}" route is missing "path"`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (typeof route.method !== 'string') {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${ctx}: extension "${extension.name}" route "${route.path}" is missing "method"`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!KNOWN_HTTP_METHODS.has(route.method.toLowerCase())) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${ctx}: extension "${extension.name}" route "${route.path}" has unsupported method "${route.method}"`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (typeof route.event !== 'string' || route.event.length === 0) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${ctx}: extension "${extension.name}" route "${route.path}" is missing "event"`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (!Object.prototype.hasOwnProperty.call(events, route.event)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${ctx}: extension "${extension.name}" route "${route.path}" references event "${route.event}" which is not declared in extension.events`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate binding.handlers if a binding was supplied.
|
|
156
|
+
if (binding && binding.handlers !== undefined) {
|
|
157
|
+
if (
|
|
158
|
+
typeof binding.handlers !== 'object' ||
|
|
159
|
+
Array.isArray(binding.handlers) ||
|
|
160
|
+
binding.handlers === null
|
|
161
|
+
) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${ctx}: "handlers" must be an object keyed by event name`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
for (const [eventName, methodRef] of Object.entries(binding.handlers)) {
|
|
167
|
+
if (typeof methodRef !== 'string' || methodRef.length === 0) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`${ctx}: handler for event "${eventName}" must be a non-empty method name string (got ${typeof methodRef})`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (!Object.prototype.hasOwnProperty.call(events, eventName)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`${ctx}: binding.handlers references event "${eventName}" which is not declared in extension "${extension.name}".events — check for typos`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the flattened list of extension-contributed routes for an integration class.
|
|
183
|
+
* Each route carries the binding name and extension name alongside the route fields
|
|
184
|
+
* so the router builder can produce useful boot-time logs.
|
|
185
|
+
*
|
|
186
|
+
* @param {Function} IntegrationClass - A class extending IntegrationBase.
|
|
187
|
+
* @returns {Array<{bindingName: string, extensionName: string, path: string, method: string, event: string}>}
|
|
188
|
+
*/
|
|
189
|
+
function getExtensionRoutes(IntegrationClass) {
|
|
190
|
+
const extensions = IntegrationClass?.Definition?.extensions || {};
|
|
191
|
+
const integrationName = IntegrationClass?.Definition?.name;
|
|
192
|
+
const flat = [];
|
|
193
|
+
for (const [bindingName, binding] of Object.entries(extensions)) {
|
|
194
|
+
// Fail fast: surface bad bindings at boot, not at first request.
|
|
195
|
+
// Mirrors the validation that _mergeExtensions does at instance time.
|
|
196
|
+
validateExtensionBinding(
|
|
197
|
+
binding && binding.extension,
|
|
198
|
+
bindingName,
|
|
199
|
+
integrationName,
|
|
200
|
+
binding
|
|
201
|
+
);
|
|
202
|
+
const useDatabase =
|
|
203
|
+
binding.useDatabase ?? binding.extension.useDatabase ?? false;
|
|
204
|
+
const routes = binding.extension.routes || [];
|
|
205
|
+
for (const route of routes) {
|
|
206
|
+
flat.push({
|
|
207
|
+
bindingName,
|
|
208
|
+
extensionName: binding.extension.name,
|
|
209
|
+
path: route.path,
|
|
210
|
+
method: route.method,
|
|
211
|
+
event: route.event,
|
|
212
|
+
useDatabase,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return flat;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get the flattened list of extension-contributed workers for an integration class.
|
|
221
|
+
* Reserved for Phase 2 — today the per-integration QueueWorker handles all events
|
|
222
|
+
* by name, so extension-contributed events flow through it without a dedicated worker.
|
|
223
|
+
*
|
|
224
|
+
* @param {Function} IntegrationClass - A class extending IntegrationBase.
|
|
225
|
+
* @returns {Array<Object>}
|
|
226
|
+
*/
|
|
227
|
+
function getExtensionWorkers(IntegrationClass) {
|
|
228
|
+
const extensions = IntegrationClass?.Definition?.extensions || {};
|
|
229
|
+
const integrationName = IntegrationClass?.Definition?.name;
|
|
230
|
+
const flat = [];
|
|
231
|
+
for (const [bindingName, binding] of Object.entries(extensions)) {
|
|
232
|
+
validateExtensionBinding(
|
|
233
|
+
binding && binding.extension,
|
|
234
|
+
bindingName,
|
|
235
|
+
integrationName,
|
|
236
|
+
binding
|
|
237
|
+
);
|
|
238
|
+
const workers = binding.extension.workers || [];
|
|
239
|
+
for (const worker of workers) {
|
|
240
|
+
flat.push({
|
|
241
|
+
bindingName,
|
|
242
|
+
extensionName: binding.extension.name,
|
|
243
|
+
...worker,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return flat;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
validateExtensionBinding,
|
|
252
|
+
getExtensionRoutes,
|
|
253
|
+
getExtensionWorkers,
|
|
254
|
+
};
|
package/integrations/index.js
CHANGED
|
@@ -10,6 +10,11 @@ const {
|
|
|
10
10
|
const {
|
|
11
11
|
LoadIntegrationContextUseCase,
|
|
12
12
|
} = require('./use-cases/load-integration-context');
|
|
13
|
+
const {
|
|
14
|
+
validateExtensionBinding,
|
|
15
|
+
getExtensionRoutes,
|
|
16
|
+
getExtensionWorkers,
|
|
17
|
+
} = require('./extension');
|
|
13
18
|
|
|
14
19
|
module.exports = {
|
|
15
20
|
IntegrationBase,
|
|
@@ -18,4 +23,7 @@ module.exports = {
|
|
|
18
23
|
checkRequiredParams,
|
|
19
24
|
getModulesDefinitionFromIntegrationClasses,
|
|
20
25
|
LoadIntegrationContextUseCase,
|
|
26
|
+
validateExtensionBinding,
|
|
27
|
+
getExtensionRoutes,
|
|
28
|
+
getExtensionWorkers,
|
|
21
29
|
};
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
const {
|
|
12
12
|
UpdateIntegrationMessages,
|
|
13
13
|
} = require('./use-cases/update-integration-messages');
|
|
14
|
+
const { validateExtensionBinding } = require('./extension');
|
|
14
15
|
|
|
15
16
|
const constantsToBeMigrated = {
|
|
16
17
|
defaultEvents: {
|
|
@@ -60,6 +61,9 @@ class IntegrationBase {
|
|
|
60
61
|
supportedVersions: [], // Eventually usable for deprecation and future test version purposes
|
|
61
62
|
|
|
62
63
|
modules: {},
|
|
64
|
+
// Tier 3 Integration Extensions — see packages/core/integrations/EXTENSIONS.md
|
|
65
|
+
// Shape: { [bindingName]: { extension, handlers?: { [eventName]: methodName } } }
|
|
66
|
+
extensions: {},
|
|
63
67
|
display: {
|
|
64
68
|
name: 'Integration Name',
|
|
65
69
|
logo: '',
|
|
@@ -430,6 +434,19 @@ class IntegrationBase {
|
|
|
430
434
|
// Default: no-op, integrations override this
|
|
431
435
|
}
|
|
432
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Queue a webhook for asynchronous worker dispatch.
|
|
439
|
+
*
|
|
440
|
+
* The dispatch event defaults to `ON_WEBHOOK` for backward compatibility
|
|
441
|
+
* with the `Definition.webhooks: true` path. Extensions (and any caller
|
|
442
|
+
* that needs the worker to invoke a specific bound handler) can override
|
|
443
|
+
* by passing `event` in the payload — it's stripped from the payload and
|
|
444
|
+
* used as the SQS message's dispatch event.
|
|
445
|
+
*
|
|
446
|
+
* @param {Object} data - Webhook payload. May include `event` to override
|
|
447
|
+
* the default `ON_WEBHOOK` dispatch event. All other fields are passed
|
|
448
|
+
* through to the worker as the `data` field of the SQS message.
|
|
449
|
+
*/
|
|
433
450
|
async queueWebhook(data) {
|
|
434
451
|
const { QueuerUtil } = require('../queues');
|
|
435
452
|
|
|
@@ -442,10 +459,12 @@ class IntegrationBase {
|
|
|
442
459
|
throw new Error(`Queue URL not found for ${queueName}`);
|
|
443
460
|
}
|
|
444
461
|
|
|
462
|
+
const { event: dispatchEvent, ...payload } = data || {};
|
|
463
|
+
|
|
445
464
|
return QueuerUtil.send(
|
|
446
465
|
{
|
|
447
|
-
event: 'ON_WEBHOOK',
|
|
448
|
-
data,
|
|
466
|
+
event: dispatchEvent || 'ON_WEBHOOK',
|
|
467
|
+
data: payload,
|
|
449
468
|
},
|
|
450
469
|
queueUrl
|
|
451
470
|
);
|
|
@@ -504,6 +523,97 @@ class IntegrationBase {
|
|
|
504
523
|
};
|
|
505
524
|
}
|
|
506
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Merge Tier 3 Integration Extension events into `this.events`.
|
|
528
|
+
*
|
|
529
|
+
* For each binding declared on `static Definition.extensions`, this method:
|
|
530
|
+
* 1. Validates the extension bundle shape and binding handlers
|
|
531
|
+
* 2. For each event the extension declares, resolves the handler in priority order:
|
|
532
|
+
* a. Subclass-defined `this.events[eventName]` (set in the constructor) — wins; if a
|
|
533
|
+
* binding tried to override that event with `handlers`, we log a warning so the
|
|
534
|
+
* author knows their override is shadowed.
|
|
535
|
+
* b. A method-name string in `binding.handlers[eventName]` → method on this instance
|
|
536
|
+
* c. The extension's own default `handler` function
|
|
537
|
+
* d. Otherwise throw — neither side provided a handler
|
|
538
|
+
* 3. Binds the resolved function to this instance and writes it to `this.events[eventName]`
|
|
539
|
+
*
|
|
540
|
+
* Two bindings declaring the same event throw a deterministic conflict error — silent
|
|
541
|
+
* "first/last writer wins" makes routing bugs nearly impossible to diagnose.
|
|
542
|
+
*
|
|
543
|
+
* @private
|
|
544
|
+
*/
|
|
545
|
+
_mergeExtensions() {
|
|
546
|
+
const extensions = this.constructor.Definition?.extensions || {};
|
|
547
|
+
const integrationName = this.constructor.Definition?.name;
|
|
548
|
+
// Tracks which event names have been claimed by an extension binding during
|
|
549
|
+
// this merge — distinct from subclass-defined events on `this.events`.
|
|
550
|
+
const mergedByExtension = new Map();
|
|
551
|
+
|
|
552
|
+
for (const [bindingName, binding] of Object.entries(extensions)) {
|
|
553
|
+
if (!binding || typeof binding !== 'object') {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`Integration "${integrationName}" extension binding "${bindingName}" must be an object`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const { extension, handlers = {} } = binding;
|
|
559
|
+
validateExtensionBinding(
|
|
560
|
+
extension,
|
|
561
|
+
bindingName,
|
|
562
|
+
integrationName,
|
|
563
|
+
binding
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const extEvents = extension.events || {};
|
|
567
|
+
for (const [eventName, eventDef] of Object.entries(extEvents)) {
|
|
568
|
+
// Conflict detection: another extension binding already claimed this event name.
|
|
569
|
+
if (mergedByExtension.has(eventName)) {
|
|
570
|
+
const prev = mergedByExtension.get(eventName);
|
|
571
|
+
throw new Error(
|
|
572
|
+
`Integration "${integrationName}" extension event conflict: ` +
|
|
573
|
+
`event "${eventName}" is declared by both binding "${prev}" and binding "${bindingName}" — ` +
|
|
574
|
+
`use distinct event names per binding or omit duplicates`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Subclass shadowing: if the subclass set this.events[eventName] before initialize(),
|
|
579
|
+
// it wins. Warn if the binding tried to wire an override that's now ignored.
|
|
580
|
+
if (this.events[eventName]) {
|
|
581
|
+
if (typeof handlers[eventName] === 'string') {
|
|
582
|
+
console.warn(
|
|
583
|
+
`[Frigg] Integration "${integrationName}" binding "${bindingName}": ` +
|
|
584
|
+
`handler "${handlers[eventName]}" for event "${eventName}" is ignored because ` +
|
|
585
|
+
`this.events["${eventName}"] was already set (subclass constructor or earlier merge)`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let fn;
|
|
592
|
+
const override = handlers[eventName];
|
|
593
|
+
if (typeof override === 'string') {
|
|
594
|
+
if (typeof this[override] !== 'function') {
|
|
595
|
+
throw new Error(
|
|
596
|
+
`Integration "${integrationName}" extension binding "${bindingName}": handler method "${override}" not found on instance`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
fn = this[override];
|
|
600
|
+
} else if (typeof eventDef.handler === 'function') {
|
|
601
|
+
fn = eventDef.handler;
|
|
602
|
+
} else {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`Extension "${extension.name}" event "${eventName}" has no default handler and binding "${bindingName}" did not provide one`
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
this.events[eventName] = {
|
|
609
|
+
type: eventDef.type,
|
|
610
|
+
handler: fn.bind(this),
|
|
611
|
+
};
|
|
612
|
+
mergedByExtension.set(eventName, bindingName);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
507
617
|
async initialize() {
|
|
508
618
|
try {
|
|
509
619
|
const additionalUserActions = await this.loadDynamicUserActions();
|
|
@@ -512,6 +622,7 @@ class IntegrationBase {
|
|
|
512
622
|
this.addError(e);
|
|
513
623
|
}
|
|
514
624
|
|
|
625
|
+
this._mergeExtensions();
|
|
515
626
|
this.registerEventHandlers();
|
|
516
627
|
}
|
|
517
628
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse-lookup use case: resolve an externalId (e.g. HubSpot portalId,
|
|
3
|
+
* Slack team_id, Asana workspace_id) to a single integration ID.
|
|
4
|
+
*
|
|
5
|
+
* Refuses to pick on ambiguous resolution at either layer:
|
|
6
|
+
* - more than one matching Entity row for the (externalId, moduleName) tuple
|
|
7
|
+
* - more than one Integration owning the matched entity
|
|
8
|
+
*
|
|
9
|
+
* A silent first-match in either case is a cross-tenant routing risk.
|
|
10
|
+
* Callers that expect a one-to-many fan-out should use
|
|
11
|
+
* {@link ListIntegrationsByEntityExternalIdUseCase} instead.
|
|
12
|
+
*/
|
|
13
|
+
class FindIntegrationByEntityExternalIdUseCase {
|
|
14
|
+
constructor({ integrationRepository, moduleRepository } = {}) {
|
|
15
|
+
if (!integrationRepository) {
|
|
16
|
+
throw new Error('integrationRepository is required');
|
|
17
|
+
}
|
|
18
|
+
if (!moduleRepository) {
|
|
19
|
+
throw new Error('moduleRepository is required');
|
|
20
|
+
}
|
|
21
|
+
this.integrationRepository = integrationRepository;
|
|
22
|
+
this.moduleRepository = moduleRepository;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async execute({ externalId, moduleName } = {}) {
|
|
26
|
+
if (!externalId) return null;
|
|
27
|
+
|
|
28
|
+
const filter = { externalId: String(externalId) };
|
|
29
|
+
if (moduleName) filter.moduleName = moduleName;
|
|
30
|
+
|
|
31
|
+
const entities = await this.moduleRepository.findEntities(filter);
|
|
32
|
+
if (!entities || entities.length === 0) {
|
|
33
|
+
console.log(
|
|
34
|
+
`[Frigg] findIntegrationByEntityExternalId: no entity for externalId=${externalId}${
|
|
35
|
+
moduleName ? ` moduleName=${moduleName}` : ''
|
|
36
|
+
}`
|
|
37
|
+
);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (entities.length > 1) {
|
|
41
|
+
const ids = entities.map((e) => e.id).join(', ');
|
|
42
|
+
throw new Error(
|
|
43
|
+
`findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
|
|
44
|
+
`${moduleName ? ` moduleName=${moduleName}` : ''} matches ${entities.length} entities [${ids}]. ` +
|
|
45
|
+
`Refusing to pick one to avoid cross-tenant routing. ` +
|
|
46
|
+
`Pass a moduleName, or use listIntegrationsByEntityExternalId if multiple integrations are expected.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const entity = entities[0];
|
|
51
|
+
const integrations =
|
|
52
|
+
await this.integrationRepository.findIntegrationsByEntityId(
|
|
53
|
+
entity.id
|
|
54
|
+
);
|
|
55
|
+
if (!integrations || integrations.length === 0) {
|
|
56
|
+
console.log(
|
|
57
|
+
`[Frigg] findIntegrationByEntityExternalId: entity ${entity.id} has no owning integrations (orphan)`
|
|
58
|
+
);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (integrations.length > 1) {
|
|
62
|
+
const ids = integrations.map((i) => i.id).join(', ');
|
|
63
|
+
throw new Error(
|
|
64
|
+
`findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
|
|
65
|
+
`${moduleName ? ` moduleName=${moduleName}` : ''} maps to ${integrations.length} integrations [${ids}]. ` +
|
|
66
|
+
`Refusing to pick one to avoid cross-tenant routing. ` +
|
|
67
|
+
`Use listIntegrationsByEntityExternalId if a one-to-many fan-out is intended.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return integrations[0].id;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { FindIntegrationByEntityExternalIdUseCase };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List all integration IDs whose module entities match an externalId.
|
|
3
|
+
*
|
|
4
|
+
* Use this instead of {@link FindIntegrationByEntityExternalIdUseCase} when a
|
|
5
|
+
* single externalId is *expected* to map to multiple integrations (intentional
|
|
6
|
+
* fan-out: one upstream account broadcasting to several Frigg integration
|
|
7
|
+
* records, possibly across tenants).
|
|
8
|
+
*
|
|
9
|
+
* Does not throw on ambiguous resolution — that's the whole point.
|
|
10
|
+
*/
|
|
11
|
+
class ListIntegrationsByEntityExternalIdUseCase {
|
|
12
|
+
constructor({ integrationRepository, moduleRepository } = {}) {
|
|
13
|
+
if (!integrationRepository) {
|
|
14
|
+
throw new Error('integrationRepository is required');
|
|
15
|
+
}
|
|
16
|
+
if (!moduleRepository) {
|
|
17
|
+
throw new Error('moduleRepository is required');
|
|
18
|
+
}
|
|
19
|
+
this.integrationRepository = integrationRepository;
|
|
20
|
+
this.moduleRepository = moduleRepository;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async execute({ externalId, moduleName } = {}) {
|
|
24
|
+
if (!externalId) return [];
|
|
25
|
+
|
|
26
|
+
const filter = { externalId: String(externalId) };
|
|
27
|
+
if (moduleName) filter.moduleName = moduleName;
|
|
28
|
+
|
|
29
|
+
const entities = await this.moduleRepository.findEntities(filter);
|
|
30
|
+
if (!entities || entities.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
const integrationIds = new Set();
|
|
33
|
+
for (const entity of entities) {
|
|
34
|
+
const owners =
|
|
35
|
+
await this.integrationRepository.findIntegrationsByEntityId(
|
|
36
|
+
entity.id
|
|
37
|
+
);
|
|
38
|
+
for (const integration of owners || []) {
|
|
39
|
+
integrationIds.add(integration.id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Array.from(integrationIds);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { ListIntegrationsByEntityExternalIdUseCase };
|
|
@@ -100,6 +100,21 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
100
100
|
return this._mapEntity(doc, credential);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
async findEntities(filter) {
|
|
104
|
+
const query = this._buildFilter(filter);
|
|
105
|
+
const docs = await findMany(this.prisma, 'Entity', query);
|
|
106
|
+
if (!docs || docs.length === 0) return [];
|
|
107
|
+
const credentialMap = await this._fetchCredentialsBulk(
|
|
108
|
+
docs.map((doc) => doc.credentialId)
|
|
109
|
+
);
|
|
110
|
+
return docs.map((doc) =>
|
|
111
|
+
this._mapEntity(
|
|
112
|
+
doc,
|
|
113
|
+
credentialMap.get(fromObjectId(doc.credentialId)) || null
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
async createEntity(entityData) {
|
|
104
119
|
const {
|
|
105
120
|
user,
|
|
@@ -91,6 +91,22 @@ class ModuleRepositoryInterface {
|
|
|
91
91
|
throw new Error('Method findEntity must be implemented by subclass');
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Find all entities matching a filter.
|
|
96
|
+
*
|
|
97
|
+
* Symmetric with findEntity but returns the full match set. Use this when
|
|
98
|
+
* a single externalId may correspond to more than one Entity row
|
|
99
|
+
* (e.g. shared upstream account across tenants) and the caller needs to
|
|
100
|
+
* detect/handle the multi-match case explicitly.
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} filter - Filter criteria
|
|
103
|
+
* @returns {Promise<Array>} Array of entity objects (empty if no match)
|
|
104
|
+
* @abstract
|
|
105
|
+
*/
|
|
106
|
+
async findEntities(filter) {
|
|
107
|
+
throw new Error('Method findEntities must be implemented by subclass');
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
/**
|
|
95
111
|
* Create a new entity
|
|
96
112
|
*
|
|
@@ -234,6 +234,34 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
|
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Find all entities matching a filter.
|
|
239
|
+
*
|
|
240
|
+
* @param {Object} filter - Filter criteria
|
|
241
|
+
* @returns {Promise<Array>} Array of entity objects with string IDs (empty if no match)
|
|
242
|
+
*/
|
|
243
|
+
async findEntities(filter) {
|
|
244
|
+
const where = this._convertFilterToWhere(filter);
|
|
245
|
+
const entities = await this.prisma.entity.findMany({
|
|
246
|
+
where,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const credentialIds = entities
|
|
250
|
+
.map((e) => e.credentialId)
|
|
251
|
+
.filter(Boolean);
|
|
252
|
+
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
253
|
+
|
|
254
|
+
return entities.map((e) => ({
|
|
255
|
+
id: e.id,
|
|
256
|
+
credential: credentialMap.get(e.credentialId) || null,
|
|
257
|
+
userId: e.userId,
|
|
258
|
+
name: e.name,
|
|
259
|
+
externalId: e.externalId,
|
|
260
|
+
moduleName: e.moduleName,
|
|
261
|
+
...(e.data || {}),
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
|
|
237
265
|
/**
|
|
238
266
|
* Create a new entity
|
|
239
267
|
* Replaces: Entity.create(entityData)
|
|
@@ -275,6 +275,34 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
275
275
|
};
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Find all entities matching a filter.
|
|
280
|
+
*
|
|
281
|
+
* @param {Object} filter - Filter criteria
|
|
282
|
+
* @returns {Promise<Array>} Array of entity objects with string IDs (empty if no match)
|
|
283
|
+
*/
|
|
284
|
+
async findEntities(filter) {
|
|
285
|
+
const where = this._convertFilterToWhere(filter);
|
|
286
|
+
const entities = await this.prisma.entity.findMany({
|
|
287
|
+
where,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const credentialIds = entities
|
|
291
|
+
.map((e) => e.credentialId)
|
|
292
|
+
.filter(Boolean);
|
|
293
|
+
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
294
|
+
|
|
295
|
+
return entities.map((e) => ({
|
|
296
|
+
id: e.id.toString(),
|
|
297
|
+
credential: credentialMap.get(e.credentialId) || null,
|
|
298
|
+
userId: e.userId?.toString(),
|
|
299
|
+
name: e.name,
|
|
300
|
+
externalId: e.externalId,
|
|
301
|
+
moduleName: e.moduleName,
|
|
302
|
+
...(e.data || {}),
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
|
|
278
306
|
/**
|
|
279
307
|
* Create a new entity
|
|
280
308
|
* Replaces: Entity.create(entityData)
|
|
@@ -172,6 +172,30 @@ class ModuleRepository extends ModuleRepositoryInterface {
|
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Find all entities matching a filter.
|
|
177
|
+
*
|
|
178
|
+
* @param {Object} filter - Filter criteria
|
|
179
|
+
* @returns {Promise<Array>} Array of entity objects (empty if no match)
|
|
180
|
+
*/
|
|
181
|
+
async findEntities(filter) {
|
|
182
|
+
const where = this._convertFilterToWhere(filter);
|
|
183
|
+
const entities = await this.prisma.entity.findMany({
|
|
184
|
+
where,
|
|
185
|
+
include: { credential: true },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return entities.map((e) => ({
|
|
189
|
+
id: e.id,
|
|
190
|
+
credential: e.credential,
|
|
191
|
+
userId: e.userId,
|
|
192
|
+
name: e.name,
|
|
193
|
+
externalId: e.externalId,
|
|
194
|
+
moduleName: e.moduleName,
|
|
195
|
+
...(e.data || {}),
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
175
199
|
/**
|
|
176
200
|
* Create a new entity
|
|
177
201
|
* Replaces: Entity.create(entityData)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.89",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
43
|
-
"@friggframework/test": "2.0.0-next.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0-next.89",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0-next.89",
|
|
43
|
+
"@friggframework/test": "2.0.0-next.89",
|
|
44
44
|
"@prisma/client": "^6.17.0",
|
|
45
45
|
"@types/lodash": "4.17.15",
|
|
46
46
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +80,5 @@
|
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public"
|
|
82
82
|
},
|
|
83
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "19d4f89c10f2c0214fda28dbbd6c3bd83430da6b"
|
|
84
84
|
}
|