@friggframework/core 2.0.0--canary.690.33ca435.0 → 2.0.0--canary.686.45cab16.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.
@@ -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
- 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);
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.
@@ -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, PROXY_UNKNOWN_BASE. 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. 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 (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.",
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; `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.",
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, or PROXY_UNKNOWN_BASE (`base` is not one of the module\'s base names; the message lists them)', ref('ProxyError')),
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` of `base`.
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
- * @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}
144
+ * @returns {{method: string, path: string}|null}
148
145
  */
149
- function matchAllowRule(rules, method, path, base = DEFAULT_BASE) {
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, base?: string, query?: Object, headers: Object, body?: string, requestBytes: number, bodyIsJson: boolean }}
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
- // 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
- }
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, ...(base !== undefined && { base }), query, headers, body, bodyIsJson, requestBytes };
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] - the module-declared URL of the base the
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
- 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
- }
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, ProxyConfigError } = require('../proxy/proxy-errors');
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, base);
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
- // 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 }
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(bases, module.api, base),
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, base)),
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
- * 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;
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.690.33ca435.0",
4
+ "version": "2.0.0--canary.686.45cab16.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.690.33ca435.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.690.33ca435.0",
53
- "@friggframework/test": "2.0.0--canary.690.33ca435.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.686.45cab16.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.686.45cab16.0",
53
+ "@friggframework/test": "2.0.0--canary.686.45cab16.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": "33ca4351de5bab91e3aaf137f4758cb9380fcebb"
93
+ "gitHead": "45cab161a94a4023c0a35a47dbe27a6c559ea25b"
94
94
  }
@@ -91,12 +91,28 @@ function resolveActiveProvider(env = process.env, { appDefinition, resolve = res
91
91
  function isDeployedRuntime(env = process.env, { provider, appDefinition } = {}) {
92
92
  if (isLocalRun(env)) return false;
93
93
  if (isAwsLambdaRuntime(env)) return true;
94
- const active = provider === undefined ? resolveActiveProvider(env, { appDefinition }) : provider;
95
- if (!active || typeof active.detect !== 'function') return false;
94
+ if (provider === undefined) {
95
+ // A named non-AWS provider whose package cannot be loaded (or has no
96
+ // detect()) cannot say where we run. Fail closed: provider-netlify's
97
+ // generated entry points set FRIGG_PROVIDER, so treating that as local
98
+ // would let a deployed function accept unsigned queue messages.
99
+ const name = appDefinition?.provider || env.FRIGG_PROVIDER;
100
+ if (isSet(name) && name !== 'aws') {
101
+ const active = resolveActiveProvider(env, { appDefinition });
102
+ if (!active || typeof active.detect !== 'function') return true;
103
+ return askProvider(active, env, true);
104
+ }
105
+ return false;
106
+ }
107
+ if (!provider || typeof provider.detect !== 'function') return false;
108
+ return askProvider(provider, env, false);
109
+ }
110
+
111
+ function askProvider(active, env, onError) {
96
112
  try {
97
113
  return active.detect(env) === true;
98
114
  } catch (_) {
99
- return false;
115
+ return onError;
100
116
  }
101
117
  }
102
118
 
@@ -15,7 +15,7 @@ declare module "@friggframework/core" {
15
15
 
16
16
  export interface EntityTypeEntry {
17
17
  /** The API module definition. */
18
- definition: { moduleName?: string; proxy?: ModuleProxyConfig; [key: string]: any };
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,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
- };