@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.690.3fe2b27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- 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 +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/app-definition-loader.js +128 -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 -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 +1 -0
- package/integrations/integration-router.js +131 -96
- 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 +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 +415 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-bases.js +169 -0
- package/modules/proxy/proxy-errors.js +31 -0
- package/modules/proxy/proxy-path.js +183 -0
- package/modules/proxy/proxy-policy.js +277 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +245 -0
- package/modules/requester/requester.js +35 -5
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +331 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -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 +214 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const { FetchError } = require('../../errors/fetch-error');
|
|
2
|
+
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
3
|
+
const {
|
|
4
|
+
normalizeProxyPath,
|
|
5
|
+
joinProxyUrl,
|
|
6
|
+
} = require('../proxy/proxy-path');
|
|
7
|
+
|
|
8
|
+
const MAX_REDIRECTS = 3;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The generic, path-checked request behind the Management API entity proxy
|
|
12
|
+
* (ADR-052 §8). It runs through the requester's own `_request`, so auth
|
|
13
|
+
* headers, token refresh (single-flight), the per-attempt timeout, telemetry
|
|
14
|
+
* and log redaction all apply. What it adds:
|
|
15
|
+
*
|
|
16
|
+
* - the URL is always `module base URL + normalised path`; the caller cannot
|
|
17
|
+
* name a host, scheme or port;
|
|
18
|
+
* - no backoff retries (the caller decides; a 429 comes back with its
|
|
19
|
+
* Retry-After), and upstream errors are returned, not thrown;
|
|
20
|
+
* - redirects are handled manually: same origin and under the base path only,
|
|
21
|
+
* at most three hops, each re-checked with `isAllowedRedirect`;
|
|
22
|
+
* - one deadline for the whole call and a cap on the response body.
|
|
23
|
+
*
|
|
24
|
+
* @param {import('./requester').Requester} requester
|
|
25
|
+
* @param {Object} params
|
|
26
|
+
* @param {string} params.method - upper case
|
|
27
|
+
* @param {string} params.path - caller path, validated here
|
|
28
|
+
* @param {Object} [params.query]
|
|
29
|
+
* @param {Object} [params.headers] - already filtered by the caller's policy
|
|
30
|
+
* @param {string|Buffer} [params.body] - already serialised
|
|
31
|
+
* @param {string} [params.baseUrl] - the module-declared URL of the base the
|
|
32
|
+
* caller named (ADR-052 amendment; resolved by the use case); falls
|
|
33
|
+
* back to the requester's `proxyBaseUrl`, `baseUrl` or `baseURL`
|
|
34
|
+
* @param {number} params.timeoutMs - budget for the whole call
|
|
35
|
+
* @param {number} params.maxResponseBytes
|
|
36
|
+
* @param {(path: string, method: string) => boolean} [params.isAllowedRedirect]
|
|
37
|
+
* @returns {Promise<{ status: number, headers: Object, body: Buffer, contentType: string, redirectBlocked: boolean }>}
|
|
38
|
+
* @throws {ProxyRequestError} INVALID_PROXY_REQUEST, PROXY_NOT_CONFIGURED,
|
|
39
|
+
* INVALID_CREDENTIALS, TIMEOUT, NETWORK_ERROR, RESPONSE_TOO_LARGE
|
|
40
|
+
*/
|
|
41
|
+
async function proxyRequest(requester, params) {
|
|
42
|
+
const {
|
|
43
|
+
method,
|
|
44
|
+
path,
|
|
45
|
+
query,
|
|
46
|
+
headers = {},
|
|
47
|
+
body,
|
|
48
|
+
baseUrl,
|
|
49
|
+
timeoutMs,
|
|
50
|
+
maxResponseBytes,
|
|
51
|
+
isAllowedRedirect,
|
|
52
|
+
} = params;
|
|
53
|
+
|
|
54
|
+
const normalizedPath = normalizeProxyPath(path);
|
|
55
|
+
const base =
|
|
56
|
+
baseUrl ??
|
|
57
|
+
requester.proxyBaseUrl ??
|
|
58
|
+
requester.baseUrl ??
|
|
59
|
+
requester.baseURL;
|
|
60
|
+
if (!base) {
|
|
61
|
+
throw new ProxyRequestError(
|
|
62
|
+
'PROXY_NOT_CONFIGURED',
|
|
63
|
+
'The module declares no base URL to proxy to'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const target = joinProxyUrl(base, normalizedPath);
|
|
67
|
+
const deadline = Date.now() + timeoutMs;
|
|
68
|
+
|
|
69
|
+
const run = async () => {
|
|
70
|
+
let url = target.url;
|
|
71
|
+
let currentMethod = method;
|
|
72
|
+
let currentBody = body;
|
|
73
|
+
let currentQuery = query;
|
|
74
|
+
for (let hop = 0; ; hop++) {
|
|
75
|
+
const remaining = deadline - Date.now();
|
|
76
|
+
if (remaining <= 0) throw timeoutError();
|
|
77
|
+
|
|
78
|
+
const response = await requester._request(url, {
|
|
79
|
+
method: currentMethod,
|
|
80
|
+
headers: { ...headers },
|
|
81
|
+
query: currentQuery,
|
|
82
|
+
body: currentBody,
|
|
83
|
+
returnFullRes: true,
|
|
84
|
+
returnErrorResponses: true,
|
|
85
|
+
noRetry: true,
|
|
86
|
+
redirect: 'manual',
|
|
87
|
+
size: maxResponseBytes,
|
|
88
|
+
timeoutMs: remaining,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const location = response.headers.get('location');
|
|
92
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
93
|
+
const next = sameApiRedirect(location, url, target);
|
|
94
|
+
const nextMethod = redirectMethod(response.status, currentMethod);
|
|
95
|
+
if (
|
|
96
|
+
next &&
|
|
97
|
+
hop < MAX_REDIRECTS &&
|
|
98
|
+
(!isAllowedRedirect || isAllowedRedirect(next.path, nextMethod))
|
|
99
|
+
) {
|
|
100
|
+
if (nextMethod !== currentMethod) currentBody = undefined;
|
|
101
|
+
currentMethod = nextMethod;
|
|
102
|
+
url = next.url;
|
|
103
|
+
currentQuery = undefined;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
return readResponse(response, maxResponseBytes, {
|
|
107
|
+
redirectBlocked: !next,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return readResponse(response, maxResponseBytes, {
|
|
111
|
+
redirectBlocked: false,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let timer;
|
|
117
|
+
const timeout = new Promise((_, reject) => {
|
|
118
|
+
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
119
|
+
});
|
|
120
|
+
const work = run();
|
|
121
|
+
// If the deadline wins, the request still settles later; nothing awaits it.
|
|
122
|
+
work.catch(() => {});
|
|
123
|
+
try {
|
|
124
|
+
return await Promise.race([work, timeout]);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw toProxyError(error);
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function timeoutError() {
|
|
133
|
+
return new ProxyRequestError('TIMEOUT', 'The upstream API did not answer in time');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A redirect target the proxy may follow: same origin as the module base URL
|
|
138
|
+
* and under its base path. Returns null for anything else.
|
|
139
|
+
*/
|
|
140
|
+
function sameApiRedirect(location, currentUrl, target) {
|
|
141
|
+
let next;
|
|
142
|
+
try {
|
|
143
|
+
next = new URL(location, currentUrl);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (next.origin !== target.origin || next.protocol !== 'https:') return null;
|
|
148
|
+
let decodedPath;
|
|
149
|
+
try {
|
|
150
|
+
decodedPath = decodeURIComponent(next.pathname);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
!(decodedPath === target.basePath || decodedPath.startsWith(`${target.basePath}/`))
|
|
156
|
+
) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const relativePath = decodedPath.slice(target.basePath.length) || '/';
|
|
160
|
+
let checkedPath;
|
|
161
|
+
try {
|
|
162
|
+
checkedPath = normalizeProxyPath(relativePath);
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
// _rawRequest percent-encodes the URL once, so hand it the decoded form.
|
|
167
|
+
return {
|
|
168
|
+
url: `${target.origin}${target.basePath}${checkedPath}${next.search ? decodeURI(next.search) : ''}`,
|
|
169
|
+
path: checkedPath,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Method for the next hop, as fetch does it. */
|
|
174
|
+
function redirectMethod(status, method) {
|
|
175
|
+
if (status === 303 && method !== 'HEAD') return 'GET';
|
|
176
|
+
if ((status === 301 || status === 302) && method === 'POST') return 'GET';
|
|
177
|
+
return method;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function readResponse(response, maxResponseBytes, { redirectBlocked }) {
|
|
181
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
182
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) {
|
|
183
|
+
destroyBody(response);
|
|
184
|
+
throw tooLarge();
|
|
185
|
+
}
|
|
186
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
187
|
+
if (buffer.length > maxResponseBytes) throw tooLarge();
|
|
188
|
+
const headers = {};
|
|
189
|
+
response.headers.forEach((value, name) => {
|
|
190
|
+
headers[name.toLowerCase()] = value;
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
status: response.status,
|
|
194
|
+
headers,
|
|
195
|
+
body: buffer,
|
|
196
|
+
contentType: headers['content-type'] || '',
|
|
197
|
+
redirectBlocked,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function destroyBody(response) {
|
|
202
|
+
try {
|
|
203
|
+
response.body?.destroy?.();
|
|
204
|
+
} catch {
|
|
205
|
+
// nothing to clean up
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function tooLarge() {
|
|
210
|
+
return new ProxyRequestError(
|
|
211
|
+
'RESPONSE_TOO_LARGE',
|
|
212
|
+
'The upstream response is larger than the proxy allows'
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Maps requester and fetch failures to the proxy's error codes. */
|
|
217
|
+
function toProxyError(error) {
|
|
218
|
+
if (error instanceof ProxyRequestError) return error;
|
|
219
|
+
const causeType = error?.cause?.type ?? error?.type;
|
|
220
|
+
if (causeType === 'max-size') return tooLarge();
|
|
221
|
+
if (error?.isTimeout) return timeoutError();
|
|
222
|
+
if (error instanceof FetchError || error?.name === 'FetchError') {
|
|
223
|
+
if (error.statusCode === 401) {
|
|
224
|
+
return new ProxyRequestError(
|
|
225
|
+
'INVALID_CREDENTIALS',
|
|
226
|
+
'The upstream API rejected the stored credentials; re-authorize the entity',
|
|
227
|
+
{ upstreamStatus: 401 }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (!error.statusCode) {
|
|
231
|
+
return new ProxyRequestError(
|
|
232
|
+
'NETWORK_ERROR',
|
|
233
|
+
'Could not reach the upstream API'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return new ProxyRequestError(
|
|
237
|
+
'UPSTREAM_ERROR',
|
|
238
|
+
'The upstream API returned an error',
|
|
239
|
+
{ upstreamStatus: error.statusCode }
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return error;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { proxyRequest, MAX_REDIRECTS };
|
|
@@ -8,6 +8,7 @@ const { getLogger } = require('../../logs');
|
|
|
8
8
|
const { redactUrl } = require('../../logs/redact');
|
|
9
9
|
const { toSanitizedSurrogate } = require('../../logs/serialize');
|
|
10
10
|
const { getLoggerScope } = require('../../logs/context');
|
|
11
|
+
const { proxyRequest } = require('./proxy-request');
|
|
11
12
|
|
|
12
13
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
13
14
|
|
|
@@ -218,9 +219,14 @@ class Requester extends Delegate {
|
|
|
218
219
|
if (options.query) {
|
|
219
220
|
let queryBuild = '?';
|
|
220
221
|
for (const key in options.query) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
)
|
|
222
|
+
const value = options.query[key];
|
|
223
|
+
// `fields[]: ['a', 'b']` -> `fields%5B%5D=a&fields%5B%5D=b`.
|
|
224
|
+
// Any other array keeps String(value), i.e. comma-joined.
|
|
225
|
+
const values =
|
|
226
|
+
Array.isArray(value) && key.endsWith('[]') ? value : [value];
|
|
227
|
+
for (const v of values) {
|
|
228
|
+
queryBuild += `${encodeURIComponent(key)}=${encodeURIComponent(v)}&`;
|
|
229
|
+
}
|
|
224
230
|
}
|
|
225
231
|
encodedUrl += queryBuild.slice(0, -1);
|
|
226
232
|
}
|
|
@@ -237,7 +243,9 @@ class Requester extends Delegate {
|
|
|
237
243
|
// recursion (with its own backoff sleeps) always gets a clean
|
|
238
244
|
// signal. Timer is cleared in the finally block regardless of
|
|
239
245
|
// outcome.
|
|
240
|
-
|
|
246
|
+
// options.timeoutMs lets one call use a tighter budget (the
|
|
247
|
+
// Management API proxy passes what is left of its deadline).
|
|
248
|
+
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
|
|
241
249
|
const controller = timeoutMs > 0 ? new AbortController() : null;
|
|
242
250
|
const timeoutHandle = controller
|
|
243
251
|
? setTimeout(() => controller.abort(), timeoutMs)
|
|
@@ -275,7 +283,11 @@ class Requester extends Delegate {
|
|
|
275
283
|
// stall at batch scale.
|
|
276
284
|
const isTimeout =
|
|
277
285
|
e?.name === 'AbortError' || e?.type === 'aborted';
|
|
278
|
-
if (
|
|
286
|
+
if (
|
|
287
|
+
e?.code === 'ECONNRESET' &&
|
|
288
|
+
!options.noRetry &&
|
|
289
|
+
attempt < this.backOff.length
|
|
290
|
+
) {
|
|
279
291
|
clearRequestTimer();
|
|
280
292
|
const delay = this.backOff[attempt] * 1000;
|
|
281
293
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
@@ -301,6 +313,7 @@ class Requester extends Delegate {
|
|
|
301
313
|
// If the status is retriable and there are back off requests left, retry the request
|
|
302
314
|
if (
|
|
303
315
|
(status === 429 || status >= 500) &&
|
|
316
|
+
!options.noRetry &&
|
|
304
317
|
attempt < this.backOff.length
|
|
305
318
|
) {
|
|
306
319
|
clearRequestTimer();
|
|
@@ -386,6 +399,12 @@ class Requester extends Delegate {
|
|
|
386
399
|
// If the error wasn't retried, throw. FetchError.create reads
|
|
387
400
|
// the response body (response.text()) — timer must still be
|
|
388
401
|
// alive to catch a stalled body stream.
|
|
402
|
+
// The proxy relays upstream errors to its caller instead of
|
|
403
|
+
// throwing; 401 keeps the refresh / invalidate handling above.
|
|
404
|
+
if (status >= 400 && options.returnErrorResponses) {
|
|
405
|
+
return response;
|
|
406
|
+
}
|
|
407
|
+
|
|
389
408
|
if (status >= 400) {
|
|
390
409
|
this._logRequestFailed(encodedUrl, options, status);
|
|
391
410
|
const fetchError = await FetchError.create({
|
|
@@ -528,6 +547,17 @@ class Requester extends Delegate {
|
|
|
528
547
|
return res;
|
|
529
548
|
}
|
|
530
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Generic, path-checked request for the Management API entity proxy
|
|
552
|
+
* (ADR-052 §8). The URL is always the module base URL plus a validated
|
|
553
|
+
* path; see ./proxy-request.js. Module methods are not involved.
|
|
554
|
+
*
|
|
555
|
+
* @param {Object} params - see proxyRequest()
|
|
556
|
+
*/
|
|
557
|
+
async _proxyRequest(params) {
|
|
558
|
+
return proxyRequest(this, params);
|
|
559
|
+
}
|
|
560
|
+
|
|
531
561
|
async _delete(options) {
|
|
532
562
|
const fetchOptions = {
|
|
533
563
|
method: 'DELETE',
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and validating ownership settings in an app definition (ADR-056
|
|
3
|
+
* §3.1, §3.3). Called once by loadAppDefinition, so a bad setting stops the app
|
|
4
|
+
* at load, not on the first request. No I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
SCOPES,
|
|
9
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
10
|
+
OWNER_RULES,
|
|
11
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
12
|
+
configError,
|
|
13
|
+
isPlainObject,
|
|
14
|
+
parseScopeSetting,
|
|
15
|
+
resolveScope,
|
|
16
|
+
} = require('./scope-resolution');
|
|
17
|
+
const { entityTypeNameOf } = require('./entity-type-registry');
|
|
18
|
+
|
|
19
|
+
/** The binding scope an integration declares for `modules[key]`, if any. */
|
|
20
|
+
function bindingScopeSetting(Definition, key) {
|
|
21
|
+
const binding = Definition?.modules?.[key];
|
|
22
|
+
if (binding?.scope !== undefined) return binding.scope;
|
|
23
|
+
const entityEntry = Definition?.entities?.[key];
|
|
24
|
+
if (entityEntry?.scope !== undefined) return entityEntry.scope;
|
|
25
|
+
// Deprecated alias from the global-entities branch: `global: true`.
|
|
26
|
+
if (entityEntry?.global === true) return SCOPES.GLOBAL;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readAppOwnership(appDefinition) {
|
|
31
|
+
const raw = appDefinition?.ownership;
|
|
32
|
+
if (raw === undefined || raw === null) {
|
|
33
|
+
return { scope: null, individualAcross: 'organization' };
|
|
34
|
+
}
|
|
35
|
+
if (!isPlainObject(raw)) {
|
|
36
|
+
throw configError('appDefinition.ownership', 'must be an object');
|
|
37
|
+
}
|
|
38
|
+
const unknown = Object.keys(raw).filter(
|
|
39
|
+
(key) => key !== 'scope' && key !== 'individualAcross'
|
|
40
|
+
);
|
|
41
|
+
if (unknown.length) {
|
|
42
|
+
throw configError(
|
|
43
|
+
'appDefinition.ownership',
|
|
44
|
+
`unknown setting(s): ${unknown.join(', ')}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const individualAcross = raw.individualAcross ?? 'organization';
|
|
48
|
+
if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
|
|
49
|
+
throw configError(
|
|
50
|
+
'appDefinition.ownership.individualAcross',
|
|
51
|
+
`must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
scope: parseScopeSetting(raw.scope, 'appDefinition.ownership.scope'),
|
|
56
|
+
individualAcross,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readIntegrationOwnership(integrationClass) {
|
|
61
|
+
const name = integrationClass?.Definition?.name || 'unnamed';
|
|
62
|
+
const raw = integrationClass?.Definition?.ownership;
|
|
63
|
+
if (raw === undefined || raw === null) return { scope: null, owner: 'derive' };
|
|
64
|
+
const where = `integration '${name}' ownership`;
|
|
65
|
+
if (!isPlainObject(raw)) throw configError(where, 'must be an object');
|
|
66
|
+
const unknown = Object.keys(raw).filter(
|
|
67
|
+
(key) => key !== 'scope' && key !== 'owner'
|
|
68
|
+
);
|
|
69
|
+
if (unknown.length) {
|
|
70
|
+
throw configError(where, `unknown setting(s): ${unknown.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
const owner = raw.owner ?? 'derive';
|
|
73
|
+
if (!OWNER_RULES.includes(owner)) {
|
|
74
|
+
throw configError(where, `owner must be one of ${OWNER_RULES.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `user.orgAdminRoles`: the token roles that may manage organization-level
|
|
81
|
+
* things. Default `['ADMIN']`. A shared-secret caller needs no role.
|
|
82
|
+
*/
|
|
83
|
+
function readOrgAdminRoles(appDefinition) {
|
|
84
|
+
const raw = appDefinition?.user?.orgAdminRoles;
|
|
85
|
+
if (raw === undefined || raw === null) return [...DEFAULT_ORG_ADMIN_ROLES];
|
|
86
|
+
if (
|
|
87
|
+
!Array.isArray(raw) ||
|
|
88
|
+
raw.length === 0 ||
|
|
89
|
+
raw.some((role) => typeof role !== 'string' || role.trim() === '')
|
|
90
|
+
) {
|
|
91
|
+
throw configError(
|
|
92
|
+
'appDefinition.user.orgAdminRoles',
|
|
93
|
+
'must be a non-empty array of role names'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return [...raw];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `user.orgAdminGateBearerTokens`: judge Frigg bearer-token callers by role
|
|
101
|
+
* like adopter-token callers. Default false: they keep their pre-roles
|
|
102
|
+
* behaviour and may manage the organization they act in.
|
|
103
|
+
*/
|
|
104
|
+
function readGateBearerTokens(appDefinition) {
|
|
105
|
+
const raw = appDefinition?.user?.orgAdminGateBearerTokens;
|
|
106
|
+
if (raw === undefined || raw === null) return false;
|
|
107
|
+
if (typeof raw !== 'boolean') {
|
|
108
|
+
throw configError('appDefinition.user.orgAdminGateBearerTokens', 'must be a boolean');
|
|
109
|
+
}
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every binding of every integration with its resolved scope and where it came
|
|
115
|
+
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
116
|
+
* for any invalid one. Exported for tooling that wants to show the chain.
|
|
117
|
+
*/
|
|
118
|
+
function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
119
|
+
const app = readAppOwnership(appDefinition);
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (const integrationClass of appDefinition?.integrations || []) {
|
|
122
|
+
const Definition = integrationClass?.Definition;
|
|
123
|
+
const name = Definition?.name || 'unnamed';
|
|
124
|
+
const ownership = readIntegrationOwnership(integrationClass);
|
|
125
|
+
for (const [key, binding] of Object.entries(Definition?.modules || {})) {
|
|
126
|
+
const moduleName = entityTypeNameOf(binding?.definition);
|
|
127
|
+
const resolved = resolveScope({
|
|
128
|
+
binding: parseScopeSetting(
|
|
129
|
+
bindingScopeSetting(Definition, key),
|
|
130
|
+
`integration '${name}' binding '${key}' scope`
|
|
131
|
+
),
|
|
132
|
+
integration: ownership.scope,
|
|
133
|
+
entityType: moduleName
|
|
134
|
+
? entityTypeRegistry?.get(moduleName)?.settings?.scope
|
|
135
|
+
: undefined,
|
|
136
|
+
app: app.scope,
|
|
137
|
+
primary,
|
|
138
|
+
});
|
|
139
|
+
rows.push({
|
|
140
|
+
integration: name,
|
|
141
|
+
binding: key,
|
|
142
|
+
moduleName: moduleName ?? null,
|
|
143
|
+
scope: resolved.scope,
|
|
144
|
+
source: resolved.source,
|
|
145
|
+
owner: ownership.owner,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return rows;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validates every ownership setting in one pass and returns the app-level
|
|
154
|
+
* config the runtime needs.
|
|
155
|
+
*
|
|
156
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
|
|
157
|
+
* @throws {OwnershipConfigError}
|
|
158
|
+
*/
|
|
159
|
+
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
160
|
+
const app = readAppOwnership(appDefinition);
|
|
161
|
+
for (const [name, entry] of entityTypeRegistry || []) {
|
|
162
|
+
parseScopeSetting(entry?.settings?.scope, `entity type '${name}' scope`);
|
|
163
|
+
}
|
|
164
|
+
const rows = listBindingScopes(
|
|
165
|
+
appDefinition,
|
|
166
|
+
entityTypeRegistry,
|
|
167
|
+
appDefinition?.user?.primary
|
|
168
|
+
);
|
|
169
|
+
// §3.3: a shared integration must not run on one person's credential.
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (row.owner === 'organization' && row.scope === SCOPES.INDIVIDUAL) {
|
|
172
|
+
throw configError(
|
|
173
|
+
`integration '${row.integration}' binding '${row.binding}'`,
|
|
174
|
+
"an organization-owned integration cannot bind an 'individual' entity"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
scope: app.scope,
|
|
180
|
+
individualAcross: app.individualAcross,
|
|
181
|
+
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
182
|
+
gateBearerTokens: readGateBearerTokens(appDefinition),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
bindingScopeSetting,
|
|
188
|
+
readAppOwnership,
|
|
189
|
+
readIntegrationOwnership,
|
|
190
|
+
listBindingScopes,
|
|
191
|
+
resolveOwnershipConfig,
|
|
192
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The object a use case asks about scopes (ADR-056 §3): the app's ownership
|
|
3
|
+
* config and entity type registry bound once, as closures. With no arguments it
|
|
4
|
+
* is the behaviour of an app that configures nothing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
9
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
10
|
+
configError,
|
|
11
|
+
parseScopeSetting,
|
|
12
|
+
resolveScope,
|
|
13
|
+
resolveOwnerForScope,
|
|
14
|
+
} = require('./scope-resolution');
|
|
15
|
+
const {
|
|
16
|
+
accessContext,
|
|
17
|
+
accessToOwner,
|
|
18
|
+
describeRecord,
|
|
19
|
+
} = require('./scope-visibility');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} [params]
|
|
23
|
+
* @param {{scope: *, individualAcross: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [params.ownership] - the raw
|
|
24
|
+
* `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
|
|
25
|
+
* `{ default, allow }` parses to itself)
|
|
26
|
+
* @param {Map} [params.entityTypeRegistry]
|
|
27
|
+
*/
|
|
28
|
+
function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
29
|
+
const appScope = parseScopeSetting(
|
|
30
|
+
ownership?.scope,
|
|
31
|
+
'appDefinition.ownership.scope'
|
|
32
|
+
);
|
|
33
|
+
const individualAcross = ownership?.individualAcross ?? 'organization';
|
|
34
|
+
if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
|
|
35
|
+
throw configError(
|
|
36
|
+
'appDefinition.ownership.individualAcross',
|
|
37
|
+
`must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
|
|
42
|
+
const gateBearerTokens = ownership?.gateBearerTokens === true;
|
|
43
|
+
|
|
44
|
+
const contextFor = (user) =>
|
|
45
|
+
accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
|
|
46
|
+
|
|
47
|
+
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
48
|
+
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
49
|
+
resolveScope({
|
|
50
|
+
request: requestScope,
|
|
51
|
+
binding,
|
|
52
|
+
integration,
|
|
53
|
+
entityType: entityTypeRegistry?.get?.(entityType)?.settings?.scope,
|
|
54
|
+
app: appScope,
|
|
55
|
+
primary: user?.config?.primary,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scope, then owner and organization, for a new entity. `legacyOrgId` (the
|
|
60
|
+
* person's legacy organization pointer) rides along so a later lookup can
|
|
61
|
+
* reuse an entity written before scoping.
|
|
62
|
+
*/
|
|
63
|
+
const resolveOwnership = ({ entityType, requestScope, user }) => {
|
|
64
|
+
const ctx = contextFor(user);
|
|
65
|
+
const resolved = resolveEntityScope({ entityType, requestScope, user });
|
|
66
|
+
return {
|
|
67
|
+
...resolveOwnerForScope(resolved.scope, ctx),
|
|
68
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
69
|
+
source: resolved.source,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The owner and organization for a scope that is already decided: a
|
|
75
|
+
* re-authorization keeps the scope its connection has, whatever the config
|
|
76
|
+
* says now. The admin gate applies as it does on create.
|
|
77
|
+
*/
|
|
78
|
+
const ownershipForScope = ({ scope, user }) => {
|
|
79
|
+
const ctx = contextFor(user);
|
|
80
|
+
return {
|
|
81
|
+
...resolveOwnerForScope(scope, ctx),
|
|
82
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
83
|
+
source: 'fixed',
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
88
|
+
const canUseEntity = (user, entity) =>
|
|
89
|
+
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `user` may do with a record owned by `ownerId` that has no
|
|
93
|
+
* organization of its own (a credential): manage, or null when not theirs.
|
|
94
|
+
*/
|
|
95
|
+
const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
|
|
96
|
+
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
individualAcross,
|
|
99
|
+
orgAdminRoles,
|
|
100
|
+
accessToOwnedBy,
|
|
101
|
+
accessContext: contextFor,
|
|
102
|
+
resolveScope: resolveEntityScope,
|
|
103
|
+
resolveOwnership,
|
|
104
|
+
ownershipForScope,
|
|
105
|
+
canUseEntity,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The policy of an app that configures nothing; what a use case gets by default. */
|
|
110
|
+
const DEFAULT_SCOPE_POLICY = createScopePolicy();
|
|
111
|
+
|
|
112
|
+
module.exports = { createScopePolicy, DEFAULT_SCOPE_POLICY };
|