@friggframework/core 2.0.0--canary.704.79d9a63.0 → 2.0.0--canary.686.e94de4d.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/CLAUDE.md +28 -40
- package/README.md +105 -0
- package/application/commands/entity-commands.js +60 -18
- package/application/commands/integration-commands.js +20 -73
- 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 -0
- package/database/encryption/README.md +76 -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 +93 -12
- package/database/encryption/integration-mapping-encryption.js +1 -1
- 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 +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +43 -4
- package/generated/prisma-mongodb/index-browser.js +40 -1
- package/generated/prisma-mongodb/index.d.ts +4761 -133
- package/generated/prisma-mongodb/index.js +43 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +92 -0
- package/generated/prisma-mongodb/wasm.js +43 -4
- package/generated/prisma-postgresql/edge.js +43 -4
- package/generated/prisma-postgresql/index-browser.js +40 -1
- package/generated/prisma-postgresql/index.d.ts +4961 -28
- package/generated/prisma-postgresql/index.js +43 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +87 -0
- package/generated/prisma-postgresql/wasm.js +43 -4
- package/handlers/WEBHOOKS.md +0 -29
- package/handlers/app-definition-loader.js +121 -19
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- 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 +102 -64
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +65 -3
- 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 -0
- package/integrations/integration-base.js +0 -29
- package/integrations/integration-router.js +131 -96
- 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 +35 -35
- package/integrations/repositories/integration-repository-interface.js +17 -31
- package/integrations/repositories/integration-repository-mongo.js +60 -129
- package/integrations/repositories/integration-repository-postgres.js +62 -134
- 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 +72 -75
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- 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/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 +408 -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-errors.js +20 -0
- package/modules/proxy/proxy-path.js +179 -0
- package/modules/proxy/proxy-policy.js +248 -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 +244 -0
- package/modules/requester/requester.js +27 -2
- 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 +286 -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/modules/utils/map-module-dto.js +3 -5
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +92 -0
- 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 +87 -0
- 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/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +197 -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/tests/doubles/test-user-repository.js +1 -1
- 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-bearer-token.js +2 -7
- 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/integrations/use-cases/add-entities-to-integration.js +0 -92
- package/integrations/use-cases/remove-entities-from-integration.js +0 -70
- package/integrations/utils/assert-entity-ids-input.js +0 -42
- package/modules/utils/to-credential-id.js +0 -14
|
@@ -3,6 +3,7 @@ const {
|
|
|
3
3
|
IntegrationRepositoryInterface,
|
|
4
4
|
} = require('./integration-repository-interface');
|
|
5
5
|
const { validateConfigPatch } = require('./config-patch-shared');
|
|
6
|
+
const { integrationNotFoundError } = require('./integration-not-found');
|
|
6
7
|
const { strictIntId } = require('./report-id');
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -51,6 +52,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
51
52
|
...integration,
|
|
52
53
|
id: integration.id?.toString(),
|
|
53
54
|
userId: integration.userId?.toString(),
|
|
55
|
+
organizationId: integration.organizationId?.toString() ?? null,
|
|
54
56
|
entities: integration.entities?.map(e => ({
|
|
55
57
|
...e,
|
|
56
58
|
id: e.id?.toString(),
|
|
@@ -60,6 +62,25 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The one place a converted integration row becomes the record the
|
|
67
|
+
* application sees (string ids, ids of its entities, its organization).
|
|
68
|
+
* @private
|
|
69
|
+
*/
|
|
70
|
+
_mapIntegration(converted) {
|
|
71
|
+
return {
|
|
72
|
+
id: converted.id,
|
|
73
|
+
entitiesIds: converted.entities.map((e) => e.id),
|
|
74
|
+
userId: converted.userId,
|
|
75
|
+
organizationId: converted.organizationId,
|
|
76
|
+
config: converted.config,
|
|
77
|
+
version: converted.version,
|
|
78
|
+
status: converted.status,
|
|
79
|
+
messages: converted.messages,
|
|
80
|
+
createdAt: converted.createdAt,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
/**
|
|
64
85
|
* Find all integrations for a user
|
|
65
86
|
*
|
|
@@ -78,16 +99,35 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
78
99
|
// Map to domain objects with string IDs
|
|
79
100
|
return integrations.map((integration) => {
|
|
80
101
|
const converted = this._convertIntegrationIds(integration);
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
return this._mapIntegration(converted);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find integrations matching any of the owner/organization pairs, in one
|
|
108
|
+
* query. `organizationId: null` matches rows with no organization. The
|
|
109
|
+
* caller (a use case) decides which pairs; this only runs them.
|
|
110
|
+
*
|
|
111
|
+
* @param {Array<{userId: string, organizationId: (string|null)}>} clauses
|
|
112
|
+
* @returns {Promise<Array>} Array of integration objects with string IDs
|
|
113
|
+
*/
|
|
114
|
+
async findIntegrationsByOwnership(clauses) {
|
|
115
|
+
if (!Array.isArray(clauses) || clauses.length === 0) return [];
|
|
116
|
+
const integrations = await this.prisma.integration.findMany({
|
|
117
|
+
where: {
|
|
118
|
+
OR: clauses.map(({ userId, organizationId }) => ({
|
|
119
|
+
userId: this._convertId(userId),
|
|
120
|
+
organizationId: this._convertId(organizationId),
|
|
121
|
+
})),
|
|
122
|
+
},
|
|
123
|
+
include: {
|
|
124
|
+
entities: true,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return integrations.map((integration) => {
|
|
129
|
+
const converted = this._convertIntegrationIds(integration);
|
|
130
|
+
return this._mapIntegration(converted);
|
|
91
131
|
});
|
|
92
132
|
}
|
|
93
133
|
|
|
@@ -117,16 +157,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
117
157
|
|
|
118
158
|
return integrations.map((integration) => {
|
|
119
159
|
const converted = this._convertIntegrationIds(integration);
|
|
120
|
-
return
|
|
121
|
-
id: converted.id,
|
|
122
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
123
|
-
userId: converted.userId,
|
|
124
|
-
config: converted.config,
|
|
125
|
-
version: converted.version,
|
|
126
|
-
status: converted.status,
|
|
127
|
-
messages: converted.messages,
|
|
128
|
-
createdAt: converted.createdAt,
|
|
129
|
-
};
|
|
160
|
+
return this._mapIntegration(converted);
|
|
130
161
|
});
|
|
131
162
|
}
|
|
132
163
|
|
|
@@ -209,15 +240,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
209
240
|
}
|
|
210
241
|
|
|
211
242
|
const converted = this._convertIntegrationIds(integration);
|
|
212
|
-
return
|
|
213
|
-
id: converted.id,
|
|
214
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
215
|
-
userId: converted.userId,
|
|
216
|
-
config: converted.config,
|
|
217
|
-
version: converted.version,
|
|
218
|
-
status: converted.status,
|
|
219
|
-
messages: converted.messages,
|
|
220
|
-
};
|
|
243
|
+
return this._mapIntegration(converted);
|
|
221
244
|
}
|
|
222
245
|
|
|
223
246
|
/**
|
|
@@ -236,19 +259,11 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
236
259
|
});
|
|
237
260
|
|
|
238
261
|
if (!integration) {
|
|
239
|
-
throw
|
|
262
|
+
throw integrationNotFoundError(id);
|
|
240
263
|
}
|
|
241
264
|
|
|
242
265
|
const converted = this._convertIntegrationIds(integration);
|
|
243
|
-
return
|
|
244
|
-
id: converted.id,
|
|
245
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
246
|
-
userId: converted.userId,
|
|
247
|
-
config: converted.config,
|
|
248
|
-
version: converted.version,
|
|
249
|
-
status: converted.status,
|
|
250
|
-
messages: converted.messages,
|
|
251
|
-
};
|
|
266
|
+
return this._mapIntegration(converted);
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
/**
|
|
@@ -328,12 +343,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
328
343
|
* @param {Array<string>} entities - Array of entity IDs (strings from application layer)
|
|
329
344
|
* @param {string} userId - User ID (string from application layer)
|
|
330
345
|
* @param {Object} config - Integration configuration
|
|
346
|
+
* @param {{organizationId?: (string|null)}} [tenancy] - the organization the
|
|
347
|
+
* integration is created in (ADR-056 §3.6)
|
|
331
348
|
* @returns {Promise<Object>} Created integration object with string IDs
|
|
332
349
|
*/
|
|
333
|
-
async createIntegration(entities, userId, config) {
|
|
350
|
+
async createIntegration(entities, userId, config, tenancy = {}) {
|
|
334
351
|
const data = {
|
|
335
352
|
config,
|
|
336
353
|
version: '0.0.0',
|
|
354
|
+
organizationId: this._convertId(tenancy.organizationId ?? null),
|
|
337
355
|
};
|
|
338
356
|
|
|
339
357
|
// PostgreSQL: use nested relations with ID conversion
|
|
@@ -354,73 +372,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
354
372
|
});
|
|
355
373
|
|
|
356
374
|
const converted = this._convertIntegrationIds(integration);
|
|
357
|
-
return
|
|
358
|
-
id: converted.id,
|
|
359
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
360
|
-
userId: converted.userId,
|
|
361
|
-
config: converted.config,
|
|
362
|
-
version: converted.version,
|
|
363
|
-
status: converted.status,
|
|
364
|
-
messages: converted.messages,
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Attach entities to an existing integration. Entities already attached
|
|
370
|
-
* are left as they are.
|
|
371
|
-
*
|
|
372
|
-
* @param {string} integrationId - Integration ID (string from application layer)
|
|
373
|
-
* @param {Array<string>} entityIds - Entity IDs to attach
|
|
374
|
-
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
375
|
-
*/
|
|
376
|
-
async addEntitiesToIntegration(integrationId, entityIds) {
|
|
377
|
-
const intId = this._convertId(integrationId);
|
|
378
|
-
|
|
379
|
-
try {
|
|
380
|
-
await this.prisma.integration.update({
|
|
381
|
-
where: { id: intId },
|
|
382
|
-
data: {
|
|
383
|
-
entities: {
|
|
384
|
-
connect: entityIds.map((id) => ({
|
|
385
|
-
id: this._convertId(id),
|
|
386
|
-
})),
|
|
387
|
-
},
|
|
388
|
-
},
|
|
389
|
-
});
|
|
390
|
-
} catch (error) {
|
|
391
|
-
if (error.code === 'P2025') return null;
|
|
392
|
-
throw error;
|
|
393
|
-
}
|
|
394
|
-
return this.findIntegrationById(integrationId);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Detach entities from an integration. Entities that are not attached
|
|
399
|
-
* are ignored. The entities themselves are not deleted.
|
|
400
|
-
*
|
|
401
|
-
* @param {string} integrationId - Integration ID (string from application layer)
|
|
402
|
-
* @param {Array<string>} entityIds - Entity IDs to detach
|
|
403
|
-
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
404
|
-
*/
|
|
405
|
-
async removeEntitiesFromIntegration(integrationId, entityIds) {
|
|
406
|
-
const intId = this._convertId(integrationId);
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
await this.prisma.integration.update({
|
|
410
|
-
where: { id: intId },
|
|
411
|
-
data: {
|
|
412
|
-
entities: {
|
|
413
|
-
disconnect: entityIds.map((id) => ({
|
|
414
|
-
id: this._convertId(id),
|
|
415
|
-
})),
|
|
416
|
-
},
|
|
417
|
-
},
|
|
418
|
-
});
|
|
419
|
-
} catch (error) {
|
|
420
|
-
if (error.code === 'P2025') return null;
|
|
421
|
-
throw error;
|
|
422
|
-
}
|
|
423
|
-
return this.findIntegrationById(integrationId);
|
|
375
|
+
return this._mapIntegration(converted);
|
|
424
376
|
}
|
|
425
377
|
|
|
426
378
|
/**
|
|
@@ -443,15 +395,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
443
395
|
}
|
|
444
396
|
|
|
445
397
|
const converted = this._convertIntegrationIds(integration);
|
|
446
|
-
return
|
|
447
|
-
id: converted.id,
|
|
448
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
449
|
-
userId: converted.userId,
|
|
450
|
-
config: converted.config,
|
|
451
|
-
version: converted.version,
|
|
452
|
-
status: converted.status,
|
|
453
|
-
messages: converted.messages,
|
|
454
|
-
};
|
|
398
|
+
return this._mapIntegration(converted);
|
|
455
399
|
}
|
|
456
400
|
|
|
457
401
|
/**
|
|
@@ -476,15 +420,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
476
420
|
});
|
|
477
421
|
|
|
478
422
|
const converted = this._convertIntegrationIds(integration);
|
|
479
|
-
return
|
|
480
|
-
id: converted.id,
|
|
481
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
482
|
-
userId: converted.userId,
|
|
483
|
-
config: converted.config,
|
|
484
|
-
version: converted.version,
|
|
485
|
-
status: converted.status,
|
|
486
|
-
messages: converted.messages,
|
|
487
|
-
};
|
|
423
|
+
return this._mapIntegration(converted);
|
|
488
424
|
}
|
|
489
425
|
|
|
490
426
|
/**
|
|
@@ -534,15 +470,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
534
470
|
|
|
535
471
|
return integrations.map((integration) => {
|
|
536
472
|
const converted = this._convertIntegrationIds(integration);
|
|
537
|
-
return
|
|
538
|
-
id: converted.id,
|
|
539
|
-
entitiesIds: converted.entities.map((e) => e.id),
|
|
540
|
-
userId: converted.userId,
|
|
541
|
-
config: converted.config,
|
|
542
|
-
version: converted.version,
|
|
543
|
-
status: converted.status,
|
|
544
|
-
messages: converted.messages,
|
|
545
|
-
};
|
|
473
|
+
return this._mapIntegration(converted);
|
|
546
474
|
});
|
|
547
475
|
}
|
|
548
476
|
}
|
|
@@ -120,7 +120,7 @@ class ProcessRepositoryMongo extends ProcessRepositoryInterface {
|
|
|
120
120
|
if (normalized.newState !== null) {
|
|
121
121
|
$set.state = normalized.newState;
|
|
122
122
|
}
|
|
123
|
-
$set.updatedAt =
|
|
123
|
+
$set.updatedAt = new Date();
|
|
124
124
|
update.$set = $set;
|
|
125
125
|
|
|
126
126
|
if (Object.keys(normalized.pushSlice).length > 0) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An integration's schedule configuration (`static Definition.schedule`),
|
|
3
|
+
* read by hosts that tick ONGOING_SYNC from a cron (provider-netlify's
|
|
4
|
+
* scheduled-sync function).
|
|
5
|
+
*
|
|
6
|
+
* schedule: {
|
|
7
|
+
* // 'class' (default): one ONGOING_SYNC per integration class per tick,
|
|
8
|
+
* // with no integration id: the worker runs it on an unhydrated
|
|
9
|
+
* // instance, so the handler finds its own installations.
|
|
10
|
+
* // 'installation': one ONGOING_SYNC per ENABLED integration record of
|
|
11
|
+
* // the class, carrying `data.integrationId`, so the worker hydrates
|
|
12
|
+
* // that installation before the handler runs.
|
|
13
|
+
* scope: 'installation',
|
|
14
|
+
* // Optional, 'installation' only: (integration, { now }) => boolean |
|
|
15
|
+
* // Promise<boolean>. Return false to skip that installation this tick.
|
|
16
|
+
* // Without it every installation is dispatched on every tick.
|
|
17
|
+
* isDue: (integration, { now }) => true,
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Validated at load (loadAppDefinition) so a mistyped scope stops the app
|
|
21
|
+
* rather than silently falling back to 'class'.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SCHEDULE_SCOPES = Object.freeze({
|
|
25
|
+
CLASS: 'class',
|
|
26
|
+
INSTALLATION: 'installation',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const DEFAULT_SCHEDULE_SCOPE = SCHEDULE_SCOPES.CLASS;
|
|
30
|
+
|
|
31
|
+
class ScheduleConfigError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'ScheduleConfigError';
|
|
35
|
+
this.code = 'SCHEDULE_CONFIG_INVALID';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Object} [definition] - an integration's static Definition
|
|
41
|
+
* @returns {{ scope: 'class'|'installation', isDue: Function|null }}
|
|
42
|
+
* @throws {ScheduleConfigError} when `schedule` is malformed
|
|
43
|
+
*/
|
|
44
|
+
function resolveScheduleConfig(definition) {
|
|
45
|
+
const name = definition?.name || 'integration';
|
|
46
|
+
const schedule = definition?.schedule;
|
|
47
|
+
if (schedule === undefined || schedule === null) {
|
|
48
|
+
return { scope: DEFAULT_SCHEDULE_SCOPE, isDue: null };
|
|
49
|
+
}
|
|
50
|
+
if (typeof schedule !== 'object' || Array.isArray(schedule)) {
|
|
51
|
+
throw new ScheduleConfigError(`${name}: Definition.schedule must be an object`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const scopes = Object.values(SCHEDULE_SCOPES);
|
|
55
|
+
const scope = schedule.scope === undefined ? DEFAULT_SCHEDULE_SCOPE : schedule.scope;
|
|
56
|
+
if (!scopes.includes(scope)) {
|
|
57
|
+
throw new ScheduleConfigError(
|
|
58
|
+
`${name}: Definition.schedule.scope must be one of ${scopes.map((s) => `'${s}'`).join(', ')} (got ${JSON.stringify(schedule.scope)})`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isDue = schedule.isDue === undefined ? null : schedule.isDue;
|
|
63
|
+
if (isDue !== null && typeof isDue !== 'function') {
|
|
64
|
+
throw new ScheduleConfigError(`${name}: Definition.schedule.isDue must be a function`);
|
|
65
|
+
}
|
|
66
|
+
if (isDue && scope !== SCHEDULE_SCOPES.INSTALLATION) {
|
|
67
|
+
throw new ScheduleConfigError(
|
|
68
|
+
`${name}: Definition.schedule.isDue applies to scope 'installation' only`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { scope, isDue };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
SCHEDULE_SCOPES,
|
|
77
|
+
DEFAULT_SCHEDULE_SCOPE,
|
|
78
|
+
ScheduleConfigError,
|
|
79
|
+
resolveScheduleConfig,
|
|
80
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { v4: uuid } = require('uuid');
|
|
2
2
|
const { validateConfigPatch } = require('../../repositories/config-patch-shared');
|
|
3
|
+
const { matchesAnyClause } = require('../../../modules/tests/doubles/ownership-matching');
|
|
3
4
|
|
|
4
5
|
class TestIntegrationRepository {
|
|
5
6
|
constructor() {
|
|
@@ -7,13 +8,14 @@ class TestIntegrationRepository {
|
|
|
7
8
|
this.operationHistory = [];
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
async createIntegration(entities, userId, config) {
|
|
11
|
+
async createIntegration(entities, userId, config, tenancy = {}) {
|
|
11
12
|
const id = uuid();
|
|
12
13
|
const record = {
|
|
13
14
|
id,
|
|
14
15
|
_id: id,
|
|
15
16
|
entitiesIds: entities,
|
|
16
17
|
userId: userId,
|
|
18
|
+
organizationId: tenancy.organizationId ?? null,
|
|
17
19
|
config,
|
|
18
20
|
version: '0.0.0',
|
|
19
21
|
status: 'IN_CREATION',
|
|
@@ -38,6 +40,15 @@ class TestIntegrationRepository {
|
|
|
38
40
|
return results;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// Answers the way a database does for the clauses a use case hands it.
|
|
44
|
+
async findIntegrationsByOwnership(clauses) {
|
|
45
|
+
const results = Array.from(this.store.values()).filter((r) =>
|
|
46
|
+
matchesAnyClause(r, clauses)
|
|
47
|
+
);
|
|
48
|
+
this.operationHistory.push({ operation: 'findByOwnership', clauses, count: results.length });
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
async findIntegrations(filter = {}) {
|
|
42
53
|
const { type, status } = filter;
|
|
43
54
|
const results = Array.from(this.store.values()).filter((r) => {
|
|
@@ -27,13 +27,19 @@ class CreateIntegration {
|
|
|
27
27
|
* and entity set, instead of creating a duplicate.
|
|
28
28
|
* @async
|
|
29
29
|
* @param {string[]} entities - Array of entity IDs to associate with the integration.
|
|
30
|
-
* @param {string} userId - ID of the
|
|
30
|
+
* @param {string} userId - ID of the OWNER of the integration (ADR-056 §3):
|
|
31
|
+
* the person or the organization's user, as ResolveIntegrationOwnership decided.
|
|
31
32
|
* @param {Object} config - Configuration object for the integration.
|
|
32
33
|
* @param {string} config.type - Type of integration to create.
|
|
34
|
+
* @param {{organizationId?: (string|null), adoptUnscoped?: boolean}} [tenancy]
|
|
35
|
+
* the organization it is created in (§3.6). A duplicate is only reused in
|
|
36
|
+
* the same organization, so one person's integration in organization A is
|
|
37
|
+
* not handed back as the one they asked for in B. `adoptUnscoped` also
|
|
38
|
+
* accepts a duplicate written before scoping (no organization).
|
|
33
39
|
* @returns {Promise<Object>} The created or reused integration DTO.
|
|
34
40
|
* @throws {Error} When integration class is not found for the specified type.
|
|
35
41
|
*/
|
|
36
|
-
async execute(entities, userId, config) {
|
|
42
|
+
async execute(entities, userId, config, tenancy = {}) {
|
|
37
43
|
console.log(
|
|
38
44
|
`[Frigg] Creating ${config?.type} integration for user ${userId} with entities [${entities}]`
|
|
39
45
|
);
|
|
@@ -41,7 +47,8 @@ class CreateIntegration {
|
|
|
41
47
|
const existing = await this._findDuplicate(
|
|
42
48
|
userId,
|
|
43
49
|
config?.type,
|
|
44
|
-
entities
|
|
50
|
+
entities,
|
|
51
|
+
tenancy
|
|
45
52
|
);
|
|
46
53
|
if (existing) {
|
|
47
54
|
console.log(
|
|
@@ -50,11 +57,18 @@ class CreateIntegration {
|
|
|
50
57
|
return this._reuseExisting(existing, config);
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
// Everything that can be refused is refused before a row is written: an
|
|
61
|
+
// unknown type, or an entity the owner cannot load, would otherwise
|
|
62
|
+
// leave an IN_CREATION integration behind that nothing moves on.
|
|
63
|
+
const integrationClass = this._requireClass(config?.type);
|
|
64
|
+
const modules = await this._loadModules(entities, userId);
|
|
65
|
+
|
|
53
66
|
const integrationRecord =
|
|
54
67
|
await this.integrationRepository.createIntegration(
|
|
55
68
|
entities,
|
|
56
69
|
userId,
|
|
57
|
-
config
|
|
70
|
+
config,
|
|
71
|
+
{ organizationId: tenancy.organizationId ?? null }
|
|
58
72
|
);
|
|
59
73
|
console.log(
|
|
60
74
|
`[Frigg] Created integration ${integrationRecord.id} for user ${userId}`
|
|
@@ -62,10 +76,11 @@ class CreateIntegration {
|
|
|
62
76
|
|
|
63
77
|
// A concurrent request may have created a matching row between the
|
|
64
78
|
// lookup above and this insert; re-check before any side effects run.
|
|
65
|
-
const survivor = await this.
|
|
79
|
+
const survivor = await this._findDuplicate(
|
|
66
80
|
userId,
|
|
67
81
|
config?.type,
|
|
68
|
-
entities
|
|
82
|
+
entities,
|
|
83
|
+
tenancy
|
|
69
84
|
);
|
|
70
85
|
if (survivor && String(survivor.id) !== String(integrationRecord.id)) {
|
|
71
86
|
console.log(
|
|
@@ -78,7 +93,8 @@ class CreateIntegration {
|
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
const integrationInstance = await this._buildInstance(
|
|
81
|
-
integrationRecord
|
|
96
|
+
integrationRecord,
|
|
97
|
+
{ integrationClass, modules }
|
|
82
98
|
);
|
|
83
99
|
console.log(
|
|
84
100
|
`[Frigg] Sending ON_CREATE for integration ${integrationRecord.id}`
|
|
@@ -135,69 +151,41 @@ class CreateIntegration {
|
|
|
135
151
|
return mapIntegrationClassToIntegrationDTO(integrationInstance);
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
async
|
|
154
|
+
async _findDuplicate(userId, type, entityIds, tenancy = {}) {
|
|
139
155
|
const candidates =
|
|
140
156
|
await this.integrationRepository.findIntegrationsByUserId(userId);
|
|
141
|
-
const target =
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
new Set((integration.entitiesIds ?? []).map(String));
|
|
158
|
-
|
|
159
|
-
const exact = live.filter((integration) => {
|
|
160
|
-
const current = entitySet(integration);
|
|
157
|
+
const target = [...(entityIds ?? [])].map(String).sort();
|
|
158
|
+
|
|
159
|
+
const matches = candidates.filter((integration) => {
|
|
160
|
+
// An IN_DELETION row is either mid-teardown or a zombie left
|
|
161
|
+
// behind by a failed deleteIntegrationById call — never a live
|
|
162
|
+
// integration. Treating it as a duplicate would hand a reinstall
|
|
163
|
+
// attempt a row nothing will ever move out of IN_DELETION,
|
|
164
|
+
// silently discarded by the queue worker forever. Let a fresh
|
|
165
|
+
// create proceed instead; the zombie is cleaned up out-of-band,
|
|
166
|
+
// matching how teardown failures are already handled here.
|
|
167
|
+
if (integration.status === 'IN_DELETION') return false;
|
|
168
|
+
if (integration.config?.type !== type) return false;
|
|
169
|
+
if (!this._sameOrganization(integration, tenancy)) return false;
|
|
170
|
+
const current = [...(integration.entitiesIds ?? [])]
|
|
171
|
+
.map(String)
|
|
172
|
+
.sort();
|
|
161
173
|
return (
|
|
162
|
-
current.
|
|
163
|
-
|
|
174
|
+
current.length === target.length &&
|
|
175
|
+
current.every((id, index) => id === target[index])
|
|
164
176
|
);
|
|
165
177
|
});
|
|
166
178
|
|
|
167
|
-
|
|
168
|
-
// so a reconnect that sends the original entities must still find the
|
|
169
|
-
// integration that now holds them, instead of creating a second one
|
|
170
|
-
// that would share those entities.
|
|
171
|
-
const supersets = live.filter((integration) => {
|
|
172
|
-
const current = entitySet(integration);
|
|
173
|
-
return [...target].every((id) => current.has(id));
|
|
174
|
-
});
|
|
175
|
-
return { exact, supersets: target.size ? supersets : [] };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async _findDuplicate(userId, type, entityIds) {
|
|
179
|
-
const { exact, supersets } = await this._findMatches(
|
|
180
|
-
userId,
|
|
181
|
-
type,
|
|
182
|
-
entityIds
|
|
183
|
-
);
|
|
184
|
-
if (exact.length) return this._pickOldest(exact);
|
|
185
|
-
return supersets.length ? this._pickOldest(supersets) : null;
|
|
179
|
+
return matches.length ? this._pickOldest(matches) : null;
|
|
186
180
|
}
|
|
187
181
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
entityIds
|
|
196
|
-
);
|
|
197
|
-
// supersets includes the exact matches; it is empty only for an
|
|
198
|
-
// empty entity set, where exact matching alone applies.
|
|
199
|
-
const matches = supersets.length ? supersets : exact;
|
|
200
|
-
return matches.length ? this._pickOldest(matches) : null;
|
|
182
|
+
/** Same organization, or a row from before scoping that belongs here. */
|
|
183
|
+
_sameOrganization(integration, tenancy) {
|
|
184
|
+
const wanted = tenancy.organizationId ?? null;
|
|
185
|
+
const has = integration.organizationId ?? null;
|
|
186
|
+
if (wanted === null && has === null) return true;
|
|
187
|
+
if (wanted !== null && has !== null) return String(wanted) === String(has);
|
|
188
|
+
return has === null && tenancy.adoptUnscoped === true;
|
|
201
189
|
}
|
|
202
190
|
|
|
203
191
|
_pickOldest(records) {
|
|
@@ -211,27 +199,36 @@ class CreateIntegration {
|
|
|
211
199
|
})[0];
|
|
212
200
|
}
|
|
213
201
|
|
|
214
|
-
|
|
202
|
+
_requireClass(type) {
|
|
215
203
|
const integrationClass = this.integrationClasses.find(
|
|
216
|
-
(integrationClass) =>
|
|
217
|
-
integrationClass.Definition.name ===
|
|
218
|
-
integrationRecord.config.type
|
|
204
|
+
(integrationClass) => integrationClass.Definition.name === type
|
|
219
205
|
);
|
|
220
|
-
|
|
221
206
|
if (!integrationClass) {
|
|
222
|
-
throw new Error(
|
|
223
|
-
`No integration class found for type: ${integrationRecord.config.type}`
|
|
224
|
-
);
|
|
207
|
+
throw new Error(`No integration class found for type: ${type}`);
|
|
225
208
|
}
|
|
209
|
+
return integrationClass;
|
|
210
|
+
}
|
|
226
211
|
|
|
212
|
+
async _loadModules(entityIds, userId) {
|
|
227
213
|
const modules = [];
|
|
228
|
-
for (const entityId of
|
|
229
|
-
|
|
230
|
-
entityId,
|
|
231
|
-
integrationRecord.userId
|
|
214
|
+
for (const entityId of entityIds) {
|
|
215
|
+
modules.push(
|
|
216
|
+
await this.moduleFactory.getModuleInstance(entityId, userId)
|
|
232
217
|
);
|
|
233
|
-
modules.push(moduleInstance);
|
|
234
218
|
}
|
|
219
|
+
return modules;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async _buildInstance(integrationRecord, preloaded) {
|
|
223
|
+
const integrationClass =
|
|
224
|
+
preloaded?.integrationClass ??
|
|
225
|
+
this._requireClass(integrationRecord.config.type);
|
|
226
|
+
const modules =
|
|
227
|
+
preloaded?.modules ??
|
|
228
|
+
(await this._loadModules(
|
|
229
|
+
integrationRecord.entitiesIds,
|
|
230
|
+
integrationRecord.userId
|
|
231
|
+
));
|
|
235
232
|
|
|
236
233
|
const integrationInstance = new integrationClass({
|
|
237
234
|
id: integrationRecord.id,
|