@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.690.3fe2b27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +43 -4
- package/generated/prisma-mongodb/index-browser.js +40 -1
- package/generated/prisma-mongodb/index.d.ts +4761 -133
- package/generated/prisma-mongodb/index.js +43 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +92 -0
- package/generated/prisma-mongodb/wasm.js +43 -4
- package/generated/prisma-postgresql/edge.js +43 -4
- package/generated/prisma-postgresql/index-browser.js +40 -1
- package/generated/prisma-postgresql/index.d.ts +4961 -28
- package/generated/prisma-postgresql/index.js +43 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +87 -0
- package/generated/prisma-postgresql/wasm.js +43 -4
- package/handlers/app-definition-loader.js +128 -19
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +65 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -0
- package/integrations/integration-router.js +131 -96
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +415 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-bases.js +169 -0
- package/modules/proxy/proxy-errors.js +31 -0
- package/modules/proxy/proxy-path.js +183 -0
- package/modules/proxy/proxy-policy.js +277 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +245 -0
- package/modules/requester/requester.js +35 -5
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +331 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +92 -0
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +87 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +214 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
package/modules/module.js
CHANGED
|
@@ -21,8 +21,17 @@ class Module extends Delegate {
|
|
|
21
21
|
* @param {string} params.userId The user id
|
|
22
22
|
* @param {Object} params.entity The entity record from the database
|
|
23
23
|
* @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
|
|
24
|
+
* @param {Object} [params.resolvedEnv] definition.env as Module.create() resolved it (may be undefined).
|
|
25
|
+
* @param {boolean} [params.envResolved=false] true when resolvedEnv carries the resolved env, even an undefined one; otherwise the static definition.env is used.
|
|
24
26
|
*/
|
|
25
|
-
constructor({
|
|
27
|
+
constructor({
|
|
28
|
+
definition,
|
|
29
|
+
userId = null,
|
|
30
|
+
entity: entityObj = null,
|
|
31
|
+
state = null,
|
|
32
|
+
resolvedEnv,
|
|
33
|
+
envResolved = false,
|
|
34
|
+
}) {
|
|
26
35
|
super({ definition, userId, entity: entityObj });
|
|
27
36
|
|
|
28
37
|
this.validateDefinition(definition);
|
|
@@ -54,8 +63,23 @@ class Module extends Delegate {
|
|
|
54
63
|
|
|
55
64
|
Object.assign(this, this.definition.requiredAuthMethods);
|
|
56
65
|
|
|
66
|
+
// A pre-resolved env (Module.create()) supports definition.env as an
|
|
67
|
+
// async function, e.g. OAuth app credentials loaded from the database
|
|
68
|
+
// by the db-credentials extension; otherwise the static object.
|
|
69
|
+
// "Resolved" is an explicit flag, not a non-null value: a function env
|
|
70
|
+
// may legitimately resolve to undefined (no static env, no DB row),
|
|
71
|
+
// and falling back to definition.env would then read the function.
|
|
72
|
+
const envValues = envResolved ? resolvedEnv : this.definition.env;
|
|
73
|
+
if (typeof envValues === 'function') {
|
|
74
|
+
// Spreading a function yields no keys, so the API client would
|
|
75
|
+
// silently get no client_id / client_secret.
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`Module ${this.name}: definition.env is a function; build the module with await Module.create(...)`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
const apiParams = {
|
|
58
|
-
...
|
|
82
|
+
...envValues,
|
|
59
83
|
delegate: this,
|
|
60
84
|
logger: this.logger,
|
|
61
85
|
...(state ? { state } : {}),
|
|
@@ -67,6 +91,33 @@ class Module extends Delegate {
|
|
|
67
91
|
this.api = new this.apiClass(apiParams);
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Resolves definition.env, supporting both a static object and a
|
|
96
|
+
* (possibly async) function.
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} definition - Module definition
|
|
99
|
+
* @returns {Promise<Object|undefined>} Resolved env values
|
|
100
|
+
*/
|
|
101
|
+
static async resolveEnv(definition) {
|
|
102
|
+
if (typeof definition.env === 'function') {
|
|
103
|
+
return await definition.env();
|
|
104
|
+
}
|
|
105
|
+
return definition.env;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Async factory that resolves definition.env first, so a module whose
|
|
110
|
+
* env is a function gets its values. Prefer it over `new Module(...)`
|
|
111
|
+
* wherever the call site can await.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} params - same as the constructor, minus resolvedEnv
|
|
114
|
+
* @returns {Promise<Module>}
|
|
115
|
+
*/
|
|
116
|
+
static async create({ definition, userId = null, entity = null, state = null }) {
|
|
117
|
+
const resolvedEnv = await Module.resolveEnv(definition);
|
|
118
|
+
return new Module({ definition, userId, entity, state, resolvedEnv, envResolved: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
70
121
|
getName() {
|
|
71
122
|
return this.name;
|
|
72
123
|
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
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 };
|
|
@@ -0,0 +1,183 @@
|
|
|
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 };
|