@praesidias/api-gateway 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/README.md +16 -2
- package/dist/cli.js +1005 -0
- package/dist/index.cjs +962 -0
- package/dist/index.d.cts +248 -0
- package/dist/index.d.ts +248 -0
- package/dist/index.js +927 -0
- package/package.json +67 -3
- package/praesidias.gateway.example.json +35 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ConfigError: () => ConfigError,
|
|
24
|
+
EnvCredentialSource: () => EnvCredentialSource,
|
|
25
|
+
FileCredentialSource: () => FileCredentialSource,
|
|
26
|
+
VaultCredentialSource: () => VaultCredentialSource,
|
|
27
|
+
compileConfig: () => compileConfig,
|
|
28
|
+
createApiGateway: () => createApiGateway,
|
|
29
|
+
createApiGatewayHandler: () => createApiGatewayHandler,
|
|
30
|
+
createApiGatewayServer: () => createApiGatewayServer,
|
|
31
|
+
loadConfig: () => loadConfig
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(index_exports);
|
|
34
|
+
|
|
35
|
+
// src/config.ts
|
|
36
|
+
var import_node_fs = require("fs");
|
|
37
|
+
var import_node_path = require("path");
|
|
38
|
+
var import_sdk = require("@praesidias/sdk");
|
|
39
|
+
var ConfigError = class extends Error {
|
|
40
|
+
};
|
|
41
|
+
var fail = (message) => {
|
|
42
|
+
throw new ConfigError(`Gateway config: ${message}`);
|
|
43
|
+
};
|
|
44
|
+
var NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
45
|
+
var METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
46
|
+
var LOOPBACK = ["localhost", "127.0.0.1", "[::1]", "::1"];
|
|
47
|
+
function compileRoute(upstream, index, route) {
|
|
48
|
+
const where = `upstreams.${upstream}.routes[${index}]`;
|
|
49
|
+
const method = String(route?.method ?? "").toUpperCase();
|
|
50
|
+
if (!METHODS.includes(method)) fail(`${where}.method must be one of ${METHODS.join(", ")}.`);
|
|
51
|
+
if (typeof route.path !== "string" || !route.path.startsWith("/") || route.path.includes("..") || /[?#*]/.test(route.path)) {
|
|
52
|
+
fail(`${where}.path must start with "/" and name a path (":name" for a segment that varies).`);
|
|
53
|
+
}
|
|
54
|
+
const params = [];
|
|
55
|
+
const pattern = route.path.split("/").map((segment) => {
|
|
56
|
+
if (segment.startsWith(":")) {
|
|
57
|
+
if (!/^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment))
|
|
58
|
+
fail(`${where}.path has a bad parameter "${segment}".`);
|
|
59
|
+
params.push(segment.slice(1));
|
|
60
|
+
return "([^/]+)";
|
|
61
|
+
}
|
|
62
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
}).join("/");
|
|
64
|
+
if (!route.ungoverned && route.action === void 0 && !(route.when ?? []).length) {
|
|
65
|
+
fail(`${where} needs an "action" (or "ungoverned": true to let it through unrecorded).`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...route,
|
|
69
|
+
method,
|
|
70
|
+
match: new RegExp(`^${pattern}/?$`),
|
|
71
|
+
params,
|
|
72
|
+
id: `${method} ${route.path}`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
var VAULT_SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
76
|
+
function compileCredential(upstream, raw) {
|
|
77
|
+
if (raw === void 0 || raw === null) return null;
|
|
78
|
+
const where = `upstreams.${upstream}.credential`;
|
|
79
|
+
const rule = raw;
|
|
80
|
+
if (typeof rule !== "object") return fail(`${where} must be an object.`);
|
|
81
|
+
if (rule.header !== void 0 && !/^[A-Za-z0-9-]+$/.test(String(rule.header)))
|
|
82
|
+
fail(`${where}.header is not a header name.`);
|
|
83
|
+
const placement = {
|
|
84
|
+
...rule.header !== void 0 ? { header: String(rule.header) } : {},
|
|
85
|
+
...rule.scheme !== void 0 ? { scheme: String(rule.scheme) } : {}
|
|
86
|
+
};
|
|
87
|
+
switch (rule.from) {
|
|
88
|
+
case "env":
|
|
89
|
+
if (typeof rule.name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rule.name))
|
|
90
|
+
fail(`${where}.name must be the name of an environment variable.`);
|
|
91
|
+
return { from: "env", name: rule.name, ...placement };
|
|
92
|
+
case "file":
|
|
93
|
+
if (typeof rule.path !== "string" || !rule.path || !(0, import_node_path.isAbsolute)(rule.path))
|
|
94
|
+
fail(`${where}.path must be the absolute path of the file that holds the secret.`);
|
|
95
|
+
return { from: "file", name: rule.path, ...placement };
|
|
96
|
+
case "vault": {
|
|
97
|
+
const path = rule.path;
|
|
98
|
+
if (typeof path !== "string" || !path.split("/").every((s) => VAULT_SEGMENT.test(s) && s !== ".." && s !== "."))
|
|
99
|
+
fail(`${where}.path must be a Vault path like "secret/data/payments/bank".`);
|
|
100
|
+
if (typeof rule.field !== "string" || !VAULT_SEGMENT.test(rule.field))
|
|
101
|
+
fail(`${where}.field must name the field in the secret, like "api_key".`);
|
|
102
|
+
return { from: "vault", name: `${path}#${rule.field}`, ...placement };
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
return fail(
|
|
106
|
+
`${where}.from must be "env", "file" or "vault". The secret itself never goes in this file.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
var LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
111
|
+
function compileHosts(upstream, raw) {
|
|
112
|
+
if (raw === void 0) return [];
|
|
113
|
+
const where = `upstreams.${upstream}.hosts`;
|
|
114
|
+
if (!Array.isArray(raw) || raw.length === 0) return fail(`${where} must be a list of hostnames.`);
|
|
115
|
+
const hosts = [];
|
|
116
|
+
for (const entry of raw) {
|
|
117
|
+
const h = String(entry ?? "").trim().toLowerCase().replace(/\.$/, "");
|
|
118
|
+
if (!h || h.length > 253 || !h.split(".").every((label) => LABEL.test(label)))
|
|
119
|
+
fail(`${where}: "${String(entry)}" is not a hostname (no scheme, port or path).`);
|
|
120
|
+
if (LOOPBACK.includes(h))
|
|
121
|
+
fail(`${where}: "${h}" is this machine. List the API's own hostnames.`);
|
|
122
|
+
if (!hosts.includes(h)) hosts.push(h);
|
|
123
|
+
}
|
|
124
|
+
return hosts;
|
|
125
|
+
}
|
|
126
|
+
function compileAgentKey(upstream, raw) {
|
|
127
|
+
if (raw === void 0) return null;
|
|
128
|
+
const where = `upstreams.${upstream}.agentKey`;
|
|
129
|
+
const rule = raw;
|
|
130
|
+
if (!rule || typeof rule !== "object") return fail(`${where} must be an object.`);
|
|
131
|
+
if (typeof rule.header !== "string" || !/^[A-Za-z0-9-]+$/.test(rule.header))
|
|
132
|
+
fail(`${where}.header is not a header name.`);
|
|
133
|
+
if (rule.scheme !== void 0 && (typeof rule.scheme !== "string" || !/^[A-Za-z]+$/.test(rule.scheme)))
|
|
134
|
+
fail(`${where}.scheme must be one word, like "Bearer".`);
|
|
135
|
+
return {
|
|
136
|
+
header: rule.header.toLowerCase(),
|
|
137
|
+
...rule.scheme !== void 0 ? { scheme: rule.scheme } : {}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function compileTls(raw) {
|
|
141
|
+
if (raw === void 0) return null;
|
|
142
|
+
const rule = raw;
|
|
143
|
+
if (!rule || typeof rule !== "object" || typeof rule.certFile !== "string" || !rule.certFile || typeof rule.keyFile !== "string" || !rule.keyFile)
|
|
144
|
+
return fail(
|
|
145
|
+
'"listen.tls" needs "certFile" and "keyFile": the certificate and its key, as files.'
|
|
146
|
+
);
|
|
147
|
+
return { certFile: rule.certFile, keyFile: rule.keyFile };
|
|
148
|
+
}
|
|
149
|
+
function compileConfig(raw) {
|
|
150
|
+
const config = raw;
|
|
151
|
+
if (!config || typeof config !== "object") fail("must be a JSON object.");
|
|
152
|
+
if (typeof config.system !== "string" || !config.system) fail('"system" is required.');
|
|
153
|
+
const host = config.listen?.host ?? "127.0.0.1";
|
|
154
|
+
const port = config.listen?.port ?? 8787;
|
|
155
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
156
|
+
fail('"listen.port" must be a port number.');
|
|
157
|
+
if (!LOOPBACK.includes(host) && config.listen?.allowRemote !== true) {
|
|
158
|
+
fail(
|
|
159
|
+
`"listen.host" is ${host}, which other machines can reach. The gateway holds API credentials, so it listens on this machine only unless "listen.allowRemote" is true.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const entries = Object.entries(config.upstreams ?? {});
|
|
163
|
+
if (entries.length === 0 && config.ask !== true) {
|
|
164
|
+
fail(
|
|
165
|
+
'"upstreams" needs at least one upstream (or set "ask": true to serve only the Ask Praesidias step).'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (Object.prototype.hasOwnProperty.call(config.upstreams ?? {}, "ask"))
|
|
169
|
+
fail('an upstream cannot be named "ask".');
|
|
170
|
+
const upstreams = /* @__PURE__ */ new Map();
|
|
171
|
+
const byHost = /* @__PURE__ */ new Map();
|
|
172
|
+
for (const [name, upstream] of entries) {
|
|
173
|
+
if (!NAME.test(name))
|
|
174
|
+
fail(`upstream name "${name}" must be lower-case letters, digits, - and _.`);
|
|
175
|
+
let base;
|
|
176
|
+
try {
|
|
177
|
+
base = new URL(upstream.baseUrl);
|
|
178
|
+
} catch {
|
|
179
|
+
return fail(`upstreams.${name}.baseUrl is not a URL.`);
|
|
180
|
+
}
|
|
181
|
+
if (base.username || base.password || base.search || base.hash)
|
|
182
|
+
fail(`upstreams.${name}.baseUrl must be a plain address: no credentials, query or fragment.`);
|
|
183
|
+
if (base.protocol !== "https:" && !(base.protocol === "http:" && LOOPBACK.includes(base.hostname))) {
|
|
184
|
+
fail(`upstreams.${name}.baseUrl must use https (http is accepted for this machine only).`);
|
|
185
|
+
}
|
|
186
|
+
const credential = compileCredential(name, upstream.credential);
|
|
187
|
+
if (!Array.isArray(upstream.routes) || upstream.routes.length === 0)
|
|
188
|
+
fail(
|
|
189
|
+
`upstreams.${name}.routes needs at least one route. A request that matches no route is refused.`
|
|
190
|
+
);
|
|
191
|
+
const routes = upstream.routes.map((route, index) => compileRoute(name, index, route));
|
|
192
|
+
const tools = {};
|
|
193
|
+
for (const route of routes) {
|
|
194
|
+
tools[route.id] = {
|
|
195
|
+
...route.action !== void 0 ? { action: route.action } : {},
|
|
196
|
+
...route.resource ? { resource: route.resource } : {},
|
|
197
|
+
...route.when ? { when: route.when } : {},
|
|
198
|
+
...route.context ? { context: route.context } : {},
|
|
199
|
+
...route.system ? { system: route.system } : {},
|
|
200
|
+
...route.ungoverned ? { ungoverned: true } : {}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
let mapping;
|
|
204
|
+
try {
|
|
205
|
+
mapping = (0, import_sdk.checkMapping)({
|
|
206
|
+
system: config.system,
|
|
207
|
+
server: name,
|
|
208
|
+
defaults: { resource: { idFrom: [] } },
|
|
209
|
+
tools
|
|
210
|
+
});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return fail(`upstreams.${name}: ${err.message.replace(/^Mapping: /, "")}`);
|
|
213
|
+
}
|
|
214
|
+
const hosts = compileHosts(name, upstream.hosts);
|
|
215
|
+
const agentKey = compileAgentKey(name, upstream.agentKey);
|
|
216
|
+
const compiled = { name, base, credential, routes, mapping, hosts, agentKey };
|
|
217
|
+
upstreams.set(name, compiled);
|
|
218
|
+
for (const h of hosts) {
|
|
219
|
+
const other = byHost.get(h);
|
|
220
|
+
if (other) fail(`"${h}" is listed under both upstreams.${other.name} and upstreams.${name}.`);
|
|
221
|
+
byHost.set(h, compiled);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const tls = compileTls(config.listen?.tls);
|
|
225
|
+
return {
|
|
226
|
+
system: config.system,
|
|
227
|
+
host,
|
|
228
|
+
port,
|
|
229
|
+
ask: config.ask === true,
|
|
230
|
+
upstreams,
|
|
231
|
+
hosts: byHost,
|
|
232
|
+
tls
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function loadConfig(path) {
|
|
236
|
+
let text;
|
|
237
|
+
try {
|
|
238
|
+
text = (0, import_node_fs.readFileSync)(path, "utf8");
|
|
239
|
+
} catch {
|
|
240
|
+
return fail(`cannot read ${path}.`);
|
|
241
|
+
}
|
|
242
|
+
let parsed;
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(text);
|
|
245
|
+
} catch {
|
|
246
|
+
return fail(`${path} is not valid JSON.`);
|
|
247
|
+
}
|
|
248
|
+
return compileConfig(parsed);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/credentials.ts
|
|
252
|
+
var import_promises = require("fs/promises");
|
|
253
|
+
var EnvCredentialSource = class {
|
|
254
|
+
constructor(env = process.env) {
|
|
255
|
+
this.env = env;
|
|
256
|
+
}
|
|
257
|
+
env;
|
|
258
|
+
get(name) {
|
|
259
|
+
const value = this.env[name];
|
|
260
|
+
return value && value.trim() ? value.trim() : void 0;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
var MAX_SECRET_BYTES = 64 * 1024;
|
|
264
|
+
var FileCredentialSource = class {
|
|
265
|
+
constructor(note = () => {
|
|
266
|
+
}) {
|
|
267
|
+
this.note = note;
|
|
268
|
+
}
|
|
269
|
+
note;
|
|
270
|
+
async get(path) {
|
|
271
|
+
try {
|
|
272
|
+
const info = await (0, import_promises.stat)(path);
|
|
273
|
+
if (!info.isFile() || info.size > MAX_SECRET_BYTES) {
|
|
274
|
+
this.note(`credential file ${path} is not a secret file`);
|
|
275
|
+
return void 0;
|
|
276
|
+
}
|
|
277
|
+
const value = (await (0, import_promises.readFile)(path, "utf8")).trim();
|
|
278
|
+
if (!value) this.note(`credential file ${path} is empty`);
|
|
279
|
+
return value || void 0;
|
|
280
|
+
} catch {
|
|
281
|
+
this.note(`credential file ${path} cannot be read`);
|
|
282
|
+
return void 0;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
var LOOPBACK2 = ["localhost", "127.0.0.1", "[::1]", "::1"];
|
|
287
|
+
function vaultAddress(env = process.env) {
|
|
288
|
+
const raw = String(env.VAULT_ADDR ?? "").trim();
|
|
289
|
+
if (!raw) return "VAULT_ADDR is not set";
|
|
290
|
+
let url;
|
|
291
|
+
try {
|
|
292
|
+
url = new URL(raw);
|
|
293
|
+
} catch {
|
|
294
|
+
return "VAULT_ADDR is not a URL";
|
|
295
|
+
}
|
|
296
|
+
if (url.username || url.password || url.search || url.hash)
|
|
297
|
+
return "VAULT_ADDR must be a plain address";
|
|
298
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK2.includes(url.hostname)))
|
|
299
|
+
return "VAULT_ADDR must use https (http is accepted for this machine only)";
|
|
300
|
+
return url;
|
|
301
|
+
}
|
|
302
|
+
var VaultCredentialSource = class {
|
|
303
|
+
env;
|
|
304
|
+
cacheMs;
|
|
305
|
+
timeoutMs;
|
|
306
|
+
note;
|
|
307
|
+
send;
|
|
308
|
+
cache = /* @__PURE__ */ new Map();
|
|
309
|
+
reading = /* @__PURE__ */ new Map();
|
|
310
|
+
constructor(options = {}) {
|
|
311
|
+
this.env = options.env ?? process.env;
|
|
312
|
+
this.cacheMs = Math.max(0, options.cacheMs ?? 6e4);
|
|
313
|
+
this.timeoutMs = options.timeoutMs ?? 5e3;
|
|
314
|
+
this.note = options.note ?? (() => {
|
|
315
|
+
});
|
|
316
|
+
this.send = options.fetch ?? fetch;
|
|
317
|
+
}
|
|
318
|
+
async get(name) {
|
|
319
|
+
const kept = this.cache.get(name);
|
|
320
|
+
if (kept && Date.now() - kept.at < this.cacheMs) return kept.value;
|
|
321
|
+
if (kept) this.cache.delete(name);
|
|
322
|
+
let pending = this.reading.get(name);
|
|
323
|
+
if (!pending) {
|
|
324
|
+
pending = this.read(name).finally(() => this.reading.delete(name));
|
|
325
|
+
this.reading.set(name, pending);
|
|
326
|
+
}
|
|
327
|
+
return pending;
|
|
328
|
+
}
|
|
329
|
+
async token() {
|
|
330
|
+
const file = String(this.env.VAULT_TOKEN_FILE ?? "").trim();
|
|
331
|
+
if (file) {
|
|
332
|
+
try {
|
|
333
|
+
const value2 = (await (0, import_promises.readFile)(file, "utf8")).trim();
|
|
334
|
+
if (value2) return value2;
|
|
335
|
+
} catch {
|
|
336
|
+
}
|
|
337
|
+
this.note("the Vault token file cannot be read");
|
|
338
|
+
return void 0;
|
|
339
|
+
}
|
|
340
|
+
const value = String(this.env.VAULT_TOKEN ?? "").trim();
|
|
341
|
+
if (!value) this.note("neither VAULT_TOKEN nor VAULT_TOKEN_FILE is set");
|
|
342
|
+
return value || void 0;
|
|
343
|
+
}
|
|
344
|
+
async read(name) {
|
|
345
|
+
const at = name.lastIndexOf("#");
|
|
346
|
+
const path = at > 0 ? name.slice(0, at) : "";
|
|
347
|
+
const field = at > 0 ? name.slice(at + 1) : "";
|
|
348
|
+
if (!path || !field) {
|
|
349
|
+
this.note(`"${name}" is not a Vault path and field`);
|
|
350
|
+
return void 0;
|
|
351
|
+
}
|
|
352
|
+
const where = vaultAddress(this.env);
|
|
353
|
+
if (typeof where === "string") {
|
|
354
|
+
this.note(where);
|
|
355
|
+
return void 0;
|
|
356
|
+
}
|
|
357
|
+
const token = await this.token();
|
|
358
|
+
if (!token) return void 0;
|
|
359
|
+
const headers = { "x-vault-token": token, accept: "application/json" };
|
|
360
|
+
const namespace = String(this.env.VAULT_NAMESPACE ?? "").trim();
|
|
361
|
+
if (namespace) headers["x-vault-namespace"] = namespace;
|
|
362
|
+
const target = new URL(`${where.pathname.replace(/\/$/, "")}/v1/${path}`, where);
|
|
363
|
+
if (target.origin !== where.origin) {
|
|
364
|
+
this.note(`"${path}" is not a Vault path`);
|
|
365
|
+
return void 0;
|
|
366
|
+
}
|
|
367
|
+
let reply2;
|
|
368
|
+
try {
|
|
369
|
+
reply2 = await this.send(target, {
|
|
370
|
+
headers,
|
|
371
|
+
redirect: "manual",
|
|
372
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
373
|
+
});
|
|
374
|
+
} catch {
|
|
375
|
+
this.note(`Vault could not be reached for ${path}`);
|
|
376
|
+
return void 0;
|
|
377
|
+
}
|
|
378
|
+
if (reply2.status !== 200) {
|
|
379
|
+
this.note(`Vault answered ${reply2.status} for ${path}`);
|
|
380
|
+
return void 0;
|
|
381
|
+
}
|
|
382
|
+
let body;
|
|
383
|
+
try {
|
|
384
|
+
body = await reply2.json();
|
|
385
|
+
} catch {
|
|
386
|
+
this.note(`Vault's answer for ${path} is not JSON`);
|
|
387
|
+
return void 0;
|
|
388
|
+
}
|
|
389
|
+
const data = body?.data;
|
|
390
|
+
const secret = data && typeof data.data === "object" && data.data !== null && data.metadata !== void 0 ? data.data : data;
|
|
391
|
+
const value = secret && typeof secret === "object" ? secret[field] : void 0;
|
|
392
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
393
|
+
this.note(`Vault has no text field "${field}" at ${path}`);
|
|
394
|
+
return void 0;
|
|
395
|
+
}
|
|
396
|
+
const trimmed = value.trim();
|
|
397
|
+
if (this.cacheMs > 0) this.cache.set(name, { value: trimmed, at: Date.now() });
|
|
398
|
+
return trimmed;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// src/gateway.ts
|
|
403
|
+
var import_node_crypto2 = require("crypto");
|
|
404
|
+
var import_node_fs2 = require("fs");
|
|
405
|
+
var import_node_http = require("http");
|
|
406
|
+
var import_node_https = require("https");
|
|
407
|
+
var import_sdk4 = require("@praesidias/sdk");
|
|
408
|
+
|
|
409
|
+
// src/ask.ts
|
|
410
|
+
var import_node_crypto = require("crypto");
|
|
411
|
+
var import_sdk3 = require("@praesidias/sdk");
|
|
412
|
+
|
|
413
|
+
// src/workload.ts
|
|
414
|
+
var import_node_async_hooks = require("async_hooks");
|
|
415
|
+
var import_sdk2 = require("@praesidias/sdk");
|
|
416
|
+
var current = new import_node_async_hooks.AsyncLocalStorage();
|
|
417
|
+
function workloadTokenOf(req) {
|
|
418
|
+
const header = req.headers[import_sdk2.WORKLOAD_TOKEN_HEADER];
|
|
419
|
+
const value = (Array.isArray(header) ? header[0] : header)?.trim();
|
|
420
|
+
return value ? value : null;
|
|
421
|
+
}
|
|
422
|
+
function withWorkloadToken(req, handle) {
|
|
423
|
+
return current.run(workloadTokenOf(req), handle);
|
|
424
|
+
}
|
|
425
|
+
function withoutWorkloadToken(options) {
|
|
426
|
+
const { workloadToken: _token, workloadTokenFile: _file, ...rest } = options ?? {};
|
|
427
|
+
return rest;
|
|
428
|
+
}
|
|
429
|
+
var relayedWorkloadToken = () => current.getStore() ?? null;
|
|
430
|
+
|
|
431
|
+
// src/ask.ts
|
|
432
|
+
function stable(value) {
|
|
433
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
434
|
+
if (value && typeof value === "object") {
|
|
435
|
+
const o = value;
|
|
436
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stable(o[k])}`).join(",")}}`;
|
|
437
|
+
}
|
|
438
|
+
return JSON.stringify(value ?? null);
|
|
439
|
+
}
|
|
440
|
+
var ACTION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
441
|
+
var MAX_BODY = 64 * 1024;
|
|
442
|
+
var reply = (res, status, body) => {
|
|
443
|
+
const text = JSON.stringify(body);
|
|
444
|
+
res.writeHead(status, {
|
|
445
|
+
"content-type": "application/json",
|
|
446
|
+
"content-length": Buffer.byteLength(text),
|
|
447
|
+
"cache-control": "no-store"
|
|
448
|
+
});
|
|
449
|
+
res.end(text);
|
|
450
|
+
};
|
|
451
|
+
function createAsk(options) {
|
|
452
|
+
const clients = /* @__PURE__ */ new Map();
|
|
453
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
454
|
+
const clientFor = (apiKey) => {
|
|
455
|
+
const id = (0, import_node_crypto.createHash)("sha256").update(apiKey).digest("hex");
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
for (const [k, entry2] of clients) if (now - entry2.at > 60 * 6e4) clients.delete(k);
|
|
458
|
+
for (const [k, entry2] of waiting) if (now - entry2.at > 24 * 60 * 6e4) waiting.delete(k);
|
|
459
|
+
let entry = clients.get(id);
|
|
460
|
+
if (!entry) {
|
|
461
|
+
entry = {
|
|
462
|
+
client: new import_sdk3.Praesidias({
|
|
463
|
+
...withoutWorkloadToken(options.sdkOptions),
|
|
464
|
+
apiKey,
|
|
465
|
+
baseUrl: options.praesidiasUrl,
|
|
466
|
+
system: options.system,
|
|
467
|
+
onApproval: "return",
|
|
468
|
+
// Each request's own workload token (workload.ts), never one shared or the gateway's.
|
|
469
|
+
workloadToken: relayedWorkloadToken
|
|
470
|
+
}),
|
|
471
|
+
at: now
|
|
472
|
+
};
|
|
473
|
+
clients.set(id, entry);
|
|
474
|
+
}
|
|
475
|
+
entry.at = now;
|
|
476
|
+
return { client: entry.client, id };
|
|
477
|
+
};
|
|
478
|
+
async function body(req) {
|
|
479
|
+
const chunks = [];
|
|
480
|
+
let size = 0;
|
|
481
|
+
for await (const chunk of req) {
|
|
482
|
+
size += chunk.length;
|
|
483
|
+
if (size > MAX_BODY) return null;
|
|
484
|
+
chunks.push(chunk);
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
488
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return async function ask(req, res, path) {
|
|
494
|
+
if (path !== "/ask" && path !== "/ask/outcome") return false;
|
|
495
|
+
if (req.method !== "POST") {
|
|
496
|
+
reply(res, 405, { allowed: false, status: "refused", message: "Use POST." });
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
const header = req.headers[options.keyHeader];
|
|
500
|
+
const apiKey = (Array.isArray(header) ? header[0] : header)?.trim();
|
|
501
|
+
if (!apiKey) {
|
|
502
|
+
reply(res, 401, {
|
|
503
|
+
allowed: false,
|
|
504
|
+
status: "refused",
|
|
505
|
+
message: `Send your Praesidias key in the ${options.keyHeader} header.`
|
|
506
|
+
});
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
const input = await body(req);
|
|
510
|
+
if (!input) {
|
|
511
|
+
reply(res, 400, { allowed: false, status: "refused", message: "Send a JSON object." });
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
const { client, id } = clientFor(apiKey);
|
|
515
|
+
try {
|
|
516
|
+
if (path === "/ask/outcome") {
|
|
517
|
+
const { decisionId, proofId, status } = input;
|
|
518
|
+
if (typeof decisionId !== "string" || typeof proofId !== "string" || status !== "succeeded" && status !== "failed") {
|
|
519
|
+
reply(res, 400, {
|
|
520
|
+
recorded: false,
|
|
521
|
+
message: 'Send decisionId, proofId and status ("succeeded" or "failed").'
|
|
522
|
+
});
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
await client.reportOutcome(
|
|
526
|
+
{ decisionId, id: proofId },
|
|
527
|
+
status === "succeeded" ? { status } : { status: "failed" }
|
|
528
|
+
);
|
|
529
|
+
reply(res, 200, { recorded: true });
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
const resource = input.resource ?? {};
|
|
533
|
+
const action = input.action;
|
|
534
|
+
if (typeof action !== "string" || !ACTION.test(action) || typeof resource.type !== "string" || !resource.type) {
|
|
535
|
+
reply(res, 400, {
|
|
536
|
+
allowed: false,
|
|
537
|
+
status: "refused",
|
|
538
|
+
message: 'Send "action" and "resource": { "type", "id" }.'
|
|
539
|
+
});
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
const target = {
|
|
543
|
+
type: resource.type.slice(0, 64),
|
|
544
|
+
id: resource.id === void 0 || resource.id === null ? null : String(resource.id).slice(0, 256)
|
|
545
|
+
};
|
|
546
|
+
const context = input.context && typeof input.context === "object" && !Array.isArray(input.context) ? input.context : {};
|
|
547
|
+
const system = typeof input.system === "string" && input.system ? input.system : void 0;
|
|
548
|
+
const call = { ...system ? { system } : {}, context };
|
|
549
|
+
const key = `${id}:${stable([system ?? options.system, action, target.type, target.id, context])}`;
|
|
550
|
+
let decision = null;
|
|
551
|
+
const held = waiting.get(key);
|
|
552
|
+
if (held) {
|
|
553
|
+
const approval = await client.approvalStatus(held.decisionId);
|
|
554
|
+
if (approval.state === "approved") {
|
|
555
|
+
decision = await client.decide(action, target, {
|
|
556
|
+
...call,
|
|
557
|
+
approvalArtifactIds: approval.approvalArtifactIds
|
|
558
|
+
});
|
|
559
|
+
} else if (approval.state === "waiting") {
|
|
560
|
+
options.log(`ask ${action}: needs approval`);
|
|
561
|
+
reply(res, 200, {
|
|
562
|
+
allowed: false,
|
|
563
|
+
status: "needs_approval",
|
|
564
|
+
message: `Needs approval. A person has to approve this first (${approval.approvalsHave} of ${approval.approvalsNeeded ?? "?"} so far). Ask again once it is approved.`,
|
|
565
|
+
decisionId: held.decisionId,
|
|
566
|
+
approvalsHave: approval.approvalsHave,
|
|
567
|
+
approvalsNeeded: approval.approvalsNeeded
|
|
568
|
+
});
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
waiting.delete(key);
|
|
572
|
+
}
|
|
573
|
+
decision ??= await client.decide(action, target, call);
|
|
574
|
+
if (decision.allowed) {
|
|
575
|
+
const proof = await client.commit(decision);
|
|
576
|
+
waiting.delete(key);
|
|
577
|
+
options.log(`ask ${action}: allowed`);
|
|
578
|
+
reply(res, 200, {
|
|
579
|
+
allowed: true,
|
|
580
|
+
status: "allowed",
|
|
581
|
+
message: "Allowed.",
|
|
582
|
+
decisionId: decision.decisionId,
|
|
583
|
+
proofId: proof.id,
|
|
584
|
+
authorization: proof.raw
|
|
585
|
+
});
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
if (decision.needsApproval) {
|
|
589
|
+
waiting.set(key, { decisionId: decision.decisionId, at: Date.now() });
|
|
590
|
+
options.log(`ask ${action}: needs approval`);
|
|
591
|
+
reply(res, 200, {
|
|
592
|
+
allowed: false,
|
|
593
|
+
status: "needs_approval",
|
|
594
|
+
message: `Needs approval. ${decision.reason.message} Ask again once it is approved.`,
|
|
595
|
+
decisionId: decision.decisionId
|
|
596
|
+
});
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
options.log(`ask ${action}: blocked (${decision.reason.code})`);
|
|
600
|
+
reply(res, 200, {
|
|
601
|
+
allowed: false,
|
|
602
|
+
status: "blocked",
|
|
603
|
+
message: `Blocked by Praesidias. ${decision.reason.message}`,
|
|
604
|
+
decisionId: decision.decisionId,
|
|
605
|
+
code: decision.reason.code
|
|
606
|
+
});
|
|
607
|
+
return true;
|
|
608
|
+
} catch (err) {
|
|
609
|
+
const code = err instanceof import_sdk3.PraesidiasError ? err.code : "ERROR";
|
|
610
|
+
options.log(`ask: not answered (${code})`);
|
|
611
|
+
const refusedKey = code === "KEY_REFUSED";
|
|
612
|
+
reply(res, refusedKey ? 401 : 503, {
|
|
613
|
+
allowed: false,
|
|
614
|
+
status: refusedKey ? "refused" : "unavailable",
|
|
615
|
+
message: refusedKey ? "Praesidias did not accept this key." : "Praesidias could not confirm this action is allowed. Do not carry it out."
|
|
616
|
+
});
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/gateway.ts
|
|
623
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
624
|
+
"connection",
|
|
625
|
+
"keep-alive",
|
|
626
|
+
"proxy-authenticate",
|
|
627
|
+
"proxy-authorization",
|
|
628
|
+
"te",
|
|
629
|
+
"trailer",
|
|
630
|
+
"transfer-encoding",
|
|
631
|
+
"upgrade",
|
|
632
|
+
"host",
|
|
633
|
+
"content-length"
|
|
634
|
+
]);
|
|
635
|
+
var FROM_AGENT = /* @__PURE__ */ new Set([
|
|
636
|
+
"authorization",
|
|
637
|
+
"cookie",
|
|
638
|
+
"x-api-key",
|
|
639
|
+
"api-key",
|
|
640
|
+
"x-auth-token",
|
|
641
|
+
"proxy-authorization",
|
|
642
|
+
// The agent's workload identity token goes to Praesidias only, never to an upstream.
|
|
643
|
+
import_sdk2.WORKLOAD_TOKEN_HEADER
|
|
644
|
+
]);
|
|
645
|
+
var json = (res, status, body) => {
|
|
646
|
+
const text = JSON.stringify(body);
|
|
647
|
+
res.writeHead(status, {
|
|
648
|
+
"content-type": "application/json",
|
|
649
|
+
"content-length": Buffer.byteLength(text),
|
|
650
|
+
"cache-control": "no-store"
|
|
651
|
+
});
|
|
652
|
+
res.end(text);
|
|
653
|
+
};
|
|
654
|
+
var UpstreamAnswered = class extends Error {
|
|
655
|
+
constructor(reply2) {
|
|
656
|
+
super(`The API answered ${reply2.status}.`);
|
|
657
|
+
this.reply = reply2;
|
|
658
|
+
}
|
|
659
|
+
reply;
|
|
660
|
+
name = "UpstreamAnswered";
|
|
661
|
+
};
|
|
662
|
+
function createApiGateway(options) {
|
|
663
|
+
return (0, import_node_http.createServer)(createApiGatewayHandler(options));
|
|
664
|
+
}
|
|
665
|
+
function createApiGatewayServer(options) {
|
|
666
|
+
const { tls } = options.config;
|
|
667
|
+
if (!tls) return createApiGateway(options);
|
|
668
|
+
let cert;
|
|
669
|
+
let key;
|
|
670
|
+
try {
|
|
671
|
+
cert = (0, import_node_fs2.readFileSync)(tls.certFile);
|
|
672
|
+
key = (0, import_node_fs2.readFileSync)(tls.keyFile);
|
|
673
|
+
} catch {
|
|
674
|
+
throw new ConfigError(
|
|
675
|
+
'Gateway config: the "listen.tls" certificate or key file cannot be read.'
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
return (0, import_node_https.createServer)({ cert, key }, createApiGatewayHandler(options));
|
|
679
|
+
}
|
|
680
|
+
var PRAESIDIAS_KEY = /^pk_[A-Za-z0-9_]{16,128}$/;
|
|
681
|
+
function hostnameOf(header) {
|
|
682
|
+
const raw = String(header ?? "").trim().toLowerCase();
|
|
683
|
+
if (!raw) return "";
|
|
684
|
+
if (raw.startsWith("[")) return raw.slice(0, raw.indexOf("]") + 1);
|
|
685
|
+
return raw.split(":")[0].replace(/\.$/, "");
|
|
686
|
+
}
|
|
687
|
+
function createApiGatewayHandler(options) {
|
|
688
|
+
const { config } = options;
|
|
689
|
+
let praesidiasAt;
|
|
690
|
+
try {
|
|
691
|
+
praesidiasAt = new URL(options.praesidiasUrl);
|
|
692
|
+
} catch {
|
|
693
|
+
throw new ConfigError("The Praesidias address is not a valid URL.");
|
|
694
|
+
}
|
|
695
|
+
const onThisMachine = ["localhost", "127.0.0.1", "[::1]"].includes(praesidiasAt.hostname);
|
|
696
|
+
if (praesidiasAt.protocol !== "https:" && !(praesidiasAt.protocol === "http:" && onThisMachine)) {
|
|
697
|
+
throw new ConfigError(
|
|
698
|
+
"The Praesidias address must use https (http is accepted for this machine only)."
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
const log = options.log ?? (() => {
|
|
702
|
+
});
|
|
703
|
+
const note = (line) => log(`credential: ${line}`);
|
|
704
|
+
const sources = {
|
|
705
|
+
env: options.credentials ?? new EnvCredentialSource(),
|
|
706
|
+
file: new FileCredentialSource(note),
|
|
707
|
+
vault: new VaultCredentialSource({ ...options.vault, note })
|
|
708
|
+
};
|
|
709
|
+
const keyHeader = (options.keyHeader ?? "x-praesidias-key").toLowerCase();
|
|
710
|
+
const maxBody = options.maxBodyBytes ?? 1024 * 1024;
|
|
711
|
+
const send = options.fetch ?? fetch;
|
|
712
|
+
const timeoutMs = options.upstreamTimeoutMs ?? 3e4;
|
|
713
|
+
const ask = config.ask ? createAsk({
|
|
714
|
+
praesidiasUrl: options.praesidiasUrl,
|
|
715
|
+
system: config.system,
|
|
716
|
+
keyHeader,
|
|
717
|
+
log,
|
|
718
|
+
...options.sdkOptions ? { sdkOptions: options.sdkOptions } : {}
|
|
719
|
+
}) : null;
|
|
720
|
+
const gates = /* @__PURE__ */ new Map();
|
|
721
|
+
const gateFor = (apiKey, upstream) => {
|
|
722
|
+
const id = `${(0, import_node_crypto2.createHash)("sha256").update(apiKey).digest("hex")}:${upstream.name}`;
|
|
723
|
+
const now = Date.now();
|
|
724
|
+
for (const [k, entry2] of gates) if (now - entry2.at > 60 * 6e4) gates.delete(k);
|
|
725
|
+
let entry = gates.get(id);
|
|
726
|
+
if (!entry) {
|
|
727
|
+
const praesidias = new import_sdk4.Praesidias({
|
|
728
|
+
...withoutWorkloadToken(options.sdkOptions),
|
|
729
|
+
apiKey,
|
|
730
|
+
baseUrl: options.praesidiasUrl,
|
|
731
|
+
system: config.system,
|
|
732
|
+
onApproval: "return",
|
|
733
|
+
// Each request's own workload token (workload.ts), never one shared or the gateway's.
|
|
734
|
+
workloadToken: relayedWorkloadToken
|
|
735
|
+
});
|
|
736
|
+
entry = {
|
|
737
|
+
gate: new import_sdk4.ToolGate(praesidias, upstream.mapping, { onApproval: "return" }),
|
|
738
|
+
at: now
|
|
739
|
+
};
|
|
740
|
+
gates.set(id, entry);
|
|
741
|
+
}
|
|
742
|
+
entry.at = now;
|
|
743
|
+
return entry.gate;
|
|
744
|
+
};
|
|
745
|
+
async function readBody(req) {
|
|
746
|
+
const chunks = [];
|
|
747
|
+
let size = 0;
|
|
748
|
+
for await (const chunk of req) {
|
|
749
|
+
size += chunk.length;
|
|
750
|
+
if (size > maxBody) return null;
|
|
751
|
+
chunks.push(chunk);
|
|
752
|
+
}
|
|
753
|
+
return Buffer.concat(chunks);
|
|
754
|
+
}
|
|
755
|
+
function parsed(body, contentType) {
|
|
756
|
+
if (body.length === 0) return {};
|
|
757
|
+
if (/^application\/(.+\+)?json\b/i.test(contentType)) {
|
|
758
|
+
try {
|
|
759
|
+
return JSON.parse(body.toString("utf8"));
|
|
760
|
+
} catch {
|
|
761
|
+
return {};
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (/^application\/x-www-form-urlencoded\b/i.test(contentType))
|
|
765
|
+
return Object.fromEntries(new URLSearchParams(body.toString("utf8")));
|
|
766
|
+
return {};
|
|
767
|
+
}
|
|
768
|
+
async function handle(req, res) {
|
|
769
|
+
const url = new URL(req.url ?? "/", "http://gateway.invalid");
|
|
770
|
+
const byHost = config.hosts.size ? config.hosts.get(hostnameOf(req.headers.host)) : void 0;
|
|
771
|
+
if (!byHost) {
|
|
772
|
+
if (req.method === "GET" && url.pathname === "/healthz") return json(res, 200, { ok: true });
|
|
773
|
+
if (ask && await ask(req, res, url.pathname)) return;
|
|
774
|
+
}
|
|
775
|
+
let name;
|
|
776
|
+
let path;
|
|
777
|
+
let upstream;
|
|
778
|
+
if (byHost) {
|
|
779
|
+
upstream = byHost;
|
|
780
|
+
name = byHost.name;
|
|
781
|
+
path = url.pathname;
|
|
782
|
+
} else {
|
|
783
|
+
const [, first = "", ...rest] = url.pathname.split("/");
|
|
784
|
+
name = first;
|
|
785
|
+
upstream = config.upstreams.get(name);
|
|
786
|
+
path = `/${rest.join("/")}`;
|
|
787
|
+
}
|
|
788
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
789
|
+
if (!upstream)
|
|
790
|
+
return json(res, 404, {
|
|
791
|
+
status: "refused",
|
|
792
|
+
message: "There is no upstream by that name. This gateway only reaches the APIs it is configured for."
|
|
793
|
+
});
|
|
794
|
+
let decoded;
|
|
795
|
+
try {
|
|
796
|
+
decoded = decodeURIComponent(path);
|
|
797
|
+
} catch {
|
|
798
|
+
return json(res, 400, { status: "refused", message: "The path is not valid." });
|
|
799
|
+
}
|
|
800
|
+
if (decoded.split("/").includes("..") || decoded.includes("\\"))
|
|
801
|
+
return json(res, 400, { status: "refused", message: "The path is not valid." });
|
|
802
|
+
let route;
|
|
803
|
+
let params = {};
|
|
804
|
+
for (const candidate of upstream.routes) {
|
|
805
|
+
if (candidate.method !== method) continue;
|
|
806
|
+
const found = candidate.match.exec(path);
|
|
807
|
+
if (found) {
|
|
808
|
+
route = candidate;
|
|
809
|
+
params = Object.fromEntries(
|
|
810
|
+
candidate.params.map((p, i) => [p, decodeURIComponent(found[i + 1] ?? "")])
|
|
811
|
+
);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (!route) {
|
|
816
|
+
log(
|
|
817
|
+
`${method} ${name}${path.replace(/[0-9a-f-]{8,}|\d{3,}/gi, ":id")}: refused (no such route)`
|
|
818
|
+
);
|
|
819
|
+
return json(res, 403, {
|
|
820
|
+
status: "refused",
|
|
821
|
+
message: "This request does not match any route this gateway is configured for, so it was not sent."
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
const header = req.headers[keyHeader];
|
|
825
|
+
let apiKey = (Array.isArray(header) ? header[0] : header)?.trim();
|
|
826
|
+
const agentKey = upstream.agentKey;
|
|
827
|
+
if (!apiKey && agentKey) {
|
|
828
|
+
const sent = req.headers[agentKey.header];
|
|
829
|
+
let value = (Array.isArray(sent) ? sent[0] : sent)?.trim() ?? "";
|
|
830
|
+
if (agentKey.scheme) {
|
|
831
|
+
const prefix = `${agentKey.scheme.toLowerCase()} `;
|
|
832
|
+
value = value.toLowerCase().startsWith(prefix) ? value.slice(prefix.length).trim() : "";
|
|
833
|
+
}
|
|
834
|
+
if (!PRAESIDIAS_KEY.test(value)) {
|
|
835
|
+
log(`${route.id} (${name}): refused (no Praesidias key in ${agentKey.header})`);
|
|
836
|
+
return json(res, 401, {
|
|
837
|
+
status: "refused",
|
|
838
|
+
message: `The ${agentKey.header} header does not hold a Praesidias key, so nothing was sent and Praesidias was not asked.`
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
apiKey = value;
|
|
842
|
+
}
|
|
843
|
+
if (!apiKey)
|
|
844
|
+
return json(res, 401, {
|
|
845
|
+
status: "refused",
|
|
846
|
+
message: `Send your Praesidias key in the ${keyHeader} header.`
|
|
847
|
+
});
|
|
848
|
+
const body = await readBody(req);
|
|
849
|
+
if (body === null)
|
|
850
|
+
return json(res, 413, { status: "refused", message: "The request body is too large." });
|
|
851
|
+
const args = {
|
|
852
|
+
params,
|
|
853
|
+
query: Object.fromEntries(url.searchParams),
|
|
854
|
+
body: parsed(body, String(req.headers["content-type"] ?? ""))
|
|
855
|
+
};
|
|
856
|
+
const forward = async () => {
|
|
857
|
+
const headers = new Headers();
|
|
858
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
859
|
+
const lower = key.toLowerCase();
|
|
860
|
+
if (value === void 0 || HOP_BY_HOP.has(lower) || FROM_AGENT.has(lower) || lower === keyHeader || lower === upstream.agentKey?.header)
|
|
861
|
+
continue;
|
|
862
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
863
|
+
}
|
|
864
|
+
if (upstream.credential) {
|
|
865
|
+
const secret = await sources[upstream.credential.from].get(upstream.credential.name);
|
|
866
|
+
if (!secret) throw new MissingCredential();
|
|
867
|
+
headers.set(
|
|
868
|
+
upstream.credential.header ?? "authorization",
|
|
869
|
+
upstream.credential.scheme ? `${upstream.credential.scheme} ${secret}` : secret
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const target = new URL(
|
|
873
|
+
upstream.base.pathname.replace(/\/$/, "") + path + url.search,
|
|
874
|
+
upstream.base
|
|
875
|
+
);
|
|
876
|
+
if (target.origin !== upstream.base.origin) throw new Error("refusing to leave the upstream");
|
|
877
|
+
const reply2 = await send(target, {
|
|
878
|
+
method,
|
|
879
|
+
headers,
|
|
880
|
+
...method === "GET" || method === "DELETE" ? {} : { body: new Uint8Array(body) },
|
|
881
|
+
redirect: "manual",
|
|
882
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
883
|
+
});
|
|
884
|
+
const answered = {
|
|
885
|
+
status: reply2.status,
|
|
886
|
+
headers: reply2.headers,
|
|
887
|
+
body: Buffer.from(await reply2.arrayBuffer())
|
|
888
|
+
};
|
|
889
|
+
if (reply2.status >= 400) throw new UpstreamAnswered(answered);
|
|
890
|
+
return answered;
|
|
891
|
+
};
|
|
892
|
+
const relay = (reply2) => {
|
|
893
|
+
const headers = {};
|
|
894
|
+
reply2.headers.forEach((value, key) => {
|
|
895
|
+
if (!HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== "content-encoding")
|
|
896
|
+
headers[key] = value;
|
|
897
|
+
});
|
|
898
|
+
res.writeHead(reply2.status, { ...headers, "content-length": reply2.body.length });
|
|
899
|
+
res.end(reply2.body);
|
|
900
|
+
};
|
|
901
|
+
let outcome;
|
|
902
|
+
try {
|
|
903
|
+
outcome = await gateFor(apiKey, upstream).run(route.id, args, forward);
|
|
904
|
+
} catch (err) {
|
|
905
|
+
if (err instanceof UpstreamAnswered) {
|
|
906
|
+
log(`${route.id} (${name}): allowed, and the API answered ${err.reply.status}`);
|
|
907
|
+
return relay(err.reply);
|
|
908
|
+
}
|
|
909
|
+
if (err instanceof MissingCredential) {
|
|
910
|
+
log(`${route.id} (${name}): not sent (the API credential is not set)`);
|
|
911
|
+
return json(res, 503, {
|
|
912
|
+
status: "unavailable",
|
|
913
|
+
message: "The gateway has no credential for this API, so the request was not sent."
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
log(`${route.id} (${name}): allowed, and the API could not be reached`);
|
|
917
|
+
return json(res, 502, {
|
|
918
|
+
status: "upstream_failed",
|
|
919
|
+
message: "The API could not be reached."
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
if (outcome.ran) {
|
|
923
|
+
log(
|
|
924
|
+
`${route.id} (${name}): ${outcome.status === "passed_through" ? "passed through (ungoverned)" : "allowed"}`
|
|
925
|
+
);
|
|
926
|
+
res.setHeader("x-praesidias-decision", outcome.decisionId ?? "");
|
|
927
|
+
return relay(outcome.value);
|
|
928
|
+
}
|
|
929
|
+
log(`${route.id} (${name}): ${outcome.status}`);
|
|
930
|
+
const status = outcome.status === "blocked" ? 403 : outcome.status === "needs_approval" ? 409 : 503;
|
|
931
|
+
return json(res, status, {
|
|
932
|
+
status: outcome.status,
|
|
933
|
+
message: outcome.message,
|
|
934
|
+
...outcome.decisionId ? { decisionId: outcome.decisionId } : {},
|
|
935
|
+
...outcome.status === "needs_approval" ? { approvalsHave: outcome.approvalsHave, approvalsNeeded: outcome.approvalsNeeded } : {}
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return (req, res) => {
|
|
939
|
+
withWorkloadToken(req, () => handle(req, res)).catch(() => {
|
|
940
|
+
if (!res.headersSent)
|
|
941
|
+
json(res, 500, {
|
|
942
|
+
status: "error",
|
|
943
|
+
message: "The gateway failed. The request was not completed."
|
|
944
|
+
});
|
|
945
|
+
else res.end();
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
var MissingCredential = class extends Error {
|
|
950
|
+
};
|
|
951
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
952
|
+
0 && (module.exports = {
|
|
953
|
+
ConfigError,
|
|
954
|
+
EnvCredentialSource,
|
|
955
|
+
FileCredentialSource,
|
|
956
|
+
VaultCredentialSource,
|
|
957
|
+
compileConfig,
|
|
958
|
+
createApiGateway,
|
|
959
|
+
createApiGatewayHandler,
|
|
960
|
+
createApiGatewayServer,
|
|
961
|
+
loadConfig
|
|
962
|
+
});
|