@friggframework/core 2.0.0--canary.707.df52379.0 → 2.0.0--canary.686.20eda6e.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 +5 -0
- package/extensions/extension-loader.js +9 -52
- package/extensions/index.js +0 -3
- package/handlers/app-definition-loader.js +1 -8
- package/index.js +0 -1
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +9 -5
- package/management-api/openapi/components.js +3 -10
- package/management-api/openapi/v2-operations.js +2 -2
- package/modules/proxy/proxy-errors.js +1 -12
- package/modules/proxy/proxy-path.js +3 -7
- package/modules/proxy/proxy-policy.js +5 -34
- package/modules/requester/proxy-request.js +1 -2
- package/modules/requester/requester.js +3 -8
- package/modules/use-cases/execute-entity-proxy-request.js +12 -57
- package/package.json +5 -5
- package/queues/providers/netlify-background-provider.js +39 -2
- package/types/modules/index.d.ts +1 -18
- package/extensions/prisma-composer.js +0 -270
- package/modules/proxy/proxy-bases.js +0 -169
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Unreleased
|
|
2
2
|
|
|
3
|
+
#### Netlify queue targets are allow-listed
|
|
4
|
+
|
|
5
|
+
- `NetlifyBackgroundProvider.send()` now accepts only a background function path on the site (`/.netlify/functions/<name>-background`) and an absolute http(s) site URL without credentials. Anything else (`//host/x`, an absolute URL, `..`, a query, an empty `URL`) throws before any request. Previously the queue id was appended to the site URL as given, so an empty site URL and a stored `//host/x` sent a signed message to another host.
|
|
6
|
+
- `NetlifySchedulerAdapter` maps any queue URL that is not such a path to the worker function, as it already did for SQS URLs.
|
|
7
|
+
|
|
3
8
|
#### Behaviour change: module credential fields are now encrypted
|
|
4
9
|
|
|
5
10
|
- `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059).
|
|
@@ -17,28 +17,6 @@ const {
|
|
|
17
17
|
coreExtensionRoutePathError,
|
|
18
18
|
} = require('../management-api/route-registry');
|
|
19
19
|
|
|
20
|
-
const SCHEMA_DB_TYPES = ['postgresql', 'mongodb'];
|
|
21
|
-
|
|
22
|
-
function schemaErrors(schema, label) {
|
|
23
|
-
if (typeof schema === 'string') {
|
|
24
|
-
return schema.endsWith('.prisma') ? [] : [`${label}: must end with .prisma`];
|
|
25
|
-
}
|
|
26
|
-
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
27
|
-
return [`${label}: must be a string (path to .prisma file) or { postgresql, mongodb } paths`];
|
|
28
|
-
}
|
|
29
|
-
const errors = [];
|
|
30
|
-
const keys = Object.keys(schema);
|
|
31
|
-
if (keys.length === 0) errors.push(`${label}: names no database type`);
|
|
32
|
-
for (const key of keys) {
|
|
33
|
-
if (!SCHEMA_DB_TYPES.includes(key)) {
|
|
34
|
-
errors.push(`${label}.${key}: unknown database type (expected ${SCHEMA_DB_TYPES.join(' or ')})`);
|
|
35
|
-
} else if (typeof schema[key] !== 'string' || !schema[key].endsWith('.prisma')) {
|
|
36
|
-
errors.push(`${label}.${key}: must be a path ending in .prisma`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return errors;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
20
|
/**
|
|
43
21
|
* Validates a single extension definition.
|
|
44
22
|
* @param {Object} ext - Raw extension config from app definition
|
|
@@ -58,18 +36,13 @@ function validateExtension(ext, index) {
|
|
|
58
36
|
errors.push(`${prefix}.name: required string`);
|
|
59
37
|
}
|
|
60
38
|
|
|
61
|
-
// schema is optional
|
|
62
|
-
// database type ({ postgresql, mongodb }), since a model written for one
|
|
63
|
-
// (an autoincrement Int id, a String[] column) does not compile for the other.
|
|
39
|
+
// schema is optional — path to a .prisma file
|
|
64
40
|
if (ext.schema !== undefined) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// They are applied with the core migrations. MongoDB has no migrations.
|
|
71
|
-
if (ext.migrations !== undefined && (typeof ext.migrations !== 'string' || ext.migrations === '')) {
|
|
72
|
-
errors.push(`${prefix}.migrations: must be a string (path to a migrations directory)`);
|
|
41
|
+
if (typeof ext.schema !== 'string') {
|
|
42
|
+
errors.push(`${prefix}.schema: must be a string (path to .prisma file)`);
|
|
43
|
+
} else if (!ext.schema.endsWith('.prisma')) {
|
|
44
|
+
errors.push(`${prefix}.schema: must end with .prisma`);
|
|
45
|
+
}
|
|
73
46
|
}
|
|
74
47
|
|
|
75
48
|
// encryption is optional — object with model names → { fields: [...] }
|
|
@@ -179,7 +152,6 @@ function loadExtensions(appDefinition) {
|
|
|
179
152
|
return {
|
|
180
153
|
name: ext.name,
|
|
181
154
|
schema: ext.schema || null,
|
|
182
|
-
migrations: ext.migrations || null,
|
|
183
155
|
encryption: ext.encryption || null,
|
|
184
156
|
routes: ext.routes
|
|
185
157
|
? { path: ext.routes.path, handler: routeHandler }
|
|
@@ -197,34 +169,19 @@ function loadExtensions(appDefinition) {
|
|
|
197
169
|
return normalized.filter(Boolean);
|
|
198
170
|
}
|
|
199
171
|
|
|
200
|
-
/**
|
|
201
|
-
* The .prisma file an extension adds for one database type.
|
|
202
|
-
* @param {Object} ext - Normalized extension
|
|
203
|
-
* @param {'postgresql'|'mongodb'} [dbType] - Omitted: a string schema only
|
|
204
|
-
* @returns {string|null}
|
|
205
|
-
*/
|
|
206
|
-
function extensionSchemaPath(ext, dbType) {
|
|
207
|
-
if (!ext?.schema) return null;
|
|
208
|
-
if (typeof ext.schema === 'string') return ext.schema;
|
|
209
|
-
return dbType ? ext.schema[dbType] || null : null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
172
|
/**
|
|
213
173
|
* Get extension schema file paths from loaded extensions.
|
|
214
174
|
* @param {Array<Object>} extensions - Normalized extensions
|
|
215
|
-
* @param {'postgresql'|'mongodb'} [dbType] - Database the paths are for. A
|
|
216
|
-
* per-database schema ({ postgresql, mongodb }) only answers when given one.
|
|
217
175
|
* @returns {string[]} Array of absolute paths to .prisma schema files
|
|
218
176
|
*/
|
|
219
|
-
function getExtensionSchemaPaths(extensions
|
|
177
|
+
function getExtensionSchemaPaths(extensions) {
|
|
220
178
|
return extensions
|
|
221
|
-
.
|
|
222
|
-
.
|
|
179
|
+
.filter((ext) => ext.schema)
|
|
180
|
+
.map((ext) => ext.schema);
|
|
223
181
|
}
|
|
224
182
|
|
|
225
183
|
module.exports = {
|
|
226
184
|
loadExtensions,
|
|
227
185
|
validateExtension,
|
|
228
186
|
getExtensionSchemaPaths,
|
|
229
|
-
extensionSchemaPath,
|
|
230
187
|
};
|
package/extensions/index.js
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
|
|
13
13
|
const { loadExtensions, validateExtension, getExtensionSchemaPaths } = require('./extension-loader');
|
|
14
14
|
const { composeSchemas, extractModelBlocks, getSchemaFilePaths } = require('./schema-composer');
|
|
15
|
-
const { composeExtensionPrisma, resolvePrismaDbTypes } = require('./prisma-composer');
|
|
16
15
|
const {
|
|
17
16
|
mountExtensionRoutes,
|
|
18
17
|
createExtensionRoutesRouter,
|
|
@@ -30,8 +29,6 @@ module.exports = {
|
|
|
30
29
|
composeSchemas,
|
|
31
30
|
extractModelBlocks,
|
|
32
31
|
getSchemaFilePaths,
|
|
33
|
-
composeExtensionPrisma,
|
|
34
|
-
resolvePrismaDbTypes,
|
|
35
32
|
|
|
36
33
|
// Route mounting
|
|
37
34
|
mountExtensionRoutes,
|
|
@@ -12,7 +12,6 @@ const {
|
|
|
12
12
|
getEntityTypeDefinitions,
|
|
13
13
|
} = require('../modules/entity-type-registry');
|
|
14
14
|
const { resolveOwnershipConfig } = require('../modules/entity-scope');
|
|
15
|
-
const { assertProxyConfig } = require('../modules/proxy/proxy-bases');
|
|
16
15
|
const {
|
|
17
16
|
resolveManagementApiConfig,
|
|
18
17
|
} = require('../management-api/management-api-config');
|
|
@@ -109,7 +108,6 @@ function discoverAppDefinition() {
|
|
|
109
108
|
* @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
|
|
110
109
|
* @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1).
|
|
111
110
|
* @throws {ManagementApiConfigError} Throws if `managementApi.proxy.scopes` is invalid (ADR-056 §3.5).
|
|
112
|
-
* @throws {ProxyConfigError} Throws if a module's `proxy.baseUrls` or an allow rule's `base` is invalid (ADR-052).
|
|
113
111
|
* @throws {ScheduleConfigError} Throws if an integration's `Definition.schedule` is invalid.
|
|
114
112
|
* @example
|
|
115
113
|
* const { integrations, userConfig, telemetry } = loadAppDefinition();
|
|
@@ -150,12 +148,7 @@ function resolveAppDefinition(appDefinition) {
|
|
|
150
148
|
// And for the Management API: `managementApi.proxy.scopes` decides whose
|
|
151
149
|
// credentials the entity proxy may act through (ADR-056 §3.5), so an
|
|
152
150
|
// unknown scope stops the app here rather than being read as a default.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// Named proxy bases (ADR-052 amendment): a module that declares both
|
|
156
|
-
// `baseUrl` and `baseUrls`, a bad base URL or name, or an allow rule for a
|
|
157
|
-
// base the module lacks, stops the app here.
|
|
158
|
-
assertProxyConfig(moduleDefinitions, managementApiConfig.proxy);
|
|
151
|
+
resolveManagementApiConfig(managementApi);
|
|
159
152
|
|
|
160
153
|
// And for each integration's `Definition.schedule`: a mistyped scope would
|
|
161
154
|
// otherwise tick ONGOING_SYNC per class when per installation was meant.
|
package/index.js
CHANGED
|
@@ -268,7 +268,6 @@ module.exports = {
|
|
|
268
268
|
extensions,
|
|
269
269
|
loadExtensions: extensions.loadExtensions,
|
|
270
270
|
composeSchemas: extensions.composeSchemas,
|
|
271
|
-
composeExtensionPrisma: extensions.composeExtensionPrisma,
|
|
272
271
|
mountExtensionRoutes: extensions.mountExtensionRoutes,
|
|
273
272
|
createExtensionRoutesRouter: extensions.createExtensionRoutesRouter,
|
|
274
273
|
runExtensionBootstraps: extensions.runExtensionBootstraps,
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
const { SchedulerServiceInterface } = require('./scheduler-service-interface');
|
|
26
|
+
const {
|
|
27
|
+
isBackgroundFunctionPath,
|
|
28
|
+
} = require('../../queues/providers/netlify-background-provider');
|
|
26
29
|
|
|
27
30
|
// The one queue worker every Netlify deployment has (provider-netlify's
|
|
28
31
|
// worker-background function); it dispatches by integrationName.
|
|
@@ -58,16 +61,17 @@ class NetlifySchedulerAdapter extends SchedulerServiceInterface {
|
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
63
|
* Where a due job is sent. Netlify has no per-integration queues, so an
|
|
61
|
-
* SQS-style queue URL (often unset here) maps to the worker function
|
|
62
|
-
*
|
|
64
|
+
* SQS-style queue URL (often unset here) maps to the worker function.
|
|
65
|
+
* A background function path ('/.netlify/functions/<name>-background')
|
|
66
|
+
* is kept; any other value, including '//host/x' or a path with '..',
|
|
67
|
+
* maps to the worker too, so a stored job can only ever reach a
|
|
68
|
+
* background function on this site.
|
|
63
69
|
*
|
|
64
70
|
* @param {string} [queueUrl]
|
|
65
71
|
* @returns {string} a path NetlifyBackgroundProvider.send() accepts
|
|
66
72
|
*/
|
|
67
73
|
toQueueResourceId(queueUrl) {
|
|
68
|
-
return
|
|
69
|
-
? queueUrl
|
|
70
|
-
: DEFAULT_WORKER_PATH;
|
|
74
|
+
return isBackgroundFunctionPath(queueUrl) ? queueUrl : DEFAULT_WORKER_PATH;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
async scheduleOneTime({
|
|
@@ -40,7 +40,7 @@ const errorBody = {
|
|
|
40
40
|
code: {
|
|
41
41
|
type: 'string',
|
|
42
42
|
description:
|
|
43
|
-
'Machine-readable code, e.g. VALIDATION_ERROR, NOT_FOUND, ENTITY_NOT_FOUND, AUTHORIZATION_SESSION_NOT_FOUND, PROXY_NOT_ALLOWED
|
|
43
|
+
'Machine-readable code, e.g. VALIDATION_ERROR, NOT_FOUND, ENTITY_NOT_FOUND, AUTHORIZATION_SESSION_NOT_FOUND, PROXY_NOT_ALLOWED. New codes may be added.',
|
|
44
44
|
},
|
|
45
45
|
message: { type: 'string' },
|
|
46
46
|
details: { description: 'Optional structured details' },
|
|
@@ -287,13 +287,7 @@ const SCHEMAS = {
|
|
|
287
287
|
type: 'string',
|
|
288
288
|
pattern: '^/',
|
|
289
289
|
description:
|
|
290
|
-
"Path relative to the module's base URL
|
|
291
|
-
},
|
|
292
|
-
base: {
|
|
293
|
-
type: 'string',
|
|
294
|
-
pattern: '^[a-z][a-z0-9-]*$',
|
|
295
|
-
description:
|
|
296
|
-
"Name of one of the module's base URLs (ADR-052 amendment); default when omitted. A name the module declares, never a URL or host. An unknown name is 400 PROXY_UNKNOWN_BASE.",
|
|
290
|
+
"Path relative to the module's base URL. No //, backslashes, ? or #, dot segments, encoded separators or double encoding. The host always comes from the module.",
|
|
297
291
|
},
|
|
298
292
|
query: {
|
|
299
293
|
type: 'object',
|
|
@@ -305,8 +299,7 @@ const SCHEMAS = {
|
|
|
305
299
|
{ type: 'array', items: { type: 'string' } },
|
|
306
300
|
],
|
|
307
301
|
},
|
|
308
|
-
description:
|
|
309
|
-
'Array values are sent comma-separated (`ids: ["1","2"]` → `ids=1,2`), except under a key ending in `[]`, which is sent once per value (`"fields[]": ["a","b"]` → `fields%5B%5D=a&fields%5B%5D=b`; an empty array sends nothing).',
|
|
302
|
+
description: 'Array values are sent comma-separated',
|
|
310
303
|
},
|
|
311
304
|
headers: {
|
|
312
305
|
type: 'object',
|
|
@@ -165,11 +165,11 @@ const V2_OPERATIONS = {
|
|
|
165
165
|
proxyEntityRequest: {
|
|
166
166
|
summary: "Call the entity's API through Frigg (beta)",
|
|
167
167
|
description:
|
|
168
|
-
"ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL;
|
|
168
|
+
"ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. ADR-056 §3.5: the caller needs use access to the entity in the active organization (an entity they cannot see there is 404), and the entity's scope must be in managementApi.proxy.scopes (default individual only; 403 PROXY_NOT_ALLOWED with details.reason 'scope', or 'access' for view-only access). The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
|
|
169
169
|
requestBody: body(ref('ProxyRequest')),
|
|
170
170
|
responses: {
|
|
171
171
|
200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
|
|
172
|
-
400: ok('INVALID_PROXY_REQUEST
|
|
172
|
+
400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
|
|
173
173
|
401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
|
|
174
174
|
403: ok("PROXY_NOT_ALLOWED: not in the allow-list, or details.reason 'scope' / 'access'", ref('ProxyError')),
|
|
175
175
|
404: ok('Entity not found, or not visible in the active organization', ref('Error')),
|
|
@@ -17,15 +17,4 @@ class ProxyRequestError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
* A module definition or the app's proxy settings are wrong. Thrown at load,
|
|
22
|
-
* so the app stops starting instead of failing on the first proxied call.
|
|
23
|
-
*/
|
|
24
|
-
class ProxyConfigError extends Error {
|
|
25
|
-
constructor(message) {
|
|
26
|
-
super(message);
|
|
27
|
-
this.name = 'ProxyConfigError';
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
module.exports = { ProxyRequestError, ProxyConfigError };
|
|
20
|
+
module.exports = { ProxyRequestError };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const { ProxyRequestError } = require('./proxy-errors');
|
|
2
|
-
const { DEFAULT_BASE, ruleBase } = require('./proxy-bases');
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Path rules for the entity proxy (ADR-052 §4). The caller supplies a path
|
|
@@ -134,7 +133,7 @@ function joinProxyUrl(baseUrl, normalizedPath) {
|
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
/**
|
|
137
|
-
* Finds the allow-list rule that permits `method` on `path
|
|
136
|
+
* Finds the allow-list rule that permits `method` on `path`.
|
|
138
137
|
*
|
|
139
138
|
* Patterns use `:param` for exactly one segment and a trailing `/**` for a
|
|
140
139
|
* subtree (the prefix itself included). `method: '*'` matches any method.
|
|
@@ -142,17 +141,14 @@ function joinProxyUrl(baseUrl, normalizedPath) {
|
|
|
142
141
|
* @param {Array<{method: string, path: string}>} rules
|
|
143
142
|
* @param {string} method - upper case
|
|
144
143
|
* @param {string} path - normalised path
|
|
145
|
-
* @
|
|
146
|
-
* rule applies to its own `base` only (no `base` means `default`)
|
|
147
|
-
* @returns {{method: string, path: string, base?: string}|null}
|
|
144
|
+
* @returns {{method: string, path: string}|null}
|
|
148
145
|
*/
|
|
149
|
-
function matchAllowRule(rules, method, path
|
|
146
|
+
function matchAllowRule(rules, method, path) {
|
|
150
147
|
const requestSegments = path.split('/').slice(1);
|
|
151
148
|
for (const rule of rules || []) {
|
|
152
149
|
if (!rule || typeof rule.path !== 'string' || typeof rule.method !== 'string') {
|
|
153
150
|
continue;
|
|
154
151
|
}
|
|
155
|
-
if (ruleBase(rule) !== base) continue;
|
|
156
152
|
const ruleMethod = rule.method.toUpperCase();
|
|
157
153
|
if (ruleMethod !== '*' && ruleMethod !== method) continue;
|
|
158
154
|
if (patternMatches(rule.path, requestSegments)) return rule;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const { ProxyRequestError } = require('./proxy-errors');
|
|
2
|
-
const { isValidBaseName } = require('./proxy-bases');
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* What the entity proxy accepts and returns (ADR-052 §5-§7). Pure functions:
|
|
@@ -53,13 +52,6 @@ const REMOVED_RESPONSE_HEADERS = [
|
|
|
53
52
|
const invalid = (message, details) =>
|
|
54
53
|
new ProxyRequestError('INVALID_PROXY_REQUEST', message, details);
|
|
55
54
|
|
|
56
|
-
/**
|
|
57
|
-
* A query key ending in `[]` is sent as one `key=value` pair per array
|
|
58
|
-
* element; the requester applies the same rule (see
|
|
59
|
-
* `_rawRequest` in requester.js).
|
|
60
|
-
*/
|
|
61
|
-
const isRepeatedQueryKey = (key) => key.endsWith('[]');
|
|
62
|
-
|
|
63
55
|
const isPlainObject = (value) =>
|
|
64
56
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
65
57
|
|
|
@@ -69,7 +61,7 @@ const isPlainObject = (value) =>
|
|
|
69
61
|
*
|
|
70
62
|
* @param {unknown} request
|
|
71
63
|
* @param {{ maxRequestBytes: number }} limits
|
|
72
|
-
* @returns {{ method: string, path: string,
|
|
64
|
+
* @returns {{ method: string, path: string, query?: Object, headers: Object, body?: string, requestBytes: number, bodyIsJson: boolean }}
|
|
73
65
|
*/
|
|
74
66
|
function validateProxyRequest(request, { maxRequestBytes }) {
|
|
75
67
|
if (!isPlainObject(request)) throw invalid('The proxy request must be a JSON object');
|
|
@@ -81,19 +73,6 @@ function validateProxyRequest(request, { maxRequestBytes }) {
|
|
|
81
73
|
throw invalid('path is required', { field: 'path' });
|
|
82
74
|
}
|
|
83
75
|
|
|
84
|
-
// `base` is a name, never a URL or host; anything else is refused before
|
|
85
|
-
// a module is looked up. Omitted means `default` (the caller applies it).
|
|
86
|
-
let base;
|
|
87
|
-
if (request.base !== undefined && request.base !== null) {
|
|
88
|
-
if (typeof request.base !== 'string' || !isValidBaseName(request.base)) {
|
|
89
|
-
throw invalid(
|
|
90
|
-
'base must be the name of one of the module\'s base URLs (lower case letters, digits and -), not a URL or host',
|
|
91
|
-
{ field: 'base' }
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
base = request.base;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
76
|
let query;
|
|
98
77
|
if (request.query !== undefined && request.query !== null) {
|
|
99
78
|
if (!isPlainObject(request.query)) throw invalid('query must be an object', { field: 'query' });
|
|
@@ -106,13 +85,8 @@ function validateProxyRequest(request, { maxRequestBytes }) {
|
|
|
106
85
|
field: `query.${key}`,
|
|
107
86
|
});
|
|
108
87
|
}
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
if (list) {
|
|
112
|
-
query[key] = isRepeatedQueryKey(key) ? [...value] : value.join(',');
|
|
113
|
-
} else {
|
|
114
|
-
query[key] = value;
|
|
115
|
-
}
|
|
88
|
+
// The requester sends one value per key; arrays go comma-separated.
|
|
89
|
+
query[key] = list ? value.join(',') : value;
|
|
116
90
|
}
|
|
117
91
|
}
|
|
118
92
|
|
|
@@ -147,19 +121,16 @@ function validateProxyRequest(request, { maxRequestBytes }) {
|
|
|
147
121
|
`The proxied request body is larger than ${maxRequestBytes} bytes`
|
|
148
122
|
);
|
|
149
123
|
}
|
|
150
|
-
return { method, path: request.path,
|
|
124
|
+
return { method, path: request.path, query, headers, body, bodyIsJson, requestBytes };
|
|
151
125
|
}
|
|
152
126
|
|
|
153
127
|
const isWildcardRule = (rule) => rule?.method === '*' || rule?.path === '/**';
|
|
154
128
|
|
|
155
|
-
// A rule's `base` is checked against the module's declared bases at load, by
|
|
156
|
-
// assertProxyConfig (proxy-bases.js); here only its shape is.
|
|
157
129
|
const isValidRule = (rule) =>
|
|
158
130
|
isPlainObject(rule) &&
|
|
159
131
|
typeof rule.method === 'string' &&
|
|
160
132
|
typeof rule.path === 'string' &&
|
|
161
|
-
rule.path.startsWith('/')
|
|
162
|
-
(rule.base === undefined || isValidBaseName(rule.base));
|
|
133
|
+
rule.path.startsWith('/');
|
|
163
134
|
|
|
164
135
|
/**
|
|
165
136
|
* The allow-list in force for a module: the app definition's list for it when
|
|
@@ -28,8 +28,7 @@ const MAX_REDIRECTS = 3;
|
|
|
28
28
|
* @param {Object} [params.query]
|
|
29
29
|
* @param {Object} [params.headers] - already filtered by the caller's policy
|
|
30
30
|
* @param {string|Buffer} [params.body] - already serialised
|
|
31
|
-
* @param {string} [params.baseUrl] -
|
|
32
|
-
* caller named (ADR-052 amendment; resolved by the use case); falls
|
|
31
|
+
* @param {string} [params.baseUrl] - module-declared proxy base URL; falls
|
|
33
32
|
* back to the requester's `proxyBaseUrl`, `baseUrl` or `baseURL`
|
|
34
33
|
* @param {number} params.timeoutMs - budget for the whole call
|
|
35
34
|
* @param {number} params.maxResponseBytes
|
|
@@ -219,14 +219,9 @@ class Requester extends Delegate {
|
|
|
219
219
|
if (options.query) {
|
|
220
220
|
let queryBuild = '?';
|
|
221
221
|
for (const key in options.query) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const values =
|
|
226
|
-
Array.isArray(value) && key.endsWith('[]') ? value : [value];
|
|
227
|
-
for (const v of values) {
|
|
228
|
-
queryBuild += `${encodeURIComponent(key)}=${encodeURIComponent(v)}&`;
|
|
229
|
-
}
|
|
222
|
+
queryBuild += `${encodeURIComponent(key)}=${encodeURIComponent(
|
|
223
|
+
options.query[key]
|
|
224
|
+
)}&`;
|
|
230
225
|
}
|
|
231
226
|
encodedUrl += queryBuild.slice(0, -1);
|
|
232
227
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
3
|
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
4
|
-
const { ProxyRequestError
|
|
4
|
+
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
5
5
|
const { SCOPES } = require('../scope-resolution');
|
|
6
6
|
const { grantsUse } = require('../scope-visibility');
|
|
7
7
|
const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
|
|
8
|
-
const { DEFAULT_BASE, resolveModuleBases } = require('../proxy/proxy-bases');
|
|
9
8
|
const {
|
|
10
9
|
validateProxyRequest,
|
|
11
10
|
resolveAllowRules,
|
|
@@ -19,7 +18,6 @@ const log = getLogger('frigg.api.proxy');
|
|
|
19
18
|
/** HTTP status and audit outcome for each proxy error code (ADR-052 §10). */
|
|
20
19
|
const ERROR_MAP = {
|
|
21
20
|
INVALID_PROXY_REQUEST: { status: 400, outcome: 'denied' },
|
|
22
|
-
PROXY_UNKNOWN_BASE: { status: 400, outcome: 'denied' },
|
|
23
21
|
PROXY_NOT_ALLOWED: { status: 403, outcome: 'denied' },
|
|
24
22
|
PAYLOAD_TOO_LARGE: { status: 413, outcome: 'too_large' },
|
|
25
23
|
UNSUPPORTED_MEDIA_TYPE: { status: 415, outcome: 'denied' },
|
|
@@ -112,19 +110,6 @@ class ExecuteEntityProxyRequest {
|
|
|
112
110
|
audit.module = definition.moduleName;
|
|
113
111
|
this.assertProxyable(entityId, entity, definition, user);
|
|
114
112
|
|
|
115
|
-
// The base is a name the module declared; the host comes from the
|
|
116
|
-
// module's declaration, never from the request.
|
|
117
|
-
const base = validated.base ?? DEFAULT_BASE;
|
|
118
|
-
audit.base = base;
|
|
119
|
-
const bases = this.basesOf(definition);
|
|
120
|
-
if (!bases.names.includes(base)) {
|
|
121
|
-
throw new ProxyRequestError(
|
|
122
|
-
'PROXY_UNKNOWN_BASE',
|
|
123
|
-
`Module ${definition.moduleName} has no base named ${base}; use one of: ${bases.names.join(', ')}`,
|
|
124
|
-
{ module: definition.moduleName, base, bases: bases.names }
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
113
|
const credentialRef = entity.credential;
|
|
129
114
|
const credentialId =
|
|
130
115
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -135,14 +120,12 @@ class ExecuteEntityProxyRequest {
|
|
|
135
120
|
}
|
|
136
121
|
|
|
137
122
|
const { rules } = resolveAllowRules(definition, this.proxyConfig.modules);
|
|
138
|
-
const rule = matchAllowRule(rules, validated.method, path
|
|
123
|
+
const rule = matchAllowRule(rules, validated.method, path);
|
|
139
124
|
if (!rule) {
|
|
140
125
|
throw new ProxyRequestError(
|
|
141
126
|
'PROXY_NOT_ALLOWED',
|
|
142
|
-
`Module ${definition.moduleName} does not allow ${validated.method} on this path
|
|
143
|
-
|
|
144
|
-
(bases.names.length > 1 ? ` of base ${base}` : ''),
|
|
145
|
-
{ module: definition.moduleName, method: validated.method, base }
|
|
127
|
+
`Module ${definition.moduleName} does not allow ${validated.method} on this path`,
|
|
128
|
+
{ module: definition.moduleName, method: validated.method }
|
|
146
129
|
);
|
|
147
130
|
}
|
|
148
131
|
audit.pattern = rule.path;
|
|
@@ -170,11 +153,11 @@ class ExecuteEntityProxyRequest {
|
|
|
170
153
|
query: validated.query,
|
|
171
154
|
headers,
|
|
172
155
|
body: validated.body,
|
|
173
|
-
baseUrl: this.resolveBaseUrl(
|
|
156
|
+
baseUrl: this.resolveBaseUrl(definition, module.api),
|
|
174
157
|
timeoutMs: this.proxyConfig.timeoutMs,
|
|
175
158
|
maxResponseBytes: this.proxyConfig.maxResponseBytes,
|
|
176
159
|
isAllowedRedirect: (nextPath, nextMethod) =>
|
|
177
|
-
Boolean(matchAllowRule(rules, nextMethod, nextPath
|
|
160
|
+
Boolean(matchAllowRule(rules, nextMethod, nextPath)),
|
|
178
161
|
});
|
|
179
162
|
audit.upstreamStatus = upstream.status;
|
|
180
163
|
audit.responseBytes = upstream.body?.length ?? 0;
|
|
@@ -266,40 +249,12 @@ class ExecuteEntityProxyRequest {
|
|
|
266
249
|
}
|
|
267
250
|
}
|
|
268
251
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
return resolveModuleBases(definition);
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (!(error instanceof ProxyConfigError)) throw error;
|
|
279
|
-
throw new ProxyRequestError(
|
|
280
|
-
'PROXY_NOT_CONFIGURED',
|
|
281
|
-
`Module ${definition.moduleName} has invalid proxy settings`
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* The URL of the named base. A base the module declared never falls back
|
|
288
|
-
* to the requester's own host (that would send the module's token to a
|
|
289
|
-
* host the module did not name); only a module that declares nothing at
|
|
290
|
-
* all uses the requester's base URL, signalled by undefined.
|
|
291
|
-
*/
|
|
292
|
-
resolveBaseUrl(bases, api, name) {
|
|
293
|
-
const declared = bases.values.get(name);
|
|
294
|
-
if (!bases.declared) return undefined;
|
|
295
|
-
const url = typeof declared === 'function' ? declared(api) : declared;
|
|
296
|
-
if (typeof url !== 'string' || url === '') {
|
|
297
|
-
throw new ProxyRequestError(
|
|
298
|
-
'PROXY_NOT_CONFIGURED',
|
|
299
|
-
`The module declares no usable URL for base ${name}`
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
return url;
|
|
252
|
+
/** A module may pin its proxy base URL (string, or a function of the api). */
|
|
253
|
+
resolveBaseUrl(definition, api) {
|
|
254
|
+
const declared = definition.proxy?.baseUrl;
|
|
255
|
+
if (typeof declared === 'function') return declared(api);
|
|
256
|
+
if (typeof declared === 'string') return declared;
|
|
257
|
+
return undefined;
|
|
303
258
|
}
|
|
304
259
|
|
|
305
260
|
/** One audit record (ADR-048) and one usage count (ADR-011) per call. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.686.20eda6e.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.686.20eda6e.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.686.20eda6e.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.686.20eda6e.0",
|
|
54
54
|
"@prisma/client": "^6.19.3",
|
|
55
55
|
"@types/lodash": "4.17.15",
|
|
56
56
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -90,5 +90,5 @@
|
|
|
90
90
|
"publishConfig": {
|
|
91
91
|
"access": "public"
|
|
92
92
|
},
|
|
93
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "20eda6e4013af2a8b0432122b697a352b40350c0"
|
|
94
94
|
}
|
|
@@ -36,6 +36,43 @@ const {
|
|
|
36
36
|
readRawBody,
|
|
37
37
|
} = require('../queue-message-auth');
|
|
38
38
|
|
|
39
|
+
// A queue target is a background function on this site, named by path and
|
|
40
|
+
// nothing else: no scheme, no host, no '//', no '..', no query. Anything
|
|
41
|
+
// else would let a stored or configured queue id send a signed message to
|
|
42
|
+
// another host (a protocol-relative '//host/x' when the base URL is empty)
|
|
43
|
+
// or to a function that is not a queue worker.
|
|
44
|
+
const BACKGROUND_FUNCTION_PATH = /^\/\.netlify\/functions\/[A-Za-z0-9][A-Za-z0-9_-]*-background$/;
|
|
45
|
+
|
|
46
|
+
function isBackgroundFunctionPath(queueId) {
|
|
47
|
+
return typeof queueId === 'string' && BACKGROUND_FUNCTION_PATH.test(queueId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The URL a message for `queueId` is POSTed to, or a thrown error.
|
|
52
|
+
* The base must be an absolute http(s) origin; the path must be a
|
|
53
|
+
* background function (see BACKGROUND_FUNCTION_PATH).
|
|
54
|
+
*/
|
|
55
|
+
function backgroundFunctionUrl(baseUrl, queueId) {
|
|
56
|
+
if (!isBackgroundFunctionPath(queueId)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Refusing to send to queue id ${JSON.stringify(String(queueId))}: ` +
|
|
59
|
+
"a Netlify queue id must be '/.netlify/functions/<name>-background'"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
let base;
|
|
63
|
+
try {
|
|
64
|
+
base = new URL(baseUrl);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'NetlifyBackgroundProvider needs an absolute site URL (options.baseUrl or the URL environment variable)'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password) {
|
|
71
|
+
throw new Error('NetlifyBackgroundProvider site URL must be http(s) with no credentials');
|
|
72
|
+
}
|
|
73
|
+
return `${base.origin}${queueId}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
39
76
|
class NetlifyBackgroundProvider extends QueueProvider {
|
|
40
77
|
/**
|
|
41
78
|
* @param {Object} [options]
|
|
@@ -68,7 +105,7 @@ class NetlifyBackgroundProvider extends QueueProvider {
|
|
|
68
105
|
* @param {string} queueId - Background function path (e.g., '/.netlify/functions/worker-background')
|
|
69
106
|
*/
|
|
70
107
|
async send(message, queueId) {
|
|
71
|
-
const url =
|
|
108
|
+
const url = backgroundFunctionUrl(this.baseUrl, queueId);
|
|
72
109
|
const body = JSON.stringify(message);
|
|
73
110
|
const headers = { 'Content-Type': 'application/json' };
|
|
74
111
|
|
|
@@ -147,4 +184,4 @@ class NetlifyBackgroundProvider extends QueueProvider {
|
|
|
147
184
|
}
|
|
148
185
|
}
|
|
149
186
|
|
|
150
|
-
module.exports = { NetlifyBackgroundProvider };
|
|
187
|
+
module.exports = { NetlifyBackgroundProvider, isBackgroundFunctionPath, backgroundFunctionUrl };
|
package/types/modules/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ declare module "@friggframework/core" {
|
|
|
15
15
|
|
|
16
16
|
export interface EntityTypeEntry {
|
|
17
17
|
/** The API module definition. */
|
|
18
|
-
definition:
|
|
18
|
+
definition: any;
|
|
19
19
|
/** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */
|
|
20
20
|
sources: readonly EntityTypeSource[];
|
|
21
21
|
/** Frozen. */
|
|
@@ -73,23 +73,6 @@ declare module "@friggframework/core" {
|
|
|
73
73
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "*";
|
|
74
74
|
/** Relative to the module's base URL. `:param` is one segment; a trailing `/**` is a subtree. */
|
|
75
75
|
path: string;
|
|
76
|
-
/** Which of the module's named base URLs the rule applies to. Default `"default"`. Must be declared by the module (checked at load). */
|
|
77
|
-
base?: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* A module definition's `proxy` setting (ADR-052). Declare `baseUrl` (one host,
|
|
82
|
-
* meaning `{ default: baseUrl }`) or `baseUrls` (several), never both.
|
|
83
|
-
* `baseUrls` values are absolute https URLs with no credentials, query or
|
|
84
|
-
* fragment (strings only, unlike `baseUrl`); names match `^[a-z][a-z0-9-]*$`.
|
|
85
|
-
* A caller picks a base by name in the proxy request's `base`.
|
|
86
|
-
*/
|
|
87
|
-
export interface ModuleProxyConfig {
|
|
88
|
-
baseUrl?: string | ((api: any) => string);
|
|
89
|
-
baseUrls?: Record<string, string>;
|
|
90
|
-
allow?: ProxyAllowRule[];
|
|
91
|
-
headers?: string[];
|
|
92
|
-
binary?: boolean;
|
|
93
76
|
}
|
|
94
77
|
|
|
95
78
|
/** `appDefinition.managementApi.proxy`: the entity proxy, POST /api/v2/entities/:id/proxy (ADR-052). */
|
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prisma composer: puts core extensions' models into the Prisma client.
|
|
3
|
-
*
|
|
4
|
-
* Core ships one generated client per database (`generated/prisma-<db>`),
|
|
5
|
-
* built from `prisma-<db>/schema.prisma`. A core extension that declares a
|
|
6
|
-
* schema (`coreExtensions[].schema`) needs its models in that client, and on
|
|
7
|
-
* PostgreSQL its tables need migrations (`coreExtensions[].migrations`).
|
|
8
|
-
* Without this step the extension's models exist nowhere: every call to
|
|
9
|
-
* `prisma.<model>` is undefined, and the table is never created.
|
|
10
|
-
*
|
|
11
|
-
* At build time, for each database the app uses:
|
|
12
|
-
* 1. The pristine core schema is kept as `schema.base.prisma` (taken from
|
|
13
|
-
* `schema.prisma` the first time), so composing again never appends twice.
|
|
14
|
-
* 2. `schema.prisma` becomes base + the extensions' models. Everything that
|
|
15
|
-
* reads the schema (prisma generate, the migration runner, the Lambda
|
|
16
|
-
* layer, which copies the generated client's schema) sees the composed one.
|
|
17
|
-
* 3. PostgreSQL: each extension migration folder is copied into
|
|
18
|
-
* `prisma-postgresql/migrations`, so `prisma migrate deploy` applies it in
|
|
19
|
-
* timestamp order with the core migrations.
|
|
20
|
-
* 4. `prisma generate` rebuilds `generated/prisma-<db>` from the composed
|
|
21
|
-
* schema, only when the schema changed or the client is missing.
|
|
22
|
-
*
|
|
23
|
-
* Removing every extension restores the base schema and regenerates. Copied
|
|
24
|
-
* migrations are left in place: a migration that may already be applied must
|
|
25
|
-
* not disappear from the history.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
const fs = require('fs');
|
|
29
|
-
const path = require('path');
|
|
30
|
-
const { execFileSync } = require('child_process');
|
|
31
|
-
const {
|
|
32
|
-
loadExtensions,
|
|
33
|
-
getExtensionSchemaPaths,
|
|
34
|
-
} = require('./extension-loader');
|
|
35
|
-
const { extractModelBlocks } = require('./schema-composer');
|
|
36
|
-
|
|
37
|
-
const DB_TYPES = ['postgresql', 'mongodb'];
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The Prisma databases an app definition uses. DocumentDB uses the MongoDB client.
|
|
41
|
-
* @param {Object} appDefinition
|
|
42
|
-
* @returns {Array<'postgresql'|'mongodb'>}
|
|
43
|
-
*/
|
|
44
|
-
function resolvePrismaDbTypes(appDefinition) {
|
|
45
|
-
const db = appDefinition?.database || {};
|
|
46
|
-
const types = [];
|
|
47
|
-
if (db.postgres?.enable) types.push('postgresql');
|
|
48
|
-
if (db.mongoDB?.enable || db.documentDB?.enable) types.push('mongodb');
|
|
49
|
-
return types;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function blockNames(schemaContent) {
|
|
53
|
-
const names = [];
|
|
54
|
-
const re = /^\s*(model|enum|type|view)\s+(\w+)\s*\{/gm;
|
|
55
|
-
let match;
|
|
56
|
-
while ((match = re.exec(schemaContent)) !== null) names.push(match[2]);
|
|
57
|
-
return names;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Base schema + the extensions' model blocks. Throws when an extension
|
|
62
|
-
* declares a model, enum or type the base (or another extension) already has.
|
|
63
|
-
*/
|
|
64
|
-
function buildComposedSchema(baseContent, extensionSchemaPaths) {
|
|
65
|
-
const seen = new Map(
|
|
66
|
-
blockNames(baseContent).map((name) => [name, 'the core schema'])
|
|
67
|
-
);
|
|
68
|
-
const sections = [];
|
|
69
|
-
|
|
70
|
-
for (const extPath of extensionSchemaPaths) {
|
|
71
|
-
if (!fs.existsSync(extPath)) {
|
|
72
|
-
throw new Error(`Extension schema not found: ${extPath}`);
|
|
73
|
-
}
|
|
74
|
-
const blocks = extractModelBlocks(fs.readFileSync(extPath, 'utf-8'));
|
|
75
|
-
if (!blocks) continue;
|
|
76
|
-
for (const name of blockNames(blocks)) {
|
|
77
|
-
if (seen.has(name)) {
|
|
78
|
-
throw new Error(
|
|
79
|
-
`Extension schema ${extPath} declares "${name}", already declared by ${seen.get(
|
|
80
|
-
name
|
|
81
|
-
)}`
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
seen.set(name, extPath);
|
|
85
|
-
}
|
|
86
|
-
sections.push(`// --- Extension schema: ${extPath} ---`, blocks, '');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (sections.length === 0) return baseContent;
|
|
90
|
-
|
|
91
|
-
return [
|
|
92
|
-
baseContent.trimEnd(),
|
|
93
|
-
'',
|
|
94
|
-
'// =========================================================================',
|
|
95
|
-
'// Extension models (composed by @friggframework/core at build; do not edit)',
|
|
96
|
-
'// =========================================================================',
|
|
97
|
-
'',
|
|
98
|
-
...sections,
|
|
99
|
-
].join('\n');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Copies each extension's migration folders into the core migrations
|
|
104
|
-
* directory. A folder already there with the same migration.sql is skipped;
|
|
105
|
-
* a different one under the same name is an error (it would silently be a
|
|
106
|
-
* different migration with an applied migration's name).
|
|
107
|
-
* @returns {string[]} names of the folders copied
|
|
108
|
-
*/
|
|
109
|
-
function copyExtensionMigrations(extensions, migrationsDir) {
|
|
110
|
-
const copied = [];
|
|
111
|
-
for (const ext of extensions) {
|
|
112
|
-
if (!ext.migrations) continue;
|
|
113
|
-
if (
|
|
114
|
-
!fs.existsSync(ext.migrations) ||
|
|
115
|
-
!fs.statSync(ext.migrations).isDirectory()
|
|
116
|
-
) {
|
|
117
|
-
throw new Error(
|
|
118
|
-
`Extension "${ext.name}" migrations directory not found: ${ext.migrations}`
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
const folders = fs
|
|
122
|
-
.readdirSync(ext.migrations, { withFileTypes: true })
|
|
123
|
-
.filter((entry) => entry.isDirectory())
|
|
124
|
-
.map((entry) => entry.name)
|
|
125
|
-
.sort();
|
|
126
|
-
for (const folder of folders) {
|
|
127
|
-
const source = path.join(ext.migrations, folder, 'migration.sql');
|
|
128
|
-
if (!fs.existsSync(source)) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
`Extension "${ext.name}" migration ${folder} has no migration.sql`
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
const sql = fs.readFileSync(source, 'utf-8');
|
|
134
|
-
const destDir = path.join(migrationsDir, folder);
|
|
135
|
-
const dest = path.join(destDir, 'migration.sql');
|
|
136
|
-
if (fs.existsSync(dest)) {
|
|
137
|
-
if (fs.readFileSync(dest, 'utf-8') !== sql) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
`Migration ${folder} from extension "${ext.name}" conflicts with an existing migration of the same name`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
145
|
-
fs.writeFileSync(dest, sql, 'utf-8');
|
|
146
|
-
copied.push(folder);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return copied;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Runs `prisma generate` with the Prisma CLI installed beside core (the app's
|
|
154
|
-
* own `prisma`), falling back to npx.
|
|
155
|
-
*/
|
|
156
|
-
function defaultGenerate({ schemaPath, corePath }) {
|
|
157
|
-
let cli;
|
|
158
|
-
try {
|
|
159
|
-
cli = require.resolve('prisma/build/index.js', {
|
|
160
|
-
paths: [corePath, process.cwd()],
|
|
161
|
-
});
|
|
162
|
-
} catch {
|
|
163
|
-
cli = null;
|
|
164
|
-
}
|
|
165
|
-
const [command, args] = cli
|
|
166
|
-
? [process.execPath, [cli, 'generate', `--schema=${schemaPath}`]]
|
|
167
|
-
: ['npx', ['prisma', 'generate', `--schema=${schemaPath}`]];
|
|
168
|
-
execFileSync(command, args, { cwd: corePath, stdio: 'inherit' });
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Composes the extensions' schemas and migrations into core's Prisma schema
|
|
173
|
-
* and regenerates the client when it changed.
|
|
174
|
-
*
|
|
175
|
-
* @param {Object} params
|
|
176
|
-
* @param {Object} params.appDefinition
|
|
177
|
-
* @param {string} params.corePath - Directory of the installed @friggframework/core
|
|
178
|
-
* @param {Array<'postgresql'|'mongodb'>} [params.dbTypes] - Defaults to the app definition's databases
|
|
179
|
-
* @param {Function} [params.generate] - ({ schemaPath, corePath, dbType }) => void
|
|
180
|
-
* @param {Function} [params.log]
|
|
181
|
-
* @returns {{ changed: boolean, results: Array<Object> }}
|
|
182
|
-
*/
|
|
183
|
-
function composeExtensionPrisma({
|
|
184
|
-
appDefinition,
|
|
185
|
-
corePath,
|
|
186
|
-
dbTypes,
|
|
187
|
-
generate = defaultGenerate,
|
|
188
|
-
log = (message) => console.log(message),
|
|
189
|
-
}) {
|
|
190
|
-
if (!corePath) throw new Error('corePath is required');
|
|
191
|
-
const extensions = loadExtensions(appDefinition);
|
|
192
|
-
const types = (dbTypes || resolvePrismaDbTypes(appDefinition)).filter((t) =>
|
|
193
|
-
DB_TYPES.includes(t)
|
|
194
|
-
);
|
|
195
|
-
const results = [];
|
|
196
|
-
|
|
197
|
-
for (const dbType of types) {
|
|
198
|
-
const schemaDir = path.join(corePath, `prisma-${dbType}`);
|
|
199
|
-
const schemaPath = path.join(schemaDir, 'schema.prisma');
|
|
200
|
-
const basePath = path.join(schemaDir, 'schema.base.prisma');
|
|
201
|
-
if (!fs.existsSync(schemaPath)) {
|
|
202
|
-
throw new Error(`Core Prisma schema not found: ${schemaPath}`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const extensionSchemas = getExtensionSchemaPaths(extensions, dbType);
|
|
206
|
-
// Migrations do not depend on a schema: an extension may ship only
|
|
207
|
-
// SQL (a table it reads with raw queries), and it must still be applied.
|
|
208
|
-
const migrations =
|
|
209
|
-
dbType === 'postgresql'
|
|
210
|
-
? copyExtensionMigrations(
|
|
211
|
-
extensions,
|
|
212
|
-
path.join(schemaDir, 'migrations')
|
|
213
|
-
)
|
|
214
|
-
: [];
|
|
215
|
-
if (migrations.length > 0) {
|
|
216
|
-
log(`[Frigg] Added extension migrations: ${migrations.join(', ')}`);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// No schema to compose and none composed before: the schema and the
|
|
220
|
-
// client stay exactly as core shipped them.
|
|
221
|
-
if (extensionSchemas.length === 0 && !fs.existsSync(basePath)) {
|
|
222
|
-
results.push({
|
|
223
|
-
dbType,
|
|
224
|
-
changed: migrations.length > 0,
|
|
225
|
-
migrations,
|
|
226
|
-
});
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (!fs.existsSync(basePath)) fs.copyFileSync(schemaPath, basePath);
|
|
231
|
-
const target = buildComposedSchema(
|
|
232
|
-
fs.readFileSync(basePath, 'utf-8'),
|
|
233
|
-
extensionSchemas
|
|
234
|
-
);
|
|
235
|
-
const current = fs.readFileSync(schemaPath, 'utf-8');
|
|
236
|
-
const schemaChanged = current !== target;
|
|
237
|
-
if (schemaChanged) fs.writeFileSync(schemaPath, target, 'utf-8');
|
|
238
|
-
|
|
239
|
-
const clientPath = path.join(
|
|
240
|
-
corePath,
|
|
241
|
-
'generated',
|
|
242
|
-
`prisma-${dbType}`,
|
|
243
|
-
'schema.prisma'
|
|
244
|
-
);
|
|
245
|
-
const clientStale =
|
|
246
|
-
!fs.existsSync(clientPath) ||
|
|
247
|
-
fs.readFileSync(clientPath, 'utf-8') !== target;
|
|
248
|
-
|
|
249
|
-
if (schemaChanged || clientStale) {
|
|
250
|
-
log(
|
|
251
|
-
`[Frigg] Composing ${extensionSchemas.length} extension schema(s) into the ${dbType} Prisma client`
|
|
252
|
-
);
|
|
253
|
-
generate({ schemaPath, corePath, dbType });
|
|
254
|
-
}
|
|
255
|
-
results.push({
|
|
256
|
-
dbType,
|
|
257
|
-
changed: schemaChanged || clientStale || migrations.length > 0,
|
|
258
|
-
migrations,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return { changed: results.some((r) => r.changed), results };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
module.exports = {
|
|
266
|
-
composeExtensionPrisma,
|
|
267
|
-
resolvePrismaDbTypes,
|
|
268
|
-
buildComposedSchema,
|
|
269
|
-
copyExtensionMigrations,
|
|
270
|
-
};
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
const { ProxyConfigError } = require('./proxy-errors');
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Named base URLs for the entity proxy (ADR-052, amendment 2026-10-18).
|
|
5
|
-
*
|
|
6
|
-
* A module declares where its API lives. One host is `proxy.baseUrl`, which
|
|
7
|
-
* means `{ default: baseUrl }`. A module whose API spans several hosts (Google:
|
|
8
|
-
* Drive, Docs, Sheets, Analytics Data, Ads) declares `proxy.baseUrls`, a map of
|
|
9
|
-
* name to URL. A caller picks a base by NAME in the proxy request's `base`
|
|
10
|
-
* field; it never supplies a URL or a host, so the host still comes only from
|
|
11
|
-
* the module.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const DEFAULT_BASE = 'default';
|
|
15
|
-
const BASE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
16
|
-
|
|
17
|
-
const isPlainObject = (value) =>
|
|
18
|
-
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
-
|
|
20
|
-
const describeRule = (rule) =>
|
|
21
|
-
`${rule && typeof rule.method === 'string' ? rule.method : '?'} ${
|
|
22
|
-
rule && typeof rule.path === 'string' ? rule.path : '?'
|
|
23
|
-
}`;
|
|
24
|
-
|
|
25
|
-
function isValidBaseName(name) {
|
|
26
|
-
return typeof name === 'string' && name.length <= 64 && BASE_NAME.test(name);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function assertBaseUrl(moduleName, name, value) {
|
|
30
|
-
const where = `Module ${moduleName} proxy.baseUrls.${name}`;
|
|
31
|
-
if (typeof value !== 'string') {
|
|
32
|
-
throw new ProxyConfigError(`${where} must be an absolute https URL string (functions are not accepted)`);
|
|
33
|
-
}
|
|
34
|
-
let url;
|
|
35
|
-
try {
|
|
36
|
-
url = new URL(value);
|
|
37
|
-
} catch {
|
|
38
|
-
throw new ProxyConfigError(`${where} is not a valid absolute URL`);
|
|
39
|
-
}
|
|
40
|
-
if (url.protocol !== 'https:') {
|
|
41
|
-
throw new ProxyConfigError(`${where} must use https`);
|
|
42
|
-
}
|
|
43
|
-
if (url.username || url.password || url.search || url.hash) {
|
|
44
|
-
throw new ProxyConfigError(
|
|
45
|
-
`${where} must not carry credentials, a query or a fragment`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The bases a module declares, validated.
|
|
52
|
-
*
|
|
53
|
-
* @param {object} definition - a module definition
|
|
54
|
-
* @returns {{ names: string[], values: Map<string, string|Function|undefined>, declared: boolean }}
|
|
55
|
-
* `declared` is false when the module names no base of its own; it then has
|
|
56
|
-
* the single base `default`, resolved from the requester at call time.
|
|
57
|
-
* @throws {ProxyConfigError}
|
|
58
|
-
*/
|
|
59
|
-
function resolveModuleBases(definition) {
|
|
60
|
-
const moduleName = definition?.moduleName ?? '(unnamed)';
|
|
61
|
-
const proxy = definition?.proxy;
|
|
62
|
-
const hasBaseUrl = proxy?.baseUrl !== undefined;
|
|
63
|
-
const hasBaseUrls = proxy?.baseUrls !== undefined;
|
|
64
|
-
|
|
65
|
-
if (hasBaseUrl && hasBaseUrls) {
|
|
66
|
-
throw new ProxyConfigError(
|
|
67
|
-
`Module ${moduleName} declares both proxy.baseUrl and proxy.baseUrls; use one ` +
|
|
68
|
-
'(baseUrl means { default: baseUrl })'
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
if (hasBaseUrls) {
|
|
72
|
-
if (!isPlainObject(proxy.baseUrls) || Object.keys(proxy.baseUrls).length === 0) {
|
|
73
|
-
throw new ProxyConfigError(
|
|
74
|
-
`Module ${moduleName} proxy.baseUrls must be a non-empty object of name to URL`
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
const values = new Map();
|
|
78
|
-
for (const [name, value] of Object.entries(proxy.baseUrls)) {
|
|
79
|
-
if (!isValidBaseName(name)) {
|
|
80
|
-
throw new ProxyConfigError(
|
|
81
|
-
`Module ${moduleName} proxy.baseUrls has the invalid name ${JSON.stringify(
|
|
82
|
-
name
|
|
83
|
-
)}; names match ${BASE_NAME}`
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
assertBaseUrl(moduleName, name, value);
|
|
87
|
-
values.set(name, value);
|
|
88
|
-
}
|
|
89
|
-
return { names: [...values.keys()], values, declared: true };
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
names: [DEFAULT_BASE],
|
|
93
|
-
values: new Map([[DEFAULT_BASE, proxy?.baseUrl]]),
|
|
94
|
-
declared: hasBaseUrl,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The base a rule applies to; a rule without `base` applies to `default`. */
|
|
99
|
-
const ruleBase = (rule) => (rule && rule.base !== undefined ? rule.base : DEFAULT_BASE);
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Load-time check of every allow rule that names a base: the app's
|
|
103
|
-
* `managementApi.proxy.modules.<module>.allow` and each module's own
|
|
104
|
-
* `proxy.allow`. A rule for a base the module does not declare could never
|
|
105
|
-
* match, which reads as "allowed" and is not, so it stops the app.
|
|
106
|
-
*
|
|
107
|
-
* @param {Array<object>} moduleDefinitions
|
|
108
|
-
* @param {{ modules?: Object }} [proxyConfig] - resolved managementApi.proxy
|
|
109
|
-
* @throws {ProxyConfigError}
|
|
110
|
-
*/
|
|
111
|
-
function assertProxyConfig(moduleDefinitions, proxyConfig = {}) {
|
|
112
|
-
const namesByModule = new Map();
|
|
113
|
-
for (const definition of moduleDefinitions || []) {
|
|
114
|
-
if (!definition?.moduleName) continue;
|
|
115
|
-
const { names } = resolveModuleBases(definition);
|
|
116
|
-
namesByModule.set(definition.moduleName, names);
|
|
117
|
-
const own = definition.proxy?.allow;
|
|
118
|
-
if (Array.isArray(own)) {
|
|
119
|
-
assertRulesUseDeclaredBases(
|
|
120
|
-
definition.moduleName,
|
|
121
|
-
names,
|
|
122
|
-
own,
|
|
123
|
-
`Module ${definition.moduleName} proxy.allow`
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
for (const [moduleName, override] of Object.entries(proxyConfig.modules || {})) {
|
|
128
|
-
const names = namesByModule.get(moduleName);
|
|
129
|
-
// An override for a module that is not registered is not new: it never applied.
|
|
130
|
-
if (!names || !Array.isArray(override?.allow)) continue;
|
|
131
|
-
assertRulesUseDeclaredBases(
|
|
132
|
-
moduleName,
|
|
133
|
-
names,
|
|
134
|
-
override.allow,
|
|
135
|
-
`managementApi.proxy.modules.${moduleName}.allow`
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function assertRulesUseDeclaredBases(moduleName, names, rules, where) {
|
|
141
|
-
for (const rule of rules) {
|
|
142
|
-
if (!isPlainObject(rule) || rule.base === undefined) continue;
|
|
143
|
-
if (!isValidBaseName(rule.base) || !names.includes(rule.base)) {
|
|
144
|
-
throw new ProxyConfigError(
|
|
145
|
-
`${where}: the rule ${describeRule(rule)} names the base ${JSON.stringify(
|
|
146
|
-
rule.base
|
|
147
|
-
)}, which module ${moduleName} does not declare (declared: ${names.join(', ')})`
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
// A rule with no base means `default`; the module must have one.
|
|
152
|
-
if (!names.includes(DEFAULT_BASE)) {
|
|
153
|
-
const bare = rules.find((rule) => isPlainObject(rule) && rule.base === undefined);
|
|
154
|
-
if (bare) {
|
|
155
|
-
throw new ProxyConfigError(
|
|
156
|
-
`${where}: the rule ${describeRule(bare)} names no base, so it applies to ` +
|
|
157
|
-
`"default", which module ${moduleName} does not declare (declared: ${names.join(', ')})`
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
module.exports = {
|
|
164
|
-
DEFAULT_BASE,
|
|
165
|
-
isValidBaseName,
|
|
166
|
-
resolveModuleBases,
|
|
167
|
-
ruleBase,
|
|
168
|
-
assertProxyConfig,
|
|
169
|
-
};
|