@friggframework/core 2.0.0--canary.680.6bacd26.0 → 2.0.0--canary.663.8de669c.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 +0 -11
- package/application/commands/integration-commands.js +8 -18
- package/application/index.js +2 -6
- package/credential/repositories/credential-repository-documentdb.js +0 -34
- package/credential/repositories/credential-repository-interface.js +0 -15
- package/credential/repositories/credential-repository-mongo.js +0 -23
- package/credential/repositories/credential-repository-postgres.js +0 -28
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-schema-registry.js +12 -41
- package/database/encryption/field-encryption-service.js +5 -69
- package/generated/prisma-mongodb/edge.js +4 -20
- package/generated/prisma-mongodb/index-browser.js +1 -17
- package/generated/prisma-mongodb/index.d.ts +95 -1543
- package/generated/prisma-mongodb/index.js +4 -20
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -27
- package/generated/prisma-mongodb/wasm.js +4 -20
- package/generated/prisma-postgresql/edge.js +4 -20
- package/generated/prisma-postgresql/index-browser.js +1 -17
- package/generated/prisma-postgresql/index.d.ts +84 -1565
- package/generated/prisma-postgresql/index.js +4 -20
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -26
- package/generated/prisma-postgresql/wasm.js +4 -20
- package/handlers/app-definition-loader.js +8 -19
- package/handlers/app-handler-helpers.js +3 -25
- package/handlers/backend-utils.js +25 -31
- package/handlers/middleware/admin-auth.js +1 -14
- package/handlers/routers/auth.js +3 -11
- package/handlers/routers/health.js +4 -11
- package/handlers/workers/integration-defined-workers.js +2 -5
- package/index.js +2 -4
- package/integrations/index.js +4 -0
- package/integrations/integration-router.js +23 -16
- package/integrations/utils/map-integration-dto.js +15 -1
- package/modules/index.js +0 -6
- package/modules/requester/requester.js +2 -27
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +0 -27
- package/prisma-postgresql/schema.prisma +0 -26
- package/types/index.d.ts +0 -1
- package/credential/use-cases/credential-operations-for-user.js +0 -117
- package/credential/use-cases/reauthorize-credential-for-user.js +0 -66
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
- package/integrations/use-cases/integration-operations-for-user.js +0 -225
- package/management-api/create-management-api-router.js +0 -49
- package/management-api/error-response.js +0 -116
- package/management-api/management-api-config.js +0 -82
- package/management-api/meta-router.js +0 -57
- package/management-api/openapi/build-openapi.js +0 -144
- package/management-api/openapi/components.js +0 -368
- package/management-api/openapi/v1-operations.js +0 -83
- package/management-api/openapi/v2-operations.js +0 -252
- package/management-api/route-registry.js +0 -294
- package/management-api/test-support/http-client.js +0 -39
- package/management-api/use-cases/get-management-api-meta.js +0 -57
- package/management-api/v1-deprecation.js +0 -120
- package/management-api/v2/create-v2-router.js +0 -85
- package/management-api/v2/dependencies.js +0 -256
- package/management-api/v2/dto.js +0 -123
- package/management-api/v2/handlers/authorization.js +0 -93
- package/management-api/v2/handlers/credentials.js +0 -29
- package/management-api/v2/handlers/entities.js +0 -74
- package/management-api/v2/handlers/integrations.js +0 -125
- package/management-api/v2/handlers/proxy.js +0 -34
- package/management-api/v2/request.js +0 -54
- package/management-api/v2/test-support/fake-dependencies.js +0 -103
- package/modules/domain/authorization-session.js +0 -144
- package/modules/entity-type-registry.js +0 -311
- package/modules/proxy/proxy-errors.js +0 -20
- package/modules/proxy/proxy-path.js +0 -179
- package/modules/proxy/proxy-policy.js +0 -248
- package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
- package/modules/repositories/authorization-session-repository-factory.js +0 -31
- package/modules/repositories/authorization-session-repository-interface.js +0 -47
- package/modules/repositories/authorization-session-repository-prisma.js +0 -87
- package/modules/requester/proxy-request.js +0 -244
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
- package/modules/use-cases/entity-operations-for-user.js +0 -125
- package/modules/use-cases/entity-types.js +0 -115
- package/modules/use-cases/execute-entity-proxy-request.js +0 -239
- package/modules/use-cases/get-authorization-requirements.js +0 -100
- package/modules/use-cases/get-entity-module-for-user.js +0 -86
- package/modules/use-cases/multi-step-authorization.js +0 -302
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
- package/types/modules/index.d.ts +0 -58
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* App-level entity type registry (ADR-059).
|
|
3
|
-
*
|
|
4
|
-
* Pure: reads an app definition, does no I/O. It answers "which entity types
|
|
5
|
-
* (API modules) can this app connect?" from two sources:
|
|
6
|
-
*
|
|
7
|
-
* 1. `appDefinition.entities`, an object keyed by entity type name whose
|
|
8
|
-
* values are `{ definition, scope?, proxy?, display? }`.
|
|
9
|
-
* 2. Inference: every module bound by `appDefinition.integrations[*]
|
|
10
|
-
* .Definition.modules` (skipped when `entities.$infer === false`).
|
|
11
|
-
*
|
|
12
|
-
* `loadAppDefinition()` builds the registry once, at load, and returns it as
|
|
13
|
-
* `entityTypeRegistry` / `moduleDefinitions`; every consumer that holds a
|
|
14
|
-
* loaded app definition reads those instead of rebuilding.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const { BaseError } = require('../errors/base-error');
|
|
18
|
-
const { getLogger } = require('../logs');
|
|
19
|
-
|
|
20
|
-
const log = getLogger('frigg.modules.entity_registry');
|
|
21
|
-
|
|
22
|
-
const INFER_KEY = '$infer';
|
|
23
|
-
const UNNAMED = '(unnamed definition)';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Raised for an invalid `entities` registry. `code` is always
|
|
27
|
-
* `ENTITY_REGISTRY_INVALID`, so callers can tell it from other load errors.
|
|
28
|
-
*/
|
|
29
|
-
class EntityRegistryError extends BaseError {
|
|
30
|
-
constructor(message, options) {
|
|
31
|
-
super(message, options);
|
|
32
|
-
this.code = 'ENTITY_REGISTRY_INVALID';
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const isPlainObject = (value) =>
|
|
37
|
-
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
38
|
-
|
|
39
|
-
const appSource = () => ({ kind: 'app' });
|
|
40
|
-
const integrationSource = (integrationClass) => ({
|
|
41
|
-
kind: 'integration',
|
|
42
|
-
name: integrationClass?.Definition?.name || 'unnamed',
|
|
43
|
-
});
|
|
44
|
-
const describeSource = (source) =>
|
|
45
|
-
source.kind === 'app' ? 'app' : `integration:${source.name}`;
|
|
46
|
-
|
|
47
|
-
const safeGetName = (definition) => {
|
|
48
|
-
if (typeof definition.getName !== 'function') return undefined;
|
|
49
|
-
try {
|
|
50
|
-
const name = definition.getName();
|
|
51
|
-
return typeof name === 'string' && name ? name : undefined;
|
|
52
|
-
} catch {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const moduleNameOf = (definition) =>
|
|
58
|
-
typeof definition?.moduleName === 'string' && definition.moduleName
|
|
59
|
-
? definition.moduleName
|
|
60
|
-
: undefined;
|
|
61
|
-
|
|
62
|
-
/** The name an entity type is known by: moduleName, else getName(). */
|
|
63
|
-
function entityTypeNameOf(definition) {
|
|
64
|
-
if (!definition) return undefined;
|
|
65
|
-
return moduleNameOf(definition) ?? safeGetName(definition);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Same object, or the same package name and version when both can be read. */
|
|
69
|
-
function isSameDefinition(a, b) {
|
|
70
|
-
if (a === b) return true;
|
|
71
|
-
const packageId = (definition) => {
|
|
72
|
-
const { packageName, version } = definition || {};
|
|
73
|
-
return typeof packageName === 'string' &&
|
|
74
|
-
packageName &&
|
|
75
|
-
typeof version === 'string' &&
|
|
76
|
-
version
|
|
77
|
-
? `${packageName}@${version}`
|
|
78
|
-
: null;
|
|
79
|
-
};
|
|
80
|
-
const left = packageId(a);
|
|
81
|
-
return left !== null && left === packageId(b);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function conflictError(name, existing, source) {
|
|
85
|
-
return new EntityRegistryError(
|
|
86
|
-
`Entity type '${name}' is supplied by two different definitions: ` +
|
|
87
|
-
`${existing.sources
|
|
88
|
-
.map(describeSource)
|
|
89
|
-
.join(', ')} and ${describeSource(source)}. ` +
|
|
90
|
-
'They must be the same definition object (or the same package name and version).'
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* A registry key must equal the definition's moduleName and, when it has one,
|
|
96
|
-
* its getName(). Case matters. This leaves one name per entity type.
|
|
97
|
-
*/
|
|
98
|
-
function assertKeyMatchesDefinition(key, definition) {
|
|
99
|
-
const moduleName = moduleNameOf(definition);
|
|
100
|
-
const getName = safeGetName(definition);
|
|
101
|
-
const matches =
|
|
102
|
-
(moduleName !== undefined || getName !== undefined) &&
|
|
103
|
-
(moduleName === undefined || key === moduleName) &&
|
|
104
|
-
(getName === undefined || key === getName);
|
|
105
|
-
if (!matches) {
|
|
106
|
-
throw new EntityRegistryError(
|
|
107
|
-
`appDefinition.entities key '${key}' does not match its definition's name ` +
|
|
108
|
-
`(moduleName '${moduleName ?? '(none)'}', getName() '${
|
|
109
|
-
getName ?? '(none)'
|
|
110
|
-
}'). ` +
|
|
111
|
-
'The key must equal both, so each entity type has one name.'
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const settingsOf = (entry) => {
|
|
117
|
-
const settings = {};
|
|
118
|
-
for (const field of ['scope', 'proxy', 'display']) {
|
|
119
|
-
if (entry[field] !== undefined) settings[field] = entry[field];
|
|
120
|
-
}
|
|
121
|
-
return settings;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* `entities` absent keeps today's behaviour: inference on, and a name clash
|
|
126
|
-
* between integrations is not fatal. `entities` present (even `{}`) opts in to
|
|
127
|
-
* the strict rules.
|
|
128
|
-
*/
|
|
129
|
-
function readEntities(appDefinition) {
|
|
130
|
-
const entities = appDefinition?.entities;
|
|
131
|
-
if (entities === undefined || entities === null) {
|
|
132
|
-
return { strict: false, infer: true, entries: [] };
|
|
133
|
-
}
|
|
134
|
-
if (!isPlainObject(entities)) {
|
|
135
|
-
throw new EntityRegistryError(
|
|
136
|
-
'appDefinition.entities must be an object keyed by entity type name'
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
const { [INFER_KEY]: inferFlag, ...rest } = entities;
|
|
140
|
-
if (inferFlag !== undefined && typeof inferFlag !== 'boolean') {
|
|
141
|
-
throw new EntityRegistryError(
|
|
142
|
-
`appDefinition.entities.${INFER_KEY} must be a boolean`
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
strict: true,
|
|
147
|
-
infer: inferFlag !== false,
|
|
148
|
-
entries: Object.entries(rest),
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function registerAppEntries(registry, entries) {
|
|
153
|
-
for (const [key, entry] of entries) {
|
|
154
|
-
if (
|
|
155
|
-
!isPlainObject(entry) ||
|
|
156
|
-
!entry.definition ||
|
|
157
|
-
typeof entry.definition !== 'object'
|
|
158
|
-
) {
|
|
159
|
-
throw new EntityRegistryError(
|
|
160
|
-
`appDefinition.entities.${key} must be an object with a 'definition'`
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
assertKeyMatchesDefinition(key, entry.definition);
|
|
164
|
-
registry.set(key, {
|
|
165
|
-
definition: entry.definition,
|
|
166
|
-
sources: [appSource()],
|
|
167
|
-
settings: settingsOf(entry),
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function* boundDefinitions(integrationClass) {
|
|
173
|
-
const modules = integrationClass?.Definition?.modules;
|
|
174
|
-
if (!isPlainObject(modules)) return;
|
|
175
|
-
for (const bound of Object.values(modules)) {
|
|
176
|
-
if (bound?.definition) yield bound.definition;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const addSource = (entry, source) => {
|
|
181
|
-
const known = entry.sources.some(
|
|
182
|
-
(s) => s.kind === source.kind && s.name === source.name
|
|
183
|
-
);
|
|
184
|
-
if (!known) entry.sources.push(source);
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Folds one integration-bound definition into the registry. Returns the name
|
|
189
|
-
* it could not register (only possible when inference is off), else undefined.
|
|
190
|
-
*/
|
|
191
|
-
function mergeBoundDefinition(registry, definition, source, { strict, infer }) {
|
|
192
|
-
const name = entityTypeNameOf(definition);
|
|
193
|
-
|
|
194
|
-
if (!name) {
|
|
195
|
-
// No moduleName / getName(): it cannot be keyed by name. Offering it
|
|
196
|
-
// as before, de-duplicated by identity, keeps apps that never named
|
|
197
|
-
// one working. It cannot be registered, so `$infer: false` rejects it.
|
|
198
|
-
if (!infer) return UNNAMED;
|
|
199
|
-
const known = [...registry.values()].find(
|
|
200
|
-
(entry) => entry.definition === definition
|
|
201
|
-
);
|
|
202
|
-
if (known) addSource(known, source);
|
|
203
|
-
else {
|
|
204
|
-
registry.set(`${UNNAMED} #${registry.size}`, {
|
|
205
|
-
definition,
|
|
206
|
-
sources: [source],
|
|
207
|
-
settings: {},
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
return undefined;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const existing = registry.get(name);
|
|
214
|
-
if (existing) {
|
|
215
|
-
if (isSameDefinition(existing.definition, definition)) {
|
|
216
|
-
addSource(existing, source);
|
|
217
|
-
} else if (strict) {
|
|
218
|
-
throw conflictError(name, existing, source);
|
|
219
|
-
} else {
|
|
220
|
-
// Pre-ADR-059 apps keep working: the first definition wins, as
|
|
221
|
-
// ModuleFactory would have picked it.
|
|
222
|
-
log.warn('Two different definitions share one entity type name', {
|
|
223
|
-
eventName: `${log.name}.duplicate_definition`,
|
|
224
|
-
entityType: name,
|
|
225
|
-
kept: existing.sources.map(describeSource).join(', '),
|
|
226
|
-
ignored: describeSource(source),
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
return undefined;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (!infer) return name;
|
|
233
|
-
registry.set(name, { definition, sources: [source], settings: {} });
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function assertAllRegistered(unregistered) {
|
|
238
|
-
if (unregistered.size === 0) return;
|
|
239
|
-
const detail = [...unregistered]
|
|
240
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
241
|
-
.map(
|
|
242
|
-
([name, sources]) => `${name} (bound by ${[...sources].join(', ')})`
|
|
243
|
-
);
|
|
244
|
-
throw new EntityRegistryError(
|
|
245
|
-
`appDefinition.entities.${INFER_KEY} is false, so every module an integration binds must be registered. Missing: ${detail.join(
|
|
246
|
-
'; '
|
|
247
|
-
)}`
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const freezeEntries = (registry) => {
|
|
252
|
-
for (const entry of registry.values()) {
|
|
253
|
-
entry.sources.forEach(Object.freeze);
|
|
254
|
-
Object.freeze(entry.sources);
|
|
255
|
-
Object.freeze(entry.settings);
|
|
256
|
-
Object.freeze(entry);
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Builds the registry for an app definition.
|
|
262
|
-
*
|
|
263
|
-
* @param {object} [appDefinition] - `{ entities?, integrations? }`
|
|
264
|
-
* @returns {Map<string, {definition: object, sources: Array<{kind: 'app'} | {kind: 'integration', name: string}>, settings: {scope?: string|object, proxy?: object, display?: object}}>}
|
|
265
|
-
* @throws {EntityRegistryError} on a key/name mismatch, two different
|
|
266
|
-
* definitions under one name (when `entities` is present), or, with
|
|
267
|
-
* `$infer: false`, a bound module that is not registered.
|
|
268
|
-
*/
|
|
269
|
-
function getEntityTypeRegistry(appDefinition = {}) {
|
|
270
|
-
const mode = readEntities(appDefinition);
|
|
271
|
-
const registry = new Map();
|
|
272
|
-
registerAppEntries(registry, mode.entries);
|
|
273
|
-
|
|
274
|
-
const unregistered = new Map();
|
|
275
|
-
for (const integrationClass of appDefinition?.integrations || []) {
|
|
276
|
-
const source = integrationSource(integrationClass);
|
|
277
|
-
for (const definition of boundDefinitions(integrationClass)) {
|
|
278
|
-
const missing = mergeBoundDefinition(
|
|
279
|
-
registry,
|
|
280
|
-
definition,
|
|
281
|
-
source,
|
|
282
|
-
mode
|
|
283
|
-
);
|
|
284
|
-
if (missing !== undefined) {
|
|
285
|
-
if (!unregistered.has(missing))
|
|
286
|
-
unregistered.set(missing, new Set());
|
|
287
|
-
unregistered.get(missing).add(describeSource(source));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
assertAllRegistered(unregistered);
|
|
292
|
-
|
|
293
|
-
freezeEntries(registry);
|
|
294
|
-
return registry;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** The definitions of a registry, in registry order. */
|
|
298
|
-
const getEntityTypeDefinitions = (registry) =>
|
|
299
|
-
[...registry.values()].map((entry) => entry.definition);
|
|
300
|
-
|
|
301
|
-
/** Names of the integrations that bind an entity type; empty for registry-only. */
|
|
302
|
-
const usedByOf = (entry) =>
|
|
303
|
-
entry.sources.filter((s) => s.kind === 'integration').map((s) => s.name);
|
|
304
|
-
|
|
305
|
-
module.exports = {
|
|
306
|
-
getEntityTypeRegistry,
|
|
307
|
-
getEntityTypeDefinitions,
|
|
308
|
-
usedByOf,
|
|
309
|
-
entityTypeNameOf,
|
|
310
|
-
EntityRegistryError,
|
|
311
|
-
};
|
|
@@ -1,20 +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
|
-
module.exports = { ProxyRequestError };
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
const { ProxyRequestError } = require('./proxy-errors');
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Path rules for the entity proxy (ADR-052 §4). The caller supplies a path
|
|
5
|
-
* relative to the module's base URL and nothing else: the host, scheme and
|
|
6
|
-
* port always come from the module.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const invalid = (reason) =>
|
|
10
|
-
new ProxyRequestError('INVALID_PROXY_REQUEST', `Invalid proxy path: ${reason}`, {
|
|
11
|
-
field: 'path',
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
// Control characters (C0, DEL, C1), any whitespace, and invisible format
|
|
15
|
-
// characters such as zero-width spaces and byte-order marks.
|
|
16
|
-
const FORBIDDEN_CHARS = /[\x00-\x20\x7f-\x9f]|\s|\p{Cf}/u;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Validates and normalises a caller-supplied path.
|
|
20
|
-
*
|
|
21
|
-
* - must start with a single `/`; `//` (anywhere), `\`, `?`, `#`, control
|
|
22
|
-
* and whitespace characters are rejected;
|
|
23
|
-
* - encoded separators (`%2f`, `%5c`) are rejected, then the path is
|
|
24
|
-
* percent-decoded exactly once; a `%` left after decoding (double
|
|
25
|
-
* encoding) is rejected, as is any decoded control or whitespace
|
|
26
|
-
* character other than a space (`%20`);
|
|
27
|
-
* - `.` and `..` segments are rejected rather than resolved.
|
|
28
|
-
*
|
|
29
|
-
* @param {unknown} path
|
|
30
|
-
* @returns {string} the decoded path, e.g. `/crm/v3/objects/contacts`
|
|
31
|
-
* @throws {ProxyRequestError} INVALID_PROXY_REQUEST
|
|
32
|
-
*/
|
|
33
|
-
function normalizeProxyPath(path) {
|
|
34
|
-
if (typeof path !== 'string' || path.length === 0) {
|
|
35
|
-
throw invalid('path must be a non-empty string');
|
|
36
|
-
}
|
|
37
|
-
if (path.length > 2048) throw invalid('path is too long');
|
|
38
|
-
if (!path.startsWith('/')) throw invalid('path must start with /');
|
|
39
|
-
if (path.startsWith('//') || path.includes('//')) {
|
|
40
|
-
throw invalid('empty path segments (//) are not allowed');
|
|
41
|
-
}
|
|
42
|
-
if (path.includes('\\')) throw invalid('backslashes are not allowed');
|
|
43
|
-
if (path.includes('?') || path.includes('#')) {
|
|
44
|
-
throw invalid('send the query string in `query`, not in the path');
|
|
45
|
-
}
|
|
46
|
-
if (FORBIDDEN_CHARS.test(path)) {
|
|
47
|
-
throw invalid('control and whitespace characters are not allowed');
|
|
48
|
-
}
|
|
49
|
-
if (/%(2f|5c)/i.test(path)) {
|
|
50
|
-
throw invalid('encoded path separators are not allowed');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let decoded;
|
|
54
|
-
try {
|
|
55
|
-
decoded = decodeURIComponent(path);
|
|
56
|
-
} catch {
|
|
57
|
-
throw invalid('malformed percent-encoding');
|
|
58
|
-
}
|
|
59
|
-
if (
|
|
60
|
-
decoded.includes('%') ||
|
|
61
|
-
decoded.includes('\\') ||
|
|
62
|
-
decoded.includes('?') ||
|
|
63
|
-
decoded.includes('#') ||
|
|
64
|
-
decoded.includes('//') ||
|
|
65
|
-
// An encoded space (%20) is a legitimate path character; every other
|
|
66
|
-
// control or whitespace character stays forbidden.
|
|
67
|
-
FORBIDDEN_CHARS.test(decoded.replaceAll(' ', ''))
|
|
68
|
-
) {
|
|
69
|
-
throw invalid('encoded characters decode to something not allowed');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const segments = decoded.split('/').slice(1);
|
|
73
|
-
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
|
74
|
-
throw invalid('dot segments (. and ..) are not allowed');
|
|
75
|
-
}
|
|
76
|
-
return decoded;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Joins a module base URL and a normalised path, then proves the result
|
|
81
|
-
* cannot leave the module's API: https only, same origin as the base URL,
|
|
82
|
-
* and under the base URL's path.
|
|
83
|
-
*
|
|
84
|
-
* @param {string} baseUrl - from the module, never from the request
|
|
85
|
-
* @param {string} normalizedPath - from normalizeProxyPath()
|
|
86
|
-
* @returns {{ url: string, basePath: string, origin: string }} `url` is not
|
|
87
|
-
* percent-encoded; the Requester encodes it once.
|
|
88
|
-
* @throws {ProxyRequestError} PROXY_NOT_CONFIGURED or INVALID_PROXY_REQUEST
|
|
89
|
-
*/
|
|
90
|
-
function joinProxyUrl(baseUrl, normalizedPath) {
|
|
91
|
-
let base;
|
|
92
|
-
try {
|
|
93
|
-
base = new URL(baseUrl);
|
|
94
|
-
} catch {
|
|
95
|
-
throw new ProxyRequestError(
|
|
96
|
-
'PROXY_NOT_CONFIGURED',
|
|
97
|
-
'The module has no valid base URL to proxy to'
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
if (base.protocol !== 'https:') {
|
|
101
|
-
throw new ProxyRequestError(
|
|
102
|
-
'PROXY_NOT_CONFIGURED',
|
|
103
|
-
'The module base URL must use https to be proxied'
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
if (base.username || base.password || base.search || base.hash) {
|
|
107
|
-
throw new ProxyRequestError(
|
|
108
|
-
'PROXY_NOT_CONFIGURED',
|
|
109
|
-
'The module base URL must not carry credentials, a query or a fragment'
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const basePath = base.pathname.replace(/\/+$/, '');
|
|
114
|
-
const url = `${base.origin}${basePath}${normalizedPath}`;
|
|
115
|
-
|
|
116
|
-
let joined;
|
|
117
|
-
try {
|
|
118
|
-
joined = new URL(url);
|
|
119
|
-
} catch {
|
|
120
|
-
throw invalid('the joined URL is not valid');
|
|
121
|
-
}
|
|
122
|
-
const joinedPath = decodeURIComponent(joined.pathname);
|
|
123
|
-
if (
|
|
124
|
-
joined.protocol !== 'https:' ||
|
|
125
|
-
joined.origin !== base.origin ||
|
|
126
|
-
joined.username ||
|
|
127
|
-
joined.password ||
|
|
128
|
-
!(joinedPath === basePath || joinedPath.startsWith(`${basePath}/`))
|
|
129
|
-
) {
|
|
130
|
-
throw invalid('the path leaves the module API');
|
|
131
|
-
}
|
|
132
|
-
return { url, basePath, origin: base.origin };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Finds the allow-list rule that permits `method` on `path`.
|
|
137
|
-
*
|
|
138
|
-
* Patterns use `:param` for exactly one segment and a trailing `/**` for a
|
|
139
|
-
* subtree (the prefix itself included). `method: '*'` matches any method.
|
|
140
|
-
*
|
|
141
|
-
* @param {Array<{method: string, path: string}>} rules
|
|
142
|
-
* @param {string} method - upper case
|
|
143
|
-
* @param {string} path - normalised path
|
|
144
|
-
* @returns {{method: string, path: string}|null}
|
|
145
|
-
*/
|
|
146
|
-
function matchAllowRule(rules, method, path) {
|
|
147
|
-
const requestSegments = path.split('/').slice(1);
|
|
148
|
-
for (const rule of rules || []) {
|
|
149
|
-
if (!rule || typeof rule.path !== 'string' || typeof rule.method !== 'string') {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const ruleMethod = rule.method.toUpperCase();
|
|
153
|
-
if (ruleMethod !== '*' && ruleMethod !== method) continue;
|
|
154
|
-
if (patternMatches(rule.path, requestSegments)) return rule;
|
|
155
|
-
}
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function patternMatches(pattern, requestSegments) {
|
|
160
|
-
if (!pattern.startsWith('/')) return false;
|
|
161
|
-
let patternSegments = pattern.split('/').slice(1);
|
|
162
|
-
let subtree = false;
|
|
163
|
-
if (patternSegments[patternSegments.length - 1] === '**') {
|
|
164
|
-
subtree = true;
|
|
165
|
-
patternSegments = patternSegments.slice(0, -1);
|
|
166
|
-
}
|
|
167
|
-
if (subtree) {
|
|
168
|
-
if (requestSegments.length < patternSegments.length) return false;
|
|
169
|
-
} else if (requestSegments.length !== patternSegments.length) {
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
return patternSegments.every((segment, index) => {
|
|
173
|
-
const actual = requestSegments[index];
|
|
174
|
-
if (segment.startsWith(':')) return actual !== undefined && actual !== '';
|
|
175
|
-
return segment === actual;
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
module.exports = { normalizeProxyPath, joinProxyUrl, matchAllowRule };
|