@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.
Files changed (131) hide show
  1. package/README.md +61 -8
  2. package/REFERENCE.md +267 -45
  3. package/dist/agent/agent-live-ai-runtime.d.ts +1 -1
  4. package/dist/agent/agent-live-ai-runtime.js +1 -1
  5. package/dist/agent/agent-live-budget.d.ts +1 -1
  6. package/dist/agent/agent-live-budget.js +1 -1
  7. package/dist/agent/agent-live-read-runtime.d.ts +1 -1
  8. package/dist/agent/agent-live-read-runtime.js +1 -1
  9. package/dist/browser/browser-admission.d.ts +47 -0
  10. package/dist/browser/browser-admission.d.ts.map +1 -0
  11. package/dist/browser/browser-admission.js +200 -0
  12. package/dist/browser/browser-admission.js.map +1 -0
  13. package/dist/browser/browser-server.d.ts +14 -7
  14. package/dist/browser/browser-server.d.ts.map +1 -1
  15. package/dist/browser/browser-server.js +462 -129
  16. package/dist/browser/browser-server.js.map +1 -1
  17. package/dist/browser/index.d.ts +1 -1
  18. package/dist/browser/index.d.ts.map +1 -1
  19. package/dist/browser/options.d.ts +48 -2
  20. package/dist/browser/options.d.ts.map +1 -1
  21. package/dist/bus/command-bus.d.ts +6 -1
  22. package/dist/bus/command-bus.d.ts.map +1 -1
  23. package/dist/bus/command-bus.js +6 -1
  24. package/dist/bus/command-bus.js.map +1 -1
  25. package/dist/bus/event-bus.d.ts +6 -1
  26. package/dist/bus/event-bus.d.ts.map +1 -1
  27. package/dist/bus/event-bus.js +6 -1
  28. package/dist/bus/event-bus.js.map +1 -1
  29. package/dist/context/bounded-context.d.ts +30 -28
  30. package/dist/context/bounded-context.d.ts.map +1 -1
  31. package/dist/context/bounded-context.js +137 -83
  32. package/dist/context/bounded-context.js.map +1 -1
  33. package/dist/context/entity-inbox.d.ts +12 -17
  34. package/dist/context/entity-inbox.d.ts.map +1 -1
  35. package/dist/context/entity-inbox.js +106 -120
  36. package/dist/context/entity-inbox.js.map +1 -1
  37. package/dist/context/local-inbox-handoff.d.ts +13 -13
  38. package/dist/context/local-inbox-handoff.d.ts.map +1 -1
  39. package/dist/context/local-inbox-handoff.js +60 -32
  40. package/dist/context/local-inbox-handoff.js.map +1 -1
  41. package/dist/context/persistent-tenant-index.d.ts +81 -0
  42. package/dist/context/persistent-tenant-index.d.ts.map +1 -0
  43. package/dist/context/persistent-tenant-index.js +270 -0
  44. package/dist/context/persistent-tenant-index.js.map +1 -0
  45. package/dist/context/projection-handoff.d.ts +13 -11
  46. package/dist/context/projection-handoff.d.ts.map +1 -1
  47. package/dist/context/projection-handoff.js +77 -34
  48. package/dist/context/projection-handoff.js.map +1 -1
  49. package/dist/context/tenant-index.d.ts +7 -6
  50. package/dist/context/tenant-index.d.ts.map +1 -1
  51. package/dist/context/tenant-index.js +2 -188
  52. package/dist/context/tenant-index.js.map +1 -1
  53. package/dist/context/tenant-unknown-error.d.ts +2 -2
  54. package/dist/context/tenant-unknown-error.d.ts.map +1 -1
  55. package/dist/context/tenant-unknown-error.js +4 -1
  56. package/dist/context/tenant-unknown-error.js.map +1 -1
  57. package/dist/delivery/delivery-builder.d.ts +8 -7
  58. package/dist/delivery/delivery-builder.d.ts.map +1 -1
  59. package/dist/delivery/delivery-builder.js +2 -2
  60. package/dist/delivery/delivery-builder.js.map +1 -1
  61. package/dist/delivery/delivery-ports.d.ts +2 -2
  62. package/dist/delivery/delivery-supervisor.d.ts.map +1 -1
  63. package/dist/delivery/delivery-supervisor.js +12 -3
  64. package/dist/delivery/delivery-supervisor.js.map +1 -1
  65. package/dist/delivery/delivery.d.ts +4 -3
  66. package/dist/delivery/delivery.d.ts.map +1 -1
  67. package/dist/delivery/delivery.js +87 -38
  68. package/dist/delivery/delivery.js.map +1 -1
  69. package/dist/delivery/inbox-retention.d.ts +7 -0
  70. package/dist/delivery/inbox-retention.d.ts.map +1 -0
  71. package/dist/delivery/inbox-retention.js +29 -0
  72. package/dist/delivery/inbox-retention.js.map +1 -0
  73. package/dist/delivery/inbox-storage.d.ts +2 -2
  74. package/dist/delivery/inbox-storage.d.ts.map +1 -1
  75. package/dist/delivery/inbox-storage.js +32 -8
  76. package/dist/delivery/inbox-storage.js.map +1 -1
  77. package/dist/delivery/inbox.d.ts +5 -9
  78. package/dist/delivery/inbox.d.ts.map +1 -1
  79. package/dist/delivery/inbox.js +4 -6
  80. package/dist/delivery/inbox.js.map +1 -1
  81. package/dist/delivery/reception-observation.d.ts +3 -3
  82. package/dist/delivery/reception-observation.js +2 -2
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +2 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/repository/command-routing.d.ts +1 -1
  88. package/dist/repository/command-routing.js +1 -1
  89. package/dist/repository/event-routing.d.ts +1 -1
  90. package/dist/repository/event-routing.js +1 -1
  91. package/dist/repository/repository.d.ts +25 -33
  92. package/dist/repository/repository.d.ts.map +1 -1
  93. package/dist/repository/repository.js +245 -230
  94. package/dist/repository/repository.js.map +1 -1
  95. package/dist/repository/state-update-routing.d.ts +1 -1
  96. package/dist/repository/state-update-routing.js +1 -1
  97. package/dist/runtime/signal-publisher.d.ts +23 -8
  98. package/dist/runtime/signal-publisher.d.ts.map +1 -1
  99. package/dist/runtime/signal-publisher.js +11 -8
  100. package/dist/runtime/signal-publisher.js.map +1 -1
  101. package/dist/server/node-coordinator.d.ts.map +1 -1
  102. package/dist/server/node-coordinator.js +220 -54
  103. package/dist/server/node-coordinator.js.map +1 -1
  104. package/dist/server/server-environment.d.ts +6 -5
  105. package/dist/server/server-environment.d.ts.map +1 -1
  106. package/dist/server/server-environment.js +13 -9
  107. package/dist/server/server-environment.js.map +1 -1
  108. package/dist/server/server.js +1 -0
  109. package/dist/server/server.js.map +1 -1
  110. package/dist/services/app-query-execution.js +2 -2
  111. package/dist/services/app-query-execution.js.map +1 -1
  112. package/dist/services/native-operation.d.ts +34 -0
  113. package/dist/services/native-operation.d.ts.map +1 -0
  114. package/dist/services/native-operation.js +77 -0
  115. package/dist/services/native-operation.js.map +1 -0
  116. package/dist/services/spine-services.d.ts +6 -0
  117. package/dist/services/spine-services.d.ts.map +1 -1
  118. package/dist/services/spine-services.js +128 -30
  119. package/dist/services/spine-services.js.map +1 -1
  120. package/dist/services/trusted-reader-ingress.d.ts +23 -5
  121. package/dist/services/trusted-reader-ingress.d.ts.map +1 -1
  122. package/dist/services/trusted-reader-ingress.js +18 -5
  123. package/dist/services/trusted-reader-ingress.js.map +1 -1
  124. package/dist/spi/delivery.d.ts +1 -0
  125. package/dist/spi/delivery.d.ts.map +1 -1
  126. package/dist/spi/delivery.js +1 -0
  127. package/dist/spi/delivery.js.map +1 -1
  128. package/dist/stand/stand.d.ts +1 -1
  129. package/dist/stand/stand.js +1 -1
  130. package/dist/tsconfig.tsbuildinfo +1 -1
  131. 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
- if (nativeOrOptions instanceof Server) {
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
- else
73
- BrowserServer.preflight("standalone", options);
74
- const native = nativeOrOptions instanceof Server ? await nativeOrOptions.start() : undefined;
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: Environment.instance().type === EnvironmentType.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
- const origin = request.headers.origin;
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 requestChunks = [];
347
- let size = 0;
348
- const requestBody = request[Symbol.asyncIterator]();
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
- ...(size === 0 ? {} : { body: Buffer.concat(requestChunks) }),
367
- signal: controller.signal,
339
+ ...(body.byteLength === 0 ? {} : { body: new Uint8Array(body) }),
340
+ signal: scope.signal,
368
341
  });
369
- const result = await Promise.race([route.onRequest(input, controller.signal), aborted]);
370
- if (controller.signal.aborted) {
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
- if (!response.writableEnded) {
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
- clearTimeout(timer);
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: BrowserServer.authRoutes(options.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 auth = context.authRoutes.get(request.url ?? "");
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.rpcRequest(request, response, context);
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
- response.setHeader("access-control-allow-origin", origin);
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;