@webpieces/core-util 0.4.957 → 0.4.959

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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/http/ApiCallInfo.d.ts +71 -11
  3. package/src/http/ApiCallInfo.js +81 -7
  4. package/src/http/ApiCallInfo.js.map +1 -1
  5. package/src/http/ContextMgr.js +12 -2
  6. package/src/http/ContextMgr.js.map +1 -1
  7. package/src/http/DeletedAuthCompileAssertions.js +3 -1
  8. package/src/http/DeletedAuthCompileAssertions.js.map +1 -1
  9. package/src/http/DestinationTrust.d.ts +33 -4
  10. package/src/http/DestinationTrust.js +54 -11
  11. package/src/http/DestinationTrust.js.map +1 -1
  12. package/src/http/EndpointCompileAssertions.js +12 -6
  13. package/src/http/EndpointCompileAssertions.js.map +1 -1
  14. package/src/http/ExternalContextKeys.d.ts +31 -0
  15. package/src/http/ExternalContextKeys.js +77 -0
  16. package/src/http/ExternalContextKeys.js.map +1 -0
  17. package/src/http/HttpContract.d.ts +45 -7
  18. package/src/http/HttpContract.js +84 -6
  19. package/src/http/HttpContract.js.map +1 -1
  20. package/src/http/HttpEndpointOptions.d.ts +22 -3
  21. package/src/http/HttpEndpointOptions.js +8 -1
  22. package/src/http/HttpEndpointOptions.js.map +1 -1
  23. package/src/http/LogApiCall.d.ts +4 -1
  24. package/src/http/LogApiCall.js +7 -5
  25. package/src/http/LogApiCall.js.map +1 -1
  26. package/src/http/QueryParams.d.ts +47 -0
  27. package/src/http/QueryParams.js +89 -0
  28. package/src/http/QueryParams.js.map +1 -0
  29. package/src/http/RouteMetadata.d.ts +16 -2
  30. package/src/http/RouteMetadata.js +19 -3
  31. package/src/http/RouteMetadata.js.map +1 -1
  32. package/src/http/RouteMetadataFactory.d.ts +25 -0
  33. package/src/http/RouteMetadataFactory.js +97 -12
  34. package/src/http/RouteMetadataFactory.js.map +1 -1
  35. package/src/http/WpAuthApiKeyCompileAssertions.js +2 -2
  36. package/src/http/WpAuthApiKeyCompileAssertions.js.map +1 -1
  37. package/src/http/api-kind.d.ts +3 -2
  38. package/src/http/api-kind.js +5 -4
  39. package/src/http/api-kind.js.map +1 -1
  40. package/src/http/auth-mode.d.ts +8 -2
  41. package/src/http/auth-mode.js +11 -5
  42. package/src/http/auth-mode.js.map +1 -1
  43. package/src/http/decorators.d.ts +39 -21
  44. package/src/http/decorators.js +69 -37
  45. package/src/http/decorators.js.map +1 -1
  46. package/src/http/external-caller.d.ts +12 -17
  47. package/src/http/external-caller.js +12 -17
  48. package/src/http/external-caller.js.map +1 -1
  49. package/src/http/http-parameter-decorators.d.ts +17 -3
  50. package/src/http/http-parameter-decorators.js +27 -6
  51. package/src/http/http-parameter-decorators.js.map +1 -1
  52. package/src/index.d.ts +11 -7
  53. package/src/index.js +30 -12
  54. package/src/index.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/core-util",
3
- "version": "0.4.957",
3
+ "version": "0.4.959",
4
4
  "description": "Utility functions for WebPieces - works in browser and Node.js",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -1,4 +1,5 @@
1
1
  import { ApiMethodInfo } from './ApiMethodInfo';
2
+ import { MaskSpec } from './LogFieldMask';
2
3
  /**
3
4
  * ApiCallInfo - the structured tag stamped into RequestContext around every API call
4
5
  * (by {@link LogApiCallImpl}), so ANY log line emitted during the call inherits a filterable
@@ -15,6 +16,7 @@ import { ApiMethodInfo } from './ApiMethodInfo';
15
16
  * - `jsonPayload.api.responseSize>100000` — the fat responses (the ones that get chunked)
16
17
  * - `jsonPayload.api.responseCount>100` — responses containing many logical items
17
18
  * - `jsonPayload.api:*` — "API traffic only" (tracing + the recorder)
19
+ * - `jsonPayload.api.http.status=404` — an HTTP client call the vendor answered 404 (#1339)
18
20
  *
19
21
  * IMPORTANT: the field names here (and on the nested {@link ApiMethodInfo}) ARE the GCP field names —
20
22
  * rename a field and the filter renames with it. The identity lives NESTED under `api.method`
@@ -47,6 +49,46 @@ export type ApiCallDirection = 'request' | 'response';
47
49
  export type ApiResult = 'success' | 'failure';
48
50
  /** Re-exported from {@link ApiMethodInfo} (its true home) so existing `ApiSide` imports keep working. */
49
51
  export type { ApiSide } from './ApiMethodInfo';
52
+ /**
53
+ * The HTTP facts of ONE client call's last transmission — surfaces as `jsonPayload.api.http.*`.
54
+ * Filled ONLY by the HTTP client (node and browser), which is the one layer that has a verb, a url
55
+ * and a status; in-process, pubsub and cloud-task calls leave {@link ApiCallInfo.http} unset. It
56
+ * exists because a vendor's 404 and its 500 were indistinguishable in the logs when only the DTOs
57
+ * were recorded.
58
+ *
59
+ * Never headers or tokens: the url is MASKED ({@link maskUrl}) before it gets here.
60
+ */
61
+ export declare class HttpCallFacts {
62
+ /** The HTTP verb, e.g. `GET`. */
63
+ readonly method: string;
64
+ /** The final absolute url, masked: no userinfo, and `@MaskLog` field names' query values redacted. */
65
+ readonly url: string;
66
+ /** The status of the last response received. */
67
+ readonly status: number;
68
+ constructor(
69
+ /** The HTTP verb, e.g. `GET`. */
70
+ method: string,
71
+ /** The final absolute url, masked: no userinfo, and `@MaskLog` field names' query values redacted. */
72
+ url: string,
73
+ /** The status of the last response received. */
74
+ status: number);
75
+ /**
76
+ * The url as it may be logged: any `user:password@` userinfo is dropped, and the value of every
77
+ * query key the endpoint's `@MaskLog` names is replaced with `*****` (a `?token=` the DTO mask
78
+ * already hides must not reappear in the url).
79
+ */
80
+ static maskUrl(url: string, mask: MaskSpec | undefined): string;
81
+ private static decode;
82
+ }
83
+ /**
84
+ * The per-call slot the HTTP client fills with {@link HttpCallFacts} as each response arrives, and
85
+ * {@link LogApiCallImpl} reads when it stamps the response tag. A holder rather than a return value
86
+ * because the facts are known DEEP in the transport while the tag is written at the top of the call;
87
+ * the last transmission (after redirects, a 401 refresh or a retry) wins.
88
+ */
89
+ export declare class HttpCallFactsHolder {
90
+ facts: HttpCallFacts | undefined;
91
+ }
50
92
  export declare class ApiCallInfo {
51
93
  /** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
52
94
  * `jsonPayload.api.method`. */
@@ -59,11 +101,13 @@ export declare class ApiCallInfo {
59
101
  * request has no duration yet. Present on BOTH the success and failure paths, so
60
102
  * `jsonPayload.api.durationMs>1000 AND api.result="failure"` finds slow failures.
61
103
  *
62
- * There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over
63
- * in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP
64
- * status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is
65
- * the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by
66
- * portable Error TYPE rather than by status code.
104
+ * There is deliberately no top-level `statusCode` beside this. LogApiCall runs deep in the
105
+ * stack over in-process calls, pubsub handlers, and cloud-task enqueues — none of which have
106
+ * an HTTP status — and business logic must not know about HTTP. `result` (see {@link ApiResult})
107
+ * is the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by
108
+ * portable Error TYPE rather than by status code. That decision STANDS (#1339): the HTTP
109
+ * client's status lives in the optional, nested {@link http} — present only where there IS an
110
+ * HTTP exchange, so nothing transport-neutral ever reads it.
67
111
  */
68
112
  readonly durationMs?: number | undefined;
69
113
  /**
@@ -81,6 +125,13 @@ export declare class ApiCallInfo {
81
125
  readonly responseSize?: number | undefined;
82
126
  /** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */
83
127
  readonly responseCount?: number | undefined;
128
+ /**
129
+ * The HTTP verb, masked url and status of the call's last transmission — `jsonPayload.api.http.*`.
130
+ * RESPONSE tag only, and only for a call made by the HTTP client (success AND failure, so
131
+ * `api.result="failure" AND api.http.status=429` finds a vendor throttling us). Unset for
132
+ * in-process, pubsub and cloud-task calls. See {@link HttpCallFacts}.
133
+ */
134
+ readonly http?: HttpCallFacts | undefined;
84
135
  constructor(
85
136
  /** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
86
137
  * `jsonPayload.api.method`. */
@@ -92,11 +143,13 @@ export declare class ApiCallInfo {
92
143
  * request has no duration yet. Present on BOTH the success and failure paths, so
93
144
  * `jsonPayload.api.durationMs>1000 AND api.result="failure"` finds slow failures.
94
145
  *
95
- * There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over
96
- * in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP
97
- * status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is
98
- * the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by
99
- * portable Error TYPE rather than by status code.
146
+ * There is deliberately no top-level `statusCode` beside this. LogApiCall runs deep in the
147
+ * stack over in-process calls, pubsub handlers, and cloud-task enqueues — none of which have
148
+ * an HTTP status — and business logic must not know about HTTP. `result` (see {@link ApiResult})
149
+ * is the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by
150
+ * portable Error TYPE rather than by status code. That decision STANDS (#1339): the HTTP
151
+ * client's status lives in the optional, nested {@link http} — present only where there IS an
152
+ * HTTP exchange, so nothing transport-neutral ever reads it.
100
153
  */
101
154
  durationMs?: number | undefined,
102
155
  /**
@@ -113,5 +166,12 @@ export declare class ApiCallInfo {
113
166
  * a thrown error produced no response body to measure. Total size, pre-chunking. */
114
167
  responseSize?: number | undefined,
115
168
  /** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */
116
- responseCount?: number | undefined);
169
+ responseCount?: number | undefined,
170
+ /**
171
+ * The HTTP verb, masked url and status of the call's last transmission — `jsonPayload.api.http.*`.
172
+ * RESPONSE tag only, and only for a call made by the HTTP client (success AND failure, so
173
+ * `api.result="failure" AND api.http.status=429` finds a vendor throttling us). Unset for
174
+ * in-process, pubsub and cloud-task calls. See {@link HttpCallFacts}.
175
+ */
176
+ http?: HttpCallFacts | undefined);
117
177
  }
@@ -1,6 +1,69 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApiCallInfo = void 0;
3
+ exports.ApiCallInfo = exports.HttpCallFactsHolder = exports.HttpCallFacts = void 0;
4
+ /**
5
+ * The HTTP facts of ONE client call's last transmission — surfaces as `jsonPayload.api.http.*`.
6
+ * Filled ONLY by the HTTP client (node and browser), which is the one layer that has a verb, a url
7
+ * and a status; in-process, pubsub and cloud-task calls leave {@link ApiCallInfo.http} unset. It
8
+ * exists because a vendor's 404 and its 500 were indistinguishable in the logs when only the DTOs
9
+ * were recorded.
10
+ *
11
+ * Never headers or tokens: the url is MASKED ({@link maskUrl}) before it gets here.
12
+ */
13
+ class HttpCallFacts {
14
+ method;
15
+ url;
16
+ status;
17
+ constructor(
18
+ /** The HTTP verb, e.g. `GET`. */
19
+ method,
20
+ /** The final absolute url, masked: no userinfo, and `@MaskLog` field names' query values redacted. */
21
+ url,
22
+ /** The status of the last response received. */
23
+ status) {
24
+ this.method = method;
25
+ this.url = url;
26
+ this.status = status;
27
+ }
28
+ /**
29
+ * The url as it may be logged: any `user:password@` userinfo is dropped, and the value of every
30
+ * query key the endpoint's `@MaskLog` names is replaced with `*****` (a `?token=` the DTO mask
31
+ * already hides must not reappear in the url).
32
+ */
33
+ // webpieces-disable no-function-outside-class -- pure static helper beside the data it builds
34
+ static maskUrl(url, mask) {
35
+ const withoutUserInfo = url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@?#]*@/i, '$1');
36
+ const queryAt = withoutUserInfo.indexOf('?');
37
+ if (queryAt < 0 || mask === undefined)
38
+ return withoutUserInfo;
39
+ const query = withoutUserInfo
40
+ .slice(queryAt + 1)
41
+ .split('&')
42
+ .map((pair) => {
43
+ const equals = pair.indexOf('=');
44
+ const key = equals < 0 ? pair : pair.slice(0, equals);
45
+ return mask.modeFor(HttpCallFacts.decode(key)) === undefined ? pair : `${key}=*****`;
46
+ })
47
+ .join('&');
48
+ return `${withoutUserInfo.slice(0, queryAt)}?${query}`;
49
+ }
50
+ // webpieces-disable no-function-outside-class -- pure static helper beside the data it builds
51
+ static decode(key) {
52
+ // A malformed escape is logged as written; it can never be a masked field name anyway.
53
+ return /%(?![0-9a-f]{2})/i.test(key) ? key : decodeURIComponent(key);
54
+ }
55
+ }
56
+ exports.HttpCallFacts = HttpCallFacts;
57
+ /**
58
+ * The per-call slot the HTTP client fills with {@link HttpCallFacts} as each response arrives, and
59
+ * {@link LogApiCallImpl} reads when it stamps the response tag. A holder rather than a return value
60
+ * because the facts are known DEEP in the transport while the tag is written at the top of the call;
61
+ * the last transmission (after redirects, a 401 refresh or a retry) wins.
62
+ */
63
+ class HttpCallFactsHolder {
64
+ facts = undefined;
65
+ }
66
+ exports.HttpCallFactsHolder = HttpCallFactsHolder;
4
67
  class ApiCallInfo {
5
68
  method;
6
69
  type;
@@ -9,6 +72,7 @@ class ApiCallInfo {
9
72
  requestSize;
10
73
  responseSize;
11
74
  responseCount;
75
+ http;
12
76
  constructor(
13
77
  /** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
14
78
  * `jsonPayload.api.method`. */
@@ -20,11 +84,13 @@ class ApiCallInfo {
20
84
  * request has no duration yet. Present on BOTH the success and failure paths, so
21
85
  * `jsonPayload.api.durationMs>1000 AND api.result="failure"` finds slow failures.
22
86
  *
23
- * There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over
24
- * in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP
25
- * status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is
26
- * the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by
27
- * portable Error TYPE rather than by status code.
87
+ * There is deliberately no top-level `statusCode` beside this. LogApiCall runs deep in the
88
+ * stack over in-process calls, pubsub handlers, and cloud-task enqueues — none of which have
89
+ * an HTTP status — and business logic must not know about HTTP. `result` (see {@link ApiResult})
90
+ * is the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by
91
+ * portable Error TYPE rather than by status code. That decision STANDS (#1339): the HTTP
92
+ * client's status lives in the optional, nested {@link http} — present only where there IS an
93
+ * HTTP exchange, so nothing transport-neutral ever reads it.
28
94
  */
29
95
  durationMs,
30
96
  /**
@@ -41,7 +107,14 @@ class ApiCallInfo {
41
107
  * a thrown error produced no response body to measure. Total size, pre-chunking. */
42
108
  responseSize,
43
109
  /** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */
44
- responseCount) {
110
+ responseCount,
111
+ /**
112
+ * The HTTP verb, masked url and status of the call's last transmission — `jsonPayload.api.http.*`.
113
+ * RESPONSE tag only, and only for a call made by the HTTP client (success AND failure, so
114
+ * `api.result="failure" AND api.http.status=429` finds a vendor throttling us). Unset for
115
+ * in-process, pubsub and cloud-task calls. See {@link HttpCallFacts}.
116
+ */
117
+ http) {
45
118
  this.method = method;
46
119
  this.type = type;
47
120
  this.result = result;
@@ -49,6 +122,7 @@ class ApiCallInfo {
49
122
  this.requestSize = requestSize;
50
123
  this.responseSize = responseSize;
51
124
  this.responseCount = responseCount;
125
+ this.http = http;
52
126
  }
53
127
  }
54
128
  exports.ApiCallInfo = ApiCallInfo;
@@ -1 +1 @@
1
- {"version":3,"file":"ApiCallInfo.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ApiCallInfo.ts"],"names":[],"mappings":";;;AAsDA,MAAa,WAAW;IAIP;IACA;IAEA;IAYA;IAUA;IAGA;IAEA;IAjCb;IACI;oCACgC;IACvB,MAAqB,EACrB,IAAsB;IAC/B,sDAAsD;IAC7C,MAAkB;IAC3B;;;;;;;;;;OAUG;IACM,UAAmB;IAC5B;;;;;;;;OAQG;IACM,WAAoB;IAC7B;yFACqF;IAC5E,YAAqB;IAC9B,4FAA4F;IACnF,aAAsB;QA9BtB,WAAM,GAAN,MAAM,CAAe;QACrB,SAAI,GAAJ,IAAI,CAAkB;QAEtB,WAAM,GAAN,MAAM,CAAY;QAYlB,eAAU,GAAV,UAAU,CAAS;QAUnB,gBAAW,GAAX,WAAW,CAAS;QAGpB,iBAAY,GAAZ,YAAY,CAAS;QAErB,kBAAa,GAAb,aAAa,CAAS;IAChC,CAAC;CACP;AApCD,kCAoCC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * ApiCallInfo - the structured tag stamped into RequestContext around every API call\n * (by {@link LogApiCallImpl}), so ANY log line emitted during the call inherits a filterable\n * `api` object rather than only the req/resp text lines.\n *\n * The node logging backends (winston/bunyan) read this struct out of context via\n * `RequestContext.buildStructuredLogFields()` and emit it AS AN OBJECT under `jsonPayload.api`,\n * which unlocks GCP Cloud Logging filters like:\n * - `jsonPayload.api.method.side=\"client\"` — every outbound call this process made\n * - `jsonPayload.api.method.side=\"server\"` — every inbound call it handled\n * - `jsonPayload.api.method.apiClass=\"SaveApi\"` — one logical method, BOTH sides (client + server)\n * - `jsonPayload.api.result=\"failure\"` — failed exchanges only\n * - `jsonPayload.api.durationMs>1000` — slow calls, either side\n * - `jsonPayload.api.responseSize>100000` — the fat responses (the ones that get chunked)\n * - `jsonPayload.api.responseCount>100` — responses containing many logical items\n * - `jsonPayload.api:*` — \"API traffic only\" (tracing + the recorder)\n *\n * IMPORTANT: the field names here (and on the nested {@link ApiMethodInfo}) ARE the GCP field names —\n * rename a field and the filter renames with it. The identity lives NESTED under `api.method`\n * (`api.method.{side,apiClass,methodName,controllerName}`); `api.type` and `api.result` sit at the top.\n *\n * NOTE: the request `httpMethod`/`path` are NOT here — an inbound request stamps them as the separate\n * top-level logged keys `jsonPayload.httpMethod` / `jsonPayload.requestPath` (see\n * {@link WebpiecesCoreHeaders} + `RequestContextHeaders.fillFromRequest`). Outbound client calls have\n * no inbound path, so they carry only the `api` identity.\n *\n * Per-hop only: the underlying `API_CALL_INFO` ContextKey is NOT transferred over the wire, so a\n * downstream server stamps its own `side:'server'` rather than inheriting the caller's `side:'client'`.\n *\n * Per CLAUDE.md: data-only structures are classes, not interfaces.\n */\n\n/** Which half of the exchange this tag describes: the outgoing 'request' or the returning 'response'. */\n/**\n * Which HALF of one api call a log line describes.\n *\n * Renamed from `ApiType`, which collided with the `@ApiType(...)` CONTRACT decorator and read as if\n * it were about the kind of API rather than the direction of one line. Per\n * `.claude/rules/no-backwards-compat.md` the old spelling is deleted rather than aliased, so a caller\n * on the old name stops compiling and the error names this one.\n */\nexport type ApiCallDirection = 'request' | 'response';\n\n/**\n * Response outcome. 'success' covers 2xx AND user errors (400/401/403/404/266 — a successfully\n * handled \"you made a mistake\"); 'failure' is a genuine server error. `success` = {@link CallOutcome.EXPECTED} from {@link ClientRegistry.classify}.\n */\nexport type ApiResult = 'success' | 'failure';\n\n/** Re-exported from {@link ApiMethodInfo} (its true home) so existing `ApiSide` imports keep working. */\nexport type { ApiSide } from './ApiMethodInfo';\n\nexport class ApiCallInfo {\n constructor(\n /** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under\n * `jsonPayload.api.method`. */\n readonly method: ApiMethodInfo,\n readonly type: ApiCallDirection,\n /** Response only — undefined on the 'request' tag. */\n readonly result?: ApiResult,\n /**\n * Wall-clock milliseconds the call took. RESPONSE tag only (undefined on 'request') — a\n * request has no duration yet. Present on BOTH the success and failure paths, so\n * `jsonPayload.api.durationMs>1000 AND api.result=\"failure\"` finds slow failures.\n *\n * There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over\n * in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP\n * status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is\n * the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by\n * portable Error TYPE rather than by status code.\n */\n readonly durationMs?: number,\n /**\n * Bytes of the serialized request DTO. Stamped on BOTH tags: the 'request' tag reports it as\n * soon as it is known, and the 'response' tag repeats it so one record shows the whole\n * exchange (`api.requestSize` + `api.responseSize` without a join).\n *\n * This is the TOTAL size of the body, measured BEFORE any log chunking — chunking is a\n * transport concern handled by the GCP backends, and a body split across 3 records still\n * reports its one true size here.\n */\n readonly requestSize?: number,\n /** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —\n * a thrown error produced no response body to measure. Total size, pre-chunking. */\n readonly responseSize?: number,\n /** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */\n readonly responseCount?: number,\n ) {}\n}\n"]}
1
+ {"version":3,"file":"ApiCallInfo.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ApiCallInfo.ts"],"names":[],"mappings":";;;AAwDA;;;;;;;;GAQG;AACH,MAAa,aAAa;IAGT;IAEA;IAEA;IANb;IACI,iCAAiC;IACxB,MAAc;IACvB,sGAAsG;IAC7F,GAAW;IACpB,gDAAgD;IACvC,MAAc;QAJd,WAAM,GAAN,MAAM,CAAQ;QAEd,QAAG,GAAH,GAAG,CAAQ;QAEX,WAAM,GAAN,MAAM,CAAQ;IACxB,CAAC;IAEJ;;;;OAIG;IACH,8FAA8F;IAC9F,MAAM,CAAC,OAAO,CAAC,GAAW,EAAE,IAA0B;QAClD,MAAM,eAAe,GAAG,GAAG,CAAC,OAAO,CAAC,qCAAqC,EAAE,IAAI,CAAC,CAAC;QACjF,MAAM,OAAO,GAAG,eAAe,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC7C,IAAI,OAAO,GAAG,CAAC,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,eAAe,CAAC;QAC9D,MAAM,KAAK,GAAG,eAAe;aACxB,KAAK,CAAC,OAAO,GAAG,CAAC,CAAC;aAClB,KAAK,CAAC,GAAG,CAAC;aACV,GAAG,CAAC,CAAC,IAAY,EAAE,EAAE;YAClB,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACjC,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;YACtD,OAAO,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,QAAQ,CAAC;QACzF,CAAC,CAAC;aACD,IAAI,CAAC,GAAG,CAAC,CAAC;QACf,OAAO,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,IAAI,KAAK,EAAE,CAAC;IAC3D,CAAC;IAED,8FAA8F;IACtF,MAAM,CAAC,MAAM,CAAC,GAAW;QAC7B,uFAAuF;QACvF,OAAO,mBAAmB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;IACzE,CAAC;CACJ;AArCD,sCAqCC;AAED;;;;;GAKG;AACH,MAAa,mBAAmB;IAC5B,KAAK,GAA8B,SAAS,CAAC;CAChD;AAFD,kDAEC;AAED,MAAa,WAAW;IAIP;IACA;IAEA;IAcA;IAUA;IAGA;IAEA;IAOA;IA1Cb;IACI;oCACgC;IACvB,MAAqB,EACrB,IAAsB;IAC/B,sDAAsD;IAC7C,MAAkB;IAC3B;;;;;;;;;;;;OAYG;IACM,UAAmB;IAC5B;;;;;;;;OAQG;IACM,WAAoB;IAC7B;yFACqF;IAC5E,YAAqB;IAC9B,4FAA4F;IACnF,aAAsB;IAC/B;;;;;OAKG;IACM,IAAoB;QAvCpB,WAAM,GAAN,MAAM,CAAe;QACrB,SAAI,GAAJ,IAAI,CAAkB;QAEtB,WAAM,GAAN,MAAM,CAAY;QAclB,eAAU,GAAV,UAAU,CAAS;QAUnB,gBAAW,GAAX,WAAW,CAAS;QAGpB,iBAAY,GAAZ,YAAY,CAAS;QAErB,kBAAa,GAAb,aAAa,CAAS;QAOtB,SAAI,GAAJ,IAAI,CAAgB;IAC9B,CAAC;CACP;AA7CD,kCA6CC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\nimport { MaskSpec } from './LogFieldMask';\n\n/**\n * ApiCallInfo - the structured tag stamped into RequestContext around every API call\n * (by {@link LogApiCallImpl}), so ANY log line emitted during the call inherits a filterable\n * `api` object rather than only the req/resp text lines.\n *\n * The node logging backends (winston/bunyan) read this struct out of context via\n * `RequestContext.buildStructuredLogFields()` and emit it AS AN OBJECT under `jsonPayload.api`,\n * which unlocks GCP Cloud Logging filters like:\n * - `jsonPayload.api.method.side=\"client\"` — every outbound call this process made\n * - `jsonPayload.api.method.side=\"server\"` — every inbound call it handled\n * - `jsonPayload.api.method.apiClass=\"SaveApi\"` — one logical method, BOTH sides (client + server)\n * - `jsonPayload.api.result=\"failure\"` — failed exchanges only\n * - `jsonPayload.api.durationMs>1000` — slow calls, either side\n * - `jsonPayload.api.responseSize>100000` — the fat responses (the ones that get chunked)\n * - `jsonPayload.api.responseCount>100` — responses containing many logical items\n * - `jsonPayload.api:*` — \"API traffic only\" (tracing + the recorder)\n * - `jsonPayload.api.http.status=404` — an HTTP client call the vendor answered 404 (#1339)\n *\n * IMPORTANT: the field names here (and on the nested {@link ApiMethodInfo}) ARE the GCP field names —\n * rename a field and the filter renames with it. The identity lives NESTED under `api.method`\n * (`api.method.{side,apiClass,methodName,controllerName}`); `api.type` and `api.result` sit at the top.\n *\n * NOTE: the request `httpMethod`/`path` are NOT here — an inbound request stamps them as the separate\n * top-level logged keys `jsonPayload.httpMethod` / `jsonPayload.requestPath` (see\n * {@link WebpiecesCoreHeaders} + `RequestContextHeaders.fillFromRequest`). Outbound client calls have\n * no inbound path, so they carry only the `api` identity.\n *\n * Per-hop only: the underlying `API_CALL_INFO` ContextKey is NOT transferred over the wire, so a\n * downstream server stamps its own `side:'server'` rather than inheriting the caller's `side:'client'`.\n *\n * Per CLAUDE.md: data-only structures are classes, not interfaces.\n */\n\n/** Which half of the exchange this tag describes: the outgoing 'request' or the returning 'response'. */\n/**\n * Which HALF of one api call a log line describes.\n *\n * Renamed from `ApiType`, which collided with the `@ApiType(...)` CONTRACT decorator and read as if\n * it were about the kind of API rather than the direction of one line. Per\n * `.claude/rules/no-backwards-compat.md` the old spelling is deleted rather than aliased, so a caller\n * on the old name stops compiling and the error names this one.\n */\nexport type ApiCallDirection = 'request' | 'response';\n\n/**\n * Response outcome. 'success' covers 2xx AND user errors (400/401/403/404/266 — a successfully\n * handled \"you made a mistake\"); 'failure' is a genuine server error. `success` = {@link CallOutcome.EXPECTED} from {@link ClientRegistry.classify}.\n */\nexport type ApiResult = 'success' | 'failure';\n\n/** Re-exported from {@link ApiMethodInfo} (its true home) so existing `ApiSide` imports keep working. */\nexport type { ApiSide } from './ApiMethodInfo';\n\n/**\n * The HTTP facts of ONE client call's last transmission — surfaces as `jsonPayload.api.http.*`.\n * Filled ONLY by the HTTP client (node and browser), which is the one layer that has a verb, a url\n * and a status; in-process, pubsub and cloud-task calls leave {@link ApiCallInfo.http} unset. It\n * exists because a vendor's 404 and its 500 were indistinguishable in the logs when only the DTOs\n * were recorded.\n *\n * Never headers or tokens: the url is MASKED ({@link maskUrl}) before it gets here.\n */\nexport class HttpCallFacts {\n constructor(\n /** The HTTP verb, e.g. `GET`. */\n readonly method: string,\n /** The final absolute url, masked: no userinfo, and `@MaskLog` field names' query values redacted. */\n readonly url: string,\n /** The status of the last response received. */\n readonly status: number,\n ) {}\n\n /**\n * The url as it may be logged: any `user:password@` userinfo is dropped, and the value of every\n * query key the endpoint's `@MaskLog` names is replaced with `*****` (a `?token=` the DTO mask\n * already hides must not reappear in the url).\n */\n // webpieces-disable no-function-outside-class -- pure static helper beside the data it builds\n static maskUrl(url: string, mask: MaskSpec | undefined): string {\n const withoutUserInfo = url.replace(/^([a-z][a-z0-9+.-]*:\\/\\/)[^/@?#]*@/i, '$1');\n const queryAt = withoutUserInfo.indexOf('?');\n if (queryAt < 0 || mask === undefined) return withoutUserInfo;\n const query = withoutUserInfo\n .slice(queryAt + 1)\n .split('&')\n .map((pair: string) => {\n const equals = pair.indexOf('=');\n const key = equals < 0 ? pair : pair.slice(0, equals);\n return mask.modeFor(HttpCallFacts.decode(key)) === undefined ? pair : `${key}=*****`;\n })\n .join('&');\n return `${withoutUserInfo.slice(0, queryAt)}?${query}`;\n }\n\n // webpieces-disable no-function-outside-class -- pure static helper beside the data it builds\n private static decode(key: string): string {\n // A malformed escape is logged as written; it can never be a masked field name anyway.\n return /%(?![0-9a-f]{2})/i.test(key) ? key : decodeURIComponent(key);\n }\n}\n\n/**\n * The per-call slot the HTTP client fills with {@link HttpCallFacts} as each response arrives, and\n * {@link LogApiCallImpl} reads when it stamps the response tag. A holder rather than a return value\n * because the facts are known DEEP in the transport while the tag is written at the top of the call;\n * the last transmission (after redirects, a 401 refresh or a retry) wins.\n */\nexport class HttpCallFactsHolder {\n facts: HttpCallFacts | undefined = undefined;\n}\n\nexport class ApiCallInfo {\n constructor(\n /** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under\n * `jsonPayload.api.method`. */\n readonly method: ApiMethodInfo,\n readonly type: ApiCallDirection,\n /** Response only — undefined on the 'request' tag. */\n readonly result?: ApiResult,\n /**\n * Wall-clock milliseconds the call took. RESPONSE tag only (undefined on 'request') — a\n * request has no duration yet. Present on BOTH the success and failure paths, so\n * `jsonPayload.api.durationMs>1000 AND api.result=\"failure\"` finds slow failures.\n *\n * There is deliberately no top-level `statusCode` beside this. LogApiCall runs deep in the\n * stack over in-process calls, pubsub handlers, and cloud-task enqueues — none of which have\n * an HTTP status — and business logic must not know about HTTP. `result` (see {@link ApiResult})\n * is the transport-neutral outcome, exactly as {@link ClientRegistry.classify} (a {@link CallOutcome}) classifies by\n * portable Error TYPE rather than by status code. That decision STANDS (#1339): the HTTP\n * client's status lives in the optional, nested {@link http} — present only where there IS an\n * HTTP exchange, so nothing transport-neutral ever reads it.\n */\n readonly durationMs?: number,\n /**\n * Bytes of the serialized request DTO. Stamped on BOTH tags: the 'request' tag reports it as\n * soon as it is known, and the 'response' tag repeats it so one record shows the whole\n * exchange (`api.requestSize` + `api.responseSize` without a join).\n *\n * This is the TOTAL size of the body, measured BEFORE any log chunking — chunking is a\n * transport concern handled by the GCP backends, and a body split across 3 records still\n * reports its one true size here.\n */\n readonly requestSize?: number,\n /** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —\n * a thrown error produced no response body to measure. Total size, pre-chunking. */\n readonly responseSize?: number,\n /** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */\n readonly responseCount?: number,\n /**\n * The HTTP verb, masked url and status of the call's last transmission — `jsonPayload.api.http.*`.\n * RESPONSE tag only, and only for a call made by the HTTP client (success AND failure, so\n * `api.result=\"failure\" AND api.http.status=429` finds a vendor throttling us). Unset for\n * in-process, pubsub and cloud-task calls. See {@link HttpCallFacts}.\n */\n readonly http?: HttpCallFacts,\n ) {}\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ContextMgr = void 0;
4
+ const ApiError_1 = require("../errors/ApiError");
4
5
  const HeaderRegistry_1 = require("./HeaderRegistry");
5
6
  const ServiceInfo_1 = require("./ServiceInfo");
6
7
  const WebpiecesCoreHeaders_1 = require("./WebpiecesCoreHeaders");
@@ -59,7 +60,9 @@ class ContextMgr {
59
60
  */
60
61
  buildOutboundHeaders(destination) {
61
62
  const outbound = new Map();
62
- for (const key of HeaderRegistry_1.HeaderRegistry.get().getTransferredKeys()) {
63
+ // An EXTERNAL_OUTBOUND destination lists EXACTLY what it sends, and each listed key is required.
64
+ const listed = destination.listedKeys();
65
+ for (const key of listed ?? HeaderRegistry_1.HeaderRegistry.get().getTransferredKeys()) {
63
66
  if (!destination.allows(key)) {
64
67
  continue;
65
68
  }
@@ -67,6 +70,13 @@ class ContextMgr {
67
70
  if (value !== undefined && value !== null && value !== '') {
68
71
  outbound.set(key.httpHeader, value);
69
72
  }
73
+ else if (listed !== undefined) {
74
+ throw new ApiError_1.ApiImplementationError(`Context key '${key.name}' (header ${key.httpHeader}) is listed in @ExternalContextKeys for this ` +
75
+ `outbound call but has no value in the context store. Set it before calling — nothing was sent.`);
76
+ }
77
+ }
78
+ if (!destination.allows(WebpiecesCoreHeaders_1.WebpiecesCoreHeaders.CLIENT_VERSION)) {
79
+ return outbound;
70
80
  }
71
81
  // CLIENT_VERSION: a browser ORIGINATES a call, so it sends its OWN app build version (from
72
82
  // ServiceInfo) and the server logs which client build called it — same rule as the server-side
@@ -103,7 +113,7 @@ class ContextMgr {
103
113
  if (value === null || value === '') {
104
114
  continue;
105
115
  }
106
- if (!destination.allows(key) || key.isTrusted()) {
116
+ if (!destination.believesResponse(key) || key.isTrusted()) {
107
117
  // DROPPED: a browser proves nothing, so it may never hold a trusted value.
108
118
  continue;
109
119
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ContextMgr.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ContextMgr.ts"],"names":[],"mappings":";;;AAGA,qDAAkD;AAClD,+CAA4C;AAC5C,iEAA8D;AAE9D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAa,UAAU;IAGC;IAFpB;IACI,iFAAiF;IACjE,YAA0B;QAA1B,iBAAY,GAAZ,YAAY,CAAc;IAC3C,CAAC;IAEJ;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,oBAAoB,CAAC,WAA6B;QAC9C,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;QAE3C,KAAK,MAAM,GAAG,IAAI,+BAAc,CAAC,GAAG,EAAE,CAAC,kBAAkB,EAAE,EAAE,CAAC;YAC1D,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC3B,SAAS;YACb,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC1C,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;gBACxD,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,UAAW,EAAE,KAAK,CAAC,CAAC;YACzC,CAAC;QACL,CAAC;QAED,2FAA2F;QAC3F,+FAA+F;QAC/F,gGAAgG;QAChG,MAAM,SAAS,GAAG,yBAAW,CAAC,UAAU,EAAE,CAAC;QAC3C,MAAM,mBAAmB,GAAG,2CAAoB,CAAC,cAAc,CAAC,UAAW,CAAC;QAC5E,IAAI,SAAS,EAAE,CAAC;YACZ,QAAQ,CAAC,GAAG,CAAC,mBAAmB,EAAE,SAAS,CAAC,CAAC;QACjD,CAAC;aAAM,CAAC;YACJ,QAAQ,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;QACzC,CAAC;QAED,OAAO,QAAQ,CAAC;IACpB,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACH,qBAAqB,CAAC,OAAgB,EAAE,WAA6B;QACjE,KAAK,MAAM,GAAG,IAAI,+BAAc,CAAC,GAAG,EAAE,CAAC,mBAAmB,EAAE,EAAE,CAAC;YAC3D,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,cAAe,CAAC,CAAC;YAC/C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;gBACjC,SAAS;YACb,CAAC;YACD,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,SAAS,EAAE,EAAE,CAAC;gBAC9C,2EAA2E;gBAC3E,SAAS;YACb,CAAC;YACD,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,kBAAkB,CAAC,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;QAClG,CAAC;IACL,CAAC;CACJ;AArFD,gCAqFC","sourcesContent":["import { ContextKey } from '../ContextKey';\nimport { ContextStore } from './ContextWriter';\nimport { DestinationTrust } from './DestinationTrust';\nimport { HeaderRegistry } from './HeaderRegistry';\nimport { ServiceInfo } from './ServiceInfo';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\n\n/**\n * ContextMgr - propagates the magic context onto outbound BROWSER requests.\n *\n * BROWSER-ONLY. Only @webpieces/http-client-browser may name this class. The server reads\n * `RequestContext` directly through `RequestContextHeaders` (in @webpieces/core-context) — a\n * `ContextReader` indirection buys a server nothing, because there is exactly one right answer there.\n *\n * Browsers have no AsyncLocalStorage, so the app holds a `MutableContextStore` and sets values as\n * they become known (login token, tenant). Every transferred key (httpHeader set) in the GLOBAL\n * {@link HeaderRegistry} is read from it and added to outbound requests. The registry is a process\n * global configured once at startup (like LogManager) and is browser-safe: it is the key SCHEMA,\n * not the value store.\n *\n * Example usage:\n * ```typescript\n * // startup, before bootstrap:\n * HeaderRegistry.configure(CompanyHeaders.ALL_HEADERS, true);\n *\n * const store = new MutableContextStore();\n * const factory = new ClientHttpBrowserFactory(store);\n * const client = factory.createRpcClient(SaveApi, new ClientConfig('http://api.example.com', ClientRole.SERVER));\n * ```\n */\nexport class ContextMgr {\n constructor(\n /** The app-held store context-key values are read from AND written back into. */\n public readonly contextStore: ContextStore,\n ) {}\n\n /**\n * Build the headers to send on an outbound request: every transferred key (httpHeader set)\n * with a non-empty value THAT THIS DESTINATION MAY RECEIVE, emitted under its `httpHeader`\n * wire name.\n *\n * NO request-id chaining. A browser ORIGINATES a trace — it has no inbound request to point\n * back at. If the app puts an `x-request-id` on the store it goes out as-is, and the server's\n * inbound transfer adopts it as hop 1's own id. Chaining is a server concern; see\n * RequestContextHeaders.\n *\n * `destination` gates TRUSTED keys exactly as it does on the server side (see\n * {@link DestinationTrust}). In practice a browser never reaches the permissive branch — twice\n * over: `BrowserProxyClient.assertEndpointSupported` refuses to bind an OIDC (`oidcSameProject()` /\n * `oidcSvcs(...)`) or `sharedSecret(...)` contract at all, so every browser destination is `jwt()` or `@WpAuthPublic`.\n * The rule is applied here anyway rather than argued away, because the OTHER guarantee people\n * reach for — \"`MutableContextStore.set` only accepts an untrusted key, so a browser store\n * cannot HOLD a trusted value\" — is true of that store and NOT of the seam: {@link ContextMgr}\n * takes any app-supplied {@link ContextStore}, whose `read` is handed an `AnyContextKey`. One\n * enforced rule in both builders beats a browser-only exemption resting on an implementation\n * detail of one implementation.\n *\n * Values are RAW (unmasked) — this map goes on the wire, not in logs.\n */\n buildOutboundHeaders(destination: DestinationTrust): Map<string, string> {\n const outbound = new Map<string, string>();\n\n for (const key of HeaderRegistry.get().getTransferredKeys()) {\n if (!destination.allows(key)) {\n continue;\n }\n const value = this.contextStore.read(key);\n if (value !== undefined && value !== null && value !== '') {\n outbound.set(key.httpHeader!, value);\n }\n }\n\n // CLIENT_VERSION: a browser ORIGINATES a call, so it sends its OWN app build version (from\n // ServiceInfo) and the server logs which client build called it — same rule as the server-side\n // RequestContextHeaders. Our version wins; absent if this app was never identified via setInfo.\n const myVersion = ServiceInfo.getVersion();\n const clientVersionHeader = WebpiecesCoreHeaders.CLIENT_VERSION.httpHeader!;\n if (myVersion) {\n outbound.set(clientVersionHeader, myVersion);\n } else {\n outbound.delete(clientVersionHeader);\n }\n\n return outbound;\n }\n\n /**\n * RESPONSE -> the store: every key that declares a `responseHeader` and appears on a response\n * this browser just received, merged into the app-held store per the key's own\n * {@link ContextKey.responseMerge}.\n *\n * The browser twin of `RequestContextHeaders.acceptResponseHeaders`, and the reason a value set\n * by a server reaches the page without the page naming a header.\n *\n * TRUSTED keys are DROPPED here, unconditionally and without consulting `destination`, and the\n * `if` is what makes that a compile-time fact rather than a convention: {@link ContextWriter.set}\n * takes an {@link AnyUntrustedContextKey}, so a browser store CANNOT hold a proven value. That is\n * the same rule as the outbound direction reaches by a different route — every browser\n * destination is `jwt()` or `@WpAuthPublic`, so `destination.allows` would refuse them\n * anyway — and `destination` is still threaded and still applied, so a future browser destination\n * that DID authenticate its caller does not silently start believing response headers.\n */\n acceptResponseHeaders(headers: Headers, destination: DestinationTrust): void {\n for (const key of HeaderRegistry.get().getResponseTxfrKeys()) {\n const value = headers.get(key.responseHeader!);\n if (value === null || value === '') {\n continue;\n }\n if (!destination.allows(key) || key.isTrusted()) {\n // DROPPED: a browser proves nothing, so it may never hold a trusted value.\n continue;\n }\n this.contextStore.set(key, key.mergeResponseValue(this.contextStore.readValue?.(key), value));\n }\n }\n}\n"]}
1
+ {"version":3,"file":"ContextMgr.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ContextMgr.ts"],"names":[],"mappings":";;;AACA,iDAA4D;AAG5D,qDAAkD;AAClD,+CAA4C;AAC5C,iEAA8D;AAE9D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAa,UAAU;IAGC;IAFpB;IACI,iFAAiF;IACjE,YAA0B;QAA1B,iBAAY,GAAZ,YAAY,CAAc;IAC3C,CAAC;IAEJ;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,oBAAoB,CAAC,WAA6B;QAC9C,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;QAE3C,iGAAiG;QACjG,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,EAAE,CAAC;QACxC,KAAK,MAAM,GAAG,IAAI,MAAM,IAAI,+BAAc,CAAC,GAAG,EAAE,CAAC,kBAAkB,EAAE,EAAE,CAAC;YACpE,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC3B,SAAS;YACb,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC1C,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;gBACxD,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,UAAW,EAAE,KAAK,CAAC,CAAC;YACzC,CAAC;iBAAM,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;gBAC9B,MAAM,IAAI,iCAAsB,CAC5B,gBAAgB,GAAG,CAAC,IAAI,aAAa,GAAG,CAAC,UAAU,+CAA+C;oBAC9F,gGAAgG,CACvG,CAAC;YACN,CAAC;QACL,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,2CAAoB,CAAC,cAAc,CAAC,EAAE,CAAC;YAC3D,OAAO,QAAQ,CAAC;QACpB,CAAC;QAED,2FAA2F;QAC3F,+FAA+F;QAC/F,gGAAgG;QAChG,MAAM,SAAS,GAAG,yBAAW,CAAC,UAAU,EAAE,CAAC;QAC3C,MAAM,mBAAmB,GAAG,2CAAoB,CAAC,cAAc,CAAC,UAAW,CAAC;QAC5E,IAAI,SAAS,EAAE,CAAC;YACZ,QAAQ,CAAC,GAAG,CAAC,mBAAmB,EAAE,SAAS,CAAC,CAAC;QACjD,CAAC;aAAM,CAAC;YACJ,QAAQ,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;QACzC,CAAC;QAED,OAAO,QAAQ,CAAC;IACpB,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACH,qBAAqB,CAAC,OAAgB,EAAE,WAA6B;QACjE,KAAK,MAAM,GAAG,IAAI,+BAAc,CAAC,GAAG,EAAE,CAAC,mBAAmB,EAAE,EAAE,CAAC;YAC3D,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,cAAe,CAAC,CAAC;YAC/C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;gBACjC,SAAS;YACb,CAAC;YACD,IAAI,CAAC,WAAW,CAAC,gBAAgB,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,SAAS,EAAE,EAAE,CAAC;gBACxD,2EAA2E;gBAC3E,SAAS;YACb,CAAC;YACD,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,kBAAkB,CAAC,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;QAClG,CAAC;IACL,CAAC;CACJ;AA/FD,gCA+FC","sourcesContent":["import { ContextKey } from '../ContextKey';\nimport { ApiImplementationError } from '../errors/ApiError';\nimport { ContextStore } from './ContextWriter';\nimport { DestinationTrust } from './DestinationTrust';\nimport { HeaderRegistry } from './HeaderRegistry';\nimport { ServiceInfo } from './ServiceInfo';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\n\n/**\n * ContextMgr - propagates the magic context onto outbound BROWSER requests.\n *\n * BROWSER-ONLY. Only @webpieces/http-client-browser may name this class. The server reads\n * `RequestContext` directly through `RequestContextHeaders` (in @webpieces/core-context) — a\n * `ContextReader` indirection buys a server nothing, because there is exactly one right answer there.\n *\n * Browsers have no AsyncLocalStorage, so the app holds a `MutableContextStore` and sets values as\n * they become known (login token, tenant). Every transferred key (httpHeader set) in the GLOBAL\n * {@link HeaderRegistry} is read from it and added to outbound requests. The registry is a process\n * global configured once at startup (like LogManager) and is browser-safe: it is the key SCHEMA,\n * not the value store.\n *\n * Example usage:\n * ```typescript\n * // startup, before bootstrap:\n * HeaderRegistry.configure(CompanyHeaders.ALL_HEADERS, true);\n *\n * const store = new MutableContextStore();\n * const factory = new ClientHttpBrowserFactory(store);\n * const client = factory.createRpcClient(SaveApi, new ClientConfig('http://api.example.com', ClientRole.SERVER));\n * ```\n */\nexport class ContextMgr {\n constructor(\n /** The app-held store context-key values are read from AND written back into. */\n public readonly contextStore: ContextStore,\n ) {}\n\n /**\n * Build the headers to send on an outbound request: every transferred key (httpHeader set)\n * with a non-empty value THAT THIS DESTINATION MAY RECEIVE, emitted under its `httpHeader`\n * wire name.\n *\n * NO request-id chaining. A browser ORIGINATES a trace — it has no inbound request to point\n * back at. If the app puts an `x-request-id` on the store it goes out as-is, and the server's\n * inbound transfer adopts it as hop 1's own id. Chaining is a server concern; see\n * RequestContextHeaders.\n *\n * `destination` gates TRUSTED keys exactly as it does on the server side (see\n * {@link DestinationTrust}). In practice a browser never reaches the permissive branch — twice\n * over: `BrowserProxyClient.assertEndpointSupported` refuses to bind an OIDC (`oidcSameProject()` /\n * `oidcSvcs(...)`) or `sharedSecret(...)` contract at all, so every browser destination is `jwt()` or `@WpAuthPublic`.\n * The rule is applied here anyway rather than argued away, because the OTHER guarantee people\n * reach for — \"`MutableContextStore.set` only accepts an untrusted key, so a browser store\n * cannot HOLD a trusted value\" — is true of that store and NOT of the seam: {@link ContextMgr}\n * takes any app-supplied {@link ContextStore}, whose `read` is handed an `AnyContextKey`. One\n * enforced rule in both builders beats a browser-only exemption resting on an implementation\n * detail of one implementation.\n *\n * Values are RAW (unmasked) — this map goes on the wire, not in logs.\n */\n buildOutboundHeaders(destination: DestinationTrust): Map<string, string> {\n const outbound = new Map<string, string>();\n\n // An EXTERNAL_OUTBOUND destination lists EXACTLY what it sends, and each listed key is required.\n const listed = destination.listedKeys();\n for (const key of listed ?? HeaderRegistry.get().getTransferredKeys()) {\n if (!destination.allows(key)) {\n continue;\n }\n const value = this.contextStore.read(key);\n if (value !== undefined && value !== null && value !== '') {\n outbound.set(key.httpHeader!, value);\n } else if (listed !== undefined) {\n throw new ApiImplementationError(\n `Context key '${key.name}' (header ${key.httpHeader}) is listed in @ExternalContextKeys for this ` +\n `outbound call but has no value in the context store. Set it before calling — nothing was sent.`,\n );\n }\n }\n if (!destination.allows(WebpiecesCoreHeaders.CLIENT_VERSION)) {\n return outbound;\n }\n\n // CLIENT_VERSION: a browser ORIGINATES a call, so it sends its OWN app build version (from\n // ServiceInfo) and the server logs which client build called it — same rule as the server-side\n // RequestContextHeaders. Our version wins; absent if this app was never identified via setInfo.\n const myVersion = ServiceInfo.getVersion();\n const clientVersionHeader = WebpiecesCoreHeaders.CLIENT_VERSION.httpHeader!;\n if (myVersion) {\n outbound.set(clientVersionHeader, myVersion);\n } else {\n outbound.delete(clientVersionHeader);\n }\n\n return outbound;\n }\n\n /**\n * RESPONSE -> the store: every key that declares a `responseHeader` and appears on a response\n * this browser just received, merged into the app-held store per the key's own\n * {@link ContextKey.responseMerge}.\n *\n * The browser twin of `RequestContextHeaders.acceptResponseHeaders`, and the reason a value set\n * by a server reaches the page without the page naming a header.\n *\n * TRUSTED keys are DROPPED here, unconditionally and without consulting `destination`, and the\n * `if` is what makes that a compile-time fact rather than a convention: {@link ContextWriter.set}\n * takes an {@link AnyUntrustedContextKey}, so a browser store CANNOT hold a proven value. That is\n * the same rule as the outbound direction reaches by a different route — every browser\n * destination is `jwt()` or `@WpAuthPublic`, so `destination.allows` would refuse them\n * anyway — and `destination` is still threaded and still applied, so a future browser destination\n * that DID authenticate its caller does not silently start believing response headers.\n */\n acceptResponseHeaders(headers: Headers, destination: DestinationTrust): void {\n for (const key of HeaderRegistry.get().getResponseTxfrKeys()) {\n const value = headers.get(key.responseHeader!);\n if (value === null || value === '') {\n continue;\n }\n if (!destination.believesResponse(key) || key.isTrusted()) {\n // DROPPED: a browser proves nothing, so it may never hold a trusted value.\n continue;\n }\n this.contextStore.set(key, key.mergeResponseValue(this.contextStore.readValue?.(key), value));\n }\n }\n}\n"]}
@@ -14,8 +14,10 @@ class DeletedAuthCompileAssertions {
14
14
  void CoreUtil.oidc;
15
15
  // @ts-expect-error use WpAuth([sharedSecret(...)]) and WpAuthorization
16
16
  void CoreUtil.WpAuthSharedSecret;
17
- // @ts-expect-error use WpAuth([webhook(...)]) and WpAuthorization
17
+ // @ts-expect-error use WpAuth([vendorAuth(...)]) and WpAuthorization
18
18
  void CoreUtil.WpAuthWebhook;
19
+ // @ts-expect-error webhook(name) is renamed vendorAuth(name): one VendorAuthHook mints, refreshes and verifies
20
+ void CoreUtil.webhook;
19
21
  // @ts-expect-error use WpAuth([apiKey(...)]) and WpAuthorization
20
22
  void CoreUtil.WpAuthApiKey;
21
23
  // @ts-expect-error use separate WpLocalOnly, authentication and authorization
@@ -1 +1 @@
1
- {"version":3,"file":"DeletedAuthCompileAssertions.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/DeletedAuthCompileAssertions.ts"],"names":[],"mappings":";;;;AAAA,2DAAqC;AAErC,4FAA4F;AAC5F,MAAa,4BAA4B;IACrC,cAAc;QACV,2DAA2D;QAC3D,KAAK,QAAQ,CAAC,SAAS,CAAC;QACxB,oGAAoG;QACpG,KAAK,QAAQ,CAAC,UAAU,CAAC;QACzB,4HAA4H;QAC5H,KAAK,QAAQ,CAAC,IAAI,CAAC;QACnB,uEAAuE;QACvE,KAAK,QAAQ,CAAC,kBAAkB,CAAC;QACjC,kEAAkE;QAClE,KAAK,QAAQ,CAAC,aAAa,CAAC;QAC5B,iEAAiE;QACjE,KAAK,QAAQ,CAAC,YAAY,CAAC;QAC3B,8EAA8E;QAC9E,KAAK,QAAQ,CAAC,eAAe,CAAC;QAC9B,iEAAiE;QACjE,KAAK,QAAQ,CAAC,YAAY,CAAC;QAC3B,oEAAoE;QACpE,KAAK,QAAQ,CAAC,WAAW,CAAC;QAC1B,kEAAkE;QAClE,KAAK,QAAQ,CAAC,aAAa,CAAC;IAChC,CAAC;CACJ;AAvBD,oEAuBC","sourcesContent":["import * as CoreUtil from '../index';\n\n/** Each removed spelling has its own assertion so one missing export cannot mask a shim. */\nexport class DeletedAuthCompileAssertions {\n removedExports(): void {\n // @ts-expect-error use WpAuth([jwt()]) and WpAuthorization\n void CoreUtil.WpAuthJwt;\n // @ts-expect-error use WpAuth([oidcSameProject()]) or WpAuth([oidcSvcs([...])]) and WpAuthorization\n void CoreUtil.WpAuthOidc;\n // @ts-expect-error oidc(...) is removed: oidcSameProject() for this GCP project, oidcSvcs([...]) for named service accounts\n void CoreUtil.oidc;\n // @ts-expect-error use WpAuth([sharedSecret(...)]) and WpAuthorization\n void CoreUtil.WpAuthSharedSecret;\n // @ts-expect-error use WpAuth([webhook(...)]) and WpAuthorization\n void CoreUtil.WpAuthWebhook;\n // @ts-expect-error use WpAuth([apiKey(...)]) and WpAuthorization\n void CoreUtil.WpAuthApiKey;\n // @ts-expect-error use separate WpLocalOnly, authentication and authorization\n void CoreUtil.WpAuthLocalOnly;\n // @ts-expect-error MCP shares receiving endpoint WpAuthorization\n void CoreUtil.WpMcpAuthJwt;\n // @ts-expect-error unresolved credentials are read with getAuthMeta\n void CoreUtil.getAuthMode;\n // @ts-expect-error roles belong to common operation authorization\n void CoreUtil.rolesRequired;\n }\n}\n"]}
1
+ {"version":3,"file":"DeletedAuthCompileAssertions.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/DeletedAuthCompileAssertions.ts"],"names":[],"mappings":";;;;AAAA,2DAAqC;AAErC,4FAA4F;AAC5F,MAAa,4BAA4B;IACrC,cAAc;QACV,2DAA2D;QAC3D,KAAK,QAAQ,CAAC,SAAS,CAAC;QACxB,oGAAoG;QACpG,KAAK,QAAQ,CAAC,UAAU,CAAC;QACzB,4HAA4H;QAC5H,KAAK,QAAQ,CAAC,IAAI,CAAC;QACnB,uEAAuE;QACvE,KAAK,QAAQ,CAAC,kBAAkB,CAAC;QACjC,qEAAqE;QACrE,KAAK,QAAQ,CAAC,aAAa,CAAC;QAC5B,+GAA+G;QAC/G,KAAK,QAAQ,CAAC,OAAO,CAAC;QACtB,iEAAiE;QACjE,KAAK,QAAQ,CAAC,YAAY,CAAC;QAC3B,8EAA8E;QAC9E,KAAK,QAAQ,CAAC,eAAe,CAAC;QAC9B,iEAAiE;QACjE,KAAK,QAAQ,CAAC,YAAY,CAAC;QAC3B,oEAAoE;QACpE,KAAK,QAAQ,CAAC,WAAW,CAAC;QAC1B,kEAAkE;QAClE,KAAK,QAAQ,CAAC,aAAa,CAAC;IAChC,CAAC;CACJ;AAzBD,oEAyBC","sourcesContent":["import * as CoreUtil from '../index';\n\n/** Each removed spelling has its own assertion so one missing export cannot mask a shim. */\nexport class DeletedAuthCompileAssertions {\n removedExports(): void {\n // @ts-expect-error use WpAuth([jwt()]) and WpAuthorization\n void CoreUtil.WpAuthJwt;\n // @ts-expect-error use WpAuth([oidcSameProject()]) or WpAuth([oidcSvcs([...])]) and WpAuthorization\n void CoreUtil.WpAuthOidc;\n // @ts-expect-error oidc(...) is removed: oidcSameProject() for this GCP project, oidcSvcs([...]) for named service accounts\n void CoreUtil.oidc;\n // @ts-expect-error use WpAuth([sharedSecret(...)]) and WpAuthorization\n void CoreUtil.WpAuthSharedSecret;\n // @ts-expect-error use WpAuth([vendorAuth(...)]) and WpAuthorization\n void CoreUtil.WpAuthWebhook;\n // @ts-expect-error webhook(name) is renamed vendorAuth(name): one VendorAuthHook mints, refreshes and verifies\n void CoreUtil.webhook;\n // @ts-expect-error use WpAuth([apiKey(...)]) and WpAuthorization\n void CoreUtil.WpAuthApiKey;\n // @ts-expect-error use separate WpLocalOnly, authentication and authorization\n void CoreUtil.WpAuthLocalOnly;\n // @ts-expect-error MCP shares receiving endpoint WpAuthorization\n void CoreUtil.WpMcpAuthJwt;\n // @ts-expect-error unresolved credentials are read with getAuthMeta\n void CoreUtil.getAuthMode;\n // @ts-expect-error roles belong to common operation authorization\n void CoreUtil.rolesRequired;\n }\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  import { AnyContextKey } from '../ContextKey';
2
2
  import { AuthMode } from './auth-mode';
3
+ import { RouteMetadata } from './RouteMetadata';
3
4
  /**
4
5
  * DestinationTrust - the OUTBOUND half of the trust model: may a TRUSTED context key
5
6
  * ({@link ContextKey.trusted}) ride to the endpoint we are about to call?
@@ -32,6 +33,12 @@ import { AuthMode } from './auth-mode';
32
33
  */
33
34
  export declare class DestinationTrust {
34
35
  private readonly verifiesCaller;
36
+ /**
37
+ * Set ONLY for an `EXTERNAL_OUTBOUND` destination: the `@ExternalContextKeys` list, which is
38
+ * then the WHOLE of what may be sent. Undefined for every destination in this repo, whose wire
39
+ * is governed by {@link forAuthMode} exactly as before.
40
+ */
41
+ private readonly externalKeys;
35
42
  /**
36
43
  * The destination authenticates its CALLER (oidcSameProject() / oidcSvcs(...) / sharedSecret(...)), so it is entitled to
37
44
  * believe context WE vouch for — this is the service-to-service identity propagation that trusted
@@ -39,12 +46,26 @@ export declare class DestinationTrust {
39
46
  */
40
47
  private static readonly VERIFIES_CALLER;
41
48
  /**
42
- * The destination cannot tell us from a browser with curl (jwt() / @WpAuthPublic / webhook(...) /
49
+ * The destination cannot tell us from a browser with curl (jwt() / @WpAuthPublic / vendorAuth(...) /
43
50
  * apiKey(...) / @WpLocalOnly / an endpoint with no declared mode), so trusted keys are omitted.
44
51
  * Untrusted keys still travel.
45
52
  */
46
53
  private static readonly CANNOT_VERIFY_CALLER;
47
54
  private constructor();
55
+ /**
56
+ * The destination of ONE route. An `EXTERNAL_OUTBOUND` route (an outside system's API) gets a
57
+ * destination that sends ONLY its `@ExternalContextKeys` and believes NO response header: a vendor
58
+ * is somebody else's process, so our `x-request-id`, `x-webpieces-*` and app keys stay home unless
59
+ * the contract lists them, and nothing it answers may write into our context. Every other route is
60
+ * {@link forAuthMode}, unchanged — so the wire to our own servers is identical.
61
+ */
62
+ static forRoute(route: RouteMetadata): DestinationTrust;
63
+ /**
64
+ * The keys an `EXTERNAL_OUTBOUND` destination sends — every one is REQUIRED, so a builder throws
65
+ * before any I/O when one has no value. Undefined for every other destination, whose builder
66
+ * iterates the registry's transferred keys and filters them with {@link allows}.
67
+ */
68
+ listedKeys(): readonly AnyContextKey[] | undefined;
48
69
  /**
49
70
  * The ONLY way to obtain one: state the destination endpoint's auth mode. `undefined` (an
50
71
  * endpoint that declared no mode) is treated as un-verifying, i.e. the SAFE answer — an absent
@@ -65,9 +86,17 @@ export declare class DestinationTrust {
65
86
  * service in our own trust domain, while a `@WpAuthPublic` / `jwt()` / partner endpoint is
66
87
  * somebody else's process whose response headers are an assertion, not a proof.
67
88
  *
68
- * A second, identically-bodied `believesResponsesFrom(key)` would be two spellings of one
69
- * decision — the shim shape `.claude/rules/no-backwards-compat.md` rejects — so the direction
70
- * lives in the CALLER's name and the rule lives here, once.
89
+ * For every destination in this repo the two directions are one question, so the response side
90
+ * ({@link believesResponse}) delegates here; only an `EXTERNAL_OUTBOUND` destination answers them
91
+ * differently (it sends listed keys and believes none).
71
92
  */
72
93
  allows(key: AnyContextKey): boolean;
94
+ /**
95
+ * May a key that came BACK on this destination's response be merged into our context? Exactly
96
+ * {@link allows} for a destination in this repo — and NOTHING from an `EXTERNAL_OUTBOUND` one,
97
+ * whose response headers are an outside system's, not a peer's. A separate method because for
98
+ * that destination the two directions genuinely differ: a key we LIST to send (`x-request-id`)
99
+ * is not thereby a key we let the vendor write back.
100
+ */
101
+ believesResponse(key: AnyContextKey): boolean;
73
102
  }
@@ -33,20 +33,50 @@ exports.DestinationTrust = void 0;
33
33
  */
34
34
  class DestinationTrust {
35
35
  verifiesCaller;
36
+ externalKeys;
36
37
  /**
37
38
  * The destination authenticates its CALLER (oidcSameProject() / oidcSvcs(...) / sharedSecret(...)), so it is entitled to
38
39
  * believe context WE vouch for — this is the service-to-service identity propagation that trusted
39
40
  * keys keep an `httpHeader` for.
40
41
  */
41
- static VERIFIES_CALLER = new DestinationTrust(true);
42
+ static VERIFIES_CALLER = new DestinationTrust(true, undefined);
42
43
  /**
43
- * The destination cannot tell us from a browser with curl (jwt() / @WpAuthPublic / webhook(...) /
44
+ * The destination cannot tell us from a browser with curl (jwt() / @WpAuthPublic / vendorAuth(...) /
44
45
  * apiKey(...) / @WpLocalOnly / an endpoint with no declared mode), so trusted keys are omitted.
45
46
  * Untrusted keys still travel.
46
47
  */
47
- static CANNOT_VERIFY_CALLER = new DestinationTrust(false);
48
- constructor(verifiesCaller) {
48
+ static CANNOT_VERIFY_CALLER = new DestinationTrust(false, undefined);
49
+ constructor(verifiesCaller,
50
+ /**
51
+ * Set ONLY for an `EXTERNAL_OUTBOUND` destination: the `@ExternalContextKeys` list, which is
52
+ * then the WHOLE of what may be sent. Undefined for every destination in this repo, whose wire
53
+ * is governed by {@link forAuthMode} exactly as before.
54
+ */
55
+ externalKeys) {
49
56
  this.verifiesCaller = verifiesCaller;
57
+ this.externalKeys = externalKeys;
58
+ }
59
+ /**
60
+ * The destination of ONE route. An `EXTERNAL_OUTBOUND` route (an outside system's API) gets a
61
+ * destination that sends ONLY its `@ExternalContextKeys` and believes NO response header: a vendor
62
+ * is somebody else's process, so our `x-request-id`, `x-webpieces-*` and app keys stay home unless
63
+ * the contract lists them, and nothing it answers may write into our context. Every other route is
64
+ * {@link forAuthMode}, unchanged — so the wire to our own servers is identical.
65
+ */
66
+ // webpieces-disable no-function-outside-class -- static factory beside forAuthMode, for the same reason
67
+ static forRoute(route) {
68
+ if (route.externalOutbound) {
69
+ return new DestinationTrust(false, route.externalContextKeys);
70
+ }
71
+ return DestinationTrust.forAuthMode(route.authMeta?.methods[0]);
72
+ }
73
+ /**
74
+ * The keys an `EXTERNAL_OUTBOUND` destination sends — every one is REQUIRED, so a builder throws
75
+ * before any I/O when one has no value. Undefined for every other destination, whose builder
76
+ * iterates the registry's transferred keys and filters them with {@link allows}.
77
+ */
78
+ listedKeys() {
79
+ return this.externalKeys;
50
80
  }
51
81
  /**
52
82
  * The ONLY way to obtain one: state the destination endpoint's auth mode. `undefined` (an
@@ -64,12 +94,11 @@ class DestinationTrust {
64
94
  return DestinationTrust.VERIFIES_CALLER;
65
95
  case 'jwt':
66
96
  case 'public':
67
- // webhook(...) authenticates an OUTSIDE VENDOR, which is not the same thing as
97
+ // vendorAuth(...) authenticates an OUTSIDE VENDOR, which is not the same thing as
68
98
  // authenticating a peer in this repo. The vendor knows nothing of webpieces context
69
99
  // headers and would never send one, so there is no identity to propagate in either
70
- // direction — and a webpieces client cannot call such an endpoint anyway (it cannot mint
71
- // the vendor's signature). Trusted keys stay home.
72
- case 'webhook':
100
+ // direction. Trusted keys stay home.
101
+ case 'vendorAuth':
73
102
  // apiKey(...) authenticates a CUSTOMER, not a peer service. The holder of the key is
74
103
  // another company's codebase, so nothing it forwards may be believed, and no webpieces
75
104
  // client can call it anyway (the framework extracts no api-key header — the app's hook
@@ -97,13 +126,27 @@ class DestinationTrust {
97
126
  * service in our own trust domain, while a `@WpAuthPublic` / `jwt()` / partner endpoint is
98
127
  * somebody else's process whose response headers are an assertion, not a proof.
99
128
  *
100
- * A second, identically-bodied `believesResponsesFrom(key)` would be two spellings of one
101
- * decision — the shim shape `.claude/rules/no-backwards-compat.md` rejects — so the direction
102
- * lives in the CALLER's name and the rule lives here, once.
129
+ * For every destination in this repo the two directions are one question, so the response side
130
+ * ({@link believesResponse}) delegates here; only an `EXTERNAL_OUTBOUND` destination answers them
131
+ * differently (it sends listed keys and believes none).
103
132
  */
104
133
  allows(key) {
134
+ if (this.externalKeys !== undefined)
135
+ return this.externalKeys.includes(key);
105
136
  return this.verifiesCaller || !key.isTrusted();
106
137
  }
138
+ /**
139
+ * May a key that came BACK on this destination's response be merged into our context? Exactly
140
+ * {@link allows} for a destination in this repo — and NOTHING from an `EXTERNAL_OUTBOUND` one,
141
+ * whose response headers are an outside system's, not a peer's. A separate method because for
142
+ * that destination the two directions genuinely differ: a key we LIST to send (`x-request-id`)
143
+ * is not thereby a key we let the vendor write back.
144
+ */
145
+ believesResponse(key) {
146
+ if (this.externalKeys !== undefined)
147
+ return false;
148
+ return this.allows(key);
149
+ }
107
150
  }
108
151
  exports.DestinationTrust = DestinationTrust;
109
152
  //# sourceMappingURL=DestinationTrust.js.map