@friggframework/core 2.0.0--canary.690.3fe2b27.0 → 2.0.0--canary.705.9cfd819.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.
Files changed (211) hide show
  1. package/CHANGELOG.md +0 -11
  2. package/CLAUDE.md +40 -28
  3. package/README.md +0 -105
  4. package/application/commands/entity-commands.js +18 -60
  5. package/application/commands/integration-commands.js +73 -20
  6. package/application/commands/scheduler-commands.js +7 -11
  7. package/application/commands/user-commands.js +5 -17
  8. package/application/index.js +2 -6
  9. package/core/Worker.js +7 -31
  10. package/core/create-handler.js +0 -9
  11. package/credential/repositories/credential-repository-documentdb.js +0 -34
  12. package/credential/repositories/credential-repository-interface.js +0 -15
  13. package/credential/repositories/credential-repository-mongo.js +0 -23
  14. package/credential/repositories/credential-repository-postgres.js +0 -28
  15. package/database/config.js +24 -48
  16. package/database/documentdb-encryption-service.js +22 -9
  17. package/database/documentdb-utils.js +0 -2
  18. package/database/encryption/README.md +14 -78
  19. package/database/encryption/documentdb-encryption-service.md +1 -78
  20. package/database/encryption/encryption-schema-registry.js +24 -121
  21. package/database/encryption/field-encryption-service.js +5 -69
  22. package/database/encryption/integration-mapping-encryption.js +1 -1
  23. package/database/prisma.js +38 -15
  24. package/database/use-cases/check-encryption-health-use-case.js +52 -43
  25. package/errors/index.js +4 -0
  26. package/errors/mapping-already-exists-error.js +14 -0
  27. package/generated/prisma-mongodb/edge.js +4 -43
  28. package/generated/prisma-mongodb/index-browser.js +1 -40
  29. package/generated/prisma-mongodb/index.d.ts +111 -4739
  30. package/generated/prisma-mongodb/index.js +4 -43
  31. package/generated/prisma-mongodb/package.json +1 -1
  32. package/generated/prisma-mongodb/schema.prisma +0 -92
  33. package/generated/prisma-mongodb/wasm.js +4 -43
  34. package/generated/prisma-postgresql/edge.js +4 -43
  35. package/generated/prisma-postgresql/index-browser.js +1 -40
  36. package/generated/prisma-postgresql/index.d.ts +33 -4966
  37. package/generated/prisma-postgresql/index.js +4 -43
  38. package/generated/prisma-postgresql/package.json +1 -1
  39. package/generated/prisma-postgresql/schema.prisma +0 -87
  40. package/generated/prisma-postgresql/wasm.js +4 -43
  41. package/handlers/WEBHOOKS.md +29 -0
  42. package/handlers/app-definition-loader.js +19 -128
  43. package/handlers/app-handler-helpers.js +3 -25
  44. package/handlers/backend-utils.js +25 -31
  45. package/handlers/middleware/admin-auth.js +1 -14
  46. package/handlers/routers/auth.js +10 -46
  47. package/handlers/routers/health.js +4 -13
  48. package/handlers/routers/integration-defined-routers.js +92 -144
  49. package/handlers/routers/integration-webhook-routers.js +64 -102
  50. package/handlers/workers/integration-defined-workers.js +2 -5
  51. package/index.js +3 -65
  52. package/infrastructure/scheduler/index.js +6 -19
  53. package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
  54. package/integrations/index.js +0 -1
  55. package/integrations/integration-base.js +29 -0
  56. package/integrations/integration-router.js +96 -131
  57. package/integrations/repositories/integration-mapping-repository-documentdb.js +50 -0
  58. package/integrations/repositories/integration-mapping-repository-interface.js +15 -0
  59. package/integrations/repositories/integration-mapping-repository-mongo.js +29 -0
  60. package/integrations/repositories/integration-mapping-repository-postgres.js +30 -0
  61. package/integrations/repositories/integration-mapping-repository.js +29 -0
  62. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  63. package/integrations/repositories/integration-repository-interface.js +31 -17
  64. package/integrations/repositories/integration-repository-mongo.js +129 -60
  65. package/integrations/repositories/integration-repository-postgres.js +134 -62
  66. package/integrations/repositories/process-repository-mongo.js +1 -1
  67. package/integrations/tests/doubles/test-integration-repository.js +1 -12
  68. package/integrations/use-cases/add-entities-to-integration.js +92 -0
  69. package/integrations/use-cases/create-integration.js +75 -72
  70. package/integrations/use-cases/get-integrations-for-user.js +40 -80
  71. package/integrations/use-cases/index.js +1 -7
  72. package/integrations/use-cases/remove-entities-from-integration.js +70 -0
  73. package/integrations/utils/assert-entity-ids-input.js +42 -0
  74. package/integrations/utils/map-integration-dto.js +14 -43
  75. package/modules/index.js +0 -14
  76. package/modules/module-factory.js +0 -12
  77. package/modules/module.js +2 -53
  78. package/modules/repositories/module-repository-documentdb.js +6 -51
  79. package/modules/repositories/module-repository-interface.js +2 -19
  80. package/modules/repositories/module-repository-mongo.js +74 -67
  81. package/modules/repositories/module-repository-postgres.js +74 -66
  82. package/modules/repositories/module-repository.js +1 -2
  83. package/modules/requester/requester.js +5 -35
  84. package/modules/use-cases/get-entities-for-user.js +13 -26
  85. package/modules/use-cases/get-entity-options-by-id.js +4 -10
  86. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  87. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  88. package/modules/use-cases/get-module.js +4 -10
  89. package/modules/use-cases/process-authorization-callback.js +19 -53
  90. package/modules/use-cases/refresh-entity-options.js +4 -10
  91. package/modules/use-cases/test-module-auth.js +4 -10
  92. package/modules/utils/map-module-dto.js +5 -3
  93. package/modules/utils/to-credential-id.js +14 -0
  94. package/package.json +5 -6
  95. package/prisma-mongodb/schema.prisma +0 -92
  96. package/prisma-postgresql/schema.prisma +0 -87
  97. package/queues/index.js +0 -25
  98. package/types/index.d.ts +0 -2
  99. package/user/repositories/user-repository-documentdb.js +2 -27
  100. package/user/repositories/user-repository-interface.js +0 -18
  101. package/user/repositories/user-repository-mongo.js +0 -24
  102. package/user/repositories/user-repository-postgres.js +0 -18
  103. package/user/tests/doubles/test-user-repository.js +1 -1
  104. package/user/use-cases/authenticate-user.js +1 -7
  105. package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
  106. package/user/use-cases/get-user-from-bearer-token.js +7 -2
  107. package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
  108. package/user/user.js +1 -64
  109. package/credential/use-cases/credential-operations-for-user.js +0 -202
  110. package/credential/use-cases/reauthorize-credential-for-user.js +0 -73
  111. package/database/encryption/encryption-config.js +0 -219
  112. package/database/encryption/test-support/fake-ciphertext.js +0 -15
  113. package/database/organization-where.js +0 -18
  114. package/database/unique-violation.js +0 -25
  115. package/extensions/bootstrap-runner.js +0 -43
  116. package/extensions/extension-loader.js +0 -187
  117. package/extensions/index.js +0 -41
  118. package/extensions/initialize-app.js +0 -123
  119. package/extensions/route-mounter.js +0 -103
  120. package/extensions/schema-composer.js +0 -188
  121. package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -216
  122. package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
  123. package/integrations/repositories/integration-not-found.js +0 -14
  124. package/integrations/schedule-config.js +0 -80
  125. package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
  126. package/integrations/use-cases/integration-operations-for-user.js +0 -256
  127. package/integrations/use-cases/resolve-integration-ownership.js +0 -177
  128. package/management-api/create-management-api-router.js +0 -49
  129. package/management-api/error-response.js +0 -116
  130. package/management-api/management-api-config.js +0 -151
  131. package/management-api/meta-router.js +0 -76
  132. package/management-api/openapi/build-openapi.js +0 -144
  133. package/management-api/openapi/components.js +0 -415
  134. package/management-api/openapi/v1-operations.js +0 -83
  135. package/management-api/openapi/v2-operations.js +0 -264
  136. package/management-api/route-registry.js +0 -411
  137. package/management-api/test-support/http-client.js +0 -39
  138. package/management-api/use-cases/get-management-api-meta.js +0 -57
  139. package/management-api/v1-deprecation.js +0 -120
  140. package/management-api/v2/create-v2-router.js +0 -85
  141. package/management-api/v2/dependencies.js +0 -281
  142. package/management-api/v2/dto.js +0 -143
  143. package/management-api/v2/handlers/authorization.js +0 -94
  144. package/management-api/v2/handlers/credentials.js +0 -33
  145. package/management-api/v2/handlers/entities.js +0 -77
  146. package/management-api/v2/handlers/integrations.js +0 -128
  147. package/management-api/v2/handlers/proxy.js +0 -34
  148. package/management-api/v2/request.js +0 -85
  149. package/management-api/v2/test-support/fake-dependencies.js +0 -103
  150. package/modules/domain/authorization-session.js +0 -144
  151. package/modules/entity-scope.js +0 -15
  152. package/modules/entity-type-registry.js +0 -311
  153. package/modules/proxy/proxy-bases.js +0 -169
  154. package/modules/proxy/proxy-errors.js +0 -31
  155. package/modules/proxy/proxy-path.js +0 -183
  156. package/modules/proxy/proxy-policy.js +0 -277
  157. package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
  158. package/modules/repositories/authorization-session-repository-factory.js +0 -31
  159. package/modules/repositories/authorization-session-repository-interface.js +0 -47
  160. package/modules/repositories/authorization-session-repository-prisma.js +0 -87
  161. package/modules/repositories/entity-not-found.js +0 -18
  162. package/modules/requester/proxy-request.js +0 -245
  163. package/modules/scope-config.js +0 -192
  164. package/modules/scope-policy.js +0 -112
  165. package/modules/scope-resolution.js +0 -288
  166. package/modules/scope-visibility.js +0 -247
  167. package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
  168. package/modules/tests/doubles/ownership-matching.js +0 -18
  169. package/modules/use-cases/authorize-entity-for-user.js +0 -61
  170. package/modules/use-cases/create-entity-from-credential.js +0 -113
  171. package/modules/use-cases/entity-operations-for-user.js +0 -162
  172. package/modules/use-cases/entity-types.js +0 -139
  173. package/modules/use-cases/execute-entity-proxy-request.js +0 -331
  174. package/modules/use-cases/get-authorization-requirements.js +0 -100
  175. package/modules/use-cases/get-entity-module-for-user.js +0 -113
  176. package/modules/use-cases/manage-access.js +0 -21
  177. package/modules/use-cases/multi-step-authorization.js +0 -329
  178. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
  179. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
  180. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
  181. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
  182. package/providers/index.js +0 -23
  183. package/providers/resolve-provider.js +0 -93
  184. package/providers/runtime-detection.js +0 -124
  185. package/queues/providers/index.js +0 -11
  186. package/queues/providers/netlify-background-provider.js +0 -150
  187. package/queues/providers/qstash-queue-provider.js +0 -219
  188. package/queues/providers/sqs-queue-provider.js +0 -70
  189. package/queues/queue-client-interface.js +0 -55
  190. package/queues/queue-message-auth.js +0 -193
  191. package/queues/queue-provider-factory.js +0 -101
  192. package/queues/queue-provider.js +0 -65
  193. package/queues/sqs-queue-client.js +0 -81
  194. package/types/modules/index.d.ts +0 -214
  195. package/types/user/index.d.ts +0 -134
  196. package/user/adopter-jwt-config.js +0 -177
  197. package/user/adopter-jwt-errors.js +0 -59
  198. package/user/adopter-token-verifier.js +0 -122
  199. package/user/app-user-helpers.js +0 -97
  200. package/user/create-user-authentication.js +0 -64
  201. package/user/membership.js +0 -51
  202. package/user/repositories/organization-membership-record.js +0 -19
  203. package/user/repositories/organization-membership-repository-documentdb.js +0 -106
  204. package/user/repositories/organization-membership-repository-factory.js +0 -42
  205. package/user/repositories/organization-membership-repository-interface.js +0 -57
  206. package/user/repositories/organization-membership-repository-mongo.js +0 -13
  207. package/user/repositories/organization-membership-repository-postgres.js +0 -29
  208. package/user/repositories/organization-membership-repository-prisma.js +0 -81
  209. package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
  210. package/user/use-cases/ensure-organization-membership.js +0 -92
  211. package/user/use-cases/find-individual-users-by-organization.js +0 -54
@@ -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
- };
@@ -1,31 +0,0 @@
1
- /**
2
- * A proxied call was refused or failed in a way the Management API maps to a
3
- * documented status (ADR-052 §10). `code` is the public error code; nothing
4
- * here carries the upstream URL, headers or credential material.
5
- */
6
- class ProxyRequestError extends Error {
7
- /**
8
- * @param {string} code - e.g. INVALID_PROXY_REQUEST, PROXY_NOT_ALLOWED, TIMEOUT
9
- * @param {string} message - safe to show the caller
10
- * @param {Object} [details] - safe structured details
11
- */
12
- constructor(code, message, details) {
13
- super(message);
14
- this.name = 'ProxyRequestError';
15
- this.code = code;
16
- this.details = details;
17
- }
18
- }
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 };
@@ -1,183 +0,0 @@
1
- const { ProxyRequestError } = require('./proxy-errors');
2
- const { DEFAULT_BASE, ruleBase } = require('./proxy-bases');
3
-
4
- /**
5
- * Path rules for the entity proxy (ADR-052 §4). The caller supplies a path
6
- * relative to the module's base URL and nothing else: the host, scheme and
7
- * port always come from the module.
8
- */
9
-
10
- const invalid = (reason) =>
11
- new ProxyRequestError('INVALID_PROXY_REQUEST', `Invalid proxy path: ${reason}`, {
12
- field: 'path',
13
- });
14
-
15
- // Control characters (C0, DEL, C1), any whitespace, and invisible format
16
- // characters such as zero-width spaces and byte-order marks.
17
- const FORBIDDEN_CHARS = /[\x00-\x20\x7f-\x9f]|\s|\p{Cf}/u;
18
-
19
- /**
20
- * Validates and normalises a caller-supplied path.
21
- *
22
- * - must start with a single `/`; `//` (anywhere), `\`, `?`, `#`, control
23
- * and whitespace characters are rejected;
24
- * - encoded separators (`%2f`, `%5c`) are rejected, then the path is
25
- * percent-decoded exactly once; a `%` left after decoding (double
26
- * encoding) is rejected, as is any decoded control or whitespace
27
- * character other than a space (`%20`);
28
- * - `.` and `..` segments are rejected rather than resolved.
29
- *
30
- * @param {unknown} path
31
- * @returns {string} the decoded path, e.g. `/crm/v3/objects/contacts`
32
- * @throws {ProxyRequestError} INVALID_PROXY_REQUEST
33
- */
34
- function normalizeProxyPath(path) {
35
- if (typeof path !== 'string' || path.length === 0) {
36
- throw invalid('path must be a non-empty string');
37
- }
38
- if (path.length > 2048) throw invalid('path is too long');
39
- if (!path.startsWith('/')) throw invalid('path must start with /');
40
- if (path.startsWith('//') || path.includes('//')) {
41
- throw invalid('empty path segments (//) are not allowed');
42
- }
43
- if (path.includes('\\')) throw invalid('backslashes are not allowed');
44
- if (path.includes('?') || path.includes('#')) {
45
- throw invalid('send the query string in `query`, not in the path');
46
- }
47
- if (FORBIDDEN_CHARS.test(path)) {
48
- throw invalid('control and whitespace characters are not allowed');
49
- }
50
- if (/%(2f|5c)/i.test(path)) {
51
- throw invalid('encoded path separators are not allowed');
52
- }
53
-
54
- let decoded;
55
- try {
56
- decoded = decodeURIComponent(path);
57
- } catch {
58
- throw invalid('malformed percent-encoding');
59
- }
60
- if (
61
- decoded.includes('%') ||
62
- decoded.includes('\\') ||
63
- decoded.includes('?') ||
64
- decoded.includes('#') ||
65
- decoded.includes('//') ||
66
- // An encoded space (%20) is a legitimate path character; every other
67
- // control or whitespace character stays forbidden.
68
- FORBIDDEN_CHARS.test(decoded.replaceAll(' ', ''))
69
- ) {
70
- throw invalid('encoded characters decode to something not allowed');
71
- }
72
-
73
- const segments = decoded.split('/').slice(1);
74
- if (segments.some((segment) => segment === '.' || segment === '..')) {
75
- throw invalid('dot segments (. and ..) are not allowed');
76
- }
77
- return decoded;
78
- }
79
-
80
- /**
81
- * Joins a module base URL and a normalised path, then proves the result
82
- * cannot leave the module's API: https only, same origin as the base URL,
83
- * and under the base URL's path.
84
- *
85
- * @param {string} baseUrl - from the module, never from the request
86
- * @param {string} normalizedPath - from normalizeProxyPath()
87
- * @returns {{ url: string, basePath: string, origin: string }} `url` is not
88
- * percent-encoded; the Requester encodes it once.
89
- * @throws {ProxyRequestError} PROXY_NOT_CONFIGURED or INVALID_PROXY_REQUEST
90
- */
91
- function joinProxyUrl(baseUrl, normalizedPath) {
92
- let base;
93
- try {
94
- base = new URL(baseUrl);
95
- } catch {
96
- throw new ProxyRequestError(
97
- 'PROXY_NOT_CONFIGURED',
98
- 'The module has no valid base URL to proxy to'
99
- );
100
- }
101
- if (base.protocol !== 'https:') {
102
- throw new ProxyRequestError(
103
- 'PROXY_NOT_CONFIGURED',
104
- 'The module base URL must use https to be proxied'
105
- );
106
- }
107
- if (base.username || base.password || base.search || base.hash) {
108
- throw new ProxyRequestError(
109
- 'PROXY_NOT_CONFIGURED',
110
- 'The module base URL must not carry credentials, a query or a fragment'
111
- );
112
- }
113
-
114
- const basePath = base.pathname.replace(/\/+$/, '');
115
- const url = `${base.origin}${basePath}${normalizedPath}`;
116
-
117
- let joined;
118
- try {
119
- joined = new URL(url);
120
- } catch {
121
- throw invalid('the joined URL is not valid');
122
- }
123
- const joinedPath = decodeURIComponent(joined.pathname);
124
- if (
125
- joined.protocol !== 'https:' ||
126
- joined.origin !== base.origin ||
127
- joined.username ||
128
- joined.password ||
129
- !(joinedPath === basePath || joinedPath.startsWith(`${basePath}/`))
130
- ) {
131
- throw invalid('the path leaves the module API');
132
- }
133
- return { url, basePath, origin: base.origin };
134
- }
135
-
136
- /**
137
- * Finds the allow-list rule that permits `method` on `path` of `base`.
138
- *
139
- * Patterns use `:param` for exactly one segment and a trailing `/**` for a
140
- * subtree (the prefix itself included). `method: '*'` matches any method.
141
- *
142
- * @param {Array<{method: string, path: string}>} rules
143
- * @param {string} method - upper case
144
- * @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}
148
- */
149
- function matchAllowRule(rules, method, path, base = DEFAULT_BASE) {
150
- const requestSegments = path.split('/').slice(1);
151
- for (const rule of rules || []) {
152
- if (!rule || typeof rule.path !== 'string' || typeof rule.method !== 'string') {
153
- continue;
154
- }
155
- if (ruleBase(rule) !== base) continue;
156
- const ruleMethod = rule.method.toUpperCase();
157
- if (ruleMethod !== '*' && ruleMethod !== method) continue;
158
- if (patternMatches(rule.path, requestSegments)) return rule;
159
- }
160
- return null;
161
- }
162
-
163
- function patternMatches(pattern, requestSegments) {
164
- if (!pattern.startsWith('/')) return false;
165
- let patternSegments = pattern.split('/').slice(1);
166
- let subtree = false;
167
- if (patternSegments[patternSegments.length - 1] === '**') {
168
- subtree = true;
169
- patternSegments = patternSegments.slice(0, -1);
170
- }
171
- if (subtree) {
172
- if (requestSegments.length < patternSegments.length) return false;
173
- } else if (requestSegments.length !== patternSegments.length) {
174
- return false;
175
- }
176
- return patternSegments.every((segment, index) => {
177
- const actual = requestSegments[index];
178
- if (segment.startsWith(':')) return actual !== undefined && actual !== '';
179
- return segment === actual;
180
- });
181
- }
182
-
183
- module.exports = { normalizeProxyPath, joinProxyUrl, matchAllowRule };
@@ -1,277 +0,0 @@
1
- const { ProxyRequestError } = require('./proxy-errors');
2
- const { isValidBaseName } = require('./proxy-bases');
3
-
4
- /**
5
- * What the entity proxy accepts and returns (ADR-052 §5-§7). Pure functions:
6
- * no I/O, so every rule is unit-tested on its own.
7
- */
8
-
9
- const PROXY_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
10
- const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
11
-
12
- /** Request headers a caller may always send (lower case). */
13
- const ALLOWED_REQUEST_HEADERS = [
14
- 'accept',
15
- 'content-type',
16
- 'accept-language',
17
- 'if-match',
18
- 'if-none-match',
19
- ];
20
-
21
- /** Request headers that are refused outright, even if a module lists them. */
22
- const REJECTED_REQUEST_HEADERS = [
23
- 'authorization',
24
- 'proxy-authorization',
25
- 'cookie',
26
- 'host',
27
- 'forwarded',
28
- 'x-real-ip',
29
- 'x-api-key',
30
- ];
31
- const REJECTED_REQUEST_HEADER_PREFIXES = ['x-forwarded-'];
32
-
33
- /** Response headers never passed back (credentials and hop-by-hop). */
34
- const REMOVED_RESPONSE_HEADERS = [
35
- 'authorization',
36
- 'proxy-authorization',
37
- 'proxy-authenticate',
38
- 'set-cookie',
39
- 'set-cookie2',
40
- 'www-authenticate',
41
- 'x-api-key',
42
- 'connection',
43
- 'keep-alive',
44
- 'te',
45
- 'trailer',
46
- 'transfer-encoding',
47
- 'upgrade',
48
- // The body is re-encoded as JSON, so these no longer describe it.
49
- 'content-length',
50
- 'content-encoding',
51
- ];
52
-
53
- const invalid = (message, details) =>
54
- new ProxyRequestError('INVALID_PROXY_REQUEST', message, details);
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
-
63
- const isPlainObject = (value) =>
64
- value !== null && typeof value === 'object' && !Array.isArray(value);
65
-
66
- /**
67
- * Validates the request body of POST /api/v2/entities/:id/proxy and returns
68
- * it with the method upper-cased and the body serialised.
69
- *
70
- * @param {unknown} request
71
- * @param {{ maxRequestBytes: number }} limits
72
- * @returns {{ method: string, path: string, base?: string, query?: Object, headers: Object, body?: string, requestBytes: number, bodyIsJson: boolean }}
73
- */
74
- function validateProxyRequest(request, { maxRequestBytes }) {
75
- if (!isPlainObject(request)) throw invalid('The proxy request must be a JSON object');
76
- const method = typeof request.method === 'string' ? request.method.toUpperCase() : '';
77
- if (!PROXY_METHODS.includes(method)) {
78
- throw invalid(`method must be one of ${PROXY_METHODS.join(', ')}`, { field: 'method' });
79
- }
80
- if (typeof request.path !== 'string') {
81
- throw invalid('path is required', { field: 'path' });
82
- }
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
-
97
- let query;
98
- if (request.query !== undefined && request.query !== null) {
99
- if (!isPlainObject(request.query)) throw invalid('query must be an object', { field: 'query' });
100
- query = {};
101
- for (const [key, value] of Object.entries(request.query)) {
102
- const scalar = ['string', 'number', 'boolean'].includes(typeof value);
103
- const list = Array.isArray(value) && value.every((v) => typeof v === 'string');
104
- if (!scalar && !list) {
105
- throw invalid(`query.${key} must be a string, number, boolean or array of strings`, {
106
- field: `query.${key}`,
107
- });
108
- }
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
- }
116
- }
117
- }
118
-
119
- const headers = {};
120
- if (request.headers !== undefined && request.headers !== null) {
121
- if (!isPlainObject(request.headers)) throw invalid('headers must be an object', { field: 'headers' });
122
- for (const [name, value] of Object.entries(request.headers)) {
123
- if (typeof value !== 'string') {
124
- throw invalid(`header ${name} must be a string`, { field: `headers.${name}` });
125
- }
126
- headers[name.toLowerCase()] = value;
127
- }
128
- }
129
-
130
- let body;
131
- let bodyIsJson = false;
132
- if (request.body !== undefined && request.body !== null) {
133
- if (!METHODS_WITH_BODY.has(method)) {
134
- throw invalid(`${method} requests cannot have a body`, { field: 'body' });
135
- }
136
- if (typeof request.body === 'string') {
137
- body = request.body;
138
- } else {
139
- body = JSON.stringify(request.body);
140
- bodyIsJson = true;
141
- }
142
- }
143
- const requestBytes = body === undefined ? 0 : Buffer.byteLength(body);
144
- if (requestBytes > maxRequestBytes) {
145
- throw new ProxyRequestError(
146
- 'PAYLOAD_TOO_LARGE',
147
- `The proxied request body is larger than ${maxRequestBytes} bytes`
148
- );
149
- }
150
- return { method, path: request.path, ...(base !== undefined && { base }), query, headers, body, bodyIsJson, requestBytes };
151
- }
152
-
153
- const isWildcardRule = (rule) => rule?.method === '*' || rule?.path === '/**';
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.
157
- const isValidRule = (rule) =>
158
- isPlainObject(rule) &&
159
- typeof rule.method === 'string' &&
160
- typeof rule.path === 'string' &&
161
- rule.path.startsWith('/') &&
162
- (rule.base === undefined || isValidBaseName(rule.base));
163
-
164
- /**
165
- * The allow-list in force for a module: the app definition's list for it when
166
- * one is given (`managementApi.proxy.modules[name].allow`), otherwise the
167
- * module's own `proxy.allow`. A module cannot grant itself a wildcard; only
168
- * the app definition can. No list means nothing may be proxied.
169
- *
170
- * @returns {{ rules: Array<{method: string, path: string}>, source: 'app'|'module'|'none', ignored: number }}
171
- */
172
- function resolveAllowRules(definition, moduleOverrides = {}) {
173
- const override = moduleOverrides?.[definition.moduleName]?.allow;
174
- if (Array.isArray(override)) {
175
- return { rules: override.filter(isValidRule), source: 'app', ignored: 0 };
176
- }
177
- const declared = Array.isArray(definition.proxy?.allow) ? definition.proxy.allow : [];
178
- const rules = declared.filter((rule) => isValidRule(rule) && !isWildcardRule(rule));
179
- return {
180
- rules,
181
- source: rules.length ? 'module' : 'none',
182
- ignored: declared.length - rules.length,
183
- };
184
- }
185
-
186
- /**
187
- * Keeps only the request headers the caller may set. A header from the
188
- * rejected list (credentials, Host, forwarding headers, or one the module's
189
- * own auth sets) fails the request instead of being dropped quietly.
190
- *
191
- * @param {Object} headers - lower-cased names
192
- * @param {Object} options
193
- * @param {string[]} [options.moduleHeaders] - from the module's `proxy.headers`
194
- * @param {string[]} [options.authHeaderNames] - headers the module's auth sets
195
- */
196
- function filterRequestHeaders(headers, { moduleHeaders = [], authHeaderNames = [] } = {}) {
197
- const rejected = new Set([
198
- ...REJECTED_REQUEST_HEADERS,
199
- ...authHeaderNames.filter(Boolean).map((name) => String(name).toLowerCase()),
200
- ]);
201
- const allowed = new Set([
202
- ...ALLOWED_REQUEST_HEADERS,
203
- ...moduleHeaders.map((name) => String(name).toLowerCase()),
204
- ]);
205
- const kept = {};
206
- for (const [name, value] of Object.entries(headers)) {
207
- if (
208
- rejected.has(name) ||
209
- REJECTED_REQUEST_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))
210
- ) {
211
- throw invalid(`The ${name} header cannot be sent through the proxy`, {
212
- field: `headers.${name}`,
213
- });
214
- }
215
- if (allowed.has(name)) kept[name] = value;
216
- }
217
- return kept;
218
- }
219
-
220
- /**
221
- * Removes credential and hop-by-hop headers from an upstream response, and
222
- * the Location of a redirect the proxy refused to follow.
223
- */
224
- function filterResponseHeaders(headers, { redirectBlocked = false, authHeaderNames = [] } = {}) {
225
- const removed = new Set([
226
- ...REMOVED_RESPONSE_HEADERS,
227
- ...authHeaderNames.filter(Boolean).map((name) => String(name).toLowerCase()),
228
- ]);
229
- if (redirectBlocked) removed.add('location');
230
- const kept = {};
231
- for (const [name, value] of Object.entries(headers || {})) {
232
- if (!removed.has(name.toLowerCase())) kept[name.toLowerCase()] = value;
233
- }
234
- return kept;
235
- }
236
-
237
- const JSON_TYPE = /^application\/([a-z0-9.+-]*\+)?json\b/i;
238
- const TEXT_TYPE = /^(text\/|application\/(xml|x-www-form-urlencoded|javascript|graphql)\b|[a-z]+\/[a-z0-9.+-]*\+xml\b)/i;
239
-
240
- /**
241
- * Turns the upstream body into the `data` of the proxy response: parsed JSON,
242
- * text, or (only if the module allows binary) base64.
243
- *
244
- * @returns {{ data: unknown, encoding?: 'base64' }}
245
- */
246
- function decodeResponseBody(body, contentType, { binary = false } = {}) {
247
- if (!body || body.length === 0) return { data: null };
248
- if (JSON_TYPE.test(contentType)) {
249
- const text = body.toString('utf8');
250
- try {
251
- return { data: JSON.parse(text) };
252
- } catch {
253
- return { data: text };
254
- }
255
- }
256
- if (!contentType || TEXT_TYPE.test(contentType)) {
257
- return { data: body.toString('utf8') };
258
- }
259
- if (binary) return { data: body.toString('base64'), encoding: 'base64' };
260
- throw new ProxyRequestError(
261
- 'UNSUPPORTED_MEDIA_TYPE',
262
- 'The upstream response is binary and this module does not allow binary proxying',
263
- { contentType: contentType.split(';')[0] }
264
- );
265
- }
266
-
267
- module.exports = {
268
- PROXY_METHODS,
269
- ALLOWED_REQUEST_HEADERS,
270
- REJECTED_REQUEST_HEADERS,
271
- REMOVED_RESPONSE_HEADERS,
272
- validateProxyRequest,
273
- resolveAllowRules,
274
- filterRequestHeaders,
275
- filterResponseHeaders,
276
- decodeResponseBody,
277
- };