@friggframework/core 2.0.0--canary.680.daae24e.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/CHANGELOG.md +0 -11
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +99 -47
- package/database/encryption/field-encryption-service.js +5 -69
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- 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 +27 -4
- package/generated/prisma-mongodb/index-browser.js +24 -1
- package/generated/prisma-mongodb/index.d.ts +3227 -47
- package/generated/prisma-mongodb/index.js +27 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +65 -0
- package/generated/prisma-mongodb/wasm.js +27 -4
- package/generated/prisma-postgresql/edge.js +27 -4
- package/generated/prisma-postgresql/index-browser.js +24 -1
- package/generated/prisma-postgresql/index.d.ts +3519 -67
- package/generated/prisma-postgresql/index.js +27 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +61 -0
- package/generated/prisma-postgresql/wasm.js +27 -4
- package/handlers/app-definition-loader.js +103 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/health.js +4 -2
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +63 -1
- 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 +5 -0
- package/integrations/integration-router.js +115 -73
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- 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 +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/management-api-config.js +71 -2
- package/management-api/meta-router.js +21 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/route-registry.js +123 -6
- package/management-api/v2/dependencies.js +40 -15
- package/management-api/v2/dto.js +20 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- package/management-api/v2/handlers/entities.js +10 -7
- package/management-api/v2/handlers/integrations.js +6 -3
- package/management-api/v2/request.js +32 -1
- package/modules/entity-scope.js +15 -0
- package/modules/index.js +8 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- 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/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/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 +50 -13
- package/modules/use-cases/entity-types.js +31 -7
- package/modules/use-cases/execute-entity-proxy-request.js +50 -3
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +34 -7
- 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 +40 -13
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +65 -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 +61 -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 +1 -0
- package/types/modules/index.d.ts +139 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
|
@@ -50,8 +50,16 @@ const RESERVED_V2_SEGMENTS = Object.freeze(['reports']);
|
|
|
50
50
|
const V1_MIGRATION_DOCS_URL =
|
|
51
51
|
'https://docs.friggframework.org/api/migrate-v1-v2';
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Functions (devtools names) that serve registered routes. `queueWorker` is
|
|
55
|
+
* the HTTP queue worker of hosts that deliver queue messages over HTTP
|
|
56
|
+
* (provider-netlify's worker-background); AWS delivers through SQS.
|
|
57
|
+
*/
|
|
58
|
+
const FUNCTIONS = Object.freeze({
|
|
59
|
+
AUTH: 'auth',
|
|
60
|
+
HEALTH: 'health',
|
|
61
|
+
QUEUE_WORKER: 'queueWorker',
|
|
62
|
+
});
|
|
55
63
|
|
|
56
64
|
const route = (version, fn) => (definition) =>
|
|
57
65
|
Object.freeze({ version, function: fn, ...definition });
|
|
@@ -180,6 +188,91 @@ const AUTH_EXTRA_ROUTES = Object.freeze(
|
|
|
180
188
|
].map(route(null, FUNCTIONS.AUTH))
|
|
181
189
|
);
|
|
182
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Unversioned routes the DB-free health Lambda serves next to /api/meta.
|
|
193
|
+
* GET /api/docs redirects to the current OpenAPI document; the docs
|
|
194
|
+
* themselves live under /api/meta (ADR-053).
|
|
195
|
+
*/
|
|
196
|
+
const DOCS_ROUTES = Object.freeze(
|
|
197
|
+
[
|
|
198
|
+
{
|
|
199
|
+
operationId: 'redirectToOpenApi',
|
|
200
|
+
method: 'GET',
|
|
201
|
+
path: '/api/docs',
|
|
202
|
+
},
|
|
203
|
+
].map(route(null, FUNCTIONS.HEALTH))
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Admin routes live under /admin (as core's /admin/db-migrate and the
|
|
208
|
+
* admin-scripts /admin/scripts do), never under the public /api prefix. A
|
|
209
|
+
* core extension (appDefinition.coreExtensions[].routes.path) mounts its admin
|
|
210
|
+
* API at /admin/<name>, served by the auth function behind the admin API key;
|
|
211
|
+
* these segments belong to other functions and cannot be taken.
|
|
212
|
+
*/
|
|
213
|
+
const ADMIN_ROUTE_PREFIX = '/admin/';
|
|
214
|
+
const RESERVED_ADMIN_SEGMENTS = Object.freeze(['scripts', 'db-migrate', 'queue']);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The queue delivery endpoint of hosts that deliver queue messages over HTTP
|
|
218
|
+
* (QStash posts here on Netlify). Every message must be signed: the worker
|
|
219
|
+
* verifies it before parsing anything. Not part of the public /api surface.
|
|
220
|
+
*/
|
|
221
|
+
const HTTP_QUEUE_ROUTES = Object.freeze(
|
|
222
|
+
[
|
|
223
|
+
{
|
|
224
|
+
operationId: 'deliverQueueMessage',
|
|
225
|
+
method: 'POST',
|
|
226
|
+
path: '/admin/queue',
|
|
227
|
+
},
|
|
228
|
+
].map(route(null, FUNCTIONS.QUEUE_WORKER))
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Why a core extension route path is not acceptable, or null when it is:
|
|
233
|
+
* `/admin/<segment>` (letters, digits, `-` and `_`, one or more segments), not
|
|
234
|
+
* a reserved segment.
|
|
235
|
+
*
|
|
236
|
+
* @param {*} path
|
|
237
|
+
* @returns {string|null}
|
|
238
|
+
*/
|
|
239
|
+
function coreExtensionRoutePathError(path) {
|
|
240
|
+
if (typeof path !== 'string' || !path.startsWith(ADMIN_ROUTE_PREFIX)) {
|
|
241
|
+
return `must start with ${ADMIN_ROUTE_PREFIX} (admin routes are not served under /api)`;
|
|
242
|
+
}
|
|
243
|
+
if (!/^\/admin(\/[A-Za-z0-9_-]+)+$/.test(path)) {
|
|
244
|
+
return 'must be /admin/<segment>[/<segment>...] using letters, digits, - and _';
|
|
245
|
+
}
|
|
246
|
+
const segment = path.split('/')[2];
|
|
247
|
+
if (RESERVED_ADMIN_SEGMENTS.includes(segment)) {
|
|
248
|
+
return `/admin/${segment} is reserved (${RESERVED_ADMIN_SEGMENTS.map((s) => `/admin/${s}`).join(', ')})`;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Route records for the core extensions' admin routes: each path and
|
|
255
|
+
* everything under it, on the auth function (core's auth router mounts the
|
|
256
|
+
* extension routes behind the admin API key).
|
|
257
|
+
*
|
|
258
|
+
* @param {Array<Object>} [coreExtensions] - appDefinition.coreExtensions (plain data;
|
|
259
|
+
* only `routes.path` is read)
|
|
260
|
+
* @returns {Array<Object>}
|
|
261
|
+
*/
|
|
262
|
+
function listCoreExtensionRoutes(coreExtensions = []) {
|
|
263
|
+
if (!Array.isArray(coreExtensions)) return [];
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const ext of coreExtensions) {
|
|
266
|
+
const path = ext?.routes?.path;
|
|
267
|
+
if (typeof path !== 'string' || coreExtensionRoutePathError(path)) continue;
|
|
268
|
+
out.push(
|
|
269
|
+
Object.freeze({ version: null, function: FUNCTIONS.AUTH, method: 'ANY', path, extension: ext.name }),
|
|
270
|
+
Object.freeze({ version: null, function: FUNCTIONS.AUTH, method: 'ANY', path: `${path}/{proxy+}`, extension: ext.name })
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
183
276
|
/**
|
|
184
277
|
* The v2 routes an app serves. Routes tied to an opt-in capability (the entity
|
|
185
278
|
* proxy, ADR-052) are left out unless the app enables it.
|
|
@@ -206,6 +299,7 @@ function listRoutes({ v1 = true, proxy = false } = {}) {
|
|
|
206
299
|
...listV2Routes({ proxy }),
|
|
207
300
|
...META_ROUTES,
|
|
208
301
|
...AUTH_EXTRA_ROUTES,
|
|
302
|
+
...DOCS_ROUTES,
|
|
209
303
|
];
|
|
210
304
|
}
|
|
211
305
|
|
|
@@ -225,18 +319,25 @@ function resourceRoot(record) {
|
|
|
225
319
|
}
|
|
226
320
|
|
|
227
321
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
322
|
+
* Gateway routes derived from the registry: the Management API, its
|
|
323
|
+
* discovery and docs routes, the core extensions' admin routes and, for
|
|
324
|
+
* hosts that deliver queue messages over HTTP, the queue endpoint. Each
|
|
325
|
+
* Management API resource root gets an exact route when the root itself is
|
|
230
326
|
* registered and a greedy `{proxy+}` route when deeper paths are. The auth
|
|
231
327
|
* function uses `ANY` (as it always has, so CORS preflights reach Express);
|
|
232
328
|
* the health function only serves GET.
|
|
233
329
|
*
|
|
330
|
+
* devtools turns these into API Gateway HTTP API events; provider-netlify
|
|
331
|
+
* into netlify.toml redirects.
|
|
332
|
+
*
|
|
234
333
|
* @param {Object} [options]
|
|
235
334
|
* @param {boolean} [options.v1=true]
|
|
236
335
|
* @param {boolean} [options.proxy=false]
|
|
336
|
+
* @param {Array<Object>} [options.coreExtensions=[]] - appDefinition.coreExtensions
|
|
337
|
+
* @param {boolean} [options.httpQueue=false] - include the HTTP queue route
|
|
237
338
|
* @returns {Array<{function: string, method: string, path: string}>}
|
|
238
339
|
*/
|
|
239
|
-
function getGatewayRoutes({ v1 = true, proxy = false } = {}) {
|
|
340
|
+
function getGatewayRoutes({ v1 = true, proxy = false, coreExtensions = [], httpQueue = false } = {}) {
|
|
240
341
|
const out = [];
|
|
241
342
|
const seen = new Set();
|
|
242
343
|
const add = (fn, method, path) => {
|
|
@@ -265,7 +366,7 @@ function getGatewayRoutes({ v1 = true, proxy = false } = {}) {
|
|
|
265
366
|
|
|
266
367
|
// Extra routes get an exact route unless a greedy route on the same
|
|
267
368
|
// function already covers them.
|
|
268
|
-
for (const record of AUTH_EXTRA_ROUTES) {
|
|
369
|
+
for (const record of [...AUTH_EXTRA_ROUTES, ...DOCS_ROUTES]) {
|
|
269
370
|
const covered = greedyRoots.has(
|
|
270
371
|
`${record.function} ${resourceRoot(record)}`
|
|
271
372
|
);
|
|
@@ -274,6 +375,16 @@ function getGatewayRoutes({ v1 = true, proxy = false } = {}) {
|
|
|
274
375
|
}
|
|
275
376
|
}
|
|
276
377
|
|
|
378
|
+
for (const record of listCoreExtensionRoutes(coreExtensions)) {
|
|
379
|
+
add(record.function, record.method, record.path);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (httpQueue) {
|
|
383
|
+
for (const record of HTTP_QUEUE_ROUTES) {
|
|
384
|
+
add(record.function, record.method, record.path);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
277
388
|
return out;
|
|
278
389
|
}
|
|
279
390
|
|
|
@@ -287,6 +398,12 @@ module.exports = {
|
|
|
287
398
|
V2_ROUTES,
|
|
288
399
|
META_ROUTES,
|
|
289
400
|
AUTH_EXTRA_ROUTES,
|
|
401
|
+
DOCS_ROUTES,
|
|
402
|
+
HTTP_QUEUE_ROUTES,
|
|
403
|
+
ADMIN_ROUTE_PREFIX,
|
|
404
|
+
RESERVED_ADMIN_SEGMENTS,
|
|
405
|
+
coreExtensionRoutePathError,
|
|
406
|
+
listCoreExtensionRoutes,
|
|
290
407
|
listRoutes,
|
|
291
408
|
listV2Routes,
|
|
292
409
|
getGatewayRoutes,
|
|
@@ -29,13 +29,18 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
29
29
|
getEntityTypeRegistry,
|
|
30
30
|
getEntityTypeDefinitions,
|
|
31
31
|
} = require('../../modules/entity-type-registry');
|
|
32
|
-
const {
|
|
33
|
-
const {
|
|
34
|
-
|
|
32
|
+
const { createScopePolicy } = require('../../modules/scope-policy');
|
|
33
|
+
const {
|
|
34
|
+
AuthorizeEntityForUser,
|
|
35
|
+
} = require('../../modules/use-cases/authorize-entity-for-user');
|
|
36
|
+
const {
|
|
37
|
+
ResolveIntegrationOwnership,
|
|
38
|
+
} = require('../../integrations/use-cases/resolve-integration-ownership');
|
|
35
39
|
const {
|
|
36
40
|
AuthenticateWithSharedSecret,
|
|
37
41
|
} = require('../../user/use-cases/authenticate-with-shared-secret');
|
|
38
42
|
const { AuthenticateUser } = require('../../user/use-cases/authenticate-user');
|
|
43
|
+
const { buildUserAuthentication } = require('../../user/create-user-authentication');
|
|
39
44
|
const { CreateIntegration } = require('../../integrations/use-cases/create-integration');
|
|
40
45
|
const { UpdateIntegration } = require('../../integrations/use-cases/update-integration');
|
|
41
46
|
const {
|
|
@@ -87,6 +92,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
87
92
|
appDefinition.entityTypeRegistry ??
|
|
88
93
|
getEntityTypeRegistry(appDefinition);
|
|
89
94
|
const moduleDefinitions = getEntityTypeDefinitions(registry);
|
|
95
|
+
// Ownership by scope (ADR-056 §3): one object every use case asks.
|
|
96
|
+
const scopePolicy = createScopePolicy({
|
|
97
|
+
ownership: appDefinition.ownership,
|
|
98
|
+
entityTypeRegistry: registry,
|
|
99
|
+
});
|
|
90
100
|
|
|
91
101
|
const moduleRepository = createModuleRepository();
|
|
92
102
|
const integrationRepository = createIntegrationRepository();
|
|
@@ -95,9 +105,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
95
105
|
const authorizationSessionRepository = createAuthorizationSessionRepository();
|
|
96
106
|
|
|
97
107
|
const authenticateUser = new AuthenticateUser({
|
|
98
|
-
|
|
99
|
-
getUserFromXFriggHeaders: new GetUserFromXFriggHeaders({ userRepository, userConfig }),
|
|
100
|
-
getUserFromAdopterJwt: new GetUserFromAdopterJwt({ userRepository, userConfig }),
|
|
108
|
+
...buildUserAuthentication({ userConfig, userRepository }),
|
|
101
109
|
authenticateWithSharedSecret: new AuthenticateWithSharedSecret(),
|
|
102
110
|
userConfig,
|
|
103
111
|
});
|
|
@@ -111,10 +119,12 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
111
119
|
const getEntityModuleForUser = new GetEntityModuleForUser({
|
|
112
120
|
moduleRepository,
|
|
113
121
|
moduleDefinitions,
|
|
122
|
+
scopePolicy,
|
|
114
123
|
});
|
|
115
124
|
const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
|
|
116
125
|
credentialRepository,
|
|
117
126
|
moduleRepository,
|
|
127
|
+
scopePolicy,
|
|
118
128
|
});
|
|
119
129
|
const getAuthorizationRequirements = new GetAuthorizationRequirements({
|
|
120
130
|
moduleDefinitions,
|
|
@@ -128,17 +138,22 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
128
138
|
moduleDefinitions,
|
|
129
139
|
getAuthorizationRequirements,
|
|
130
140
|
authorizationSessionRepository,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
authorizeEntityForUser: new AuthorizeEntityForUser({
|
|
142
|
+
scopePolicy,
|
|
143
|
+
processAuthorizationCallback: new ProcessAuthorizationCallback({
|
|
144
|
+
moduleRepository,
|
|
145
|
+
credentialRepository,
|
|
146
|
+
integrationRepository,
|
|
147
|
+
moduleDefinitions,
|
|
148
|
+
}),
|
|
136
149
|
}),
|
|
137
150
|
moduleRepository,
|
|
138
151
|
credentialRepository,
|
|
152
|
+
scopePolicy,
|
|
139
153
|
});
|
|
140
154
|
const getOwnedIntegrationRecord = new integrationOps.GetOwnedIntegrationRecord({
|
|
141
155
|
integrationRepository,
|
|
156
|
+
scopePolicy,
|
|
142
157
|
});
|
|
143
158
|
const getIntegrationInstanceForUser = new integrationOps.GetIntegrationInstanceForUser({
|
|
144
159
|
getOwnedIntegrationRecord,
|
|
@@ -148,6 +163,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
148
163
|
return {
|
|
149
164
|
config,
|
|
150
165
|
moduleDefinitions,
|
|
166
|
+
scopePolicy,
|
|
151
167
|
repositories: {
|
|
152
168
|
moduleRepository,
|
|
153
169
|
integrationRepository,
|
|
@@ -163,6 +179,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
163
179
|
integrationClasses,
|
|
164
180
|
moduleFactory,
|
|
165
181
|
moduleRepository,
|
|
182
|
+
scopePolicy,
|
|
166
183
|
}),
|
|
167
184
|
getPossibleIntegrations: new GetPossibleIntegrations({ integrationClasses }),
|
|
168
185
|
createIntegrationForUser: new integrationOps.CreateIntegrationForUser({
|
|
@@ -171,7 +188,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
171
188
|
integrationClasses,
|
|
172
189
|
moduleFactory,
|
|
173
190
|
}),
|
|
174
|
-
|
|
191
|
+
resolveIntegrationOwnership: new ResolveIntegrationOwnership({
|
|
192
|
+
moduleRepository,
|
|
193
|
+
integrationClasses,
|
|
194
|
+
scopePolicy,
|
|
195
|
+
}),
|
|
175
196
|
integrationClasses,
|
|
176
197
|
}),
|
|
177
198
|
getIntegrationInstanceForUser,
|
|
@@ -203,7 +224,10 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
203
224
|
|
|
204
225
|
// Entities
|
|
205
226
|
getEntityModuleForUser,
|
|
206
|
-
listEntitiesForUser: new entityOps.ListEntitiesForUser({
|
|
227
|
+
listEntitiesForUser: new entityOps.ListEntitiesForUser({
|
|
228
|
+
moduleRepository,
|
|
229
|
+
scopePolicy,
|
|
230
|
+
}),
|
|
207
231
|
getEntityForUser: new entityOps.GetEntityForUser({ getEntityModuleForUser }),
|
|
208
232
|
deleteEntityForUser: new entityOps.DeleteEntityForUser({
|
|
209
233
|
getEntityModuleForUser,
|
|
@@ -215,14 +239,15 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
215
239
|
refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({
|
|
216
240
|
getEntityModuleForUser,
|
|
217
241
|
}),
|
|
218
|
-
listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }),
|
|
219
|
-
getEntityType: new GetEntityType({ entityTypeRegistry: registry }),
|
|
242
|
+
listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry, scopePolicy }),
|
|
243
|
+
getEntityType: new GetEntityType({ entityTypeRegistry: registry, scopePolicy }),
|
|
220
244
|
getAuthorizationRequirements,
|
|
221
245
|
|
|
222
246
|
// Credentials
|
|
223
247
|
listCredentialsForUser: new credentialOps.ListCredentialsForUser({
|
|
224
248
|
credentialRepository,
|
|
225
249
|
moduleRepository,
|
|
250
|
+
scopePolicy,
|
|
226
251
|
}),
|
|
227
252
|
getCredentialForUser,
|
|
228
253
|
deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
|
package/management-api/v2/dto.js
CHANGED
|
@@ -13,6 +13,20 @@ const idOf = (value) => {
|
|
|
13
13
|
return String(value);
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* `{ scope, owner, organizationId, access }` (ADR-056 §3.4): whose a record is
|
|
18
|
+
* and what the caller may do with it. Empty when the use case did not say.
|
|
19
|
+
*/
|
|
20
|
+
function tenancyFields(tenancy) {
|
|
21
|
+
if (!tenancy) return {};
|
|
22
|
+
return {
|
|
23
|
+
scope: tenancy.scope,
|
|
24
|
+
owner: tenancy.owner,
|
|
25
|
+
organizationId: tenancy.organizationId ?? null,
|
|
26
|
+
access: tenancy.access,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/** An entity (connected account). */
|
|
17
31
|
function toEntityDto(entity) {
|
|
18
32
|
const credential =
|
|
@@ -30,6 +44,7 @@ function toEntityDto(entity) {
|
|
|
30
44
|
typeof credential?.authIsValid === 'boolean'
|
|
31
45
|
? credential.authIsValid
|
|
32
46
|
: null,
|
|
47
|
+
...tenancyFields(entity.tenancy),
|
|
33
48
|
};
|
|
34
49
|
}
|
|
35
50
|
|
|
@@ -42,6 +57,7 @@ function toIntegrationDto(integration) {
|
|
|
42
57
|
config: integration.config ?? {},
|
|
43
58
|
version: integration.version ?? null,
|
|
44
59
|
messages: integration.messages ?? {},
|
|
60
|
+
...tenancyFields(integration.tenancy),
|
|
45
61
|
};
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -59,6 +75,8 @@ const CREDENTIAL_METADATA = new Set([
|
|
|
59
75
|
'type',
|
|
60
76
|
'entityIds',
|
|
61
77
|
'status',
|
|
78
|
+
// Added by the credential use cases (ADR-056 §3.4); never provider data.
|
|
79
|
+
'tenancy',
|
|
62
80
|
]);
|
|
63
81
|
|
|
64
82
|
/**
|
|
@@ -110,11 +128,13 @@ function maskCredential(credential) {
|
|
|
110
128
|
entityCount: entityIds.length,
|
|
111
129
|
createdAt: toIso(credential.createdAt),
|
|
112
130
|
updatedAt: toIso(credential.updatedAt),
|
|
131
|
+
...tenancyFields(credential.tenancy),
|
|
113
132
|
data,
|
|
114
133
|
};
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
module.exports = {
|
|
137
|
+
tenancyFields,
|
|
118
138
|
toEntityDto,
|
|
119
139
|
toIntegrationDto,
|
|
120
140
|
maskCredential,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { maskCredential } = require('../dto');
|
|
2
|
+
const { parseListFilters } = require('../request');
|
|
2
3
|
|
|
3
4
|
/** Management API v2 credential handlers, keyed by operationId. */
|
|
4
5
|
function createCredentialHandlers(useCases) {
|
|
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
|
|
|
6
7
|
useCases;
|
|
7
8
|
|
|
8
9
|
return {
|
|
9
|
-
async listCredentials(
|
|
10
|
-
const credentials = await listCredentialsForUser.execute(
|
|
10
|
+
async listCredentials(req, res, user) {
|
|
11
|
+
const credentials = await listCredentialsForUser.execute(
|
|
12
|
+
user,
|
|
13
|
+
parseListFilters(req.query)
|
|
14
|
+
);
|
|
11
15
|
res.json({ credentials: credentials.map(maskCredential) });
|
|
12
16
|
},
|
|
13
17
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { toEntityDto } = require('../dto');
|
|
2
|
-
const { objectBody, parseStep } = require('../request');
|
|
2
|
+
const { objectBody, parseStep, parseListFilters } = require('../request');
|
|
3
3
|
|
|
4
4
|
/** Management API v2 entity and entity-type handlers, keyed by operationId. */
|
|
5
5
|
function createEntityHandlers(useCases) {
|
|
@@ -16,17 +16,20 @@ function createEntityHandlers(useCases) {
|
|
|
16
16
|
} = useCases;
|
|
17
17
|
|
|
18
18
|
return {
|
|
19
|
-
async listEntities(
|
|
20
|
-
const entities = await listEntitiesForUser.execute(
|
|
19
|
+
async listEntities(req, res, user) {
|
|
20
|
+
const entities = await listEntitiesForUser.execute(
|
|
21
|
+
user,
|
|
22
|
+
parseListFilters(req.query)
|
|
23
|
+
);
|
|
21
24
|
res.json({ entities: entities.map(toEntityDto) });
|
|
22
25
|
},
|
|
23
26
|
|
|
24
|
-
async listEntityTypes(_req, res) {
|
|
25
|
-
res.json({ types: await listEntityTypes.execute() });
|
|
27
|
+
async listEntityTypes(_req, res, user) {
|
|
28
|
+
res.json({ types: await listEntityTypes.execute({ user }) });
|
|
26
29
|
},
|
|
27
30
|
|
|
28
|
-
async getEntityType(req, res) {
|
|
29
|
-
res.json(await getEntityType.execute(req.params.entityType));
|
|
31
|
+
async getEntityType(req, res, user) {
|
|
32
|
+
res.json(await getEntityType.execute(req.params.entityType, { user }));
|
|
30
33
|
},
|
|
31
34
|
|
|
32
35
|
// Describes a step's requirements without starting an authorization
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { toIntegrationDto } = require('../dto');
|
|
2
|
-
const { objectBody, optionalString } = require('../request');
|
|
2
|
+
const { objectBody, optionalString, parseListFilters } = require('../request');
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Management API v2 integration handlers, keyed by registry operationId.
|
|
@@ -27,8 +27,11 @@ function createIntegrationHandlers(useCases) {
|
|
|
27
27
|
);
|
|
28
28
|
|
|
29
29
|
return {
|
|
30
|
-
async listIntegrations(
|
|
31
|
-
const integrations = await getIntegrationsForUser.execute(
|
|
30
|
+
async listIntegrations(req, res, user) {
|
|
31
|
+
const integrations = await getIntegrationsForUser.execute(
|
|
32
|
+
user,
|
|
33
|
+
parseListFilters(req.query)
|
|
34
|
+
);
|
|
32
35
|
res.json({ integrations: integrations.map(toIntegrationDto) });
|
|
33
36
|
},
|
|
34
37
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { LIST_OWNERS } = require('../../modules/scope-visibility');
|
|
2
3
|
|
|
3
4
|
/** The JSON body as an object, or a 400 VALIDATION_ERROR. */
|
|
4
5
|
function objectBody(req) {
|
|
@@ -51,4 +52,34 @@ function optionalString(value, name) {
|
|
|
51
52
|
return value;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
const LIST_SCOPES = ['individual', 'organization'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `?scope=individual|organization` and `?owner=me|org` on a list (ADR-056
|
|
59
|
+
* §3.4). Anything else is a 400.
|
|
60
|
+
*/
|
|
61
|
+
function parseListFilters(query = {}) {
|
|
62
|
+
const scope = optionalString(query.scope, 'scope');
|
|
63
|
+
const owner = optionalString(query.owner, 'owner');
|
|
64
|
+
if (scope !== undefined && !LIST_SCOPES.includes(scope)) {
|
|
65
|
+
throw Boom.badRequest(`scope must be one of ${LIST_SCOPES.join(', ')}`, {
|
|
66
|
+
code: 'VALIDATION_ERROR',
|
|
67
|
+
details: { field: 'scope' },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (owner !== undefined && !Object.hasOwn(LIST_OWNERS, owner)) {
|
|
71
|
+
throw Boom.badRequest('owner must be me or org', {
|
|
72
|
+
code: 'VALIDATION_ERROR',
|
|
73
|
+
details: { field: 'owner' },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return { scope, owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
objectBody,
|
|
81
|
+
requireFields,
|
|
82
|
+
parseStep,
|
|
83
|
+
optionalString,
|
|
84
|
+
parseListFilters,
|
|
85
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity scopes and ownership (ADR-056 §3), re-exported from the four files it
|
|
3
|
+
* is split into:
|
|
4
|
+
*
|
|
5
|
+
* scope-resolution constants, scope resolution, ownership on create
|
|
6
|
+
* scope-config reading and validating app-definition settings
|
|
7
|
+
* scope-visibility who may see which stored row
|
|
8
|
+
* scope-policy the object a use case asks
|
|
9
|
+
*/
|
|
10
|
+
module.exports = {
|
|
11
|
+
...require('./scope-resolution'),
|
|
12
|
+
...require('./scope-config'),
|
|
13
|
+
...require('./scope-visibility'),
|
|
14
|
+
...require('./scope-policy'),
|
|
15
|
+
};
|
package/modules/index.js
CHANGED
|
@@ -8,6 +8,11 @@ const {
|
|
|
8
8
|
getEntityTypeRegistry,
|
|
9
9
|
EntityRegistryError,
|
|
10
10
|
} = require('./entity-type-registry');
|
|
11
|
+
const {
|
|
12
|
+
resolveScope,
|
|
13
|
+
listBindingScopes,
|
|
14
|
+
OwnershipConfigError,
|
|
15
|
+
} = require('./entity-scope');
|
|
11
16
|
|
|
12
17
|
module.exports = {
|
|
13
18
|
ApiKeyRequester,
|
|
@@ -18,4 +23,7 @@ module.exports = {
|
|
|
18
23
|
ModuleFactory,
|
|
19
24
|
getEntityTypeRegistry,
|
|
20
25
|
EntityRegistryError,
|
|
26
|
+
resolveScope,
|
|
27
|
+
listBindingScopes,
|
|
28
|
+
OwnershipConfigError,
|
|
21
29
|
};
|
|
@@ -45,6 +45,18 @@ class ModuleFactory {
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// definition.env may be an async function (e.g. the db-credentials
|
|
49
|
+
// extension's bootstrap loads OAuth app credentials from the
|
|
50
|
+
// database); resolve it first. A static env keeps the plain
|
|
51
|
+
// constructor, exactly as before.
|
|
52
|
+
if (typeof moduleDefinition.env === 'function') {
|
|
53
|
+
return await Module.create({
|
|
54
|
+
userId,
|
|
55
|
+
entity,
|
|
56
|
+
definition: moduleDefinition,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
return new Module({
|
|
49
61
|
userId,
|
|
50
62
|
entity,
|
package/modules/module.js
CHANGED
|
@@ -21,8 +21,17 @@ class Module extends Delegate {
|
|
|
21
21
|
* @param {string} params.userId The user id
|
|
22
22
|
* @param {Object} params.entity The entity record from the database
|
|
23
23
|
* @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
|
|
24
|
+
* @param {Object} [params.resolvedEnv] definition.env as Module.create() resolved it (may be undefined).
|
|
25
|
+
* @param {boolean} [params.envResolved=false] true when resolvedEnv carries the resolved env, even an undefined one; otherwise the static definition.env is used.
|
|
24
26
|
*/
|
|
25
|
-
constructor({
|
|
27
|
+
constructor({
|
|
28
|
+
definition,
|
|
29
|
+
userId = null,
|
|
30
|
+
entity: entityObj = null,
|
|
31
|
+
state = null,
|
|
32
|
+
resolvedEnv,
|
|
33
|
+
envResolved = false,
|
|
34
|
+
}) {
|
|
26
35
|
super({ definition, userId, entity: entityObj });
|
|
27
36
|
|
|
28
37
|
this.validateDefinition(definition);
|
|
@@ -54,8 +63,23 @@ class Module extends Delegate {
|
|
|
54
63
|
|
|
55
64
|
Object.assign(this, this.definition.requiredAuthMethods);
|
|
56
65
|
|
|
66
|
+
// A pre-resolved env (Module.create()) supports definition.env as an
|
|
67
|
+
// async function, e.g. OAuth app credentials loaded from the database
|
|
68
|
+
// by the db-credentials extension; otherwise the static object.
|
|
69
|
+
// "Resolved" is an explicit flag, not a non-null value: a function env
|
|
70
|
+
// may legitimately resolve to undefined (no static env, no DB row),
|
|
71
|
+
// and falling back to definition.env would then read the function.
|
|
72
|
+
const envValues = envResolved ? resolvedEnv : this.definition.env;
|
|
73
|
+
if (typeof envValues === 'function') {
|
|
74
|
+
// Spreading a function yields no keys, so the API client would
|
|
75
|
+
// silently get no client_id / client_secret.
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`Module ${this.name}: definition.env is a function; build the module with await Module.create(...)`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
const apiParams = {
|
|
58
|
-
...
|
|
82
|
+
...envValues,
|
|
59
83
|
delegate: this,
|
|
60
84
|
logger: this.logger,
|
|
61
85
|
...(state ? { state } : {}),
|
|
@@ -67,6 +91,33 @@ class Module extends Delegate {
|
|
|
67
91
|
this.api = new this.apiClass(apiParams);
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Resolves definition.env, supporting both a static object and a
|
|
96
|
+
* (possibly async) function.
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} definition - Module definition
|
|
99
|
+
* @returns {Promise<Object|undefined>} Resolved env values
|
|
100
|
+
*/
|
|
101
|
+
static async resolveEnv(definition) {
|
|
102
|
+
if (typeof definition.env === 'function') {
|
|
103
|
+
return await definition.env();
|
|
104
|
+
}
|
|
105
|
+
return definition.env;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Async factory that resolves definition.env first, so a module whose
|
|
110
|
+
* env is a function gets its values. Prefer it over `new Module(...)`
|
|
111
|
+
* wherever the call site can await.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} params - same as the constructor, minus resolvedEnv
|
|
114
|
+
* @returns {Promise<Module>}
|
|
115
|
+
*/
|
|
116
|
+
static async create({ definition, userId = null, entity = null, state = null }) {
|
|
117
|
+
const resolvedEnv = await Module.resolveEnv(definition);
|
|
118
|
+
return new Module({ definition, userId, entity, state, resolvedEnv, envResolved: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
70
121
|
getName() {
|
|
71
122
|
return this.name;
|
|
72
123
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error `findEntityById` throws for an id with no row, and the one
|
|
3
|
+
* predicate that recognises it (as integration-not-found.js does for
|
|
4
|
+
* integrations). The adapters build the error here and use cases match it
|
|
5
|
+
* here, so a missing entity can be told from any other failure without
|
|
6
|
+
* widening a regex.
|
|
7
|
+
*
|
|
8
|
+
* Why it matters: a use case that answers 404 for another tenant's entity must
|
|
9
|
+
* answer the same 404 for an id that does not exist. Otherwise the status code
|
|
10
|
+
* says which ids exist (ADR-052 §3, ADR-056 §3.6).
|
|
11
|
+
*/
|
|
12
|
+
const MESSAGE = /^Entity .+ not found$/;
|
|
13
|
+
|
|
14
|
+
const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
|
|
15
|
+
|
|
16
|
+
const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
|
|
17
|
+
|
|
18
|
+
module.exports = { entityNotFoundError, isEntityNotFound };
|