@webpieces/core-util 0.4.790 → 0.4.792
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 +1 -1
- package/package.json +1 -1
- package/src/errors/ReceivedApiErrorRule.d.ts +43 -0
- package/src/errors/ReceivedApiErrorRule.js +65 -0
- package/src/errors/ReceivedApiErrorRule.js.map +1 -0
- package/src/errors/index.d.ts +1 -0
- package/src/errors/index.js +3 -1
- package/src/errors/index.js.map +1 -1
- package/src/http/ClientRegistry.d.ts +29 -18
- package/src/http/ClientRegistry.js +37 -27
- package/src/http/ClientRegistry.js.map +1 -1
- package/src/http/ErrorTranslator.d.ts +115 -0
- package/src/http/{ErrorTranslators.js → ErrorTranslator.js} +1 -1
- package/src/http/ErrorTranslator.js.map +1 -0
- package/src/http/FailureClassifier.d.ts +1 -1
- package/src/http/FailureClassifier.js.map +1 -1
- package/src/http/HttpResponseDto.d.ts +1 -1
- package/src/http/HttpResponseDto.js +1 -1
- package/src/http/HttpResponseDto.js.map +1 -1
- package/src/http/ServiceInfo.d.ts +1 -1
- package/src/http/ServiceInfo.js +1 -1
- package/src/http/ServiceInfo.js.map +1 -1
- package/src/http/Surface.d.ts +39 -0
- package/src/http/Surface.js +28 -0
- package/src/http/Surface.js.map +1 -0
- package/src/http/WebpiecesCoreHeaders.d.ts +18 -0
- package/src/http/WebpiecesCoreHeaders.js +18 -0
- package/src/http/WebpiecesCoreHeaders.js.map +1 -1
- package/src/http/WebpiecesDefaultErrorTranslator.d.ts +70 -0
- package/src/http/WebpiecesDefaultErrorTranslator.js +136 -0
- package/src/http/WebpiecesDefaultErrorTranslator.js.map +1 -0
- package/src/index.d.ts +5 -1
- package/src/index.js +15 -3
- package/src/index.js.map +1 -1
- package/src/ipc/IpcConnection.js +6 -8
- package/src/ipc/IpcConnection.js.map +1 -1
- package/src/ipc/IpcErrorTranslator.d.ts +39 -0
- package/src/ipc/IpcErrorTranslator.js +3 -0
- package/src/ipc/IpcErrorTranslator.js.map +1 -0
- package/src/ipc/IpcRegistry.d.ts +55 -0
- package/src/ipc/IpcRegistry.js +73 -0
- package/src/ipc/IpcRegistry.js.map +1 -0
- package/src/ipc/WebpiecesDefaultIpcErrorTranslator.d.ts +28 -0
- package/src/ipc/WebpiecesDefaultIpcErrorTranslator.js +49 -0
- package/src/ipc/WebpiecesDefaultIpcErrorTranslator.js.map +1 -0
- package/src/ipc/index.d.ts +4 -0
- package/src/ipc/index.js +9 -1
- package/src/ipc/index.js.map +1 -1
- package/src/http/ErrorTranslators.d.ts +0 -106
- package/src/http/ErrorTranslators.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ErrorTranslator.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ErrorTranslator.ts"],"names":[],"mappings":"","sourcesContent":["import { HttpResponseDto } from './HttpResponseDto';\n\n/**\n * ErrorTranslator - ONE symmetric place an app owns HTTP error translation, in BOTH directions, over\n * the WHOLE response.\n *\n * An app implements this ONCE and registers it ONCE per process via\n * {@link ClientRegistry.setErrorTranslator} — on the server AND in the browser. Its `toWire` runs on\n * the SERVER (`ExpressWrapper.handleError`) and its `fromWire` runs on every CLIENT in that process\n * (`ClientErrorTranslator.throwIfFailure`, shared by `http-client-node` and `http-client-browser`).\n * The payoff is type symmetry across the wire: the server throws `OrderNotFoundError` and the caller\n * CATCHES `OrderNotFoundError`, instead of decoding a status code by hand at every call site.\n *\n * # THERE IS ALWAYS A TRANSLATOR\n *\n * {@link ClientRegistry.getErrorTranslator} is non-optional and starts life holding\n * {@link WebpiecesDefaultErrorTranslator}. So no caller anywhere asks \"did anyone register one\" —\n * every call site is ONE unconditional line. A registered app translator REPLACES the default and\n * declines by DELEGATING to it:\n *\n * ```ts\n * private readonly fallback = new WebpiecesDefaultErrorTranslator();\n * ```\n *\n * That is one call, it is visible at the call site, and it composes: an app that wants to DECORATE\n * the default rather than replace it does so by calling it and editing the result. A `next` parameter\n * was considered and rejected — it buys nothing this does not, and it puts the framework back in the\n * business of sequencing somebody else's precedence.\n *\n * # Both halves speak {@link HttpResponseDto}, and that is the point\n *\n * `toWire` PRODUCES exactly what `fromWire` CONSUMES. Reading the two methods against each other in\n * one file is what makes a mistake visible, which is why this is one object rather than two\n * separately-registered functions.\n *\n * # The whole response, not a status plus a body\n *\n * The previous contract could express only `(statusCode, protocolError)`, so an app could not put its\n * own trace id, `Retry-After`, `WWW-Authenticate` or a cookie on an error response. A translator now\n * returns the status code, the reason phrase, the header LIST and the body — everything.\n *\n * # `toWire` RETURNS a response; `fromWire` THROWS\n *\n * They are mirrors, not twins. `toWire` is called inside a catch and its whole job is to produce a\n * value, so it returns one. `fromWire` is called on a response the caller is about to be handed, and\n * its whole job is to stop that from happening, so it THROWS and is typed `void`. Returning an\n * `Error` the caller then has to remember to throw was the previous shape and it is exactly the bug\n * class this deletes: a `void` return cannot be silently dropped.\n *\n * There is exactly ONE registered ErrorTranslator per process (a `set`, not an `add` to a list):\n * precedence between an app's own layers is the APP's to compose explicitly inside its `toWire`,\n * rather than something hidden in registration order inside the framework.\n *\n * # Not covered: 404 / unknown route\n *\n * A request that matches no route has no route context and never reaches this seam. That is\n * deliberate and out of scope.\n *\n * This is a business-logic contract (methods, not data), so it is an interface per the webpieces\n * guidelines.\n *\n * ```ts\n * class OrderErrorPayload {\n * constructor(public message: string, public errorCode: string) {}\n * }\n *\n * export class OrderErrorTranslator implements ErrorTranslator {\n * private readonly fallback = new WebpiecesDefaultErrorTranslator();\n *\n * toWire(error: Error): HttpResponseDto {\n * if (!(error instanceof OrderNotFoundError)) {\n * return this.fallback.toWire(error); // not mine -> webpieces default\n * }\n * const body = new OrderErrorPayload(error.message, 'ORDER_NOT_FOUND');\n * return new HttpResponseDto(\n * new HttpResponseStatus(460, 'Order Not Found'),\n * [new HttpHeader('x-order-trace', error.traceId)],\n * body,\n * );\n * }\n *\n * fromWire(response: HttpResponseDto): void {\n * if (response.status.code !== 460) {\n * this.fallback.fromWire(response); // not mine -> webpieces default\n * return;\n * }\n * throw new OrderNotFoundError(String(response.body));\n * }\n * }\n *\n * // startup, ONCE per process (server AND browser)\n * ClientRegistry.setErrorTranslator(new OrderErrorTranslator());\n * ```\n */\nexport interface ErrorTranslator {\n /**\n * SERVER: exception -> the ENTIRE response (status code, reason phrase, headers, body). Always a\n * response, never `undefined`; decline by returning\n * `new WebpiecesDefaultErrorTranslator().toWire(error)`.\n */\n toWire(error: Error): HttpResponseDto;\n\n /**\n * CLIENT: the ENTIRE response -> a THROW. Receives the SAME shape `toWire` produces, normalised\n * from whichever transport read it.\n *\n * Called for EVERY response, including a 2xx — deliberately, so an app whose 200 body signals\n * failure can turn it into a throw. Returning normally means \"let this response through\"; the\n * webpieces default returns normally for any 2xx that is not 266 and throws otherwise. Decline by\n * calling `new WebpiecesDefaultErrorTranslator().fromWire(response)`.\n *\n * A non-2xx response can never reach a typed caller even if an app translator forgets to throw:\n * `ClientErrorTranslator.throwIfFailure` applies the webpieces default behind it. That is bug\n * containment, not a \"was one registered\" branch.\n */\n fromWire(response: HttpResponseDto): void;\n}\n"]}
|
|
@@ -9,7 +9,7 @@ import { ApiMethodInfo } from './ApiMethodInfo';
|
|
|
9
9
|
* rather than a bare function — so "find usages" in an IDE lands on every implementation.
|
|
10
10
|
*
|
|
11
11
|
* Registered on {@link ClientRegistry} at startup — the same browser-safe, no-DI, populated-once
|
|
12
|
-
* singleton that owns URL mappings and {@link
|
|
12
|
+
* singleton that owns URL mappings and {@link ErrorTranslator}. TWO tiers, resolved most-specific
|
|
13
13
|
* first (see {@link ClientRegistry.classifyFailure}):
|
|
14
14
|
*
|
|
15
15
|
* 1. `ClientRegistry.setDefaultFailureClassifier(c)` — ONE per app/company. It reads
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FailureClassifier.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/FailureClassifier.ts"],"names":[],"mappings":";;;AAuCA;;;;;GAKG;AACH,MAAa,sBAAsB;IAEX;IACA;IAFpB,YACoB,QAAgB,EAChB,UAA6B;QAD7B,aAAQ,GAAR,QAAQ,CAAQ;QAChB,eAAU,GAAV,UAAU,CAAmB;IAC9C,CAAC;CACP;AALD,wDAKC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * Decides whether a thrown API-call error is a real FAILURE — the process not working, SURFACE it\n * (LogApiCall logs `[API-*-resp-FAIL]`, `jsonPayload.api.result='failure'` — what dashboards/alerts\n * count) — or an EXPECTED non-failure — the process working correctly, a handled condition\n * (`[API-*-resp-OTHER]`, `result='success'`).\n *\n * This is BEHAVIOR, so it is an interface (per CLAUDE.md), passed as an object with a NAMED method\n * rather than a bare function — so \"find usages\" in an IDE lands on every implementation.\n *\n * Registered on {@link ClientRegistry} at startup — the same browser-safe, no-DI, populated-once\n * singleton that owns URL mappings and {@link
|
|
1
|
+
{"version":3,"file":"FailureClassifier.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/FailureClassifier.ts"],"names":[],"mappings":";;;AAuCA;;;;;GAKG;AACH,MAAa,sBAAsB;IAEX;IACA;IAFpB,YACoB,QAAgB,EAChB,UAA6B;QAD7B,aAAQ,GAAR,QAAQ,CAAQ;QAChB,eAAU,GAAV,UAAU,CAAmB;IAC9C,CAAC;CACP;AALD,wDAKC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * Decides whether a thrown API-call error is a real FAILURE — the process not working, SURFACE it\n * (LogApiCall logs `[API-*-resp-FAIL]`, `jsonPayload.api.result='failure'` — what dashboards/alerts\n * count) — or an EXPECTED non-failure — the process working correctly, a handled condition\n * (`[API-*-resp-OTHER]`, `result='success'`).\n *\n * This is BEHAVIOR, so it is an interface (per CLAUDE.md), passed as an object with a NAMED method\n * rather than a bare function — so \"find usages\" in an IDE lands on every implementation.\n *\n * Registered on {@link ClientRegistry} at startup — the same browser-safe, no-DI, populated-once\n * singleton that owns URL mappings and {@link ErrorTranslator}. TWO tiers, resolved most-specific\n * first (see {@link ClientRegistry.classifyFailure}):\n *\n * 1. `ClientRegistry.setDefaultFailureClassifier(c)` — ONE per app/company. It reads\n * {@link ApiMethodInfo.side}, so a single strategy covers the SERVER router AND all INTERNAL\n * clients (webpieces http client, cloud tasks). Optional: if unset, webpieces uses\n * {@link WebpiecesDefaultFailureClassifier} (server 4xx = non-failure; client non-266 = failure).\n * 2. `ClientRegistry.addFailureClassifier(apiClass, c)` — per EXTERNAL client, keyed by\n * {@link ApiMethodInfo.apiClass} ('FirestoreAdminClient', 'ClaudeApi', 'TwilioApi'). Each external\n * API qualifies errors differently (a Firestore 404 miss, a Twilio 429 retry are EXPECTED, not\n * failures), so it overrides the default for that apiClass only.\n *\n * Internal clients and the server register NOTHING — the ABSENCE of an apiClass entry is the signal;\n * they fall through to tier 1. An external client that forgets to register is FAIL-SAFE: it also\n * falls to tier 1 (client side ⇒ every non-266 = failure) until it opts in.\n */\nexport interface FailureClassifier {\n /**\n * @param error - the already-normalized thrown error (callers pass `toError(err)`)\n * @param methodInfo - the call identity; `side` distinguishes server/client, `apiClass` the client\n * @returns `true` = real failure; `false` = expected/non-failure; `undefined` = DEFER\n * (a per-apiClass classifier defers to the app default; the app default defers to the\n * webpieces built-in). Deferring is what makes classifiers additive AND override-capable.\n */\n isFailure(error: Error, methodInfo: ApiMethodInfo): boolean | undefined;\n}\n\n/**\n * A per-external-client registration pair: the {@link ApiMethodInfo.apiClass} to key on, and the\n * {@link FailureClassifier} to apply for it. A data-only structure, so it is a class (not an inline\n * object literal), per CLAUDE.md — lets startup wiring carry a list of these and hand each to\n * {@link ClientRegistry.addFailureClassifier}.\n */\nexport class KeyedFailureClassifier {\n constructor(\n public readonly apiClass: string,\n public readonly classifier: FailureClassifier,\n ) {}\n}\n"]}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* # Why a DTO at all, instead of handing an app express's `res` or fetch's `Response`
|
|
9
9
|
*
|
|
10
10
|
* Node/express and browser fetch model a response completely differently, and an
|
|
11
|
-
* {@link
|
|
11
|
+
* {@link ErrorTranslator} implementation is registered ONCE and serves BOTH — the server writing a
|
|
12
12
|
* response and every client in the process reading one. So neither transport's object can be the
|
|
13
13
|
* currency. webpieces normalises both into this DTO at its own boundary, and the app only ever sees
|
|
14
14
|
* this. One form, both transports, both directions.
|
|
@@ -11,7 +11,7 @@ exports.HttpResponseDto = exports.HttpResponseStatus = exports.HttpHeader = void
|
|
|
11
11
|
* # Why a DTO at all, instead of handing an app express's `res` or fetch's `Response`
|
|
12
12
|
*
|
|
13
13
|
* Node/express and browser fetch model a response completely differently, and an
|
|
14
|
-
* {@link
|
|
14
|
+
* {@link ErrorTranslator} implementation is registered ONCE and serves BOTH — the server writing a
|
|
15
15
|
* response and every client in the process reading one. So neither transport's object can be the
|
|
16
16
|
* currency. webpieces normalises both into this DTO at its own boundary, and the app only ever sees
|
|
17
17
|
* this. One form, both transports, both directions.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HttpResponseDto.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/HttpResponseDto.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAa,UAAU;IAEC;IACA;IAFpB,YACoB,IAAY,EACZ,KAAa;QADb,SAAI,GAAJ,IAAI,CAAQ;QACZ,UAAK,GAAL,KAAK,CAAQ;IAC9B,CAAC;CACP;AALD,gCAKC;AAED,4FAA4F;AAC5F,MAAa,kBAAkB;IAEP;IACA;IAFpB,YACoB,IAAY,EACZ,MAAc;QADd,SAAI,GAAJ,IAAI,CAAQ;QACZ,WAAM,GAAN,MAAM,CAAQ;IAC/B,CAAC;CACP;AALD,gDAKC;AAED;;;;;;GAMG;AACH,gFAAgF;AAChF,MAAa,eAAe;IAEJ;IACA;IAEA;IAJpB,YACoB,MAA0B,EAC1B,OAA8B;IAC9C,mLAAmL;IACnK,IAAW;QAHX,WAAM,GAAN,MAAM,CAAoB;QAC1B,YAAO,GAAP,OAAO,CAAuB;QAE9B,SAAI,GAAJ,IAAI,CAAO;IAC5B,CAAC;CACP;AAPD,0CAOC","sourcesContent":["/**\n * HttpResponseDto - the ENTIRE HTTP response, as pure data, in the ONE form webpieces speaks.\n *\n * Modelled on java webpieces' `http/http1_1-parser/.../api/dto/` (`HttpPayload` -> `HttpMessage` ->\n * `HttpResponse` -> `HttpResponseStatusLine` -> `HttpResponseStatus`), flattened to the KISS subset\n * this framework actually needs.\n *\n * # Why a DTO at all, instead of handing an app express's `res` or fetch's `Response`\n *\n * Node/express and browser fetch model a response completely differently, and an\n * {@link
|
|
1
|
+
{"version":3,"file":"HttpResponseDto.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/HttpResponseDto.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAa,UAAU;IAEC;IACA;IAFpB,YACoB,IAAY,EACZ,KAAa;QADb,SAAI,GAAJ,IAAI,CAAQ;QACZ,UAAK,GAAL,KAAK,CAAQ;IAC9B,CAAC;CACP;AALD,gCAKC;AAED,4FAA4F;AAC5F,MAAa,kBAAkB;IAEP;IACA;IAFpB,YACoB,IAAY,EACZ,MAAc;QADd,SAAI,GAAJ,IAAI,CAAQ;QACZ,WAAM,GAAN,MAAM,CAAQ;IAC/B,CAAC;CACP;AALD,gDAKC;AAED;;;;;;GAMG;AACH,gFAAgF;AAChF,MAAa,eAAe;IAEJ;IACA;IAEA;IAJpB,YACoB,MAA0B,EAC1B,OAA8B;IAC9C,mLAAmL;IACnK,IAAW;QAHX,WAAM,GAAN,MAAM,CAAoB;QAC1B,YAAO,GAAP,OAAO,CAAuB;QAE9B,SAAI,GAAJ,IAAI,CAAO;IAC5B,CAAC;CACP;AAPD,0CAOC","sourcesContent":["/**\n * HttpResponseDto - the ENTIRE HTTP response, as pure data, in the ONE form webpieces speaks.\n *\n * Modelled on java webpieces' `http/http1_1-parser/.../api/dto/` (`HttpPayload` -> `HttpMessage` ->\n * `HttpResponse` -> `HttpResponseStatusLine` -> `HttpResponseStatus`), flattened to the KISS subset\n * this framework actually needs.\n *\n * # Why a DTO at all, instead of handing an app express's `res` or fetch's `Response`\n *\n * Node/express and browser fetch model a response completely differently, and an\n * {@link ErrorTranslator} implementation is registered ONCE and serves BOTH — the server writing a\n * response and every client in the process reading one. So neither transport's object can be the\n * currency. webpieces normalises both into this DTO at its own boundary, and the app only ever sees\n * this. One form, both transports, both directions.\n *\n * # The two properties copied from java deliberately\n *\n * - **Headers are a LIST of `{name, value}`, not a Map.** HTTP permits repeats (`Set-Cookie` is the\n * everyday one) and a Map silently drops all but the last. The REQUEST side of webpieces-ts\n * already respects this (`readExpressHeaders` returns `Map<string, string[]>`), so the response\n * side must not be the half that loses data.\n * - **Status is `{ code, reason }`, not a bare number.** The reason phrase is part of the response\n * and an app may want its own ('Order Not Found' beside a 460).\n *\n * `HttpVersion` from the java DTO is deliberately OMITTED: express and fetch each own the version,\n * and no app decision depends on it.\n *\n * Pure data, so these are CLASSES with explicit constructors (webpieces guideline: data => classes),\n * and they live in core-util so the identical types reach a node server and a browser bundle.\n */\nexport class HttpHeader {\n constructor(\n public readonly name: string,\n public readonly value: string,\n ) {}\n}\n\n/** The status line's status: the numeric code AND the reason phrase that goes beside it. */\nexport class HttpResponseStatus {\n constructor(\n public readonly code: number,\n public readonly reason: string,\n ) {}\n}\n\n/**\n * The whole response: status (code + reason), the header LIST, and the body.\n *\n * `body` is `unknown` because it is whatever the app chose to publish. webpieces' OWN default puts a\n * {@link ApiErrorPayload} there, and the built-in client mapping reads it back as one — but an app that\n * owns the whole response owns the body shape too, so the framework does not constrain it.\n */\n// webpieces-disable no-any-unknown -- the app owns the full-response body shape\nexport class HttpResponseDto<TBody = unknown> {\n constructor(\n public readonly status: HttpResponseStatus,\n public readonly headers: readonly HttpHeader[],\n // webpieces-disable no-any-unknown -- the app owns the body shape when it owns the response; webpieces' own default puts a ApiErrorPayload here, an app puts whatever it publishes\n public readonly body: TBody,\n ) {}\n}\n"]}
|
|
@@ -65,6 +65,6 @@ export declare class ServiceInfo {
|
|
|
65
65
|
* This build's version, or `undefined` when unset — same non-throwing contract as {@link getName}.
|
|
66
66
|
*/
|
|
67
67
|
static getVersion(): string | undefined;
|
|
68
|
-
/** Reset — for tests, mirroring {@link ClientRegistry.
|
|
68
|
+
/** Reset — for tests, mirroring {@link ClientRegistry.resetForTests}. */
|
|
69
69
|
static clear(): void;
|
|
70
70
|
}
|
package/src/http/ServiceInfo.js
CHANGED
|
@@ -86,7 +86,7 @@ class ServiceInfo {
|
|
|
86
86
|
static getVersion() {
|
|
87
87
|
return ServiceInfo.svcVersion;
|
|
88
88
|
}
|
|
89
|
-
/** Reset — for tests, mirroring {@link ClientRegistry.
|
|
89
|
+
/** Reset — for tests, mirroring {@link ClientRegistry.resetForTests}. */
|
|
90
90
|
// webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); populated once at startup, never DI-injected
|
|
91
91
|
static clear() {
|
|
92
92
|
ServiceInfo.svcName = undefined;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ServiceInfo.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ServiceInfo.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,MAAa,WAAW;IACpB,gEAAgE;IACxD,MAAM,CAAC,OAAO,CAAqB;IAE3C,+FAA+F;IACvF,MAAM,CAAC,UAAU,CAAqB;IAE9C;;;;;;;;;;OAUG;IACH,4JAA4J;IAC5J,MAAM,CAAC,OAAO,CAAC,IAAY,EAAE,OAAe;QACxC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC;QACnF,CAAC;QACD,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACX,qFAAqF;gBACrF,qFAAqF;gBACrF,2BAA2B,CAC9B,CAAC;QACN,CAAC;QACD,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;QAC3B,WAAW,CAAC,UAAU,GAAG,OAAO,CAAC;IACrC,CAAC;IAED;;;;;;OAMG;IACH,4JAA4J;IAC5J,MAAM,CAAC,OAAO;QACV,OAAO,WAAW,CAAC,OAAO,CAAC;IAC/B,CAAC;IAED;;OAEG;IACH,4JAA4J;IAC5J,MAAM,CAAC,UAAU;QACb,OAAO,WAAW,CAAC,UAAU,CAAC;IAClC,CAAC;IAED,
|
|
1
|
+
{"version":3,"file":"ServiceInfo.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ServiceInfo.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,MAAa,WAAW;IACpB,gEAAgE;IACxD,MAAM,CAAC,OAAO,CAAqB;IAE3C,+FAA+F;IACvF,MAAM,CAAC,UAAU,CAAqB;IAE9C;;;;;;;;;;OAUG;IACH,4JAA4J;IAC5J,MAAM,CAAC,OAAO,CAAC,IAAY,EAAE,OAAe;QACxC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC;QACnF,CAAC;QACD,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACX,qFAAqF;gBACrF,qFAAqF;gBACrF,2BAA2B,CAC9B,CAAC;QACN,CAAC;QACD,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;QAC3B,WAAW,CAAC,UAAU,GAAG,OAAO,CAAC;IACrC,CAAC;IAED;;;;;;OAMG;IACH,4JAA4J;IAC5J,MAAM,CAAC,OAAO;QACV,OAAO,WAAW,CAAC,OAAO,CAAC;IAC/B,CAAC;IAED;;OAEG;IACH,4JAA4J;IAC5J,MAAM,CAAC,UAAU;QACb,OAAO,WAAW,CAAC,UAAU,CAAC;IAClC,CAAC;IAED,yEAAyE;IACzE,4JAA4J;IAC5J,MAAM,CAAC,KAAK;QACR,WAAW,CAAC,OAAO,GAAG,SAAS,CAAC;QAChC,WAAW,CAAC,UAAU,GAAG,SAAS,CAAC;IACvC,CAAC;CACJ;AA5DD,kCA4DC","sourcesContent":["/**\n * ServiceInfo - the ONE answer to \"what service am I, and which build of it?\", for every part of\n * webpieces that needs it.\n *\n * Configured like {@link HeaderRegistry} / {@link ClientRegistry} / LogManager — populated once at\n * startup, then globally accessible with NO DI wiring. Browser-safe (no `process.env`, no node-only\n * deps), which is why it lives in core-util beside {@link ClientRegistry}.\n *\n * ```ts\n * // startup, FIRST:\n * ServiceInfo.setInfo('my-service', '2.1.0');\n * ```\n *\n * WHY THIS EXISTS. Both facts used to be artifacts of WHICH LOGGING LIBRARY YOU PICKED rather than\n * facts about the service:\n * - the name lived on `BunyanFactoryOptions`, because bunyan REQUIRES a root-logger name\n * (`options.name (string) is required` — bunyan's own TypeError). winston has no such concept, so\n * a winston app shipped its logs UNNAMED.\n * - the version lived on `WinstonFactoryOptions` as `svcGitHash`, so a bunyan app could not stamp a\n * version AT ALL — and the name presumed a git SHA, which not every project deploys from.\n *\n * Switching backends silently changed which fields your logs carried. Several unrelated readers\n * need these same two facts, so they belong to the framework, not to a backend:\n * - the winston backend, to stamp `svcName` + `version`;\n * - the bunyan backend, to stamp `version` (its root-logger `name` is a fixed constant now);\n * - {@link WebpiecesCoreHeaders.REQUEST_ID_SOURCE}, to record WHO minted a request id;\n * - {@link WebpiecesCoreHeaders.CLIENT_VERSION}, so a downstream server can log which build called it.\n *\n * VERSION IS OPAQUE. It is whatever string identifies THIS build — a git SHA, a semver tag, a CI\n * build number. webpieces neither parses nor derives it; the app decides where it comes from (a\n * generated file, an env var, a Docker build arg) and passes it here.\n *\n * DOES NOT THROW ON READ. {@link getName} / {@link getVersion} return `undefined` when unset, so a\n * log line emitted before `setInfo` (early boot) still ships — it simply omits the version. Logging\n * never blocks on identity. The \"a deployed build MUST identify itself\" guarantee is enforced ONCE,\n * loudly, at startup by whoever calls {@link setInfo} — `setupRuntime` takes name+version as REQUIRED\n * inputs and calls it, so a blank value throws and kills the deploy — instead of every reader throwing.\n */\nexport class ServiceInfo {\n /** This service's name. Process-global; set once at startup. */\n private static svcName: string | undefined;\n\n /** This build's version — an opaque app-chosen string. Process-global; set once at startup. */\n private static svcVersion: string | undefined;\n\n /**\n * Identify this service. Call it at startup.\n *\n * LAST CALL WINS, deliberately. A real deployment identifies itself once, but an in-process test\n * can legitimately boot TWO services back-to-back (see the app-example e2e two-server flow), so a\n * \"one process = one service\" rule would reject a case that genuinely exists.\n *\n * @param name - this service's name, e.g. 'my-service'.\n * @param version - the opaque identifier of THIS build (git SHA, semver, CI build number).\n * @throws Error on a blank name or version — that is always a bug, never a use case.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); populated once at startup, never DI-injected\n static setInfo(name: string, version: string): void {\n if (!name || !name.trim()) {\n throw new Error('ServiceInfo.setInfo(...) requires a non-blank service name.');\n }\n if (!version || !version.trim()) {\n throw new Error(\n 'ServiceInfo.setInfo(...) requires a non-blank version. It is opaque to webpieces — ' +\n 'a git SHA, a semver tag, a CI build number — but every deployed build must be able ' +\n 'to say which build it is.',\n );\n }\n ServiceInfo.svcName = name;\n ServiceInfo.svcVersion = version;\n }\n\n /**\n * This service's name, or `undefined` when {@link setInfo} has not been called. Does NOT throw:\n * readers (logging backends, requestIdSource, outbound CLIENT_VERSION) simply omit the field when\n * it is missing, so a pre-`setInfo` log line still emits. Identity is REQUIRED where it matters —\n * `setupRuntime` takes name+version and calls {@link setInfo} at startup, so a real server is\n * always identified before it serves traffic.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); populated once at startup, never DI-injected\n static getName(): string | undefined {\n return ServiceInfo.svcName;\n }\n\n /**\n * This build's version, or `undefined` when unset — same non-throwing contract as {@link getName}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); populated once at startup, never DI-injected\n static getVersion(): string | undefined {\n return ServiceInfo.svcVersion;\n }\n\n /** Reset — for tests, mirroring {@link ClientRegistry.resetForTests}. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); populated once at startup, never DI-injected\n static clear(): void {\n ServiceInfo.svcName = undefined;\n ServiceInfo.svcVersion = undefined;\n }\n}\n"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH KIND OF CALLER is driving this request, all the way from the edge.
|
|
3
|
+
*
|
|
4
|
+
* The same endpoint can be reached by a browser GUI, by an LLM through the MCP bridge, and by an
|
|
5
|
+
* external partner against a published REST contract. "Does an end-user error render as 266 or as a
|
|
6
|
+
* real 4xx" is therefore a property of the CALLER, not of the server — which is why the per-router
|
|
7
|
+
* `setEndUserStatus` this replaced could never express it: one router serves all three.
|
|
8
|
+
*
|
|
9
|
+
* webpieces already knows which, from the auth mode that matched the incoming request:
|
|
10
|
+
*
|
|
11
|
+
* | how the request authenticated | surface |
|
|
12
|
+
* |-------------------------------|--------------|
|
|
13
|
+
* | `@WpAuthJwt` | `gui` |
|
|
14
|
+
* | `@WpMcpAuthJwt` (MCP bridge) | `llm` |
|
|
15
|
+
* | `@WpAuthApiKey` | `public-api` |
|
|
16
|
+
*
|
|
17
|
+
* It is set at the EDGE ONLY and then PROPAGATES: the surface is the ORIGINAL caller's, so a hop that
|
|
18
|
+
* already received one INHERITS it unchanged. `@WpAuthOidc` is an internal service-to-service hop, not
|
|
19
|
+
* a surface of its own, and must never overwrite a propagated value — `GUI -> api1 -> api2` leaves
|
|
20
|
+
* api2 seeing `gui`, which is the whole point of carrying it.
|
|
21
|
+
*/
|
|
22
|
+
export type Surface = 'gui' | 'llm' | 'public-api';
|
|
23
|
+
/**
|
|
24
|
+
* The ONE derivation of "what HTTP status does an `ApiEndUserError` publish on this request" — the
|
|
25
|
+
* per-request replacement for the deleted per-router `EndUserStatus`.
|
|
26
|
+
*
|
|
27
|
+
* - `gui` and `llm` -> **266**. Both are webpieces clients that DECODE the body: a browser client
|
|
28
|
+
* reconstructs the typed `ApiEndUserError`, and the MCP bridge calls through `ApiFactory` and
|
|
29
|
+
* renders the result for the model itself. A real 4xx would throw away the message either one is
|
|
30
|
+
* there to show.
|
|
31
|
+
* - `public-api` -> the status the THROW SITE published (`ApiEndUserError.edgeHttpStatus`, else 400),
|
|
32
|
+
* because a partner's REST contract promises real statuses and has no webpieces client to decode
|
|
33
|
+
* anything.
|
|
34
|
+
* - **absent** -> 266, the historical default, so a public endpoint or a non-webpieces caller behaves
|
|
35
|
+
* exactly as it did before this key existed.
|
|
36
|
+
*/
|
|
37
|
+
export declare class SurfaceEndUserStatus {
|
|
38
|
+
static statusFor(surface: Surface | undefined, edgeHttpStatus: number | undefined): number;
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SurfaceEndUserStatus = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The ONE derivation of "what HTTP status does an `ApiEndUserError` publish on this request" — the
|
|
6
|
+
* per-request replacement for the deleted per-router `EndUserStatus`.
|
|
7
|
+
*
|
|
8
|
+
* - `gui` and `llm` -> **266**. Both are webpieces clients that DECODE the body: a browser client
|
|
9
|
+
* reconstructs the typed `ApiEndUserError`, and the MCP bridge calls through `ApiFactory` and
|
|
10
|
+
* renders the result for the model itself. A real 4xx would throw away the message either one is
|
|
11
|
+
* there to show.
|
|
12
|
+
* - `public-api` -> the status the THROW SITE published (`ApiEndUserError.edgeHttpStatus`, else 400),
|
|
13
|
+
* because a partner's REST contract promises real statuses and has no webpieces client to decode
|
|
14
|
+
* anything.
|
|
15
|
+
* - **absent** -> 266, the historical default, so a public endpoint or a non-webpieces caller behaves
|
|
16
|
+
* exactly as it did before this key existed.
|
|
17
|
+
*/
|
|
18
|
+
class SurfaceEndUserStatus {
|
|
19
|
+
// webpieces-disable no-function-outside-class -- stateless per-request derivation, no state to hold
|
|
20
|
+
static statusFor(surface, edgeHttpStatus) {
|
|
21
|
+
if (surface === 'public-api') {
|
|
22
|
+
return edgeHttpStatus ?? 400;
|
|
23
|
+
}
|
|
24
|
+
return 266;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.SurfaceEndUserStatus = SurfaceEndUserStatus;
|
|
28
|
+
//# sourceMappingURL=Surface.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Surface.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/Surface.ts"],"names":[],"mappings":";;;AAuBA;;;;;;;;;;;;;GAaG;AACH,MAAa,oBAAoB;IAC7B,oGAAoG;IACpG,MAAM,CAAC,SAAS,CAAC,OAA4B,EAAE,cAAkC;QAC7E,IAAI,OAAO,KAAK,YAAY,EAAE,CAAC;YAC3B,OAAO,cAAc,IAAI,GAAG,CAAC;QACjC,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;CACJ;AARD,oDAQC","sourcesContent":["/**\n * WHICH KIND OF CALLER is driving this request, all the way from the edge.\n *\n * The same endpoint can be reached by a browser GUI, by an LLM through the MCP bridge, and by an\n * external partner against a published REST contract. \"Does an end-user error render as 266 or as a\n * real 4xx\" is therefore a property of the CALLER, not of the server — which is why the per-router\n * `setEndUserStatus` this replaced could never express it: one router serves all three.\n *\n * webpieces already knows which, from the auth mode that matched the incoming request:\n *\n * | how the request authenticated | surface |\n * |-------------------------------|--------------|\n * | `@WpAuthJwt` | `gui` |\n * | `@WpMcpAuthJwt` (MCP bridge) | `llm` |\n * | `@WpAuthApiKey` | `public-api` |\n *\n * It is set at the EDGE ONLY and then PROPAGATES: the surface is the ORIGINAL caller's, so a hop that\n * already received one INHERITS it unchanged. `@WpAuthOidc` is an internal service-to-service hop, not\n * a surface of its own, and must never overwrite a propagated value — `GUI -> api1 -> api2` leaves\n * api2 seeing `gui`, which is the whole point of carrying it.\n */\nexport type Surface = 'gui' | 'llm' | 'public-api';\n\n/**\n * The ONE derivation of \"what HTTP status does an `ApiEndUserError` publish on this request\" — the\n * per-request replacement for the deleted per-router `EndUserStatus`.\n *\n * - `gui` and `llm` -> **266**. Both are webpieces clients that DECODE the body: a browser client\n * reconstructs the typed `ApiEndUserError`, and the MCP bridge calls through `ApiFactory` and\n * renders the result for the model itself. A real 4xx would throw away the message either one is\n * there to show.\n * - `public-api` -> the status the THROW SITE published (`ApiEndUserError.edgeHttpStatus`, else 400),\n * because a partner's REST contract promises real statuses and has no webpieces client to decode\n * anything.\n * - **absent** -> 266, the historical default, so a public endpoint or a non-webpieces caller behaves\n * exactly as it did before this key existed.\n */\nexport class SurfaceEndUserStatus {\n // webpieces-disable no-function-outside-class -- stateless per-request derivation, no state to hold\n static statusFor(surface: Surface | undefined, edgeHttpStatus: number | undefined): number {\n if (surface === 'public-api') {\n return edgeHttpStatus ?? 400;\n }\n return 266;\n }\n}\n"]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ContextKey, AnyContextKey } from '../ContextKey';
|
|
2
2
|
import { ApiCallInfo } from './ApiCallInfo';
|
|
3
|
+
import { Surface } from './Surface';
|
|
3
4
|
/**
|
|
4
5
|
* Core framework context keys — the minimum the WebPieces framework needs to correlate one
|
|
5
6
|
* request across every service it touches, and across every log line each of them writes.
|
|
@@ -97,6 +98,23 @@ export declare class WebpiecesCoreHeaders {
|
|
|
97
98
|
static readonly ORG_ID: ContextKey<string, "trusted">;
|
|
98
99
|
static readonly USER_ID: ContextKey<string, "trusted">;
|
|
99
100
|
static readonly USER_ROLES: ContextKey<string, "trusted">;
|
|
101
|
+
/**
|
|
102
|
+
* WHICH KIND OF CALLER is driving this request — `gui`, `llm` or `public-api`. See
|
|
103
|
+
* {@link Surface} for the table and for why it is a property of the CALLER and not of the server.
|
|
104
|
+
*
|
|
105
|
+
* TRUSTED, and that is load-bearing twice over. It is what makes an app safe to branch on it in
|
|
106
|
+
* its OWN authorization logic ("this endpoint is not exposed to partners"), and it is what makes
|
|
107
|
+
* the value in {@link SurfaceEndUserStatus} believable: webpieces sets it itself, from the auth
|
|
108
|
+
* mode that matched, never from caller input. A caller that simply SENDS `x-wp-surface` at an
|
|
109
|
+
* endpoint whose auth did not establish one is rejected by `AuthFilter.reconcileWireTrust`, like
|
|
110
|
+
* every other unvouched trusted header.
|
|
111
|
+
*
|
|
112
|
+
* It keeps its `httpHeader` because PROPAGATION is the point: the surface belongs to the ORIGINAL
|
|
113
|
+
* caller, so `GUI -> api1 -> api2` must leave api2 seeing `gui`. An internal `@WpAuthOidc` hop
|
|
114
|
+
* verifies its caller, so the propagated value is admitted verbatim — and the auth filter only
|
|
115
|
+
* ever SETS a surface when none arrived, so an inherited one is never overwritten.
|
|
116
|
+
*/
|
|
117
|
+
static readonly SURFACE: ContextKey<Surface, "trusted">;
|
|
100
118
|
/**
|
|
101
119
|
* Turns on test-case recording for this request (Java: x-webpieces-recording).
|
|
102
120
|
* Transferred so recording follows the request across service hops.
|
|
@@ -100,6 +100,23 @@ class WebpiecesCoreHeaders {
|
|
|
100
100
|
static ORG_ID = ContextKey_1.ContextKey.trusted('orgId', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)', 'x-org-id');
|
|
101
101
|
static USER_ID = ContextKey_1.ContextKey.trusted('userId', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)', 'x-user-id');
|
|
102
102
|
static USER_ROLES = ContextKey_1.ContextKey.trusted('roles', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)', 'x-webpieces-roles');
|
|
103
|
+
/**
|
|
104
|
+
* WHICH KIND OF CALLER is driving this request — `gui`, `llm` or `public-api`. See
|
|
105
|
+
* {@link Surface} for the table and for why it is a property of the CALLER and not of the server.
|
|
106
|
+
*
|
|
107
|
+
* TRUSTED, and that is load-bearing twice over. It is what makes an app safe to branch on it in
|
|
108
|
+
* its OWN authorization logic ("this endpoint is not exposed to partners"), and it is what makes
|
|
109
|
+
* the value in {@link SurfaceEndUserStatus} believable: webpieces sets it itself, from the auth
|
|
110
|
+
* mode that matched, never from caller input. A caller that simply SENDS `x-wp-surface` at an
|
|
111
|
+
* endpoint whose auth did not establish one is rejected by `AuthFilter.reconcileWireTrust`, like
|
|
112
|
+
* every other unvouched trusted header.
|
|
113
|
+
*
|
|
114
|
+
* It keeps its `httpHeader` because PROPAGATION is the point: the surface belongs to the ORIGINAL
|
|
115
|
+
* caller, so `GUI -> api1 -> api2` must leave api2 seeing `gui`. An internal `@WpAuthOidc` hop
|
|
116
|
+
* verifies its caller, so the propagated value is admitted verbatim — and the auth filter only
|
|
117
|
+
* ever SETS a surface when none arrived, so an inherited one is never overwritten.
|
|
118
|
+
*/
|
|
119
|
+
static SURFACE = ContextKey_1.ContextKey.trusted('surface', 'set by the webpieces AuthFilter from the auth mode that matched the incoming request, or inherited from the edge hop that did', 'x-wp-surface');
|
|
103
120
|
/**
|
|
104
121
|
* Turns on test-case recording for this request (Java: x-webpieces-recording).
|
|
105
122
|
* Transferred so recording follows the request across service hops.
|
|
@@ -258,6 +275,7 @@ class WebpiecesCoreHeaders {
|
|
|
258
275
|
WebpiecesCoreHeaders.USER_ID,
|
|
259
276
|
WebpiecesCoreHeaders.ORG_ID,
|
|
260
277
|
WebpiecesCoreHeaders.USER_ROLES,
|
|
278
|
+
WebpiecesCoreHeaders.SURFACE,
|
|
261
279
|
WebpiecesCoreHeaders.RECORDING,
|
|
262
280
|
WebpiecesCoreHeaders.API_CALL_INFO,
|
|
263
281
|
WebpiecesCoreHeaders.HTTP_METHOD,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WebpiecesCoreHeaders.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/WebpiecesCoreHeaders.ts"],"names":[],"mappings":"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","sourcesContent":["import { ContextKey, AnyContextKey } from '../ContextKey';\nimport { ApiCallInfo } from './ApiCallInfo';\n\n/**\n * Core framework context keys — the minimum the WebPieces framework needs to correlate one\n * request across every service it touches, and across every log line each of them writes.\n *\n * ONE id, propagated unchanged. The first service to see a request without an `x-request-id`\n * generates one (RequestContextHeaders.fillFromRequest); every hop copies it onward verbatim. Grep that id and you\n * have the whole call tree. There is no per-hop id and no parent pointer: a chain of ids you must\n * stitch back together buys nothing a single shared id does not already give you.\n *\n * Lives in core-util (browser-safe) so both the http clients and http-server can reference it.\n *\n * Exposed as {@link HeaderRegistry.DEFAULT_HEADERS} — a service opts into these by\n * passing `platformHeaders=true` to `HeaderRegistry.configure(...)`.\n *\n * Each key's `name` is the logical/log name; `httpHeader` is the wire name.\n */\nexport class WebpiecesCoreHeaders {\n /**\n * The id that correlates every hop of one request, and every log line of every hop.\n * Generated by the first service to see a request without one; propagated unchanged after that.\n */\n static readonly REQUEST_ID = ContextKey.untrusted<string>('requestId', 'x-request-id');\n\n /**\n * WHICH SERVICE MINTED {@link REQUEST_ID} — the name from {@link ServiceInfo}, stamped by\n * `RequestContextHeaders.fillFromRequest` ONLY on the branch that generates a new id (i.e. when\n * the inbound request carried no `x-request-id`). It answers the question the id alone cannot:\n * \"this trace starts here — is that right?\" An id appearing with no source means it came from\n * outside; an id sourced by a service that should never be an entry point is a routing bug.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire, and that is the WHOLE POINT. If it\n * travelled, hop 2 would inherit it, hop 3 would inherit it, and \"who started this trace\"\n * would be indistinguishable from \"who passed it along\" — the origin, the one fact this key\n * carries, would be lost. It is absent on every hop that did NOT mint the id, which is exactly\n * the signal: present == I am the origin.\n * - `isLogged` TRUE → emitted as a plain string at `jsonPayload.requestIdSource`.\n */\n static readonly REQUEST_ID_SOURCE = ContextKey.untrusted<string>(\n 'requestIdSource',\n /*httpHeader*/ undefined,\n );\n\n /**\n * The CALLER's build version — so a downstream server's logs record which build of the client\n * called it (surfaces as `jsonPayload.clientVersion`). Distinct from the log line's own `version`\n * (this service's build): `version` answers \"which build wrote this line?\", `clientVersion`\n * answers \"which build asked us to?\".\n *\n * - `httpHeader` SET → transferred over the wire, BUT unlike a normal transferred key it is NOT\n * copied from the context onward. Each hop OVERWRITES it with its OWN `ServiceInfo.getVersion()`\n * as it becomes the client to the next hop (see `buildOutboundHeaders`), so on any given server\n * `clientVersion` is always the IMMEDIATE caller's version, never a stale grand-caller's.\n * - `isLogged` TRUE → the inbound value lands in the context and flows through the normal log\n * field map; no backend change needed.\n */\n static readonly CLIENT_VERSION = ContextKey.untrusted<string>(\n 'clientVersion',\n 'x-webpieces-client-version',\n );\n\n /**\n * A frontend/app-minted correlation id that groups every request triggered by ONE user ACTION.\n *\n * An \"action\" is a single thing the user did in the GUI — a CLICK on a button/link, or TYPING in a\n * field — or a background poller tick: anything that may fan out into MULTIPLE remote calls. That one\n * action fires 1..N browser HTTP calls, each of which gets its own framework-minted {@link REQUEST_ID}\n * (one per HTTP call, shared within that call's server→server subtree). `actionId` sits ABOVE\n * `requestId` and is what stitches those N requests back to the single action that caused them:\n *\n * actionId (app-minted, ONE per user action, rides EVERY call of that action)\n * └── 1..N requestId (framework-minted, ONE per HTTP call)\n *\n * Grep one `actionId` in the logs → every `requestId` it spawned, and every log line of the whole\n * action. Minted and refreshed by the app (a UI concern), carried under `x-webpieces-actionid`.\n *\n * Browser/app-minted ONLY: unlike {@link REQUEST_ID}, the framework transfers and logs it but must\n * NOT auto-mint one server-side. Absent `actionId` ⇒ a non-action flow (system / cron / task), which\n * is the correct signal.\n *\n * - `httpHeader` SET → transferred: copied off the inbound request into context and re-emitted on\n * outbound hops, so the id follows the action across services.\n * - `isLogged` TRUE → emitted as a plain string on every log line of the request.\n */\n static readonly ACTION_ID = ContextKey.untrusted<string>('actionId', 'x-webpieces-actionid');\n\n /**\n * WHO the request is acting as, and WHAT they may do. All three are TRUSTED keys: they are the\n * inputs to authorization decisions, so a reader must be able to tell \"the framework proved\n * this\" from \"the caller typed this\" — see the trust section of the {@link ContextKey} doc.\n *\n * They keep their `httpHeader`, because propagating a verified identity to the next internal\n * service is the point. What makes that safe is not the header being absent, it is WHO is\n * allowed to have set it: an inbound value is held PENDING by\n * `RequestContextHeaders.fillFromRequest` and admitted by `AuthFilter` only on a route that\n * verified its CALLER (`@WpAuthOidc` / `@WpAuthSharedSecret`). On a browser-reachable route\n * (`@WpAuthJwt` / public) the value must match what the authenticator itself derived, or the\n * request is rejected.\n *\n * `provenance` says \"an app-bound JwtHook\" rather than naming one hook, because the framework\n * default ({@link DefaultJwtHook}) stamps NO context entries at all — an app supplies a hook that\n * returns {@link ContextTuple}s for the keys it can vouch for. Any of these three that an app's\n * hook does NOT stamp will be rejected when a caller supplies it, which is the correct and loud\n * outcome: nothing is vouching for it.\n */\n static readonly ORG_ID = ContextKey.trusted<string>(\n 'orgId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-org-id',\n );\n\n static readonly USER_ID = ContextKey.trusted<string>(\n 'userId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-user-id',\n );\n\n static readonly USER_ROLES = ContextKey.trusted<string>(\n 'roles',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-webpieces-roles',\n );\n\n /**\n * Turns on test-case recording for this request (Java: x-webpieces-recording).\n * Transferred so recording follows the request across service hops.\n */\n static readonly RECORDING = ContextKey.untrusted<string>('recording', 'x-webpieces-recording');\n\n /**\n * The structured API-call tag ({@link ApiCallInfo}) stamped by {@link LogApiCallImpl} around every\n * outbound (client) / inbound (server) call. It rides the magic context so EVERY log line emitted\n * during the call inherits a filterable `api` object, surfacing in GCP as nested\n * `jsonPayload.api.{side,type,result,path,method}`.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire. Per-hop only: each server/client hop\n * stamps its own tag, so a downstream server records `side:'server'`, never the caller's `side:'client'`.\n * - `isLogged` TRUE → emitted by the logging backends. It carries an OBJECT value, so the backends\n * read it via {@link HeaderRegistry.buildStructuredLogFields} (object-aware); the flat\n * `buildLogFields()` string map deliberately skips it (typeof-string guard).\n */\n static readonly API_CALL_INFO = ContextKey.untrusted<ApiCallInfo>(\n 'api',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The inbound request's HTTP method and path, stamped ONCE from the {@link HttpRequest} by\n * `RequestContextHeaders.fillFromRequest` (the atomic inbound choke point every transport funnels\n * through). They surface as top-level `jsonPayload.httpMethod` / `jsonPayload.requestPath` so every\n * log line of the request carries them — they used to ride inside {@link ApiCallInfo} (`api.path` /\n * `api.method`) but that coupled a per-CALL logger to the per-REQUEST transport shape.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire: a downstream hop stamps its OWN inbound\n * method/path, never the caller's. Outbound client calls never set these (no inbound path).\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly HTTP_METHOD = ContextKey.untrusted<string>(\n 'httpMethod',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n static readonly REQUEST_PATH = ContextKey.untrusted<string>(\n 'requestPath',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The routed endpoint's IMPLEMENTATION identity: the concrete controller class name\n * ({@link RouteMetadata.controllerClassName}, e.g. `LoginController`) and the handler method NAME\n * ({@link RouteMetadata.methodName}, e.g. `login`), stamped once per request by {@link LogApiFilter}\n * after route matching so every subsequent log line of the request carries them.\n *\n * These say WHICH CODE ran, which is what you actually grep for — far more useful than the raw\n * `requestPath`. They are the top-level, filterable twin of what previously only lived nested in\n * {@link ApiCallInfo} (`api.method.controllerName` / `api.method.methodName`). The local console\n * formatters render them together as a compact `[Controller.method]` bracket; GCP keeps them as two\n * separate `jsonPayload.controller` / `jsonPayload.method` fields.\n *\n * NOTE: `method` here is the CODE method name (e.g. `login`), NOT the HTTP verb — that is\n * {@link HTTP_METHOD} (`httpMethod`).\n *\n * - `httpHeader` UNDEFINED → NOT transferred: each hop stamps its OWN routed controller/method.\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly CONTROLLER = ContextKey.untrusted<string>(\n 'controller',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n static readonly METHOD = ContextKey.untrusted<string>(\n 'method',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The base URL ONE outbound call should go to, overriding whatever the client's `ClientConfig`\n * bound at construction — the answer to \"POST our published contract to a URL the PARTNER\n * registered at runtime\" (an `OrganizationWebhook.url` column, an OAuth callback, a per-tenant\n * or self-hosted host). The destination is DATA, not deployment, so it cannot be a svcName and\n * there is nothing to register in {@link ClientRegistry}.\n *\n * ```ts\n * RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\n * return partnerWebhookClient.deliver(envelope);\n * });\n * ```\n *\n * The contract's path IS appended to it (base + `@ApiPath` + `@Endpoint`). When the stored value\n * is a complete url — the usual partner-webhook row — use {@link OVERRIDE_FULL_URL} instead.\n *\n * ONLY a client carrying a `ContextBaseUrlFilter` reads it. Every other client IGNORES this key\n * entirely, which is what stops an ambient value re-pointing every other client in the same\n * fan-out loop at a partner's server. Installing that ONE filter IS the opt-in, so\n * `grep -rn ContextBaseUrlFilter` enumerates every client that can be re-pointed at all.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire, and that is load-bearing. This value\n * names where THIS hop goes. If it travelled, the callee would inherit it and re-point ITS\n * own outbound calls at the same host — one partner-supplied URL turning into an SSRF pivot\n * across the whole call tree. It is per-hop, always.\n * - `isLogged` TRUE → the destination of a partner delivery is exactly what you want in the log\n * line when one fails.\n *\n * UNTRUSTED, necessarily: it comes from a database column a partner edited. That is precisely\n * why re-pointing a request ARMS the framework's SSRF guard, automatically, rather than trusting\n * it — the guard sits beneath every app filter and reads the fact that the request was moved.\n */\n static readonly OVERRIDE_BASE_URL = ContextKey.untrusted<string>(\n 'overrideBaseUrl',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The COMPLETE url — host, path AND query — ONE outbound call should be sent to, verbatim. The\n * sibling of {@link OVERRIDE_BASE_URL} for the other shape of runtime destination: a partner\n * that does not implement our contract at their own host, but hands us one opaque url to POST\n * to (`https://hooks.partner.example/in/abc?token=xyz`, straight out of a webhook row). With a\n * base-URL override the contract's path would be appended to it and the url would be wrong; with\n * this one the contract's path is NOT appended and nothing is inserted.\n *\n * ```ts\n * RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\n * return partnerWebhookClient.deliver(envelope);\n * });\n * ```\n *\n * ONLY a client carrying a `ContextFullUrlFilter` reads it, for exactly the reasons given on\n * {@link OVERRIDE_BASE_URL}: installing that filter IS the opt-in, and every other client ignores\n * the key. Not transferred over the wire (per-hop, or it becomes an SSRF pivot across the call\n * tree), logged (it is what you want in a failed-delivery log line), and UNTRUSTED (a partner\n * edited it) — which is why re-pointing at it arms the framework's SSRF guard automatically.\n */\n static readonly OVERRIDE_FULL_URL = ContextKey.untrusted<string>(\n 'overrideFullUrl',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * NO CREDENTIAL KEYS LIVE HERE.\n *\n * `authorization` and `x-webpieces-shared-secret` used to be ContextKeys. That made them\n * TRANSFERRED keys, so the inbound transfer copied them off the request into the\n * RequestContext, and every outbound RPC call and enqueued Cloud Task then carried the\n * caller's credential onward — to services that had no business seeing it.\n *\n * A credential belongs to ONE request hop. It is read straight off the {@link HttpRequest}\n * by the framework AuthFilter, and written straight onto the outbound request by the client\n * that mints it (NodeProxyClient, GcpTaskInvoker, InMemoryTaskInvoker). It never enters the\n * magic context, so nothing can propagate it by accident.\n *\n * An app that genuinely wants a credential to travel can still register its own ContextKey for\n * it — but that is now an explicit, visible decision rather than the default.\n */\n\n /**\n * All core context keys (the platform DEFAULT_HEADERS set). A `static readonly` CONSTANT, not a\n * method: it is compile-time data — a list of the key definitions above — read once at the startup\n * composition root (`HeaderRegistry.configure` / `HeaderRegistry.DEFAULT_HEADERS`). A method here\n * would be un-injectable behavior the DI design graph can't reach; a constant is honest data.\n */\n static readonly ALL_HEADERS: AnyContextKey[] = [\n WebpiecesCoreHeaders.REQUEST_ID,\n WebpiecesCoreHeaders.REQUEST_ID_SOURCE,\n WebpiecesCoreHeaders.CLIENT_VERSION,\n WebpiecesCoreHeaders.ACTION_ID,\n WebpiecesCoreHeaders.USER_ID,\n WebpiecesCoreHeaders.ORG_ID,\n WebpiecesCoreHeaders.USER_ROLES,\n WebpiecesCoreHeaders.RECORDING,\n WebpiecesCoreHeaders.API_CALL_INFO,\n WebpiecesCoreHeaders.HTTP_METHOD,\n WebpiecesCoreHeaders.REQUEST_PATH,\n WebpiecesCoreHeaders.CONTROLLER,\n WebpiecesCoreHeaders.METHOD,\n WebpiecesCoreHeaders.OVERRIDE_BASE_URL,\n WebpiecesCoreHeaders.OVERRIDE_FULL_URL,\n ];\n}\n"]}
|
|
1
|
+
{"version":3,"file":"WebpiecesCoreHeaders.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/WebpiecesCoreHeaders.ts"],"names":[],"mappings":";;;AAAA,8CAA0D;AAI1D;;;;;;;;;;;;;;;GAeG;AACH,MAAa,oBAAoB;IAC7B;;;OAGG;IACH,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,SAAS,CAAS,WAAW,EAAE,cAAc,CAAC,CAAC;IAEvF;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAU,iBAAiB,GAAG,uBAAU,CAAC,SAAS,CACpD,iBAAiB;IACjB,cAAc,CAAC,SAAS,CAC3B,CAAC;IAEF;;;;;;;;;;;;OAYG;IACH,MAAM,CAAU,cAAc,GAAG,uBAAU,CAAC,SAAS,CACjD,eAAe,EACf,4BAA4B,CAC/B,CAAC;IAEF;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,MAAM,CAAU,SAAS,GAAG,uBAAU,CAAC,SAAS,CAAS,UAAU,EAAE,sBAAsB,CAAC,CAAC;IAE7F;;;;;;;;;;;;;;;;;;OAkBG;IACH,MAAM,CAAU,MAAM,GAAG,uBAAU,CAAC,OAAO,CACvC,OAAO,EACP,oGAAoG,EACpG,UAAU,CACb,CAAC;IAEF,MAAM,CAAU,OAAO,GAAG,uBAAU,CAAC,OAAO,CACxC,QAAQ,EACR,oGAAoG,EACpG,WAAW,CACd,CAAC;IAEF,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,OAAO,CAC3C,OAAO,EACP,oGAAoG,EACpG,mBAAmB,CACtB,CAAC;IAEF;;;;;;;;;;;;;;;OAeG;IACH,MAAM,CAAU,OAAO,GAAG,uBAAU,CAAC,OAAO,CACxC,SAAS,EACT,+HAA+H,EAC/H,cAAc,CACjB,CAAC;IAEF;;;OAGG;IACH,MAAM,CAAU,SAAS,GAAG,uBAAU,CAAC,SAAS,CAAS,WAAW,EAAE,uBAAuB,CAAC,CAAC;IAE/F;;;;;;;;;;;OAWG;IACH,MAAM,CAAU,aAAa,GAAG,uBAAU,CAAC,SAAS,CAChD,KAAK;IACL,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF;;;;;;;;;;OAUG;IACH,MAAM,CAAU,WAAW,GAAG,uBAAU,CAAC,SAAS,CAC9C,YAAY;IACZ,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF,MAAM,CAAU,YAAY,GAAG,uBAAU,CAAC,SAAS,CAC/C,aAAa;IACb,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,SAAS,CAC7C,YAAY;IACZ,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF,MAAM,CAAU,MAAM,GAAG,uBAAU,CAAC,SAAS,CACzC,QAAQ;IACR,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,MAAM,CAAU,iBAAiB,GAAG,uBAAU,CAAC,SAAS,CACpD,iBAAiB;IACjB,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,MAAM,CAAU,iBAAiB,GAAG,uBAAU,CAAC,SAAS,CACpD,iBAAiB;IACjB,cAAc,CAAC,SAAS;IACxB,cAAc,CAAC,KAAK;IACpB,YAAY,CAAC,IAAI,CACpB,CAAC;IAEF;;;;;;;;;;;;;;;OAeG;IAEH;;;;;OAKG;IACH,MAAM,CAAU,WAAW,GAAoB;QAC3C,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,iBAAiB;QACtC,oBAAoB,CAAC,cAAc;QACnC,oBAAoB,CAAC,SAAS;QAC9B,oBAAoB,CAAC,OAAO;QAC5B,oBAAoB,CAAC,MAAM;QAC3B,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,OAAO;QAC5B,oBAAoB,CAAC,SAAS;QAC9B,oBAAoB,CAAC,aAAa;QAClC,oBAAoB,CAAC,WAAW;QAChC,oBAAoB,CAAC,YAAY;QACjC,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,MAAM;QAC3B,oBAAoB,CAAC,iBAAiB;QACtC,oBAAoB,CAAC,iBAAiB;KACzC,CAAC;;AA9TN,oDA+TC","sourcesContent":["import { ContextKey, AnyContextKey } from '../ContextKey';\nimport { ApiCallInfo } from './ApiCallInfo';\nimport { Surface } from './Surface';\n\n/**\n * Core framework context keys — the minimum the WebPieces framework needs to correlate one\n * request across every service it touches, and across every log line each of them writes.\n *\n * ONE id, propagated unchanged. The first service to see a request without an `x-request-id`\n * generates one (RequestContextHeaders.fillFromRequest); every hop copies it onward verbatim. Grep that id and you\n * have the whole call tree. There is no per-hop id and no parent pointer: a chain of ids you must\n * stitch back together buys nothing a single shared id does not already give you.\n *\n * Lives in core-util (browser-safe) so both the http clients and http-server can reference it.\n *\n * Exposed as {@link HeaderRegistry.DEFAULT_HEADERS} — a service opts into these by\n * passing `platformHeaders=true` to `HeaderRegistry.configure(...)`.\n *\n * Each key's `name` is the logical/log name; `httpHeader` is the wire name.\n */\nexport class WebpiecesCoreHeaders {\n /**\n * The id that correlates every hop of one request, and every log line of every hop.\n * Generated by the first service to see a request without one; propagated unchanged after that.\n */\n static readonly REQUEST_ID = ContextKey.untrusted<string>('requestId', 'x-request-id');\n\n /**\n * WHICH SERVICE MINTED {@link REQUEST_ID} — the name from {@link ServiceInfo}, stamped by\n * `RequestContextHeaders.fillFromRequest` ONLY on the branch that generates a new id (i.e. when\n * the inbound request carried no `x-request-id`). It answers the question the id alone cannot:\n * \"this trace starts here — is that right?\" An id appearing with no source means it came from\n * outside; an id sourced by a service that should never be an entry point is a routing bug.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire, and that is the WHOLE POINT. If it\n * travelled, hop 2 would inherit it, hop 3 would inherit it, and \"who started this trace\"\n * would be indistinguishable from \"who passed it along\" — the origin, the one fact this key\n * carries, would be lost. It is absent on every hop that did NOT mint the id, which is exactly\n * the signal: present == I am the origin.\n * - `isLogged` TRUE → emitted as a plain string at `jsonPayload.requestIdSource`.\n */\n static readonly REQUEST_ID_SOURCE = ContextKey.untrusted<string>(\n 'requestIdSource',\n /*httpHeader*/ undefined,\n );\n\n /**\n * The CALLER's build version — so a downstream server's logs record which build of the client\n * called it (surfaces as `jsonPayload.clientVersion`). Distinct from the log line's own `version`\n * (this service's build): `version` answers \"which build wrote this line?\", `clientVersion`\n * answers \"which build asked us to?\".\n *\n * - `httpHeader` SET → transferred over the wire, BUT unlike a normal transferred key it is NOT\n * copied from the context onward. Each hop OVERWRITES it with its OWN `ServiceInfo.getVersion()`\n * as it becomes the client to the next hop (see `buildOutboundHeaders`), so on any given server\n * `clientVersion` is always the IMMEDIATE caller's version, never a stale grand-caller's.\n * - `isLogged` TRUE → the inbound value lands in the context and flows through the normal log\n * field map; no backend change needed.\n */\n static readonly CLIENT_VERSION = ContextKey.untrusted<string>(\n 'clientVersion',\n 'x-webpieces-client-version',\n );\n\n /**\n * A frontend/app-minted correlation id that groups every request triggered by ONE user ACTION.\n *\n * An \"action\" is a single thing the user did in the GUI — a CLICK on a button/link, or TYPING in a\n * field — or a background poller tick: anything that may fan out into MULTIPLE remote calls. That one\n * action fires 1..N browser HTTP calls, each of which gets its own framework-minted {@link REQUEST_ID}\n * (one per HTTP call, shared within that call's server→server subtree). `actionId` sits ABOVE\n * `requestId` and is what stitches those N requests back to the single action that caused them:\n *\n * actionId (app-minted, ONE per user action, rides EVERY call of that action)\n * └── 1..N requestId (framework-minted, ONE per HTTP call)\n *\n * Grep one `actionId` in the logs → every `requestId` it spawned, and every log line of the whole\n * action. Minted and refreshed by the app (a UI concern), carried under `x-webpieces-actionid`.\n *\n * Browser/app-minted ONLY: unlike {@link REQUEST_ID}, the framework transfers and logs it but must\n * NOT auto-mint one server-side. Absent `actionId` ⇒ a non-action flow (system / cron / task), which\n * is the correct signal.\n *\n * - `httpHeader` SET → transferred: copied off the inbound request into context and re-emitted on\n * outbound hops, so the id follows the action across services.\n * - `isLogged` TRUE → emitted as a plain string on every log line of the request.\n */\n static readonly ACTION_ID = ContextKey.untrusted<string>('actionId', 'x-webpieces-actionid');\n\n /**\n * WHO the request is acting as, and WHAT they may do. All three are TRUSTED keys: they are the\n * inputs to authorization decisions, so a reader must be able to tell \"the framework proved\n * this\" from \"the caller typed this\" — see the trust section of the {@link ContextKey} doc.\n *\n * They keep their `httpHeader`, because propagating a verified identity to the next internal\n * service is the point. What makes that safe is not the header being absent, it is WHO is\n * allowed to have set it: an inbound value is held PENDING by\n * `RequestContextHeaders.fillFromRequest` and admitted by `AuthFilter` only on a route that\n * verified its CALLER (`@WpAuthOidc` / `@WpAuthSharedSecret`). On a browser-reachable route\n * (`@WpAuthJwt` / public) the value must match what the authenticator itself derived, or the\n * request is rejected.\n *\n * `provenance` says \"an app-bound JwtHook\" rather than naming one hook, because the framework\n * default ({@link DefaultJwtHook}) stamps NO context entries at all — an app supplies a hook that\n * returns {@link ContextTuple}s for the keys it can vouch for. Any of these three that an app's\n * hook does NOT stamp will be rejected when a caller supplies it, which is the correct and loud\n * outcome: nothing is vouching for it.\n */\n static readonly ORG_ID = ContextKey.trusted<string>(\n 'orgId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-org-id',\n );\n\n static readonly USER_ID = ContextKey.trusted<string>(\n 'userId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-user-id',\n );\n\n static readonly USER_ROLES = ContextKey.trusted<string>(\n 'roles',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-webpieces-roles',\n );\n\n /**\n * WHICH KIND OF CALLER is driving this request — `gui`, `llm` or `public-api`. See\n * {@link Surface} for the table and for why it is a property of the CALLER and not of the server.\n *\n * TRUSTED, and that is load-bearing twice over. It is what makes an app safe to branch on it in\n * its OWN authorization logic (\"this endpoint is not exposed to partners\"), and it is what makes\n * the value in {@link SurfaceEndUserStatus} believable: webpieces sets it itself, from the auth\n * mode that matched, never from caller input. A caller that simply SENDS `x-wp-surface` at an\n * endpoint whose auth did not establish one is rejected by `AuthFilter.reconcileWireTrust`, like\n * every other unvouched trusted header.\n *\n * It keeps its `httpHeader` because PROPAGATION is the point: the surface belongs to the ORIGINAL\n * caller, so `GUI -> api1 -> api2` must leave api2 seeing `gui`. An internal `@WpAuthOidc` hop\n * verifies its caller, so the propagated value is admitted verbatim — and the auth filter only\n * ever SETS a surface when none arrived, so an inherited one is never overwritten.\n */\n static readonly SURFACE = ContextKey.trusted<Surface>(\n 'surface',\n 'set by the webpieces AuthFilter from the auth mode that matched the incoming request, or inherited from the edge hop that did',\n 'x-wp-surface',\n );\n\n /**\n * Turns on test-case recording for this request (Java: x-webpieces-recording).\n * Transferred so recording follows the request across service hops.\n */\n static readonly RECORDING = ContextKey.untrusted<string>('recording', 'x-webpieces-recording');\n\n /**\n * The structured API-call tag ({@link ApiCallInfo}) stamped by {@link LogApiCallImpl} around every\n * outbound (client) / inbound (server) call. It rides the magic context so EVERY log line emitted\n * during the call inherits a filterable `api` object, surfacing in GCP as nested\n * `jsonPayload.api.{side,type,result,path,method}`.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire. Per-hop only: each server/client hop\n * stamps its own tag, so a downstream server records `side:'server'`, never the caller's `side:'client'`.\n * - `isLogged` TRUE → emitted by the logging backends. It carries an OBJECT value, so the backends\n * read it via {@link HeaderRegistry.buildStructuredLogFields} (object-aware); the flat\n * `buildLogFields()` string map deliberately skips it (typeof-string guard).\n */\n static readonly API_CALL_INFO = ContextKey.untrusted<ApiCallInfo>(\n 'api',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The inbound request's HTTP method and path, stamped ONCE from the {@link HttpRequest} by\n * `RequestContextHeaders.fillFromRequest` (the atomic inbound choke point every transport funnels\n * through). They surface as top-level `jsonPayload.httpMethod` / `jsonPayload.requestPath` so every\n * log line of the request carries them — they used to ride inside {@link ApiCallInfo} (`api.path` /\n * `api.method`) but that coupled a per-CALL logger to the per-REQUEST transport shape.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire: a downstream hop stamps its OWN inbound\n * method/path, never the caller's. Outbound client calls never set these (no inbound path).\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly HTTP_METHOD = ContextKey.untrusted<string>(\n 'httpMethod',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n static readonly REQUEST_PATH = ContextKey.untrusted<string>(\n 'requestPath',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The routed endpoint's IMPLEMENTATION identity: the concrete controller class name\n * ({@link RouteMetadata.controllerClassName}, e.g. `LoginController`) and the handler method NAME\n * ({@link RouteMetadata.methodName}, e.g. `login`), stamped once per request by {@link LogApiFilter}\n * after route matching so every subsequent log line of the request carries them.\n *\n * These say WHICH CODE ran, which is what you actually grep for — far more useful than the raw\n * `requestPath`. They are the top-level, filterable twin of what previously only lived nested in\n * {@link ApiCallInfo} (`api.method.controllerName` / `api.method.methodName`). The local console\n * formatters render them together as a compact `[Controller.method]` bracket; GCP keeps them as two\n * separate `jsonPayload.controller` / `jsonPayload.method` fields.\n *\n * NOTE: `method` here is the CODE method name (e.g. `login`), NOT the HTTP verb — that is\n * {@link HTTP_METHOD} (`httpMethod`).\n *\n * - `httpHeader` UNDEFINED → NOT transferred: each hop stamps its OWN routed controller/method.\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly CONTROLLER = ContextKey.untrusted<string>(\n 'controller',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n static readonly METHOD = ContextKey.untrusted<string>(\n 'method',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The base URL ONE outbound call should go to, overriding whatever the client's `ClientConfig`\n * bound at construction — the answer to \"POST our published contract to a URL the PARTNER\n * registered at runtime\" (an `OrganizationWebhook.url` column, an OAuth callback, a per-tenant\n * or self-hosted host). The destination is DATA, not deployment, so it cannot be a svcName and\n * there is nothing to register in {@link ClientRegistry}.\n *\n * ```ts\n * RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\n * return partnerWebhookClient.deliver(envelope);\n * });\n * ```\n *\n * The contract's path IS appended to it (base + `@ApiPath` + `@Endpoint`). When the stored value\n * is a complete url — the usual partner-webhook row — use {@link OVERRIDE_FULL_URL} instead.\n *\n * ONLY a client carrying a `ContextBaseUrlFilter` reads it. Every other client IGNORES this key\n * entirely, which is what stops an ambient value re-pointing every other client in the same\n * fan-out loop at a partner's server. Installing that ONE filter IS the opt-in, so\n * `grep -rn ContextBaseUrlFilter` enumerates every client that can be re-pointed at all.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire, and that is load-bearing. This value\n * names where THIS hop goes. If it travelled, the callee would inherit it and re-point ITS\n * own outbound calls at the same host — one partner-supplied URL turning into an SSRF pivot\n * across the whole call tree. It is per-hop, always.\n * - `isLogged` TRUE → the destination of a partner delivery is exactly what you want in the log\n * line when one fails.\n *\n * UNTRUSTED, necessarily: it comes from a database column a partner edited. That is precisely\n * why re-pointing a request ARMS the framework's SSRF guard, automatically, rather than trusting\n * it — the guard sits beneath every app filter and reads the fact that the request was moved.\n */\n static readonly OVERRIDE_BASE_URL = ContextKey.untrusted<string>(\n 'overrideBaseUrl',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * The COMPLETE url — host, path AND query — ONE outbound call should be sent to, verbatim. The\n * sibling of {@link OVERRIDE_BASE_URL} for the other shape of runtime destination: a partner\n * that does not implement our contract at their own host, but hands us one opaque url to POST\n * to (`https://hooks.partner.example/in/abc?token=xyz`, straight out of a webhook row). With a\n * base-URL override the contract's path would be appended to it and the url would be wrong; with\n * this one the contract's path is NOT appended and nothing is inserted.\n *\n * ```ts\n * RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\n * return partnerWebhookClient.deliver(envelope);\n * });\n * ```\n *\n * ONLY a client carrying a `ContextFullUrlFilter` reads it, for exactly the reasons given on\n * {@link OVERRIDE_BASE_URL}: installing that filter IS the opt-in, and every other client ignores\n * the key. Not transferred over the wire (per-hop, or it becomes an SSRF pivot across the call\n * tree), logged (it is what you want in a failed-delivery log line), and UNTRUSTED (a partner\n * edited it) — which is why re-pointing at it arms the framework's SSRF guard automatically.\n */\n static readonly OVERRIDE_FULL_URL = ContextKey.untrusted<string>(\n 'overrideFullUrl',\n /*httpHeader*/ undefined,\n /*maskInLogs*/ false,\n /*isLogged*/ true,\n );\n\n /**\n * NO CREDENTIAL KEYS LIVE HERE.\n *\n * `authorization` and `x-webpieces-shared-secret` used to be ContextKeys. That made them\n * TRANSFERRED keys, so the inbound transfer copied them off the request into the\n * RequestContext, and every outbound RPC call and enqueued Cloud Task then carried the\n * caller's credential onward — to services that had no business seeing it.\n *\n * A credential belongs to ONE request hop. It is read straight off the {@link HttpRequest}\n * by the framework AuthFilter, and written straight onto the outbound request by the client\n * that mints it (NodeProxyClient, GcpTaskInvoker, InMemoryTaskInvoker). It never enters the\n * magic context, so nothing can propagate it by accident.\n *\n * An app that genuinely wants a credential to travel can still register its own ContextKey for\n * it — but that is now an explicit, visible decision rather than the default.\n */\n\n /**\n * All core context keys (the platform DEFAULT_HEADERS set). A `static readonly` CONSTANT, not a\n * method: it is compile-time data — a list of the key definitions above — read once at the startup\n * composition root (`HeaderRegistry.configure` / `HeaderRegistry.DEFAULT_HEADERS`). A method here\n * would be un-injectable behavior the DI design graph can't reach; a constant is honest data.\n */\n static readonly ALL_HEADERS: AnyContextKey[] = [\n WebpiecesCoreHeaders.REQUEST_ID,\n WebpiecesCoreHeaders.REQUEST_ID_SOURCE,\n WebpiecesCoreHeaders.CLIENT_VERSION,\n WebpiecesCoreHeaders.ACTION_ID,\n WebpiecesCoreHeaders.USER_ID,\n WebpiecesCoreHeaders.ORG_ID,\n WebpiecesCoreHeaders.USER_ROLES,\n WebpiecesCoreHeaders.SURFACE,\n WebpiecesCoreHeaders.RECORDING,\n WebpiecesCoreHeaders.API_CALL_INFO,\n WebpiecesCoreHeaders.HTTP_METHOD,\n WebpiecesCoreHeaders.REQUEST_PATH,\n WebpiecesCoreHeaders.CONTROLLER,\n WebpiecesCoreHeaders.METHOD,\n WebpiecesCoreHeaders.OVERRIDE_BASE_URL,\n WebpiecesCoreHeaders.OVERRIDE_FULL_URL,\n ];\n}\n"]}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ErrorTranslator } from './ErrorTranslator';
|
|
2
|
+
import { HttpResponseDto } from './HttpResponseDto';
|
|
3
|
+
/**
|
|
4
|
+
* THE webpieces default {@link ErrorTranslator} — ONE class, BOTH halves, used by the node server, by
|
|
5
|
+
* every node client and by the browser bundle alike.
|
|
6
|
+
*
|
|
7
|
+
* It is what {@link ClientRegistry.getErrorTranslator} holds until an app installs its own, and it is
|
|
8
|
+
* what an app's own translator calls to DECLINE an error:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* private readonly fallback = new WebpiecesDefaultErrorTranslator();
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* so "no translator registered" and "a translator that does not claim this error" produce
|
|
15
|
+
* byte-identical output. It lives in core-util, beside {@link ClientRegistry}, because core-util is
|
|
16
|
+
* the package every API contract already depends on and the only one both a server and a browser
|
|
17
|
+
* bundle can reach. Both halves needed nothing else: the outbound half wants
|
|
18
|
+
* {@link ApiErrorBoundary} + {@link ApiErrorHttpStatus}, the inbound half wants
|
|
19
|
+
* {@link ApiErrorCodec} + {@link ReceivedApiErrorRule}, and all four were already here.
|
|
20
|
+
*
|
|
21
|
+
* Stateless, so {@link WEBPIECES_DEFAULT_ERROR_TRANSLATOR} is a shared instance — but constructing
|
|
22
|
+
* your own costs nothing and reads better at a delegation site.
|
|
23
|
+
*/
|
|
24
|
+
export declare class WebpiecesDefaultErrorTranslator implements ErrorTranslator {
|
|
25
|
+
private readonly boundary;
|
|
26
|
+
private readonly genericMessages;
|
|
27
|
+
/**
|
|
28
|
+
* SERVER: an exception in, the exact HTTP response out.
|
|
29
|
+
*
|
|
30
|
+
* The payload IS the answer. {@link ApiErrorBoundary.encode} has already applied both shared
|
|
31
|
+
* rules — a caller-local `ApiConnectionError` (and every non-`ApiError`) publishes as
|
|
32
|
+
* `implementation`, and only an `ApiEndUserError`'s own message is disclosed — and the payload
|
|
33
|
+
* carries everything HTTP needs after that: `kind`, `statusCode`, `edgeHttpStatus` and
|
|
34
|
+
* `retryAfterSeconds`. So there is no `instanceof` here and nothing re-derived from the raw
|
|
35
|
+
* error; unknown throws and caller-local failures come out as a 500 implementation failure
|
|
36
|
+
* because that is what the payload says.
|
|
37
|
+
*
|
|
38
|
+
* An `ApiEndUserError` publishes 266 here, ALWAYS. Whether a particular REQUEST republishes that
|
|
39
|
+
* as a real 4xx is a property of the CALLER, not of the error, so it is decided once per request
|
|
40
|
+
* from `WebpiecesCoreHeaders.SURFACE` by the server boundary (`ExpressWrapper.handleError`) —
|
|
41
|
+
* see {@link SurfaceEndUserStatus}. That is why this class takes no constructor argument any
|
|
42
|
+
* more: the old `new ApiErrorHttpMapper('gui' | 'edge')` made every delegating app translator
|
|
43
|
+
* responsible for repeating its router's mode, and repeating it wrongly was silent.
|
|
44
|
+
*/
|
|
45
|
+
toWire(error: Error): HttpResponseDto;
|
|
46
|
+
/**
|
|
47
|
+
* CLIENT: the response in, a THROW out — or a silent return for an ordinary success.
|
|
48
|
+
*
|
|
49
|
+
* Called for EVERY response, 2xx included (see {@link ErrorTranslator.fromWire}). A 2xx that is
|
|
50
|
+
* not 266 is exactly the case where webpieces has nothing to say, so it returns and the caller
|
|
51
|
+
* gets its DTO. Everything else throws, which is what lets `ProxyClient` call this and then simply
|
|
52
|
+
* stop — a failure response can never fall through to a typed caller.
|
|
53
|
+
*
|
|
54
|
+
* The rule applied is {@link ReceivedApiErrorRule}, shared verbatim with IPC: 4xx is MY bug, 5xx
|
|
55
|
+
* is THEIRS, an `ApiDependencyError` is already attributed and passes through, 266 is the end
|
|
56
|
+
* user's own answer.
|
|
57
|
+
*/
|
|
58
|
+
fromWire(response: HttpResponseDto): void;
|
|
59
|
+
/** The reason phrase webpieces writes beside a status it chose itself. */
|
|
60
|
+
genericMessage(code: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* A webpieces peer sends an {@link ApiErrorPayload}; a foreign responder sends anything at all.
|
|
63
|
+
* The status is only believed when it AGREES with the kind the body claims — a body saying
|
|
64
|
+
* `not-found` beside a 500 is a mismatched response, not a 404, and falls back to the status.
|
|
65
|
+
*/
|
|
66
|
+
private decodeBody;
|
|
67
|
+
private fallbackMessage;
|
|
68
|
+
}
|
|
69
|
+
/** Process-wide built-in instance — stateless, so one shared instance is enough. */
|
|
70
|
+
export declare const WEBPIECES_DEFAULT_ERROR_TRANSLATOR: WebpiecesDefaultErrorTranslator;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WEBPIECES_DEFAULT_ERROR_TRANSLATOR = exports.WebpiecesDefaultErrorTranslator = void 0;
|
|
4
|
+
const ApiErrorBoundary_1 = require("../errors/ApiErrorBoundary");
|
|
5
|
+
const ApiErrorCodec_1 = require("../errors/ApiErrorCodec");
|
|
6
|
+
const ApiError_1 = require("../errors/ApiError");
|
|
7
|
+
const ReceivedApiErrorRule_1 = require("../errors/ReceivedApiErrorRule");
|
|
8
|
+
const ApiErrorHttpStatus_1 = require("./ApiErrorHttpStatus");
|
|
9
|
+
const HttpResponseDto_1 = require("./HttpResponseDto");
|
|
10
|
+
/**
|
|
11
|
+
* THE webpieces default {@link ErrorTranslator} — ONE class, BOTH halves, used by the node server, by
|
|
12
|
+
* every node client and by the browser bundle alike.
|
|
13
|
+
*
|
|
14
|
+
* It is what {@link ClientRegistry.getErrorTranslator} holds until an app installs its own, and it is
|
|
15
|
+
* what an app's own translator calls to DECLINE an error:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* private readonly fallback = new WebpiecesDefaultErrorTranslator();
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* so "no translator registered" and "a translator that does not claim this error" produce
|
|
22
|
+
* byte-identical output. It lives in core-util, beside {@link ClientRegistry}, because core-util is
|
|
23
|
+
* the package every API contract already depends on and the only one both a server and a browser
|
|
24
|
+
* bundle can reach. Both halves needed nothing else: the outbound half wants
|
|
25
|
+
* {@link ApiErrorBoundary} + {@link ApiErrorHttpStatus}, the inbound half wants
|
|
26
|
+
* {@link ApiErrorCodec} + {@link ReceivedApiErrorRule}, and all four were already here.
|
|
27
|
+
*
|
|
28
|
+
* Stateless, so {@link WEBPIECES_DEFAULT_ERROR_TRANSLATOR} is a shared instance — but constructing
|
|
29
|
+
* your own costs nothing and reads better at a delegation site.
|
|
30
|
+
*/
|
|
31
|
+
class WebpiecesDefaultErrorTranslator {
|
|
32
|
+
boundary = new ApiErrorBoundary_1.ApiErrorBoundary();
|
|
33
|
+
genericMessages = new Map([
|
|
34
|
+
[266, 'End User Error'],
|
|
35
|
+
[400, 'Bad Request'],
|
|
36
|
+
[401, 'Unauthorized'],
|
|
37
|
+
[403, 'Forbidden'],
|
|
38
|
+
[404, 'Not Found'],
|
|
39
|
+
[408, 'Request Timeout'],
|
|
40
|
+
[409, 'Conflict'],
|
|
41
|
+
[412, 'Precondition Failed'],
|
|
42
|
+
[415, 'Unsupported Media Type'],
|
|
43
|
+
[422, 'Unprocessable Content'],
|
|
44
|
+
[429, 'Too Many Requests'],
|
|
45
|
+
[500, 'Internal Server Error'],
|
|
46
|
+
[501, 'Not Implemented'],
|
|
47
|
+
[502, 'Bad Gateway'],
|
|
48
|
+
[503, 'Service Unavailable'],
|
|
49
|
+
[504, 'Gateway Timeout'],
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* SERVER: an exception in, the exact HTTP response out.
|
|
53
|
+
*
|
|
54
|
+
* The payload IS the answer. {@link ApiErrorBoundary.encode} has already applied both shared
|
|
55
|
+
* rules — a caller-local `ApiConnectionError` (and every non-`ApiError`) publishes as
|
|
56
|
+
* `implementation`, and only an `ApiEndUserError`'s own message is disclosed — and the payload
|
|
57
|
+
* carries everything HTTP needs after that: `kind`, `statusCode`, `edgeHttpStatus` and
|
|
58
|
+
* `retryAfterSeconds`. So there is no `instanceof` here and nothing re-derived from the raw
|
|
59
|
+
* error; unknown throws and caller-local failures come out as a 500 implementation failure
|
|
60
|
+
* because that is what the payload says.
|
|
61
|
+
*
|
|
62
|
+
* An `ApiEndUserError` publishes 266 here, ALWAYS. Whether a particular REQUEST republishes that
|
|
63
|
+
* as a real 4xx is a property of the CALLER, not of the error, so it is decided once per request
|
|
64
|
+
* from `WebpiecesCoreHeaders.SURFACE` by the server boundary (`ExpressWrapper.handleError`) —
|
|
65
|
+
* see {@link SurfaceEndUserStatus}. That is why this class takes no constructor argument any
|
|
66
|
+
* more: the old `new ApiErrorHttpMapper('gui' | 'edge')` made every delegating app translator
|
|
67
|
+
* responsible for repeating its router's mode, and repeating it wrongly was silent.
|
|
68
|
+
*/
|
|
69
|
+
toWire(error) {
|
|
70
|
+
const payload = this.boundary.encode(error);
|
|
71
|
+
// The boundary never publishes kind 'connection' — that is the whole first rule above — so
|
|
72
|
+
// this is the published subset ApiErrorHttpStatus.codeFor accepts.
|
|
73
|
+
const kind = payload.kind;
|
|
74
|
+
const status = ApiErrorHttpStatus_1.ApiErrorHttpStatus.codeFor(kind, payload.statusCode);
|
|
75
|
+
const headers = payload.retryAfterSeconds !== undefined
|
|
76
|
+
? [new HttpResponseDto_1.HttpHeader('retry-after', String(payload.retryAfterSeconds))]
|
|
77
|
+
: [];
|
|
78
|
+
return new HttpResponseDto_1.HttpResponseDto(new HttpResponseDto_1.HttpResponseStatus(status, this.genericMessage(status)), headers, payload);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* CLIENT: the response in, a THROW out — or a silent return for an ordinary success.
|
|
82
|
+
*
|
|
83
|
+
* Called for EVERY response, 2xx included (see {@link ErrorTranslator.fromWire}). A 2xx that is
|
|
84
|
+
* not 266 is exactly the case where webpieces has nothing to say, so it returns and the caller
|
|
85
|
+
* gets its DTO. Everything else throws, which is what lets `ProxyClient` call this and then simply
|
|
86
|
+
* stop — a failure response can never fall through to a typed caller.
|
|
87
|
+
*
|
|
88
|
+
* The rule applied is {@link ReceivedApiErrorRule}, shared verbatim with IPC: 4xx is MY bug, 5xx
|
|
89
|
+
* is THEIRS, an `ApiDependencyError` is already attributed and passes through, 266 is the end
|
|
90
|
+
* user's own answer.
|
|
91
|
+
*/
|
|
92
|
+
fromWire(response) {
|
|
93
|
+
const code = response.status.code;
|
|
94
|
+
if (code >= 200 && code < 300 && code !== 266) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const decoded = this.decodeBody(response);
|
|
98
|
+
const message = decoded?.message ?? this.fallbackMessage(response.body, response.status.reason);
|
|
99
|
+
throw ReceivedApiErrorRule_1.ReceivedApiErrorRule.adapt(code, message, decoded);
|
|
100
|
+
}
|
|
101
|
+
/** The reason phrase webpieces writes beside a status it chose itself. */
|
|
102
|
+
genericMessage(code) {
|
|
103
|
+
return this.genericMessages.get(code) ?? 'Request Failed';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A webpieces peer sends an {@link ApiErrorPayload}; a foreign responder sends anything at all.
|
|
107
|
+
* The status is only believed when it AGREES with the kind the body claims — a body saying
|
|
108
|
+
* `not-found` beside a 500 is a mismatched response, not a 404, and falls back to the status.
|
|
109
|
+
*/
|
|
110
|
+
decodeBody(response) {
|
|
111
|
+
if (!ApiErrorCodec_1.ApiErrorCodec.isPayload(response.body)) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const decoded = ApiErrorCodec_1.ApiErrorCodec.decode(response.body);
|
|
115
|
+
if (!ApiErrorHttpStatus_1.ApiErrorHttpStatus.hasCode(decoded)) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
// `statusCode` is the 'coded' kind's own status, the one thing the shared table cannot know.
|
|
119
|
+
const statusCode = decoded instanceof ApiError_1.ApiCodedError ? decoded.statusCode : undefined;
|
|
120
|
+
const declared = ApiErrorHttpStatus_1.ApiErrorHttpStatus.codeFor(decoded.kind, statusCode);
|
|
121
|
+
return declared === response.status.code ? decoded : undefined;
|
|
122
|
+
}
|
|
123
|
+
// webpieces-disable no-any-unknown -- HTTP response bodies are app-owned until this boundary safely inspects them
|
|
124
|
+
fallbackMessage(body, reason) {
|
|
125
|
+
if (typeof body === 'object' && body !== null) {
|
|
126
|
+
const message = Object.getOwnPropertyDescriptor(body, 'message')?.value;
|
|
127
|
+
if (typeof message === 'string' && message.length > 0)
|
|
128
|
+
return message.slice(0, 4096);
|
|
129
|
+
}
|
|
130
|
+
return reason || 'Request Failed';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
exports.WebpiecesDefaultErrorTranslator = WebpiecesDefaultErrorTranslator;
|
|
134
|
+
/** Process-wide built-in instance — stateless, so one shared instance is enough. */
|
|
135
|
+
exports.WEBPIECES_DEFAULT_ERROR_TRANSLATOR = new WebpiecesDefaultErrorTranslator();
|
|
136
|
+
//# sourceMappingURL=WebpiecesDefaultErrorTranslator.js.map
|