@friggframework/core 2.0.0--canary.680.6bacd26.0 → 2.0.0--canary.663.8de669c.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/application/commands/integration-commands.js +8 -18
- package/application/index.js +2 -6
- package/credential/repositories/credential-repository-documentdb.js +0 -34
- package/credential/repositories/credential-repository-interface.js +0 -15
- package/credential/repositories/credential-repository-mongo.js +0 -23
- package/credential/repositories/credential-repository-postgres.js +0 -28
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-schema-registry.js +12 -41
- package/database/encryption/field-encryption-service.js +5 -69
- package/generated/prisma-mongodb/edge.js +4 -20
- package/generated/prisma-mongodb/index-browser.js +1 -17
- package/generated/prisma-mongodb/index.d.ts +95 -1543
- package/generated/prisma-mongodb/index.js +4 -20
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -27
- package/generated/prisma-mongodb/wasm.js +4 -20
- package/generated/prisma-postgresql/edge.js +4 -20
- package/generated/prisma-postgresql/index-browser.js +1 -17
- package/generated/prisma-postgresql/index.d.ts +84 -1565
- package/generated/prisma-postgresql/index.js +4 -20
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -26
- package/generated/prisma-postgresql/wasm.js +4 -20
- package/handlers/app-definition-loader.js +8 -19
- package/handlers/app-handler-helpers.js +3 -25
- package/handlers/backend-utils.js +25 -31
- package/handlers/middleware/admin-auth.js +1 -14
- package/handlers/routers/auth.js +3 -11
- package/handlers/routers/health.js +4 -11
- package/handlers/workers/integration-defined-workers.js +2 -5
- package/index.js +2 -4
- package/integrations/index.js +4 -0
- package/integrations/integration-router.js +23 -16
- package/integrations/utils/map-integration-dto.js +15 -1
- package/modules/index.js +0 -6
- package/modules/requester/requester.js +2 -27
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +0 -27
- package/prisma-postgresql/schema.prisma +0 -26
- package/types/index.d.ts +0 -1
- package/credential/use-cases/credential-operations-for-user.js +0 -117
- package/credential/use-cases/reauthorize-credential-for-user.js +0 -66
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
- package/integrations/use-cases/integration-operations-for-user.js +0 -225
- package/management-api/create-management-api-router.js +0 -49
- package/management-api/error-response.js +0 -116
- package/management-api/management-api-config.js +0 -82
- package/management-api/meta-router.js +0 -57
- package/management-api/openapi/build-openapi.js +0 -144
- package/management-api/openapi/components.js +0 -368
- package/management-api/openapi/v1-operations.js +0 -83
- package/management-api/openapi/v2-operations.js +0 -252
- package/management-api/route-registry.js +0 -294
- package/management-api/test-support/http-client.js +0 -39
- package/management-api/use-cases/get-management-api-meta.js +0 -57
- package/management-api/v1-deprecation.js +0 -120
- package/management-api/v2/create-v2-router.js +0 -85
- package/management-api/v2/dependencies.js +0 -256
- package/management-api/v2/dto.js +0 -123
- package/management-api/v2/handlers/authorization.js +0 -93
- package/management-api/v2/handlers/credentials.js +0 -29
- package/management-api/v2/handlers/entities.js +0 -74
- package/management-api/v2/handlers/integrations.js +0 -125
- package/management-api/v2/handlers/proxy.js +0 -34
- package/management-api/v2/request.js +0 -54
- package/management-api/v2/test-support/fake-dependencies.js +0 -103
- package/modules/domain/authorization-session.js +0 -144
- package/modules/entity-type-registry.js +0 -311
- package/modules/proxy/proxy-errors.js +0 -20
- package/modules/proxy/proxy-path.js +0 -179
- package/modules/proxy/proxy-policy.js +0 -248
- package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
- package/modules/repositories/authorization-session-repository-factory.js +0 -31
- package/modules/repositories/authorization-session-repository-interface.js +0 -47
- package/modules/repositories/authorization-session-repository-prisma.js +0 -87
- package/modules/requester/proxy-request.js +0 -244
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
- package/modules/use-cases/entity-operations-for-user.js +0 -125
- package/modules/use-cases/entity-types.js +0 -115
- package/modules/use-cases/execute-entity-proxy-request.js +0 -239
- package/modules/use-cases/get-authorization-requirements.js +0 -100
- package/modules/use-cases/get-entity-module-for-user.js +0 -86
- package/modules/use-cases/multi-step-authorization.js +0 -302
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
- package/types/modules/index.d.ts +0 -58
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
const Boom = require('@hapi/boom');
|
|
2
|
-
const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Management API v2 integration operations. They put an ownership check
|
|
6
|
-
* (missing or foreign integration = 404) in front of the existing
|
|
7
|
-
* integration use cases, and pass the record's owner id on, so the
|
|
8
|
-
* organisation user model works the same as for entities.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const notFound = (integrationId) =>
|
|
12
|
-
Boom.notFound(`Integration ${integrationId} not found`, {
|
|
13
|
-
code: 'INTEGRATION_NOT_FOUND',
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
/** The integration record, or 404 when it is missing or not the caller's. */
|
|
17
|
-
class GetOwnedIntegrationRecord {
|
|
18
|
-
constructor({ integrationRepository }) {
|
|
19
|
-
this.integrationRepository = integrationRepository;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async execute(integrationId, user) {
|
|
23
|
-
let record = null;
|
|
24
|
-
try {
|
|
25
|
-
record = await this.integrationRepository.findIntegrationById(
|
|
26
|
-
integrationId
|
|
27
|
-
);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
|
|
30
|
-
throw notFound(integrationId);
|
|
31
|
-
}
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
34
|
-
if (!record || !userOwns(user, record.userId)) {
|
|
35
|
-
throw notFound(integrationId);
|
|
36
|
-
}
|
|
37
|
-
return record;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Creates (or reuses) an integration between entities the caller owns.
|
|
43
|
-
* Unlike v1, the entities' ownership and the integration type are checked
|
|
44
|
-
* before anything is written.
|
|
45
|
-
*/
|
|
46
|
-
class CreateIntegrationForUser {
|
|
47
|
-
constructor({ createIntegration, getEntityModuleForUser, integrationClasses }) {
|
|
48
|
-
this.createIntegration = createIntegration;
|
|
49
|
-
this.getEntityModuleForUser = getEntityModuleForUser;
|
|
50
|
-
this.integrationClasses = integrationClasses || [];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async execute({ entities, config }, user) {
|
|
54
|
-
if (!config || typeof config !== 'object' || typeof config.type !== 'string') {
|
|
55
|
-
throw Boom.badRequest('config.type is required', {
|
|
56
|
-
code: 'VALIDATION_ERROR',
|
|
57
|
-
details: { field: 'config.type' },
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
const known = this.integrationClasses.some(
|
|
61
|
-
(IntegrationClass) => IntegrationClass.Definition?.name === config.type
|
|
62
|
-
);
|
|
63
|
-
if (!known) {
|
|
64
|
-
throw Boom.badRequest(`Unknown integration type '${config.type}'`, {
|
|
65
|
-
code: 'UNKNOWN_INTEGRATION_TYPE',
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
if (
|
|
69
|
-
!Array.isArray(entities) ||
|
|
70
|
-
entities.some((id) => typeof id !== 'string' && typeof id !== 'number')
|
|
71
|
-
) {
|
|
72
|
-
throw Boom.badRequest('entities must be an array of entity ids', {
|
|
73
|
-
code: 'VALIDATION_ERROR',
|
|
74
|
-
details: { field: 'entities' },
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
for (const entityId of entities) {
|
|
78
|
-
await this.getEntityModuleForUser.loadOwnedEntity(String(entityId), user);
|
|
79
|
-
}
|
|
80
|
-
return this.createIntegration.execute(
|
|
81
|
-
entities.map(String),
|
|
82
|
-
user.getId(),
|
|
83
|
-
config
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The hydrated integration instance (modules loaded) for an owned integration. */
|
|
89
|
-
class GetIntegrationInstanceForUser {
|
|
90
|
-
constructor({ getOwnedIntegrationRecord, getIntegrationInstance }) {
|
|
91
|
-
this.getOwnedIntegrationRecord = getOwnedIntegrationRecord;
|
|
92
|
-
this.getIntegrationInstance = getIntegrationInstance;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async execute(integrationId, user) {
|
|
96
|
-
const record = await this.getOwnedIntegrationRecord.execute(
|
|
97
|
-
integrationId,
|
|
98
|
-
user
|
|
99
|
-
);
|
|
100
|
-
return this.getIntegrationInstance.execute(record.id, record.userId);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Replaces an owned integration's config. */
|
|
105
|
-
class UpdateIntegrationForUser {
|
|
106
|
-
constructor({ getOwnedIntegrationRecord, updateIntegration }) {
|
|
107
|
-
this.getOwnedIntegrationRecord = getOwnedIntegrationRecord;
|
|
108
|
-
this.updateIntegration = updateIntegration;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async execute(integrationId, user, config) {
|
|
112
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
113
|
-
throw Boom.badRequest('config must be an object', {
|
|
114
|
-
code: 'VALIDATION_ERROR',
|
|
115
|
-
details: { field: 'config' },
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
const record = await this.getOwnedIntegrationRecord.execute(
|
|
119
|
-
integrationId,
|
|
120
|
-
user
|
|
121
|
-
);
|
|
122
|
-
return this.updateIntegration.execute(record.id, record.userId, config);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Deletes an owned integration (runs its onDelete hooks). */
|
|
127
|
-
class RemoveIntegrationForUser {
|
|
128
|
-
constructor({ getOwnedIntegrationRecord, deleteIntegrationForUser }) {
|
|
129
|
-
this.getOwnedIntegrationRecord = getOwnedIntegrationRecord;
|
|
130
|
-
this.deleteIntegrationForUser = deleteIntegrationForUser;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async execute(integrationId, user) {
|
|
134
|
-
const record = await this.getOwnedIntegrationRecord.execute(
|
|
135
|
-
integrationId,
|
|
136
|
-
user
|
|
137
|
-
);
|
|
138
|
-
await this.deleteIntegrationForUser.execute(record.id, record.userId);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Sends one of the framework's user-facing events (GET_CONFIG_OPTIONS,
|
|
144
|
-
* GET_USER_ACTIONS, ...) to an owned integration. Handlers pass fixed event
|
|
145
|
-
* names only; caller-chosen action ids go through RunIntegrationActionForUser.
|
|
146
|
-
*/
|
|
147
|
-
class SendIntegrationEventForUser {
|
|
148
|
-
constructor({ getIntegrationInstanceForUser }) {
|
|
149
|
-
this.getIntegrationInstanceForUser = getIntegrationInstanceForUser;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async execute(integrationId, user, event, payload) {
|
|
153
|
-
const instance = await this.getIntegrationInstanceForUser.execute(
|
|
154
|
-
integrationId,
|
|
155
|
-
user
|
|
156
|
-
);
|
|
157
|
-
return instance.send(event, payload);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Runs a user action on an owned integration. Unlike v1, the action id must
|
|
163
|
-
* be one the integration lists as a user action (static USER_ACTION events
|
|
164
|
-
* or its dynamic user actions), so a caller cannot fire internal events such
|
|
165
|
-
* as lifecycle hooks.
|
|
166
|
-
*/
|
|
167
|
-
class RunIntegrationActionForUser {
|
|
168
|
-
constructor({ getIntegrationInstanceForUser }) {
|
|
169
|
-
this.getIntegrationInstanceForUser = getIntegrationInstanceForUser;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async execute(integrationId, user, actionId, payload) {
|
|
173
|
-
const instance = await this.getIntegrationInstanceForUser.execute(
|
|
174
|
-
integrationId,
|
|
175
|
-
user
|
|
176
|
-
);
|
|
177
|
-
const actions =
|
|
178
|
-
typeof instance.loadUserActions === 'function'
|
|
179
|
-
? await instance.loadUserActions()
|
|
180
|
-
: {};
|
|
181
|
-
if (!actions || !Object.prototype.hasOwnProperty.call(actions, actionId)) {
|
|
182
|
-
throw Boom.notFound(
|
|
183
|
-
`Integration ${integrationId} has no user action '${actionId}'`,
|
|
184
|
-
{ code: 'ACTION_NOT_FOUND' }
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
return instance.send(actionId, payload);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Runs the integration's auth test and reconciles its status. Returns the
|
|
193
|
-
* errors recorded during the test.
|
|
194
|
-
*/
|
|
195
|
-
class TestIntegrationAuthForUser {
|
|
196
|
-
constructor({ getIntegrationInstanceForUser }) {
|
|
197
|
-
this.getIntegrationInstanceForUser = getIntegrationInstanceForUser;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** @returns {Promise<{ ok: boolean, errors: Array<Object> }>} */
|
|
201
|
-
async execute(integrationId, user) {
|
|
202
|
-
const instance = await this.getIntegrationInstanceForUser.execute(
|
|
203
|
-
integrationId,
|
|
204
|
-
user
|
|
205
|
-
);
|
|
206
|
-
const start = Date.now();
|
|
207
|
-
const authPassed = await instance.testAuth();
|
|
208
|
-
await instance.reconcileAuthStatus(authPassed);
|
|
209
|
-
const errors = (instance.record?.messages?.errors || []).filter(
|
|
210
|
-
({ timestamp }) => timestamp >= start
|
|
211
|
-
);
|
|
212
|
-
return { ok: Boolean(authPassed) && errors.length === 0, errors };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
module.exports = {
|
|
217
|
-
GetOwnedIntegrationRecord,
|
|
218
|
-
CreateIntegrationForUser,
|
|
219
|
-
GetIntegrationInstanceForUser,
|
|
220
|
-
UpdateIntegrationForUser,
|
|
221
|
-
RemoveIntegrationForUser,
|
|
222
|
-
SendIntegrationEventForUser,
|
|
223
|
-
RunIntegrationActionForUser,
|
|
224
|
-
TestIntegrationAuthForUser,
|
|
225
|
-
};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
const express = require('express');
|
|
2
|
-
const { resolveManagementApiConfig } = require('./management-api-config');
|
|
3
|
-
const {
|
|
4
|
-
createV1DeprecationRouter,
|
|
5
|
-
createV1DisabledRouter,
|
|
6
|
-
} = require('./v1-deprecation');
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The Management API served by the auth Lambda (ADR-053): v2 under /api/v2
|
|
10
|
-
* and the frozen, deprecated v1 at the unprefixed paths. With
|
|
11
|
-
* `managementApi.v1: false` the v1 handlers are never built and the v1 paths
|
|
12
|
-
* answer 410.
|
|
13
|
-
*
|
|
14
|
-
* @param {Object} [params]
|
|
15
|
-
* @param {Object} [params.appDefinition] - defaults to loadAppDefinition().
|
|
16
|
-
* @param {Function} [params.createV1Router] - defaults to createIntegrationRouter.
|
|
17
|
-
* @param {Function} [params.createV2Router] - defaults to createManagementApiV2Router.
|
|
18
|
-
*/
|
|
19
|
-
function createManagementApiRouter({
|
|
20
|
-
appDefinition,
|
|
21
|
-
createV1Router,
|
|
22
|
-
createV2Router,
|
|
23
|
-
} = {}) {
|
|
24
|
-
const definition =
|
|
25
|
-
appDefinition ||
|
|
26
|
-
require('../handlers/app-definition-loader').loadAppDefinition();
|
|
27
|
-
const config = resolveManagementApiConfig(definition.managementApi);
|
|
28
|
-
|
|
29
|
-
const buildV1 =
|
|
30
|
-
createV1Router ||
|
|
31
|
-
require('../integrations/integration-router').createIntegrationRouter;
|
|
32
|
-
const buildV2 =
|
|
33
|
-
createV2Router ||
|
|
34
|
-
require('./v2/create-v2-router').createManagementApiV2Router;
|
|
35
|
-
|
|
36
|
-
const router = express.Router();
|
|
37
|
-
router.use(buildV2({ appDefinition: definition, config }));
|
|
38
|
-
|
|
39
|
-
if (config.v1) {
|
|
40
|
-
router.use(createV1DeprecationRouter());
|
|
41
|
-
router.use(buildV1());
|
|
42
|
-
} else {
|
|
43
|
-
router.use(createV1DisabledRouter());
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return router;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = { createManagementApiRouter };
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
const Boom = require('@hapi/boom');
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Error responses for the HTTP boundary (handlers/app-handler-helpers.js).
|
|
5
|
-
*
|
|
6
|
-
* - v1 and other unversioned routes keep their historical body:
|
|
7
|
-
* `{ error: message }` for 4xx, `{ error: 'Internal Server Error' }` for 5xx.
|
|
8
|
-
* - Management API v2 and /api/meta answer `{ error: { code, message, details? } }`
|
|
9
|
-
* and carry `Frigg-API-Version: 2` even when the error happened before any
|
|
10
|
-
* v2 router ran (for example a body-parser failure).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const API_VERSION_HEADER = 'Frigg-API-Version';
|
|
14
|
-
|
|
15
|
-
const CODE_BY_STATUS = {
|
|
16
|
-
400: 'BAD_REQUEST',
|
|
17
|
-
401: 'UNAUTHORIZED',
|
|
18
|
-
403: 'FORBIDDEN',
|
|
19
|
-
404: 'NOT_FOUND',
|
|
20
|
-
405: 'METHOD_NOT_ALLOWED',
|
|
21
|
-
409: 'CONFLICT',
|
|
22
|
-
410: 'GONE',
|
|
23
|
-
413: 'PAYLOAD_TOO_LARGE',
|
|
24
|
-
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
25
|
-
422: 'UNPROCESSABLE_ENTITY',
|
|
26
|
-
429: 'RATE_LIMITED',
|
|
27
|
-
500: 'INTERNAL_ERROR',
|
|
28
|
-
501: 'NOT_IMPLEMENTED',
|
|
29
|
-
502: 'BAD_GATEWAY',
|
|
30
|
-
503: 'SERVICE_UNAVAILABLE',
|
|
31
|
-
504: 'GATEWAY_TIMEOUT',
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// body-parser (via http-errors) tags its failures with `type`.
|
|
35
|
-
const CODE_BY_BODY_PARSER_TYPE = {
|
|
36
|
-
'entity.parse.failed': 'INVALID_JSON',
|
|
37
|
-
'entity.too.large': 'PAYLOAD_TOO_LARGE',
|
|
38
|
-
'encoding.unsupported': 'UNSUPPORTED_MEDIA_TYPE',
|
|
39
|
-
'charset.unsupported': 'UNSUPPORTED_MEDIA_TYPE',
|
|
40
|
-
'request.aborted': 'BAD_REQUEST',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/** True for paths that use the v2 error contract. */
|
|
44
|
-
function isVersionedManagementApiPath(path = '') {
|
|
45
|
-
return (
|
|
46
|
-
path === '/api/v2' ||
|
|
47
|
-
path.startsWith('/api/v2/') ||
|
|
48
|
-
path === '/api/meta' ||
|
|
49
|
-
path.startsWith('/api/meta/')
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The HTTP status for an error. Boom errors carry theirs. Other errors are
|
|
55
|
-
* trusted only when they declare a client error as safe to expose, as
|
|
56
|
-
* http-errors (and so body-parser) does; anything else is a 500, so an
|
|
57
|
-
* upstream FetchError's `statusCode` is never mistaken for ours.
|
|
58
|
-
*/
|
|
59
|
-
function resolveStatusCode(err) {
|
|
60
|
-
if (err?.isBoom) return err.output.statusCode;
|
|
61
|
-
const status = err?.status ?? err?.statusCode;
|
|
62
|
-
if (
|
|
63
|
-
err?.expose === true &&
|
|
64
|
-
Number.isInteger(status) &&
|
|
65
|
-
status >= 400 &&
|
|
66
|
-
status < 500
|
|
67
|
-
) {
|
|
68
|
-
return status;
|
|
69
|
-
}
|
|
70
|
-
return 500;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Normalises any thrown value into a Boom error with the right status. */
|
|
74
|
-
function toBoom(err) {
|
|
75
|
-
if (err?.isBoom) return err;
|
|
76
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
77
|
-
return Boom.boomify(error, { statusCode: resolveStatusCode(err) });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** The v2 error body for a Boom error. */
|
|
81
|
-
function toV2ErrorBody(boomError) {
|
|
82
|
-
const statusCode = boomError.output.statusCode;
|
|
83
|
-
const data =
|
|
84
|
-
boomError.data && typeof boomError.data === 'object'
|
|
85
|
-
? boomError.data
|
|
86
|
-
: {};
|
|
87
|
-
const exposeServerError = statusCode >= 500 && data.expose === true;
|
|
88
|
-
|
|
89
|
-
const code =
|
|
90
|
-
(typeof data.code === 'string' && data.code) ||
|
|
91
|
-
CODE_BY_BODY_PARSER_TYPE[boomError.type] ||
|
|
92
|
-
CODE_BY_STATUS[statusCode] ||
|
|
93
|
-
(statusCode >= 500 ? 'INTERNAL_ERROR' : 'BAD_REQUEST');
|
|
94
|
-
|
|
95
|
-
const message =
|
|
96
|
-
statusCode < 500 || exposeServerError
|
|
97
|
-
? boomError.message
|
|
98
|
-
: 'Internal Server Error';
|
|
99
|
-
|
|
100
|
-
const body = { code, message };
|
|
101
|
-
if (
|
|
102
|
-
data.details !== undefined &&
|
|
103
|
-
(statusCode < 500 || exposeServerError)
|
|
104
|
-
) {
|
|
105
|
-
body.details = data.details;
|
|
106
|
-
}
|
|
107
|
-
return { error: body };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
module.exports = {
|
|
111
|
-
API_VERSION_HEADER,
|
|
112
|
-
isVersionedManagementApiPath,
|
|
113
|
-
resolveStatusCode,
|
|
114
|
-
toBoom,
|
|
115
|
-
toV2ErrorBody,
|
|
116
|
-
};
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolves `appDefinition.managementApi` (ADR-052, ADR-053).
|
|
3
|
-
*
|
|
4
|
-
* ```js
|
|
5
|
-
* managementApi: {
|
|
6
|
-
* v1: false, // drop the deprecated v1 routes (default true)
|
|
7
|
-
* proxy: {
|
|
8
|
-
* enable: true, // entity proxy, off by default (ADR-052)
|
|
9
|
-
* timeoutMs: 25000, // whole upstream call, lower only
|
|
10
|
-
* maxRequestBytes: 1048576,
|
|
11
|
-
* maxResponseBytes: 5242880, // lower only
|
|
12
|
-
* modules: { // per-module allow-list override
|
|
13
|
-
* hubspot: { allow: 'module' },
|
|
14
|
-
* erp: { allow: [{ method: 'GET', path: '/v1/customers/**' }] },
|
|
15
|
-
* },
|
|
16
|
-
* },
|
|
17
|
-
* }
|
|
18
|
-
* ```
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
// ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
|
|
22
|
-
// at 6MB, so the timeout and response defaults are also ceilings: an app may
|
|
23
|
-
// lower them, not raise them.
|
|
24
|
-
const DEFAULT_PROXY_LIMITS = Object.freeze({
|
|
25
|
-
timeoutMs: 25_000,
|
|
26
|
-
maxRequestBytes: 1024 * 1024,
|
|
27
|
-
maxResponseBytes: 5 * 1024 * 1024,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
// The request-body limit has no platform ceiling below API Gateway's 10MB, so
|
|
31
|
-
// an app may raise it up to that.
|
|
32
|
-
const MAX_REQUEST_BYTES_CEILING = 10 * 1024 * 1024;
|
|
33
|
-
|
|
34
|
-
const isPositiveNumber = (value) =>
|
|
35
|
-
typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
36
|
-
|
|
37
|
-
/** The value, capped at `ceiling`; the ceiling when the value is unusable. */
|
|
38
|
-
function positiveCapped(value, ceiling) {
|
|
39
|
-
return isPositiveNumber(value) ? Math.min(value, ceiling) : ceiling;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The value, capped at `ceiling`; `fallback` when the value is unusable. */
|
|
43
|
-
function positiveOr(value, fallback, ceiling) {
|
|
44
|
-
return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* @param {Object} [managementApi] - `appDefinition.managementApi`
|
|
49
|
-
* @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, modules: Object } }}
|
|
50
|
-
*/
|
|
51
|
-
function resolveManagementApiConfig(managementApi) {
|
|
52
|
-
const config =
|
|
53
|
-
managementApi && typeof managementApi === 'object' ? managementApi : {};
|
|
54
|
-
const proxy =
|
|
55
|
-
config.proxy && typeof config.proxy === 'object' ? config.proxy : {};
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
v1: config.v1 !== false,
|
|
59
|
-
proxy: {
|
|
60
|
-
enable: proxy.enable === true,
|
|
61
|
-
timeoutMs: positiveCapped(
|
|
62
|
-
proxy.timeoutMs,
|
|
63
|
-
DEFAULT_PROXY_LIMITS.timeoutMs
|
|
64
|
-
),
|
|
65
|
-
maxRequestBytes: positiveOr(
|
|
66
|
-
proxy.maxRequestBytes,
|
|
67
|
-
DEFAULT_PROXY_LIMITS.maxRequestBytes,
|
|
68
|
-
MAX_REQUEST_BYTES_CEILING
|
|
69
|
-
),
|
|
70
|
-
maxResponseBytes: positiveCapped(
|
|
71
|
-
proxy.maxResponseBytes,
|
|
72
|
-
DEFAULT_PROXY_LIMITS.maxResponseBytes
|
|
73
|
-
),
|
|
74
|
-
modules:
|
|
75
|
-
proxy.modules && typeof proxy.modules === 'object'
|
|
76
|
-
? proxy.modules
|
|
77
|
-
: {},
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
module.exports = { resolveManagementApiConfig, DEFAULT_PROXY_LIMITS };
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
const { Router } = require('express');
|
|
2
|
-
const Boom = require('@hapi/boom');
|
|
3
|
-
const { resolveManagementApiConfig } = require('./management-api-config');
|
|
4
|
-
const {
|
|
5
|
-
GetManagementApiMeta,
|
|
6
|
-
} = require('./use-cases/get-management-api-meta');
|
|
7
|
-
const { isValidAdminApiKey } = require('../handlers/middleware/admin-auth');
|
|
8
|
-
const { version: coreVersion } = require('../package.json');
|
|
9
|
-
const { MANAGEMENT_API_VERSIONS } = require('./route-registry');
|
|
10
|
-
const { buildOpenApiDocument } = require('./openapi/build-openapi');
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Version-neutral discovery routes (ADR-053 §4, §5), mounted on the DB-free
|
|
14
|
-
* health Lambda. No authentication: the admin API key only adds the core
|
|
15
|
-
* version to the response.
|
|
16
|
-
*
|
|
17
|
-
* @param {Object} params
|
|
18
|
-
* @param {Object} params.appDefinition - the loaded app definition
|
|
19
|
-
* (`managementApi` is read).
|
|
20
|
-
*/
|
|
21
|
-
function createManagementApiMetaRouter({ appDefinition = {} } = {}) {
|
|
22
|
-
const config = resolveManagementApiConfig(appDefinition.managementApi);
|
|
23
|
-
const getMeta = new GetManagementApiMeta({ config, coreVersion });
|
|
24
|
-
|
|
25
|
-
const router = Router();
|
|
26
|
-
|
|
27
|
-
router.get('/api/meta', (req, res) => {
|
|
28
|
-
const isAdmin = isValidAdminApiKey(req.get('x-frigg-admin-api-key'));
|
|
29
|
-
res.set('Cache-Control', 'no-store');
|
|
30
|
-
res.json(getMeta.execute({ isAdmin }));
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// One OpenAPI document per major (ADR-053 §5). Static per deployment, so
|
|
34
|
-
// built once; the entity proxy appears only when the app enables it.
|
|
35
|
-
const documents = new Map();
|
|
36
|
-
for (const major of Object.keys(MANAGEMENT_API_VERSIONS)) {
|
|
37
|
-
router.get(`/api/meta/openapi/v${major}.json`, (_req, res) => {
|
|
38
|
-
if (!documents.has(major)) {
|
|
39
|
-
documents.set(
|
|
40
|
-
major,
|
|
41
|
-
buildOpenApiDocument(major, { proxy: config.proxy.enable })
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
res.set('Cache-Control', 'public, max-age=300');
|
|
45
|
-
res.json(documents.get(major));
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Anything else under /api/meta is a v2-shaped 404, not Express's page.
|
|
50
|
-
router.use('/api/meta', (req, _res, next) => {
|
|
51
|
-
next(Boom.notFound(`No Management API meta route for ${req.method} ${req.originalUrl.split('?')[0]}`));
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
return router;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
module.exports = { createManagementApiMetaRouter };
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
const {
|
|
2
|
-
MANAGEMENT_API_VERSIONS,
|
|
3
|
-
V1_ROUTES,
|
|
4
|
-
META_ROUTES,
|
|
5
|
-
V1_MIGRATION_DOCS_URL,
|
|
6
|
-
listV2Routes,
|
|
7
|
-
toGatewayPath,
|
|
8
|
-
} = require('../route-registry');
|
|
9
|
-
const { SCHEMAS, RESPONSES, SECURITY_SCHEMES } = require('./components');
|
|
10
|
-
const { V2_OPERATIONS, META_OPERATIONS } = require('./v2-operations');
|
|
11
|
-
const { V1_OPERATIONS } = require('./v1-operations');
|
|
12
|
-
const { version: coreVersion } = require('../../package.json');
|
|
13
|
-
|
|
14
|
-
const COMPATIBILITY_RULES = `
|
|
15
|
-
Compatibility (ADR-053): within a major, changes are additive only. Clients
|
|
16
|
-
must ignore response fields and enum values they do not know. Operations
|
|
17
|
-
marked \`x-frigg-stability: beta\` may still change in a core minor.
|
|
18
|
-
|
|
19
|
-
Errors: \`{ "error": { "code", "message", "details"? } }\`. Every response
|
|
20
|
-
carries \`Frigg-API-Version: 2\`.`;
|
|
21
|
-
|
|
22
|
-
const DESCRIPTIONS = {
|
|
23
|
-
1: `The original, unprefixed Management API. Deprecated (ADR-053): every response carries
|
|
24
|
-
\`Deprecation\` and \`Link: <${V1_MIGRATION_DOCS_URL}>; rel="deprecation"\`. It is frozen,
|
|
25
|
-
and \`managementApi.v1: false\` turns it off. Use v2.`,
|
|
26
|
-
2: `Integrations, entities, credentials, multi-step authorization and the entity proxy, under
|
|
27
|
-
\`/api/v2\`. Discover what a deployment serves with \`GET /api/meta\`.
|
|
28
|
-
${COMPATIBILITY_RULES}`,
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const TAGS = [
|
|
32
|
-
{ name: 'Integrations', description: 'Integrations between connected entities' },
|
|
33
|
-
{ name: 'Entities', description: 'Connected accounts, entity types and the entity proxy' },
|
|
34
|
-
{ name: 'Credentials', description: 'Stored credentials (always masked) and re-authorization' },
|
|
35
|
-
{ name: 'Authorization', description: 'Single- and multi-step authorization' },
|
|
36
|
-
{ name: 'Meta', description: 'Version-neutral discovery' },
|
|
37
|
-
];
|
|
38
|
-
|
|
39
|
-
/** Path parameters, derived from the Express path. */
|
|
40
|
-
function pathParameters(expressPath) {
|
|
41
|
-
return [...expressPath.matchAll(/:([A-Za-z0-9_]+)/g)].map(([, name]) => ({
|
|
42
|
-
name,
|
|
43
|
-
in: 'path',
|
|
44
|
-
required: true,
|
|
45
|
-
schema: { type: 'string' },
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function operationFor(route, docs, extra = {}) {
|
|
50
|
-
if (!docs) {
|
|
51
|
-
throw new Error(
|
|
52
|
-
`No OpenAPI documentation for ${route.method} ${route.path} (${route.operationId || 'v1'})`
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
const parameters = [...pathParameters(route.path), ...(docs.parameters || [])];
|
|
56
|
-
return {
|
|
57
|
-
operationId: route.operationId,
|
|
58
|
-
...(route.tag && { tags: [route.tag] }),
|
|
59
|
-
...docs,
|
|
60
|
-
...(parameters.length && { parameters }),
|
|
61
|
-
...(route.stability && { 'x-frigg-stability': route.stability }),
|
|
62
|
-
...extra,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function addOperation(paths, route, operation) {
|
|
67
|
-
const path = toGatewayPath(route.path);
|
|
68
|
-
paths[path] = paths[path] || {};
|
|
69
|
-
// An ANY route (router.all in v1) is documented as GET and POST, the two
|
|
70
|
-
// methods callers use.
|
|
71
|
-
const methods = route.method === 'ANY' ? ['get', 'post'] : [route.method.toLowerCase()];
|
|
72
|
-
for (const method of methods) {
|
|
73
|
-
paths[path][method] = {
|
|
74
|
-
...operation,
|
|
75
|
-
...(methods.length > 1 && {
|
|
76
|
-
operationId: `${operation.operationId}${method === 'get' ? '' : 'Post'}`,
|
|
77
|
-
}),
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Builds the OpenAPI 3 document for one Management API major from the route
|
|
84
|
-
* registry (ADR-053 §5).
|
|
85
|
-
*
|
|
86
|
-
* @param {string} major - '1' or '2'
|
|
87
|
-
* @param {Object} [options]
|
|
88
|
-
* @param {boolean} [options.proxy=true] - include the entity proxy (served
|
|
89
|
-
* documents follow managementApi.proxy.enable)
|
|
90
|
-
* @returns {Object}
|
|
91
|
-
*/
|
|
92
|
-
function buildOpenApiDocument(major, { proxy = true } = {}) {
|
|
93
|
-
const info = MANAGEMENT_API_VERSIONS[major];
|
|
94
|
-
if (!info) throw new Error(`Unknown Management API version: ${major}`);
|
|
95
|
-
|
|
96
|
-
const paths = {};
|
|
97
|
-
if (major === '1') {
|
|
98
|
-
for (const route of V1_ROUTES) {
|
|
99
|
-
const key = `${route.method} ${route.path}`;
|
|
100
|
-
addOperation(
|
|
101
|
-
paths,
|
|
102
|
-
route,
|
|
103
|
-
operationFor({ ...route, operationId: V1_OPERATIONS[key]?.operationId }, V1_OPERATIONS[key], {
|
|
104
|
-
deprecated: true,
|
|
105
|
-
})
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
} else {
|
|
109
|
-
for (const route of listV2Routes({ proxy })) {
|
|
110
|
-
addOperation(paths, route, operationFor(route, V2_OPERATIONS[route.operationId]));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
for (const route of META_ROUTES) {
|
|
114
|
-
addOperation(
|
|
115
|
-
paths,
|
|
116
|
-
route,
|
|
117
|
-
operationFor({ ...route, tag: 'Meta' }, META_OPERATIONS[route.operationId])
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return {
|
|
122
|
-
openapi: '3.0.3',
|
|
123
|
-
info: {
|
|
124
|
-
title: `Frigg Management API v${major}`,
|
|
125
|
-
version: coreVersion,
|
|
126
|
-
description: DESCRIPTIONS[major],
|
|
127
|
-
license: { name: 'MIT' },
|
|
128
|
-
},
|
|
129
|
-
'x-frigg-api-version': major,
|
|
130
|
-
'x-frigg-api-status': info.status,
|
|
131
|
-
...(info.deprecatedAt && { 'x-frigg-deprecated-at': info.deprecatedAt }),
|
|
132
|
-
servers: [{ url: '/', description: 'This Frigg deployment' }],
|
|
133
|
-
security: [{ bearerAuth: [] }, { friggApiKey: [] }],
|
|
134
|
-
tags: TAGS,
|
|
135
|
-
paths,
|
|
136
|
-
components: {
|
|
137
|
-
schemas: SCHEMAS,
|
|
138
|
-
responses: RESPONSES,
|
|
139
|
-
securitySchemes: SECURITY_SCHEMES,
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
module.exports = { buildOpenApiDocument };
|