@spine-event-engine/server 2.0.0-snapshot.26 → 2.0.0-snapshot.29
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/README.md +61 -8
- package/REFERENCE.md +267 -45
- package/dist/agent/agent-live-ai-runtime.d.ts +1 -1
- package/dist/agent/agent-live-ai-runtime.js +1 -1
- package/dist/agent/agent-live-budget.d.ts +1 -1
- package/dist/agent/agent-live-budget.js +1 -1
- package/dist/agent/agent-live-read-runtime.d.ts +1 -1
- package/dist/agent/agent-live-read-runtime.js +1 -1
- package/dist/browser/browser-admission.d.ts +47 -0
- package/dist/browser/browser-admission.d.ts.map +1 -0
- package/dist/browser/browser-admission.js +200 -0
- package/dist/browser/browser-admission.js.map +1 -0
- package/dist/browser/browser-server.d.ts +14 -7
- package/dist/browser/browser-server.d.ts.map +1 -1
- package/dist/browser/browser-server.js +462 -129
- package/dist/browser/browser-server.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/options.d.ts +48 -2
- package/dist/browser/options.d.ts.map +1 -1
- package/dist/bus/command-bus.d.ts +6 -1
- package/dist/bus/command-bus.d.ts.map +1 -1
- package/dist/bus/command-bus.js +6 -1
- package/dist/bus/command-bus.js.map +1 -1
- package/dist/bus/event-bus.d.ts +6 -1
- package/dist/bus/event-bus.d.ts.map +1 -1
- package/dist/bus/event-bus.js +6 -1
- package/dist/bus/event-bus.js.map +1 -1
- package/dist/context/bounded-context.d.ts +30 -28
- package/dist/context/bounded-context.d.ts.map +1 -1
- package/dist/context/bounded-context.js +137 -83
- package/dist/context/bounded-context.js.map +1 -1
- package/dist/context/entity-inbox.d.ts +12 -17
- package/dist/context/entity-inbox.d.ts.map +1 -1
- package/dist/context/entity-inbox.js +106 -120
- package/dist/context/entity-inbox.js.map +1 -1
- package/dist/context/local-inbox-handoff.d.ts +13 -13
- package/dist/context/local-inbox-handoff.d.ts.map +1 -1
- package/dist/context/local-inbox-handoff.js +60 -32
- package/dist/context/local-inbox-handoff.js.map +1 -1
- package/dist/context/persistent-tenant-index.d.ts +81 -0
- package/dist/context/persistent-tenant-index.d.ts.map +1 -0
- package/dist/context/persistent-tenant-index.js +270 -0
- package/dist/context/persistent-tenant-index.js.map +1 -0
- package/dist/context/projection-handoff.d.ts +13 -11
- package/dist/context/projection-handoff.d.ts.map +1 -1
- package/dist/context/projection-handoff.js +77 -34
- package/dist/context/projection-handoff.js.map +1 -1
- package/dist/context/tenant-index.d.ts +7 -6
- package/dist/context/tenant-index.d.ts.map +1 -1
- package/dist/context/tenant-index.js +2 -188
- package/dist/context/tenant-index.js.map +1 -1
- package/dist/context/tenant-unknown-error.d.ts +2 -2
- package/dist/context/tenant-unknown-error.d.ts.map +1 -1
- package/dist/context/tenant-unknown-error.js +4 -1
- package/dist/context/tenant-unknown-error.js.map +1 -1
- package/dist/delivery/delivery-builder.d.ts +8 -7
- package/dist/delivery/delivery-builder.d.ts.map +1 -1
- package/dist/delivery/delivery-builder.js +2 -2
- package/dist/delivery/delivery-builder.js.map +1 -1
- package/dist/delivery/delivery-ports.d.ts +2 -2
- package/dist/delivery/delivery-supervisor.d.ts.map +1 -1
- package/dist/delivery/delivery-supervisor.js +12 -3
- package/dist/delivery/delivery-supervisor.js.map +1 -1
- package/dist/delivery/delivery.d.ts +4 -3
- package/dist/delivery/delivery.d.ts.map +1 -1
- package/dist/delivery/delivery.js +87 -38
- package/dist/delivery/delivery.js.map +1 -1
- package/dist/delivery/inbox-retention.d.ts +7 -0
- package/dist/delivery/inbox-retention.d.ts.map +1 -0
- package/dist/delivery/inbox-retention.js +29 -0
- package/dist/delivery/inbox-retention.js.map +1 -0
- package/dist/delivery/inbox-storage.d.ts +2 -2
- package/dist/delivery/inbox-storage.d.ts.map +1 -1
- package/dist/delivery/inbox-storage.js +32 -8
- package/dist/delivery/inbox-storage.js.map +1 -1
- package/dist/delivery/inbox.d.ts +5 -9
- package/dist/delivery/inbox.d.ts.map +1 -1
- package/dist/delivery/inbox.js +4 -6
- package/dist/delivery/inbox.js.map +1 -1
- package/dist/delivery/reception-observation.d.ts +3 -3
- package/dist/delivery/reception-observation.js +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/repository/command-routing.d.ts +1 -1
- package/dist/repository/command-routing.js +1 -1
- package/dist/repository/event-routing.d.ts +1 -1
- package/dist/repository/event-routing.js +1 -1
- package/dist/repository/repository.d.ts +25 -33
- package/dist/repository/repository.d.ts.map +1 -1
- package/dist/repository/repository.js +245 -230
- package/dist/repository/repository.js.map +1 -1
- package/dist/repository/state-update-routing.d.ts +1 -1
- package/dist/repository/state-update-routing.js +1 -1
- package/dist/runtime/signal-publisher.d.ts +23 -8
- package/dist/runtime/signal-publisher.d.ts.map +1 -1
- package/dist/runtime/signal-publisher.js +11 -8
- package/dist/runtime/signal-publisher.js.map +1 -1
- package/dist/server/node-coordinator.d.ts.map +1 -1
- package/dist/server/node-coordinator.js +220 -54
- package/dist/server/node-coordinator.js.map +1 -1
- package/dist/server/server-environment.d.ts +6 -5
- package/dist/server/server-environment.d.ts.map +1 -1
- package/dist/server/server-environment.js +13 -9
- package/dist/server/server-environment.js.map +1 -1
- package/dist/server/server.js +1 -0
- package/dist/server/server.js.map +1 -1
- package/dist/services/app-query-execution.js +2 -2
- package/dist/services/app-query-execution.js.map +1 -1
- package/dist/services/native-operation.d.ts +34 -0
- package/dist/services/native-operation.d.ts.map +1 -0
- package/dist/services/native-operation.js +77 -0
- package/dist/services/native-operation.js.map +1 -0
- package/dist/services/spine-services.d.ts +6 -0
- package/dist/services/spine-services.d.ts.map +1 -1
- package/dist/services/spine-services.js +128 -30
- package/dist/services/spine-services.js.map +1 -1
- package/dist/services/trusted-reader-ingress.d.ts +23 -5
- package/dist/services/trusted-reader-ingress.d.ts.map +1 -1
- package/dist/services/trusted-reader-ingress.js +18 -5
- package/dist/services/trusted-reader-ingress.js.map +1 -1
- package/dist/spi/delivery.d.ts +1 -0
- package/dist/spi/delivery.d.ts.map +1 -1
- package/dist/spi/delivery.js +1 -0
- package/dist/spi/delivery.js.map +1 -1
- package/dist/stand/stand.d.ts +1 -1
- package/dist/stand/stand.js +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -14
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
16
16
|
/* eslint-disable @typescript-eslint/require-await */
|
|
17
17
|
import * as http from "node:http";
|
|
18
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
19
|
import { createNativeGatewayServices, DynamicSubscriptionCreator, DynamicUnaryForwarder, InMemorySubscriptionBindings, NativeSubscriptionCreator, SubscriptionGateway, TransportFacts, UnaryGateway, } from "@spine-event-engine/auth";
|
|
20
|
+
import { Code, ConnectError, cors as connectCors, createContextKey, } from "@connectrpc/connect";
|
|
19
21
|
import { connectNodeAdapter, createGrpcTransport, Http2SessionManager, } from "@connectrpc/connect-node";
|
|
20
22
|
import { AuthenticationService } from "@spine-event-engine/proto/auth";
|
|
21
23
|
import { ApplicationNode } from "@spine-event-engine/deployment";
|
|
22
24
|
import { CommandService, QueryService, SubscriptionService, } from "@spine-event-engine/proto/client";
|
|
23
25
|
import { Server, serverBuilderAccess } from "../server/server.js";
|
|
26
|
+
import { BrowserAdmissionPolicy } from "./browser-admission.js";
|
|
24
27
|
import { attachDurableSubscriptionCleanup, isDurableSubscriptionBindings, } from "./durable-subscription-bindings.js";
|
|
25
28
|
import reservedSpineRpcPaths from "../server/reserved-spine-rpc-paths.json" with { type: "json" };
|
|
26
29
|
import { Environment, EnvironmentType } from "../server/environment.js";
|
|
@@ -28,6 +31,29 @@ import { ProcessServerCoordinator } from "../server/process-server-coordinator.j
|
|
|
28
31
|
import { ServerEnvironment } from "../server/server-environment.js";
|
|
29
32
|
const gracefulBrowserDrainMs = 100;
|
|
30
33
|
const reservedPathSet = new Set(reservedSpineRpcPaths);
|
|
34
|
+
const credentialContext = new AsyncLocalStorage();
|
|
35
|
+
const admittedCredential = createContextKey(undefined, {
|
|
36
|
+
description: "Browser credential admitted for one Connect request",
|
|
37
|
+
});
|
|
38
|
+
const emptyAdmission = Object.freeze({
|
|
39
|
+
csrfHeaderNames: Object.freeze([]),
|
|
40
|
+
credentialCookieNames: Object.freeze([]),
|
|
41
|
+
stateCookieNames: Object.freeze([]),
|
|
42
|
+
});
|
|
43
|
+
const bearerCredentials = Object.freeze({
|
|
44
|
+
admission: emptyAdmission,
|
|
45
|
+
read: async ({ headers }) => {
|
|
46
|
+
const supplied = headers.authorization;
|
|
47
|
+
if (supplied === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (typeof supplied !== "string")
|
|
50
|
+
return { kind: "rejected" };
|
|
51
|
+
const match = /^Bearer ([\x21-\x7e]+)$/.exec(supplied);
|
|
52
|
+
return match === null
|
|
53
|
+
? { kind: "rejected" }
|
|
54
|
+
: { kind: "bearer", value: match[1] ?? "" };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
31
57
|
/**
|
|
32
58
|
* Runs the private native endpoint behind one admitted browser listener.
|
|
33
59
|
*
|
|
@@ -64,17 +90,14 @@ export const browserServerImplementation = Object.freeze({
|
|
|
64
90
|
*/
|
|
65
91
|
async run(nativeOrOptions, suppliedOptions) {
|
|
66
92
|
const options = suppliedOptions ?? nativeOrOptions;
|
|
67
|
-
|
|
93
|
+
const combined = nativeOrOptions instanceof Server;
|
|
94
|
+
if (combined) {
|
|
68
95
|
if (!serverBuilderAccess.isLoopback(nativeOrOptions))
|
|
69
96
|
throw new Error("Combined browser server requires a loopback native listener.");
|
|
70
|
-
BrowserServer.preflight("combined", options);
|
|
71
97
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const running = native === undefined
|
|
76
|
-
? await BrowserServer.open(undefined, options)
|
|
77
|
-
: await BrowserServer.open(native, options);
|
|
98
|
+
const prepared = BrowserServer.preflight(combined ? "combined" : "standalone", options);
|
|
99
|
+
const native = combined ? await nativeOrOptions.start() : undefined;
|
|
100
|
+
const running = await BrowserServerValues.openWithOptions(native, native, prepared);
|
|
78
101
|
return ProcessServerCoordinator.add(running, native === undefined ? undefined : ServerEnvironment.instance(), undefined, () => undefined);
|
|
79
102
|
},
|
|
80
103
|
/**
|
|
@@ -85,13 +108,18 @@ export const browserServerImplementation = Object.freeze({
|
|
|
85
108
|
* @returns Normalized listener options after all preflight checks pass.
|
|
86
109
|
*/
|
|
87
110
|
preflight(mode, input) {
|
|
111
|
+
const admission = BrowserServerValues.snapshotAdmission(input);
|
|
112
|
+
const production = Environment.instance().type === EnvironmentType.Production;
|
|
113
|
+
BrowserServer.requireDurableBindings(input, production);
|
|
88
114
|
const options = {
|
|
89
115
|
...input,
|
|
116
|
+
origins: Object.freeze([...input.origins]),
|
|
117
|
+
...admission,
|
|
90
118
|
host: input.host ?? "127.0.0.1",
|
|
91
119
|
port: input.port ?? 0,
|
|
92
120
|
readMaxBytes: input.readMaxBytes ?? 4_194_304,
|
|
93
121
|
writeMaxBytes: input.writeMaxBytes ?? 4_194_304,
|
|
94
|
-
production
|
|
122
|
+
production,
|
|
95
123
|
};
|
|
96
124
|
if (!options.host.trim())
|
|
97
125
|
throw new Error("Browser server host must not be blank.");
|
|
@@ -103,9 +131,7 @@ export const browserServerImplementation = Object.freeze({
|
|
|
103
131
|
throw new Error("Browser server writeMaxBytes must be a positive integer.");
|
|
104
132
|
if (mode === "standalone" && options.backend === undefined && options.discovery === undefined)
|
|
105
133
|
throw new Error("Standalone browser server requires a backend or discovery.");
|
|
106
|
-
BrowserServer.requireDurableBindings(options, options.production);
|
|
107
134
|
BrowserServer.origins(options.origins);
|
|
108
|
-
BrowserServer.authRoutes(options.authRoutes);
|
|
109
135
|
const capacity = options.maxActiveAuthRequests ?? 64;
|
|
110
136
|
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
|
111
137
|
throw new Error("Browser maxActiveAuthRequests must be a positive safe integer.");
|
|
@@ -118,18 +144,9 @@ export const browserServerImplementation = Object.freeze({
|
|
|
118
144
|
* @returns Adapters that the gateway uses to admit each incoming request.
|
|
119
145
|
*/
|
|
120
146
|
requests(options) {
|
|
147
|
+
void options;
|
|
121
148
|
return {
|
|
122
|
-
credential: (context) =>
|
|
123
|
-
const headers = Object.fromEntries(context.requestHeader.entries());
|
|
124
|
-
const extracted = options.cookies?.extract(headers);
|
|
125
|
-
if (extracted !== undefined) {
|
|
126
|
-
return extracted.kind === "rejected" ? { kind: "bearer", value: "" } : extracted;
|
|
127
|
-
}
|
|
128
|
-
const authorization = context.requestHeader.get("authorization") ?? "";
|
|
129
|
-
return /^Bearer [^\s]+$/.test(authorization)
|
|
130
|
-
? { kind: "bearer", value: authorization.slice("Bearer ".length) }
|
|
131
|
-
: undefined;
|
|
132
|
-
},
|
|
149
|
+
credential: (context) => context.values?.get(admittedCredential),
|
|
133
150
|
transport: (context) => {
|
|
134
151
|
const origin = context.requestHeader.get("origin");
|
|
135
152
|
return TransportFacts.from({
|
|
@@ -308,110 +325,28 @@ export const browserServerImplementation = Object.freeze({
|
|
|
308
325
|
* @returns Completion after the HTTP response and request cleanup settle.
|
|
309
326
|
*/
|
|
310
327
|
async dispatchAuth(request, response, route, active, writeMaxBytes) {
|
|
311
|
-
|
|
312
|
-
if ((origin === undefined && route.allowMissingOrigin !== true) ||
|
|
313
|
-
(origin !== undefined && !route.origins.includes(origin))) {
|
|
314
|
-
response.statusCode = 403;
|
|
315
|
-
response.end();
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const length = request.headers["content-length"];
|
|
319
|
-
if (length !== undefined && (!/^\d+$/.test(length) || Number(length) > route.maxRequestBytes)) {
|
|
320
|
-
response.statusCode = 413;
|
|
321
|
-
response.end();
|
|
328
|
+
if (!AuthRouteScope.admit(request, response, route))
|
|
322
329
|
return;
|
|
323
|
-
|
|
324
|
-
const controller = new AbortController();
|
|
325
|
-
active.add(controller);
|
|
326
|
-
const timer = setTimeout(() => {
|
|
327
|
-
controller.abort();
|
|
328
|
-
request.resume();
|
|
329
|
-
}, route.timeoutMs);
|
|
330
|
-
const abort = () => {
|
|
331
|
-
controller.abort();
|
|
332
|
-
};
|
|
333
|
-
request.once("aborted", abort);
|
|
334
|
-
response.once("close", abort);
|
|
335
|
-
let rejectAbort;
|
|
336
|
-
const aborted = new Promise((_resolve, reject) => {
|
|
337
|
-
rejectAbort = reject;
|
|
338
|
-
});
|
|
339
|
-
let responseReader;
|
|
340
|
-
const onAbort = () => {
|
|
341
|
-
rejectAbort(new Error("browser auth request aborted"));
|
|
342
|
-
void responseReader?.cancel().catch(() => undefined);
|
|
343
|
-
};
|
|
344
|
-
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
330
|
+
const scope = new AuthRouteScope(request, response, route.timeoutMs, active);
|
|
345
331
|
try {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
for (;;) {
|
|
350
|
-
const next = await Promise.race([requestBody.next(), aborted]);
|
|
351
|
-
if (next.done)
|
|
352
|
-
break;
|
|
353
|
-
const bytes = Buffer.from(next.value);
|
|
354
|
-
size += bytes.byteLength;
|
|
355
|
-
if (size > route.maxRequestBytes) {
|
|
356
|
-
response.statusCode = 413;
|
|
357
|
-
response.end();
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
requestChunks.push(bytes);
|
|
361
|
-
}
|
|
332
|
+
const body = await scope.body(route.maxRequestBytes);
|
|
333
|
+
if (body === undefined)
|
|
334
|
+
return;
|
|
362
335
|
const url = new URL(request.url ?? route.path, `http://${request.headers.host ?? "localhost"}`);
|
|
363
336
|
const input = new Request(url, {
|
|
364
337
|
method: route.method,
|
|
365
338
|
headers: request.headers,
|
|
366
|
-
...(
|
|
367
|
-
signal:
|
|
339
|
+
...(body.byteLength === 0 ? {} : { body: new Uint8Array(body) }),
|
|
340
|
+
signal: scope.signal,
|
|
368
341
|
});
|
|
369
|
-
const result = await Promise.
|
|
370
|
-
|
|
371
|
-
if (!response.writableEnded) {
|
|
372
|
-
response.statusCode = 504;
|
|
373
|
-
response.end();
|
|
374
|
-
}
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
responseReader = result.body?.getReader();
|
|
378
|
-
const responseChunks = [];
|
|
379
|
-
let responseBytes = 0;
|
|
380
|
-
if (responseReader !== undefined) {
|
|
381
|
-
for (;;) {
|
|
382
|
-
const next = await Promise.race([responseReader.read(), aborted]);
|
|
383
|
-
if (next.done)
|
|
384
|
-
break;
|
|
385
|
-
responseBytes += next.value.byteLength;
|
|
386
|
-
if (responseBytes > writeMaxBytes) {
|
|
387
|
-
await responseReader.cancel().catch(() => undefined);
|
|
388
|
-
response.statusCode = 413;
|
|
389
|
-
response.end();
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
responseChunks.push(next.value);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
response.statusCode = result.status;
|
|
396
|
-
result.headers.forEach((value, key) => response.setHeader(key, value));
|
|
397
|
-
response.end(Buffer.concat(responseChunks));
|
|
342
|
+
const result = await scope.wait(Promise.resolve(route.onRequest(input, scope.signal)));
|
|
343
|
+
await scope.send(result, writeMaxBytes);
|
|
398
344
|
}
|
|
399
345
|
catch {
|
|
400
|
-
|
|
401
|
-
if (controller.signal.aborted) {
|
|
402
|
-
response.shouldKeepAlive = false;
|
|
403
|
-
response.setHeader("connection", "close");
|
|
404
|
-
}
|
|
405
|
-
response.statusCode = controller.signal.aborted ? 504 : 500;
|
|
406
|
-
response.end();
|
|
407
|
-
}
|
|
346
|
+
scope.fail();
|
|
408
347
|
}
|
|
409
348
|
finally {
|
|
410
|
-
|
|
411
|
-
request.off("aborted", abort);
|
|
412
|
-
response.off("close", abort);
|
|
413
|
-
controller.signal.removeEventListener("abort", onAbort);
|
|
414
|
-
active.delete(controller);
|
|
349
|
+
scope.close();
|
|
415
350
|
}
|
|
416
351
|
},
|
|
417
352
|
/**
|
|
@@ -469,7 +404,340 @@ export const browserServerTestAccess = Object.freeze({
|
|
|
469
404
|
...browserServerImplementation,
|
|
470
405
|
});
|
|
471
406
|
const BrowserServer = browserServerImplementation;
|
|
407
|
+
/**
|
|
408
|
+
* Tracks one bounded authentication request through body and response streaming.
|
|
409
|
+
*/
|
|
410
|
+
class AuthRouteScope {
|
|
411
|
+
request;
|
|
412
|
+
response;
|
|
413
|
+
active;
|
|
414
|
+
/**
|
|
415
|
+
* Checks route origin and declared body length before allocating request state.
|
|
416
|
+
* @param request Incoming HTTP request.
|
|
417
|
+
* @param response Response for rejected input.
|
|
418
|
+
* @param route Selected authentication route.
|
|
419
|
+
* @returns Whether the request may enter the route callback.
|
|
420
|
+
*/
|
|
421
|
+
static admit(request, response, route) {
|
|
422
|
+
const origin = request.headers.origin;
|
|
423
|
+
if ((origin === undefined && route.allowMissingOrigin !== true) ||
|
|
424
|
+
(origin !== undefined && !route.origins.includes(origin))) {
|
|
425
|
+
response.statusCode = 403;
|
|
426
|
+
response.end();
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
const length = request.headers["content-length"];
|
|
430
|
+
if (length !== undefined && (!/^\d+$/.test(length) || Number(length) > route.maxRequestBytes)) {
|
|
431
|
+
response.statusCode = 413;
|
|
432
|
+
response.end();
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
#controller = new AbortController();
|
|
438
|
+
#aborted;
|
|
439
|
+
#timer;
|
|
440
|
+
#responseReader;
|
|
441
|
+
#abort = () => {
|
|
442
|
+
this.#controller.abort();
|
|
443
|
+
};
|
|
444
|
+
#onAbort;
|
|
445
|
+
/**
|
|
446
|
+
* Starts the route deadline and disconnect fences.
|
|
447
|
+
* @param request Incoming HTTP request.
|
|
448
|
+
* @param response Response to complete or abort.
|
|
449
|
+
* @param timeoutMs Configured route deadline in milliseconds.
|
|
450
|
+
* @param active Controllers drained when the host closes.
|
|
451
|
+
*/
|
|
452
|
+
constructor(request, response, timeoutMs, active) {
|
|
453
|
+
this.request = request;
|
|
454
|
+
this.response = response;
|
|
455
|
+
this.active = active;
|
|
456
|
+
let rejectAbort;
|
|
457
|
+
this.#aborted = new Promise((_resolve, reject) => {
|
|
458
|
+
rejectAbort = reject;
|
|
459
|
+
});
|
|
460
|
+
this.#onAbort = () => {
|
|
461
|
+
rejectAbort(new Error("browser auth request aborted"));
|
|
462
|
+
void this.#responseReader?.cancel().catch(() => undefined);
|
|
463
|
+
};
|
|
464
|
+
active.add(this.#controller);
|
|
465
|
+
this.#timer = setTimeout(() => {
|
|
466
|
+
this.#controller.abort();
|
|
467
|
+
request.resume();
|
|
468
|
+
}, timeoutMs);
|
|
469
|
+
request.once("aborted", this.#abort);
|
|
470
|
+
response.once("close", this.#abort);
|
|
471
|
+
this.#controller.signal.addEventListener("abort", this.#onAbort, { once: true });
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Exposes the signal passed to the configured route callback.
|
|
475
|
+
* @returns Signal that aborts on deadline, disconnect or host drain.
|
|
476
|
+
*/
|
|
477
|
+
get signal() {
|
|
478
|
+
return this.#controller.signal;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Awaits one route step while allowing deadline and disconnect to stop it.
|
|
482
|
+
* @typeParam T Result returned by the pending route step.
|
|
483
|
+
* @param pending Body, callback, or response-read step.
|
|
484
|
+
* @returns The step's result when the request remains active.
|
|
485
|
+
*/
|
|
486
|
+
async wait(pending) {
|
|
487
|
+
const result = await Promise.race([pending, this.#aborted]);
|
|
488
|
+
if (this.signal.aborted)
|
|
489
|
+
throw new Error("browser auth request aborted");
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Reads the bounded route body before constructing a WHATWG request.
|
|
494
|
+
* @param limit Configured maximum request bytes.
|
|
495
|
+
* @returns Complete body, or undefined after a 413 response.
|
|
496
|
+
*/
|
|
497
|
+
async body(limit) {
|
|
498
|
+
const chunks = [];
|
|
499
|
+
let size = 0;
|
|
500
|
+
const body = this.request[Symbol.asyncIterator]();
|
|
501
|
+
for (;;) {
|
|
502
|
+
const next = await this.wait(body.next());
|
|
503
|
+
if (next.done)
|
|
504
|
+
return Buffer.concat(chunks);
|
|
505
|
+
const bytes = Buffer.from(next.value);
|
|
506
|
+
size += bytes.byteLength;
|
|
507
|
+
if (size > limit) {
|
|
508
|
+
this.response.statusCode = 413;
|
|
509
|
+
this.response.end();
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
chunks.push(bytes);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Streams a bounded route response and preserves separate Set-Cookie fields.
|
|
517
|
+
* @param result Configured route callback result.
|
|
518
|
+
* @param limit Existing response-size transport bound.
|
|
519
|
+
* @returns Completion after the response is sent or rejected for size.
|
|
520
|
+
*/
|
|
521
|
+
async send(result, limit) {
|
|
522
|
+
this.#requireActive();
|
|
523
|
+
this.#responseReader = result.body?.getReader();
|
|
524
|
+
const chunks = [];
|
|
525
|
+
let size = 0;
|
|
526
|
+
if (this.#responseReader !== undefined) {
|
|
527
|
+
for (;;) {
|
|
528
|
+
const next = await this.wait(this.#responseReader.read());
|
|
529
|
+
if (next.done)
|
|
530
|
+
break;
|
|
531
|
+
size += next.value.byteLength;
|
|
532
|
+
if (size > limit) {
|
|
533
|
+
await this.wait(this.#responseReader.cancel().catch(() => undefined));
|
|
534
|
+
this.#requireActive();
|
|
535
|
+
this.response.statusCode = 413;
|
|
536
|
+
this.response.end();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
chunks.push(next.value);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
this.#requireActive();
|
|
543
|
+
this.response.statusCode = result.status;
|
|
544
|
+
this.#forwardHeaders(result.headers);
|
|
545
|
+
this.response.end(Buffer.concat(chunks));
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Copies callback headers while retaining host CORS and separate cookie values.
|
|
549
|
+
* @param headers Callback response headers.
|
|
550
|
+
*/
|
|
551
|
+
#forwardHeaders(headers) {
|
|
552
|
+
headers.forEach((value, key) => {
|
|
553
|
+
if (key === "vary") {
|
|
554
|
+
const values = value
|
|
555
|
+
.split(",")
|
|
556
|
+
.map((part) => part.trim())
|
|
557
|
+
.filter(Boolean);
|
|
558
|
+
this.response.setHeader("vary", [...new Set(["Origin", ...values])].join(", "));
|
|
559
|
+
}
|
|
560
|
+
else if (key !== "set-cookie" && !key.startsWith("access-control-"))
|
|
561
|
+
this.response.setHeader(key, value);
|
|
562
|
+
});
|
|
563
|
+
const cookies = headers.getSetCookie();
|
|
564
|
+
if (cookies.length > 0)
|
|
565
|
+
this.response.setHeader("set-cookie", cookies);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Rejects output after a deadline or disconnect aborts the route.
|
|
569
|
+
*/
|
|
570
|
+
#requireActive() {
|
|
571
|
+
if (this.signal.aborted)
|
|
572
|
+
throw new Error("browser auth request aborted");
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Completes a failed route without exposing partial callback output.
|
|
576
|
+
*/
|
|
577
|
+
fail() {
|
|
578
|
+
if (this.response.writableEnded)
|
|
579
|
+
return;
|
|
580
|
+
if (this.signal.aborted) {
|
|
581
|
+
this.response.shouldKeepAlive = false;
|
|
582
|
+
this.response.setHeader("connection", "close");
|
|
583
|
+
}
|
|
584
|
+
this.response.statusCode = this.signal.aborted ? 504 : 500;
|
|
585
|
+
this.response.end();
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Removes the deadline and active-request membership.
|
|
589
|
+
*/
|
|
590
|
+
close() {
|
|
591
|
+
clearTimeout(this.#timer);
|
|
592
|
+
this.request.off("aborted", this.#abort);
|
|
593
|
+
this.response.off("close", this.#abort);
|
|
594
|
+
this.signal.removeEventListener("abort", this.#onAbort);
|
|
595
|
+
this.active.delete(this.#controller);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
472
598
|
const BrowserServerValues = Object.freeze({
|
|
599
|
+
/**
|
|
600
|
+
* Gets one unambiguous original Origin field for credentialed CORS.
|
|
601
|
+
* @param request Incoming Node request before header normalization.
|
|
602
|
+
* @returns The sole raw Origin value, when present exactly once.
|
|
603
|
+
*/
|
|
604
|
+
rawOrigin(request) {
|
|
605
|
+
let origin;
|
|
606
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
607
|
+
if (request.rawHeaders[index]?.toLowerCase() !== "origin")
|
|
608
|
+
continue;
|
|
609
|
+
if (origin !== undefined)
|
|
610
|
+
return undefined;
|
|
611
|
+
origin = request.rawHeaders[index + 1];
|
|
612
|
+
}
|
|
613
|
+
return origin;
|
|
614
|
+
},
|
|
615
|
+
/**
|
|
616
|
+
* Applies the host's exact-origin credentialed CORS policy to every admitted response.
|
|
617
|
+
* @param response Node response receiving policy headers.
|
|
618
|
+
* @param origin Validated exact origin from the raw request.
|
|
619
|
+
* @param csrfNames Frozen active reader or route CSRF header names.
|
|
620
|
+
* @param method Configured route method or RPC POST method.
|
|
621
|
+
* @param rpc Whether gRPC status headers must be exposed.
|
|
622
|
+
*/
|
|
623
|
+
cors(response, origin, csrfNames, method, rpc) {
|
|
624
|
+
response.setHeader("access-control-allow-origin", origin);
|
|
625
|
+
response.setHeader("access-control-allow-credentials", "true");
|
|
626
|
+
response.setHeader("access-control-allow-methods", `${method},OPTIONS`);
|
|
627
|
+
response.setHeader("access-control-allow-headers", [
|
|
628
|
+
...new Set(["authorization", "x-csrf-token", ...connectCors.allowedHeaders, ...csrfNames].map((name) => name.toLowerCase())),
|
|
629
|
+
].join(","));
|
|
630
|
+
if (rpc)
|
|
631
|
+
response.setHeader("access-control-expose-headers", connectCors.exposedHeaders.map((name) => name.toLowerCase()).join(","));
|
|
632
|
+
response.setHeader("access-control-max-age", "600");
|
|
633
|
+
response.setHeader("vary", "Origin");
|
|
634
|
+
},
|
|
635
|
+
/**
|
|
636
|
+
* Reads request-local raw facts inside Connect's deadline and disconnect signal.
|
|
637
|
+
* @param reader Configured browser credential reader.
|
|
638
|
+
* @returns Connect interceptor that transfers only the admitted credential to the Gateway.
|
|
639
|
+
*/
|
|
640
|
+
credentialInterceptor(reader) {
|
|
641
|
+
return (next) => async (request) => {
|
|
642
|
+
const facts = credentialContext.getStore()?.facts;
|
|
643
|
+
if (facts === undefined)
|
|
644
|
+
throw new ConnectError("Browser credential facts are absent.", Code.Internal);
|
|
645
|
+
const credential = await BrowserServerValues.readCredential(reader, facts, request.signal);
|
|
646
|
+
request.contextValues.set(admittedCredential, credential);
|
|
647
|
+
return next(request);
|
|
648
|
+
};
|
|
649
|
+
},
|
|
650
|
+
/**
|
|
651
|
+
* Reads a credential under Connect cancellation and classifies terminal outcomes.
|
|
652
|
+
* @param reader Configured browser credential reader.
|
|
653
|
+
* @param facts Immutable raw headers, method, and request-local URL.
|
|
654
|
+
* @param signal Connect request signal including its protocol deadline.
|
|
655
|
+
* @returns Credential or absence before application handling.
|
|
656
|
+
*/
|
|
657
|
+
async readCredential(reader, facts, signal) {
|
|
658
|
+
const result = await BrowserServerValues.awaitReader(reader, facts, signal);
|
|
659
|
+
if (signal.aborted)
|
|
660
|
+
throw BrowserServerValues.abortReason(signal);
|
|
661
|
+
if (result?.kind === "rejected")
|
|
662
|
+
throw new ConnectError("Browser credential rejected.", Code.Unauthenticated);
|
|
663
|
+
return result;
|
|
664
|
+
},
|
|
665
|
+
/**
|
|
666
|
+
* Awaits a reader until Connect aborts or reaches its deadline.
|
|
667
|
+
* @param reader Configured browser credential reader.
|
|
668
|
+
* @param facts Immutable raw request facts.
|
|
669
|
+
* @param signal Connect request signal.
|
|
670
|
+
* @returns Reader result before terminal classification.
|
|
671
|
+
*/
|
|
672
|
+
async awaitReader(reader, facts, signal) {
|
|
673
|
+
if (signal.aborted)
|
|
674
|
+
throw BrowserServerValues.abortReason(signal);
|
|
675
|
+
const cancelled = new Error("browser request cancelled");
|
|
676
|
+
let onAbort;
|
|
677
|
+
const cancellation = new Promise((_resolve, reject) => {
|
|
678
|
+
onAbort = () => {
|
|
679
|
+
reject(cancelled);
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
683
|
+
try {
|
|
684
|
+
return await Promise.race([
|
|
685
|
+
Promise.resolve().then(() => {
|
|
686
|
+
if (signal.aborted)
|
|
687
|
+
throw cancelled;
|
|
688
|
+
return reader.read(facts);
|
|
689
|
+
}),
|
|
690
|
+
cancellation,
|
|
691
|
+
]);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (error === cancelled)
|
|
695
|
+
throw BrowserServerValues.abortReason(signal);
|
|
696
|
+
throw new ConnectError("Browser credential reader unavailable.", Code.Unavailable, undefined, undefined, error);
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
signal.removeEventListener("abort", onAbort);
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
/**
|
|
703
|
+
* Returns Connect's deadline reason or a classified request cancellation.
|
|
704
|
+
* @param signal Aborted Connect request signal.
|
|
705
|
+
* @returns Deadline or cancellation error for the current call.
|
|
706
|
+
*/
|
|
707
|
+
abortReason(signal) {
|
|
708
|
+
return signal.reason instanceof ConnectError
|
|
709
|
+
? signal.reason
|
|
710
|
+
: new ConnectError("Browser request cancelled.", Code.Canceled);
|
|
711
|
+
},
|
|
712
|
+
/**
|
|
713
|
+
* Validates and copies reader and route declarations before any startup await.
|
|
714
|
+
* @param input Untrusted browser configuration at the startup boundary.
|
|
715
|
+
* @returns One frozen route and admission snapshot.
|
|
716
|
+
*/
|
|
717
|
+
snapshotAdmission(input) {
|
|
718
|
+
if (input.credentials !== undefined && input.cookies !== undefined)
|
|
719
|
+
throw new Error("Browser credentials and legacy cookies cannot both be configured.");
|
|
720
|
+
const reader = input.credentials ?? input.cookies ?? bearerCredentials;
|
|
721
|
+
const routes = BrowserServer.authRoutes(input.authRoutes);
|
|
722
|
+
const policy = BrowserAdmissionPolicy.create(reader.admission, routes);
|
|
723
|
+
const copied = new Map();
|
|
724
|
+
for (const [path, route] of routes) {
|
|
725
|
+
const admission = policy.routes.get(path);
|
|
726
|
+
if (admission === undefined)
|
|
727
|
+
throw new Error("Browser route admission snapshot is absent.");
|
|
728
|
+
copied.set(path, Object.freeze({
|
|
729
|
+
...route,
|
|
730
|
+
origins: Object.freeze([...route.origins]),
|
|
731
|
+
admission,
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
credentialReader: reader,
|
|
736
|
+
admissionPolicy: policy,
|
|
737
|
+
authRouteMap: copied,
|
|
738
|
+
authRoutes: Object.freeze([...copied.values()]),
|
|
739
|
+
};
|
|
740
|
+
},
|
|
473
741
|
/**
|
|
474
742
|
* Builds the admitted browser listener from normalized options.
|
|
475
743
|
*
|
|
@@ -527,7 +795,7 @@ const BrowserServerValues = Object.freeze({
|
|
|
527
795
|
return BrowserServerValues.createListener({
|
|
528
796
|
options,
|
|
529
797
|
origins: BrowserServer.origins(options.origins),
|
|
530
|
-
authRoutes:
|
|
798
|
+
authRoutes: options.authRouteMap,
|
|
531
799
|
activeAuth,
|
|
532
800
|
maxActiveAuthRequests,
|
|
533
801
|
draining,
|
|
@@ -789,6 +1057,7 @@ const BrowserServerValues = Object.freeze({
|
|
|
789
1057
|
requests: input.requests,
|
|
790
1058
|
});
|
|
791
1059
|
const handler = connectNodeAdapter({
|
|
1060
|
+
interceptors: [BrowserServerValues.credentialInterceptor(input.options.credentialReader)],
|
|
792
1061
|
routes: (router) => {
|
|
793
1062
|
router.service(AuthenticationService, services.authentication);
|
|
794
1063
|
router.service(CommandService, services.command);
|
|
@@ -800,7 +1069,12 @@ const BrowserServerValues = Object.freeze({
|
|
|
800
1069
|
});
|
|
801
1070
|
const context = { ...input, handler };
|
|
802
1071
|
return http.createServer((request, response) => {
|
|
803
|
-
BrowserServerValues.routeRequest(request, response, context)
|
|
1072
|
+
void BrowserServerValues.routeRequest(request, response, context).catch(() => {
|
|
1073
|
+
if (!response.writableEnded) {
|
|
1074
|
+
response.statusCode = 500;
|
|
1075
|
+
response.end();
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
804
1078
|
});
|
|
805
1079
|
},
|
|
806
1080
|
/**
|
|
@@ -809,19 +1083,86 @@ const BrowserServerValues = Object.freeze({
|
|
|
809
1083
|
* @param request The incoming HTTP request.
|
|
810
1084
|
* @param response The HTTP response to complete with the route outcome.
|
|
811
1085
|
* @param context Listener admission policy, active requests, and the RPC routing callback.
|
|
1086
|
+
* @returns Completion after admission or dispatch to the selected route.
|
|
812
1087
|
*/
|
|
813
|
-
routeRequest(request, response, context) {
|
|
1088
|
+
async routeRequest(request, response, context) {
|
|
814
1089
|
if (context.draining()) {
|
|
815
1090
|
response.statusCode = 503;
|
|
816
1091
|
response.end();
|
|
817
1092
|
return;
|
|
818
1093
|
}
|
|
819
|
-
const
|
|
1094
|
+
const url = BrowserServerValues.requestUrl(request);
|
|
1095
|
+
if (url === undefined) {
|
|
1096
|
+
response.statusCode = 400;
|
|
1097
|
+
response.end();
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const auth = context.authRoutes.get(url.pathname);
|
|
1101
|
+
const active = auth?.admission ?? context.options.admissionPolicy.reader;
|
|
1102
|
+
BrowserServerValues.requestCors(request, response, context, auth, active.csrfHeaderNames);
|
|
1103
|
+
const inspected = context.options.admissionPolicy.inspect(request, active);
|
|
1104
|
+
if (inspected.kind === "rejected") {
|
|
1105
|
+
response.statusCode = 400;
|
|
1106
|
+
response.end();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
820
1109
|
if (auth !== undefined) {
|
|
821
1110
|
BrowserServerValues.authRequest(request, response, auth, context);
|
|
822
1111
|
return;
|
|
823
1112
|
}
|
|
824
|
-
BrowserServerValues.
|
|
1113
|
+
BrowserServerValues.readRpc(request, response, context, inspected.headers, url);
|
|
1114
|
+
},
|
|
1115
|
+
/**
|
|
1116
|
+
* Applies CORS only for one allowed raw Origin and the selected route or reader.
|
|
1117
|
+
* @param request Incoming request before normalization.
|
|
1118
|
+
* @param response Response receiving host policy.
|
|
1119
|
+
* @param context Listener origins and active reader.
|
|
1120
|
+
* @param auth Selected authentication route, if any.
|
|
1121
|
+
* @param csrfNames Frozen CSRF names from the active declaration.
|
|
1122
|
+
*/
|
|
1123
|
+
requestCors(request, response, context, auth, csrfNames) {
|
|
1124
|
+
const origin = BrowserServerValues.rawOrigin(request);
|
|
1125
|
+
if (origin === undefined ||
|
|
1126
|
+
(auth === undefined ? !context.origins.has(origin) : !auth.origins.includes(origin)))
|
|
1127
|
+
return;
|
|
1128
|
+
BrowserServerValues.cors(response, origin, csrfNames, auth?.method ?? "POST", auth === undefined);
|
|
1129
|
+
},
|
|
1130
|
+
/**
|
|
1131
|
+
* Parses one request target before route selection and reader invocation.
|
|
1132
|
+
* @param request Incoming HTTP request.
|
|
1133
|
+
* @returns Request-local URL, or undefined for an invalid target.
|
|
1134
|
+
*/
|
|
1135
|
+
requestUrl(request) {
|
|
1136
|
+
try {
|
|
1137
|
+
return new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
return undefined;
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
/**
|
|
1144
|
+
* Dispatches immutable raw facts to Connect for deadline-bound credential admission.
|
|
1145
|
+
* @param request Incoming HTTP request.
|
|
1146
|
+
* @param response Response for terminal rejection or forwarding.
|
|
1147
|
+
* @param context Frozen host policy and gateway handler.
|
|
1148
|
+
* @param headers Immutable duplicate-preserving raw fields.
|
|
1149
|
+
* @param url Selected request URL before the reader receives its copy.
|
|
1150
|
+
*/
|
|
1151
|
+
readRpc(request, response, context, headers, url) {
|
|
1152
|
+
if (!context.origins.has(request.headers.origin ?? "")) {
|
|
1153
|
+
response.statusCode = 403;
|
|
1154
|
+
response.end();
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
credentialContext.run({
|
|
1158
|
+
facts: {
|
|
1159
|
+
headers,
|
|
1160
|
+
method: request.method ?? "GET",
|
|
1161
|
+
url: new URL(url),
|
|
1162
|
+
},
|
|
1163
|
+
}, () => {
|
|
1164
|
+
BrowserServerValues.rpcRequest(request, response, context);
|
|
1165
|
+
});
|
|
825
1166
|
},
|
|
826
1167
|
/**
|
|
827
1168
|
* Handles the configured browser authentication route and its preflight.
|
|
@@ -843,8 +1184,6 @@ const BrowserServerValues = Object.freeze({
|
|
|
843
1184
|
}
|
|
844
1185
|
const origin = request.headers.origin;
|
|
845
1186
|
if (request.method === "OPTIONS" && origin !== undefined && auth.origins.includes(origin)) {
|
|
846
|
-
response.setHeader("access-control-allow-origin", origin);
|
|
847
|
-
response.setHeader("access-control-allow-methods", `${auth.method},OPTIONS`);
|
|
848
1187
|
response.statusCode = 204;
|
|
849
1188
|
}
|
|
850
1189
|
else
|
|
@@ -865,14 +1204,8 @@ const BrowserServerValues = Object.freeze({
|
|
|
865
1204
|
response.end();
|
|
866
1205
|
return;
|
|
867
1206
|
}
|
|
868
|
-
|
|
869
|
-
response.setHeader("access-control-allow-credentials", "true");
|
|
870
|
-
response.setHeader("access-control-expose-headers", "grpc-status,grpc-message");
|
|
871
|
-
response.setHeader("vary", "Origin");
|
|
1207
|
+
BrowserServerValues.cors(response, origin, context.options.admissionPolicy.reader.csrfHeaderNames, "POST", true);
|
|
872
1208
|
if (request.method === "OPTIONS") {
|
|
873
|
-
response.setHeader("access-control-allow-methods", "POST,OPTIONS");
|
|
874
|
-
response.setHeader("access-control-allow-headers", "authorization,content-type,connect-protocol-version,x-csrf-token");
|
|
875
|
-
response.setHeader("access-control-max-age", "600");
|
|
876
1209
|
response.statusCode = 204;
|
|
877
1210
|
response.end();
|
|
878
1211
|
return;
|