@friggframework/core 2.0.0--canary.686.ba9ded3.0 → 2.0.0--canary.690.33ca435.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/handlers/app-definition-loader.js +8 -1
- package/management-api/openapi/components.js +10 -3
- package/management-api/openapi/v2-operations.js +2 -2
- package/modules/proxy/proxy-bases.js +169 -0
- package/modules/proxy/proxy-errors.js +12 -1
- package/modules/proxy/proxy-path.js +7 -3
- package/modules/proxy/proxy-policy.js +34 -5
- package/modules/requester/proxy-request.js +2 -1
- package/modules/requester/requester.js +8 -3
- package/modules/use-cases/execute-entity-proxy-request.js +57 -12
- package/package.json +5 -5
- package/types/modules/index.d.ts +18 -1
|
@@ -12,6 +12,7 @@ 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');
|
|
15
16
|
const {
|
|
16
17
|
resolveManagementApiConfig,
|
|
17
18
|
} = require('../management-api/management-api-config');
|
|
@@ -108,6 +109,7 @@ function discoverAppDefinition() {
|
|
|
108
109
|
* @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
|
|
109
110
|
* @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1).
|
|
110
111
|
* @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).
|
|
111
113
|
* @throws {ScheduleConfigError} Throws if an integration's `Definition.schedule` is invalid.
|
|
112
114
|
* @example
|
|
113
115
|
* const { integrations, userConfig, telemetry } = loadAppDefinition();
|
|
@@ -148,7 +150,12 @@ function resolveAppDefinition(appDefinition) {
|
|
|
148
150
|
// And for the Management API: `managementApi.proxy.scopes` decides whose
|
|
149
151
|
// credentials the entity proxy may act through (ADR-056 §3.5), so an
|
|
150
152
|
// unknown scope stops the app here rather than being read as a default.
|
|
151
|
-
resolveManagementApiConfig(managementApi);
|
|
153
|
+
const managementApiConfig = resolveManagementApiConfig(managementApi);
|
|
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);
|
|
152
159
|
|
|
153
160
|
// And for each integration's `Definition.schedule`: a mistyped scope would
|
|
154
161
|
// otherwise tick ONGOING_SYNC per class when per installation was meant.
|
|
@@ -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. New codes may be added.',
|
|
43
|
+
'Machine-readable code, e.g. VALIDATION_ERROR, NOT_FOUND, ENTITY_NOT_FOUND, AUTHORIZATION_SESSION_NOT_FOUND, PROXY_NOT_ALLOWED, PROXY_UNKNOWN_BASE. New codes may be added.',
|
|
44
44
|
},
|
|
45
45
|
message: { type: 'string' },
|
|
46
46
|
details: { description: 'Optional structured details' },
|
|
@@ -287,7 +287,13 @@ const SCHEMAS = {
|
|
|
287
287
|
type: 'string',
|
|
288
288
|
pattern: '^/',
|
|
289
289
|
description:
|
|
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.",
|
|
290
|
+
"Path relative to the module's base URL (the one named by `base`). No //, backslashes, ? or #, dot segments, encoded separators or double encoding. The host always comes from the module.",
|
|
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.",
|
|
291
297
|
},
|
|
292
298
|
query: {
|
|
293
299
|
type: 'object',
|
|
@@ -299,7 +305,8 @@ const SCHEMAS = {
|
|
|
299
305
|
{ type: 'array', items: { type: 'string' } },
|
|
300
306
|
],
|
|
301
307
|
},
|
|
302
|
-
description:
|
|
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).',
|
|
303
310
|
},
|
|
304
311
|
headers: {
|
|
305
312
|
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; `base` optionally names one of the module's several base URLs (default `default`; a name, never a URL), and allow-list rules match per base. 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', ref('ProxyError')),
|
|
172
|
+
400: ok('INVALID_PROXY_REQUEST, or PROXY_UNKNOWN_BASE (`base` is not one of the module\'s base names; the message lists them)', 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')),
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
};
|
|
@@ -17,4 +17,15 @@ class ProxyRequestError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
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 };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { ProxyRequestError } = require('./proxy-errors');
|
|
2
|
+
const { DEFAULT_BASE, ruleBase } = require('./proxy-bases');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Path rules for the entity proxy (ADR-052 §4). The caller supplies a path
|
|
@@ -133,7 +134,7 @@ function joinProxyUrl(baseUrl, normalizedPath) {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
/**
|
|
136
|
-
* Finds the allow-list rule that permits `method` on `path`.
|
|
137
|
+
* Finds the allow-list rule that permits `method` on `path` of `base`.
|
|
137
138
|
*
|
|
138
139
|
* Patterns use `:param` for exactly one segment and a trailing `/**` for a
|
|
139
140
|
* subtree (the prefix itself included). `method: '*'` matches any method.
|
|
@@ -141,14 +142,17 @@ function joinProxyUrl(baseUrl, normalizedPath) {
|
|
|
141
142
|
* @param {Array<{method: string, path: string}>} rules
|
|
142
143
|
* @param {string} method - upper case
|
|
143
144
|
* @param {string} path - normalised path
|
|
144
|
-
* @
|
|
145
|
+
* @param {string} [base='default'] - the named base the request targets; a
|
|
146
|
+
* rule applies to its own `base` only (no `base` means `default`)
|
|
147
|
+
* @returns {{method: string, path: string, base?: string}|null}
|
|
145
148
|
*/
|
|
146
|
-
function matchAllowRule(rules, method, path) {
|
|
149
|
+
function matchAllowRule(rules, method, path, base = DEFAULT_BASE) {
|
|
147
150
|
const requestSegments = path.split('/').slice(1);
|
|
148
151
|
for (const rule of rules || []) {
|
|
149
152
|
if (!rule || typeof rule.path !== 'string' || typeof rule.method !== 'string') {
|
|
150
153
|
continue;
|
|
151
154
|
}
|
|
155
|
+
if (ruleBase(rule) !== base) continue;
|
|
152
156
|
const ruleMethod = rule.method.toUpperCase();
|
|
153
157
|
if (ruleMethod !== '*' && ruleMethod !== method) continue;
|
|
154
158
|
if (patternMatches(rule.path, requestSegments)) return rule;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { ProxyRequestError } = require('./proxy-errors');
|
|
2
|
+
const { isValidBaseName } = require('./proxy-bases');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* What the entity proxy accepts and returns (ADR-052 §5-§7). Pure functions:
|
|
@@ -52,6 +53,13 @@ const REMOVED_RESPONSE_HEADERS = [
|
|
|
52
53
|
const invalid = (message, details) =>
|
|
53
54
|
new ProxyRequestError('INVALID_PROXY_REQUEST', message, details);
|
|
54
55
|
|
|
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
|
+
|
|
55
63
|
const isPlainObject = (value) =>
|
|
56
64
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
57
65
|
|
|
@@ -61,7 +69,7 @@ const isPlainObject = (value) =>
|
|
|
61
69
|
*
|
|
62
70
|
* @param {unknown} request
|
|
63
71
|
* @param {{ maxRequestBytes: number }} limits
|
|
64
|
-
* @returns {{ method: string, path: string, query?: Object, headers: Object, body?: string, requestBytes: number, bodyIsJson: boolean }}
|
|
72
|
+
* @returns {{ method: string, path: string, base?: string, query?: Object, headers: Object, body?: string, requestBytes: number, bodyIsJson: boolean }}
|
|
65
73
|
*/
|
|
66
74
|
function validateProxyRequest(request, { maxRequestBytes }) {
|
|
67
75
|
if (!isPlainObject(request)) throw invalid('The proxy request must be a JSON object');
|
|
@@ -73,6 +81,19 @@ function validateProxyRequest(request, { maxRequestBytes }) {
|
|
|
73
81
|
throw invalid('path is required', { field: 'path' });
|
|
74
82
|
}
|
|
75
83
|
|
|
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
|
+
|
|
76
97
|
let query;
|
|
77
98
|
if (request.query !== undefined && request.query !== null) {
|
|
78
99
|
if (!isPlainObject(request.query)) throw invalid('query must be an object', { field: 'query' });
|
|
@@ -85,8 +106,13 @@ function validateProxyRequest(request, { maxRequestBytes }) {
|
|
|
85
106
|
field: `query.${key}`,
|
|
86
107
|
});
|
|
87
108
|
}
|
|
88
|
-
//
|
|
89
|
-
|
|
109
|
+
// A key ending in `[]` (`fields[]`) is sent once per value, as
|
|
110
|
+
// Airtable and Fathom expect; every other array goes comma-separated.
|
|
111
|
+
if (list) {
|
|
112
|
+
query[key] = isRepeatedQueryKey(key) ? [...value] : value.join(',');
|
|
113
|
+
} else {
|
|
114
|
+
query[key] = value;
|
|
115
|
+
}
|
|
90
116
|
}
|
|
91
117
|
}
|
|
92
118
|
|
|
@@ -121,16 +147,19 @@ function validateProxyRequest(request, { maxRequestBytes }) {
|
|
|
121
147
|
`The proxied request body is larger than ${maxRequestBytes} bytes`
|
|
122
148
|
);
|
|
123
149
|
}
|
|
124
|
-
return { method, path: request.path, query, headers, body, bodyIsJson, requestBytes };
|
|
150
|
+
return { method, path: request.path, ...(base !== undefined && { base }), query, headers, body, bodyIsJson, requestBytes };
|
|
125
151
|
}
|
|
126
152
|
|
|
127
153
|
const isWildcardRule = (rule) => rule?.method === '*' || rule?.path === '/**';
|
|
128
154
|
|
|
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.
|
|
129
157
|
const isValidRule = (rule) =>
|
|
130
158
|
isPlainObject(rule) &&
|
|
131
159
|
typeof rule.method === 'string' &&
|
|
132
160
|
typeof rule.path === 'string' &&
|
|
133
|
-
rule.path.startsWith('/')
|
|
161
|
+
rule.path.startsWith('/') &&
|
|
162
|
+
(rule.base === undefined || isValidBaseName(rule.base));
|
|
134
163
|
|
|
135
164
|
/**
|
|
136
165
|
* The allow-list in force for a module: the app definition's list for it when
|
|
@@ -28,7 +28,8 @@ 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] - module-declared
|
|
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
|
|
32
33
|
* back to the requester's `proxyBaseUrl`, `baseUrl` or `baseURL`
|
|
33
34
|
* @param {number} params.timeoutMs - budget for the whole call
|
|
34
35
|
* @param {number} params.maxResponseBytes
|
|
@@ -219,9 +219,14 @@ class Requester extends Delegate {
|
|
|
219
219
|
if (options.query) {
|
|
220
220
|
let queryBuild = '?';
|
|
221
221
|
for (const key in options.query) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
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
|
+
}
|
|
225
230
|
}
|
|
226
231
|
encodedUrl += queryBuild.slice(0, -1);
|
|
227
232
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
3
|
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
4
|
-
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
4
|
+
const { ProxyRequestError, ProxyConfigError } = 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');
|
|
8
9
|
const {
|
|
9
10
|
validateProxyRequest,
|
|
10
11
|
resolveAllowRules,
|
|
@@ -18,6 +19,7 @@ const log = getLogger('frigg.api.proxy');
|
|
|
18
19
|
/** HTTP status and audit outcome for each proxy error code (ADR-052 §10). */
|
|
19
20
|
const ERROR_MAP = {
|
|
20
21
|
INVALID_PROXY_REQUEST: { status: 400, outcome: 'denied' },
|
|
22
|
+
PROXY_UNKNOWN_BASE: { status: 400, outcome: 'denied' },
|
|
21
23
|
PROXY_NOT_ALLOWED: { status: 403, outcome: 'denied' },
|
|
22
24
|
PAYLOAD_TOO_LARGE: { status: 413, outcome: 'too_large' },
|
|
23
25
|
UNSUPPORTED_MEDIA_TYPE: { status: 415, outcome: 'denied' },
|
|
@@ -110,6 +112,19 @@ class ExecuteEntityProxyRequest {
|
|
|
110
112
|
audit.module = definition.moduleName;
|
|
111
113
|
this.assertProxyable(entityId, entity, definition, user);
|
|
112
114
|
|
|
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
|
+
|
|
113
128
|
const credentialRef = entity.credential;
|
|
114
129
|
const credentialId =
|
|
115
130
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -120,12 +135,14 @@ class ExecuteEntityProxyRequest {
|
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
const { rules } = resolveAllowRules(definition, this.proxyConfig.modules);
|
|
123
|
-
const rule = matchAllowRule(rules, validated.method, path);
|
|
138
|
+
const rule = matchAllowRule(rules, validated.method, path, base);
|
|
124
139
|
if (!rule) {
|
|
125
140
|
throw new ProxyRequestError(
|
|
126
141
|
'PROXY_NOT_ALLOWED',
|
|
127
|
-
`Module ${definition.moduleName} does not allow ${validated.method} on this path
|
|
128
|
-
|
|
142
|
+
`Module ${definition.moduleName} does not allow ${validated.method} on this path` +
|
|
143
|
+
// Single-base modules keep the message they always had.
|
|
144
|
+
(bases.names.length > 1 ? ` of base ${base}` : ''),
|
|
145
|
+
{ module: definition.moduleName, method: validated.method, base }
|
|
129
146
|
);
|
|
130
147
|
}
|
|
131
148
|
audit.pattern = rule.path;
|
|
@@ -153,11 +170,11 @@ class ExecuteEntityProxyRequest {
|
|
|
153
170
|
query: validated.query,
|
|
154
171
|
headers,
|
|
155
172
|
body: validated.body,
|
|
156
|
-
baseUrl: this.resolveBaseUrl(
|
|
173
|
+
baseUrl: this.resolveBaseUrl(bases, module.api, base),
|
|
157
174
|
timeoutMs: this.proxyConfig.timeoutMs,
|
|
158
175
|
maxResponseBytes: this.proxyConfig.maxResponseBytes,
|
|
159
176
|
isAllowedRedirect: (nextPath, nextMethod) =>
|
|
160
|
-
Boolean(matchAllowRule(rules, nextMethod, nextPath)),
|
|
177
|
+
Boolean(matchAllowRule(rules, nextMethod, nextPath, base)),
|
|
161
178
|
});
|
|
162
179
|
audit.upstreamStatus = upstream.status;
|
|
163
180
|
audit.responseBytes = upstream.body?.length ?? 0;
|
|
@@ -249,12 +266,40 @@ class ExecuteEntityProxyRequest {
|
|
|
249
266
|
}
|
|
250
267
|
}
|
|
251
268
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
269
|
+
/**
|
|
270
|
+
* The module's bases. Load already validated the definition
|
|
271
|
+
* (assertProxyConfig), so this only fails for a definition that skipped
|
|
272
|
+
* load; that is a configuration fault (501), never an unmapped 500.
|
|
273
|
+
*/
|
|
274
|
+
basesOf(definition) {
|
|
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;
|
|
258
303
|
}
|
|
259
304
|
|
|
260
305
|
/** 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.690.33ca435.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.690.33ca435.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.690.33ca435.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.690.33ca435.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": "33ca4351de5bab91e3aaf137f4758cb9380fcebb"
|
|
94
94
|
}
|
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: any;
|
|
18
|
+
definition: { moduleName?: string; proxy?: ModuleProxyConfig; [key: string]: any };
|
|
19
19
|
/** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */
|
|
20
20
|
sources: readonly EntityTypeSource[];
|
|
21
21
|
/** Frozen. */
|
|
@@ -73,6 +73,23 @@ 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;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
/** `appDefinition.managementApi.proxy`: the entity proxy, POST /api/v2/entities/:id/proxy (ADR-052). */
|