@webpieces/http-routing 0.4.598 → 0.4.599

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/http-routing",
3
- "version": "0.4.598",
3
+ "version": "0.4.599",
4
4
  "description": "Decorator-based routing with auto-wiring for WebPieces",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,9 +22,9 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@inversifyjs/binding-decorators": "1.1.5",
25
- "@webpieces/core-context": "0.4.598",
26
- "@webpieces/core-util": "0.4.598",
27
- "@webpieces/gcp-identity": "0.4.598",
25
+ "@webpieces/core-context": "0.4.599",
26
+ "@webpieces/core-util": "0.4.599",
27
+ "@webpieces/gcp-identity": "0.4.599",
28
28
  "inversify": "7.10.4",
29
29
  "jsonwebtoken": "9.0.2",
30
30
  "minimatch": "10.0.1"
@@ -18,7 +18,7 @@ export declare class SharedSecrets {
18
18
  * AuthValues - what {@link JwtHook.parseJwt} returns: the authenticated user's id + roles (used
19
19
  * by the framework to stamp a principal and enforce @AuthJwt({roles: [...]})) plus any extra context
20
20
  * entries the app wants set (orgId, tenant, ...). The framework sets `entries` into RequestContext
21
- * via {@link RequestContext.putHeader}. Data-only structure (a class, per the guidelines).
21
+ * via {@link RequestContext.putTrusted}. Data-only structure (a class, per the guidelines).
22
22
  */
23
23
  export declare class AuthValues {
24
24
  readonly userId: string;
package/src/AuthConfig.js CHANGED
@@ -24,7 +24,7 @@ exports.SharedSecrets = SharedSecrets;
24
24
  * AuthValues - what {@link JwtHook.parseJwt} returns: the authenticated user's id + roles (used
25
25
  * by the framework to stamp a principal and enforce @AuthJwt({roles: [...]})) plus any extra context
26
26
  * entries the app wants set (orgId, tenant, ...). The framework sets `entries` into RequestContext
27
- * via {@link RequestContext.putHeader}. Data-only structure (a class, per the guidelines).
27
+ * via {@link RequestContext.putTrusted}. Data-only structure (a class, per the guidelines).
28
28
  */
29
29
  class AuthValues {
30
30
  userId;
@@ -1 +1 @@
1
- {"version":3,"file":"AuthConfig.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthConfig.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;GASG;AACH,MAAa,aAAa;IAEF;IACA;IAFpB,YACoB,OAAe,EACf,OAAe;QADf,YAAO,GAAP,OAAO,CAAQ;QACf,YAAO,GAAP,OAAO,CAAQ;IAChC,CAAC;CACP;AALD,sCAKC;AAED;;;;;GAKG;AACH,MAAa,UAAU;IAEC;IACA;IACA;IAEA;IALpB,YACoB,MAAc,EACd,QAAkB,EAAE,EACpB,UAA0B,EAAE;IAC5C,wGAAwG;IACxF,SAAkC,EAAE;QAJpC,WAAM,GAAN,MAAM,CAAQ;QACd,UAAK,GAAL,KAAK,CAAe;QACpB,YAAO,GAAP,OAAO,CAAqB;QAE5B,WAAM,GAAN,MAAM,CAA8B;IACrD,CAAC;CACP;AARD,gCAQC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAa,UAAU;IACnB,wGAAwG;IAC/F,aAAa,CAAgC;IAEtD,YAAY,gBAA+C,EAAE;QACzD,IAAI,CAAC,aAAa,GAAG,aAAa,CAAC;IACvC,CAAC;CACJ;AAPD,gCAOC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,WAAW,GAAG,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC","sourcesContent":["import { ContextTuple } from '@webpieces/core-util';\n\n/**\n * SharedSecrets - the accepted values for ONE `@AuthSharedSecret(name)`. BOTH secret1 AND secret2\n * are accepted — this is what makes zero-downtime ROTATION possible:\n *\n * to rotate: shift secret2 → secret1, and put the NEW secret in secret2. Callers cut over from\n * the old value to the new during the window; once every caller sends the new one, the stale\n * value falls out on the next shift. At all times EITHER key works, so no request is dropped.\n *\n * Data-only structure (a class, per the guidelines). Leave secret2 empty for a single secret.\n */\nexport class SharedSecrets {\n constructor(\n public readonly secret1: string,\n public readonly secret2: string,\n ) {}\n}\n\n/**\n * AuthValues - what {@link JwtHook.parseJwt} returns: the authenticated user's id + roles (used\n * by the framework to stamp a principal and enforce @AuthJwt({roles: [...]})) plus any extra context\n * entries the app wants set (orgId, tenant, ...). The framework sets `entries` into RequestContext\n * via {@link RequestContext.putHeader}. Data-only structure (a class, per the guidelines).\n */\nexport class AuthValues {\n constructor(\n public readonly userId: string,\n public readonly roles: string[] = [],\n public readonly entries: ContextTuple[] = [],\n // webpieces-disable no-any-unknown -- raw JWT claims for app-defined authorization (inOrg, tenant, ...)\n public readonly claims: Record<string, unknown> = {},\n ) {}\n}\n\n/**\n * AuthConfig - the app-provided SHARED-SECRET state the framework {@link AuthFilter} reads to\n * enforce `@AuthSharedSecret(name)` endpoints. It holds ONLY the accepted secret values (STATE) —\n * there is no verification code here. The verification MECHANISMS are separate optional hooks the\n * app binds when it needs them:\n *\n * - user JWT → bind a {@link JwtHook} (parseJwt + authorizeJwt).\n * - OIDC → bind an {@link OidcHook} to override the framework's default verifier; a server that\n * binds nothing still verifies Google OIDC via the built-in {@link DefaultOidcVerifier}.\n *\n * So a zero-wiring server accepts service-to-service OIDC out of the box, and an app only binds the\n * pieces it actually uses. This class is injected `@optional` into AuthFilter (rebindable in tests);\n * when unbound, shared-secret endpoints simply have no accepted secret and fail fast (401).\n */\nexport class AuthConfig {\n /** Accepted shared-secret values keyed by `@AuthSharedSecret(name)`. DEFAULT empty — pass to enable. */\n readonly sharedSecrets: Record<string, SharedSecrets>;\n\n constructor(sharedSecrets: Record<string, SharedSecrets> = {}) {\n this.sharedSecrets = sharedSecrets;\n }\n}\n\n/**\n * DI identifier for the optional {@link AuthConfig} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(AUTH_CONFIG)`\n * correct — undefined when unbound. The AuthConfig class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const AUTH_CONFIG = Symbol.for('AuthConfig');\n"]}
1
+ {"version":3,"file":"AuthConfig.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthConfig.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;GASG;AACH,MAAa,aAAa;IAEF;IACA;IAFpB,YACoB,OAAe,EACf,OAAe;QADf,YAAO,GAAP,OAAO,CAAQ;QACf,YAAO,GAAP,OAAO,CAAQ;IAChC,CAAC;CACP;AALD,sCAKC;AAED;;;;;GAKG;AACH,MAAa,UAAU;IAEC;IACA;IACA;IAEA;IALpB,YACoB,MAAc,EACd,QAAkB,EAAE,EACpB,UAA0B,EAAE;IAC5C,wGAAwG;IACxF,SAAkC,EAAE;QAJpC,WAAM,GAAN,MAAM,CAAQ;QACd,UAAK,GAAL,KAAK,CAAe;QACpB,YAAO,GAAP,OAAO,CAAqB;QAE5B,WAAM,GAAN,MAAM,CAA8B;IACrD,CAAC;CACP;AARD,gCAQC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAa,UAAU;IACnB,wGAAwG;IAC/F,aAAa,CAAgC;IAEtD,YAAY,gBAA+C,EAAE;QACzD,IAAI,CAAC,aAAa,GAAG,aAAa,CAAC;IACvC,CAAC;CACJ;AAPD,gCAOC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,WAAW,GAAG,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC","sourcesContent":["import { ContextTuple } from '@webpieces/core-util';\n\n/**\n * SharedSecrets - the accepted values for ONE `@AuthSharedSecret(name)`. BOTH secret1 AND secret2\n * are accepted — this is what makes zero-downtime ROTATION possible:\n *\n * to rotate: shift secret2 → secret1, and put the NEW secret in secret2. Callers cut over from\n * the old value to the new during the window; once every caller sends the new one, the stale\n * value falls out on the next shift. At all times EITHER key works, so no request is dropped.\n *\n * Data-only structure (a class, per the guidelines). Leave secret2 empty for a single secret.\n */\nexport class SharedSecrets {\n constructor(\n public readonly secret1: string,\n public readonly secret2: string,\n ) {}\n}\n\n/**\n * AuthValues - what {@link JwtHook.parseJwt} returns: the authenticated user's id + roles (used\n * by the framework to stamp a principal and enforce @AuthJwt({roles: [...]})) plus any extra context\n * entries the app wants set (orgId, tenant, ...). The framework sets `entries` into RequestContext\n * via {@link RequestContext.putTrusted}. Data-only structure (a class, per the guidelines).\n */\nexport class AuthValues {\n constructor(\n public readonly userId: string,\n public readonly roles: string[] = [],\n public readonly entries: ContextTuple[] = [],\n // webpieces-disable no-any-unknown -- raw JWT claims for app-defined authorization (inOrg, tenant, ...)\n public readonly claims: Record<string, unknown> = {},\n ) {}\n}\n\n/**\n * AuthConfig - the app-provided SHARED-SECRET state the framework {@link AuthFilter} reads to\n * enforce `@AuthSharedSecret(name)` endpoints. It holds ONLY the accepted secret values (STATE) —\n * there is no verification code here. The verification MECHANISMS are separate optional hooks the\n * app binds when it needs them:\n *\n * - user JWT → bind a {@link JwtHook} (parseJwt + authorizeJwt).\n * - OIDC → bind an {@link OidcHook} to override the framework's default verifier; a server that\n * binds nothing still verifies Google OIDC via the built-in {@link DefaultOidcVerifier}.\n *\n * So a zero-wiring server accepts service-to-service OIDC out of the box, and an app only binds the\n * pieces it actually uses. This class is injected `@optional` into AuthFilter (rebindable in tests);\n * when unbound, shared-secret endpoints simply have no accepted secret and fail fast (401).\n */\nexport class AuthConfig {\n /** Accepted shared-secret values keyed by `@AuthSharedSecret(name)`. DEFAULT empty — pass to enable. */\n readonly sharedSecrets: Record<string, SharedSecrets>;\n\n constructor(sharedSecrets: Record<string, SharedSecrets> = {}) {\n this.sharedSecrets = sharedSecrets;\n }\n}\n\n/**\n * DI identifier for the optional {@link AuthConfig} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(AUTH_CONFIG)`\n * correct — undefined when unbound. The AuthConfig class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const AUTH_CONFIG = Symbol.for('AuthConfig');\n"]}
@@ -28,6 +28,40 @@ export declare class AuthFilter extends Filter<MethodMeta, WpResponse<unknown>>
28
28
  private readonly oidcHook?;
29
29
  constructor(oidcVerifier: DefaultOidcVerifier, authConfig?: AuthConfig | undefined, jwtHook?: JwtHook | undefined, oidcHook?: OidcHook | undefined);
30
30
  filter(meta: MethodMeta, nextFilter: Service<MethodMeta, WpResponse<unknown>>): Promise<WpResponse<unknown>>;
31
+ /**
32
+ * Decide what happens to the trusted keys that arrived on the WIRE and were held back by
33
+ * {@link PendingWireTrust} (read that class for why they are held rather than written).
34
+ *
35
+ * `callerVerified` — the endpoint authenticated the SENDER (`@AuthOidc`, `@AuthSharedSecret`).
36
+ * The sender is a service we trust, this is the service-to-service hop, and its forwarded
37
+ * identity is admitted as-is. This is the case that makes propagating a verified userId across
38
+ * internal services work.
39
+ *
40
+ * Otherwise the sender is a browser or anyone else with curl, and the ONLY acceptable inbound
41
+ * trusted value is one the authenticator independently derived to the same value. Everything
42
+ * else is rejected — see {@link requireVouched}.
43
+ *
44
+ * Runs AFTER the mode enforcement above, because that is what stamps the authenticator's own
45
+ * values (`applyAuthValues`); comparing before it ran would compare against nothing.
46
+ */
47
+ private reconcileWireTrust;
48
+ /**
49
+ * On a browser-reachable route, an inbound trusted header must match what the authenticator
50
+ * itself derived, or the request dies. Both failure shapes are rejections, not repairs:
51
+ *
52
+ * - DIFFERENT value — the caller said `alice`, the credential says `bob`. Silently letting the
53
+ * credential win is not safe, because upstream rate limiters commonly bucket on the header
54
+ * rather than the token: the request was already counted against the wrong principal, so
55
+ * every forged header would be a free rate-limit bypass. No honest caller contradicts its own
56
+ * credential.
57
+ * - NOTHING vouched for it — nobody derived this key at all, so there is no evidence behind a
58
+ * value a stranger typed. This is the common case, not the exotic one: the framework's
59
+ * {@link DefaultJwtHook} stamps NO entries, and an app hook only stamps the keys it can prove,
60
+ * so any other trusted key a caller sends lands here.
61
+ *
62
+ * The pending value is discarded either way — the throw is what leaves the request.
63
+ */
64
+ private requireVouched;
31
65
  private enforceJwt;
32
66
  private enforceOidc;
33
67
  /** `provided` is the Authorization bearer value — the secret itself, same header as a JWT. */
@@ -69,6 +69,7 @@ let AuthFilter = class AuthFilter extends Filter_1.Filter {
69
69
  if (!mode || mode.kind === 'public') {
70
70
  // Public: best-effort parse so a logged-out page can still know the logged-in user.
71
71
  this.bestEffortJwt(authHeader);
72
+ this.reconcileWireTrust(/*callerVerified*/ false);
72
73
  return nextFilter.invoke(meta);
73
74
  }
74
75
  switch (mode.kind) {
@@ -82,8 +83,65 @@ let AuthFilter = class AuthFilter extends Filter_1.Filter {
82
83
  this.enforceSharedSecret(this.credential(authHeader, SHARED_SECRET_SCHEME), mode.secretKey);
83
84
  break;
84
85
  }
86
+ // OIDC and shared-secret both authenticate the CALLER ITSELF, so an internal service is on
87
+ // the other end and the trusted context it forwarded may be believed. A user JWT proves only
88
+ // who the USER is — the sender is still whoever holds the token, i.e. a browser.
89
+ this.reconcileWireTrust(/*callerVerified*/ mode.kind === 'oidc' || mode.kind === 'shared-secret');
85
90
  return nextFilter.invoke(meta);
86
91
  }
92
+ /**
93
+ * Decide what happens to the trusted keys that arrived on the WIRE and were held back by
94
+ * {@link PendingWireTrust} (read that class for why they are held rather than written).
95
+ *
96
+ * `callerVerified` — the endpoint authenticated the SENDER (`@AuthOidc`, `@AuthSharedSecret`).
97
+ * The sender is a service we trust, this is the service-to-service hop, and its forwarded
98
+ * identity is admitted as-is. This is the case that makes propagating a verified userId across
99
+ * internal services work.
100
+ *
101
+ * Otherwise the sender is a browser or anyone else with curl, and the ONLY acceptable inbound
102
+ * trusted value is one the authenticator independently derived to the same value. Everything
103
+ * else is rejected — see {@link requireVouched}.
104
+ *
105
+ * Runs AFTER the mode enforcement above, because that is what stamps the authenticator's own
106
+ * values (`applyAuthValues`); comparing before it ran would compare against nothing.
107
+ */
108
+ reconcileWireTrust(callerVerified) {
109
+ const pending = core_context_1.PendingWireTrust.takeAll();
110
+ for (const item of pending) {
111
+ if (callerVerified) {
112
+ core_context_1.RequestContext.putTrusted(item.key, item.value);
113
+ }
114
+ else {
115
+ this.requireVouched(item);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * On a browser-reachable route, an inbound trusted header must match what the authenticator
121
+ * itself derived, or the request dies. Both failure shapes are rejections, not repairs:
122
+ *
123
+ * - DIFFERENT value — the caller said `alice`, the credential says `bob`. Silently letting the
124
+ * credential win is not safe, because upstream rate limiters commonly bucket on the header
125
+ * rather than the token: the request was already counted against the wrong principal, so
126
+ * every forged header would be a free rate-limit bypass. No honest caller contradicts its own
127
+ * credential.
128
+ * - NOTHING vouched for it — nobody derived this key at all, so there is no evidence behind a
129
+ * value a stranger typed. This is the common case, not the exotic one: the framework's
130
+ * {@link DefaultJwtHook} stamps NO entries, and an app hook only stamps the keys it can prove,
131
+ * so any other trusted key a caller sends lands here.
132
+ *
133
+ * The pending value is discarded either way — the throw is what leaves the request.
134
+ */
135
+ requireVouched(item) {
136
+ const vouched = core_context_1.RequestContext.getTrusted(item.key);
137
+ if (vouched === item.value) {
138
+ return;
139
+ }
140
+ log.error(`Rejecting inbound '${item.key.httpHeader}': it is a TRUSTED context key, this route does ` +
141
+ `not authenticate its caller, and the credential ` +
142
+ (vouched === undefined ? 'vouched for no such value' : 'derived a different value') + '.');
143
+ throw new core_util_1.HttpUnauthorizedError(`Header '${item.key.httpHeader}' cannot be supplied by the caller on this endpoint`);
144
+ }
87
145
  enforceJwt(header, requirement) {
88
146
  const token = this.credential(header, BEARER_SCHEME);
89
147
  if (!token) {
@@ -139,7 +197,9 @@ let AuthFilter = class AuthFilter extends Filter_1.Filter {
139
197
  /** Stamp the parsed user's context entries + the principal into the RequestContext. */
140
198
  applyAuthValues(values) {
141
199
  for (const entry of values.entries) {
142
- core_context_1.RequestContext.putHeader(entry.key, entry.value);
200
+ // ContextTuple.key is a TRUSTED key by type, so this is the one sanctioned write of a
201
+ // proven identity: the app's JwtHook derived it from a credential we just verified.
202
+ core_context_1.RequestContext.putTrusted(entry.key, entry.value);
143
203
  }
144
204
  core_context_1.RequestContext.put(PRINCIPAL_KEY, values);
145
205
  }
@@ -1 +1 @@
1
- {"version":3,"file":"AuthFilter.js","sourceRoot":"","sources":["../../../../../../packages/http/http-routing/src/filters/AuthFilter.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject, optional } from 'inversify';\nimport { timingSafeEqual } from 'crypto';\nimport { provideFrameworkSingleton, RequestContext } from '@webpieces/core-context';\nimport { HttpUnauthorizedError, JwtRequirement, LogManager, toError } from '@webpieces/core-util';\nimport { Filter, WpResponse, Service } from '../Filter';\nimport { MethodMeta } from '../MethodMeta';\nimport { AuthConfig, AUTH_CONFIG, AuthValues, SharedSecrets } from '../AuthConfig';\nimport { JwtHook, JWT_HOOK, OidcHook, OIDC_HOOK } from '../AuthHooks';\nimport { DefaultOidcVerifier } from '../DefaultOidcVerifier';\n\nconst log = LogManager.getLogger('AuthFilter');\n\n/**\n * The ONE credential header, read straight off the inbound HttpRequest.\n *\n * Deliberately NOT a ContextKey: a ContextKey with an httpHeader is a TRANSFERRED key, which would\n * put the caller's credential into RequestContext and hence onto every outbound call this service\n * makes, and onto every Cloud Task it enqueues. A credential belongs to ONE request hop.\n */\nconst AUTHORIZATION_HEADER = 'authorization';\n\n/**\n * The scheme (first word of the Authorization value) names WHICH credential follows, so a secret\n * can never be mistaken for a token, nor accepted where the other was expected:\n *\n * Authorization: Bearer <user JWT | service OIDC token>\n * Authorization: Webpieces <@AuthSharedSecret value>\n *\n * The scheme is REQUIRED. A bare value with no scheme is rejected.\n */\nconst BEARER_SCHEME = 'Bearer';\nconst SHARED_SECRET_SCHEME = 'Webpieces';\n\n/** Reserved context key holding the authenticated {@link AuthValues} (stamped after a jwt parse). */\nconst PRINCIPAL_KEY = '__webpieces_principal__';\n\n/**\n * AuthFilter - the ONE framework auth filter, auto-installed just below the error filter on every\n * route. It is TRANSPORT-NEUTRAL: it reads the raw credential from the {@link HttpRequest} in\n * RequestContext (never express), so the SAME check runs over HTTP and via createApiClient.\n *\n * It enforces the endpoint's AuthMode from separately-bound pieces, each OPTIONAL except the OIDC\n * default:\n * - shared-secret → constant-time compare vs the {@link AuthConfig} secret VALUE (state). No\n * AuthConfig bound → no accepted secret → fail fast (401).\n * - jwt → the bound {@link JwtHook} (`parseJwt` + `authorizeJwt`). No JwtHook bound →\n * \"not enabled\" (401): JWT needs an app secret + payload shape.\n * - oidc → the bound {@link OidcHook} if any, else the framework {@link DefaultOidcVerifier}\n * run DIRECTLY — so a server that wires NOTHING still verifies Google OIDC.\n * - public → BEST-EFFORT jwt parse (only if a JwtHook is bound): stamp the user's context so\n * a logged-out page still knows who is logged in; never fails.\n *\n * Zero wiring = OIDC just works; an app only binds the hooks it actually uses.\n */\n@provideFrameworkSingleton()\n// webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\nexport class AuthFilter extends Filter<MethodMeta, WpResponse<unknown>> {\n constructor(\n // Framework default, always available — verifies Google OIDC with zero app wiring.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- AuthFilter is DI-resolved via the esbuild/vitest path, which elides type-only imports (no design:paramtypes), so every param needs its explicit token\n @inject(DefaultOidcVerifier) private readonly oidcVerifier: DefaultOidcVerifier,\n // @optional: only bind an AuthConfig to enable @AuthSharedSecret endpoints.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- see above: explicit token required for DI-resolved param\n @optional() @inject(AUTH_CONFIG) private readonly authConfig?: AuthConfig,\n // @optional: only bind a JwtHook to enable @AuthJwt endpoints.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- see above: explicit token required for DI-resolved param\n @optional() @inject(JWT_HOOK) private readonly jwtHook?: JwtHook,\n // @optional: only bind an OidcHook to OVERRIDE the DefaultOidcVerifier caller policy.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- see above: explicit token required for DI-resolved param\n @optional() @inject(OIDC_HOOK) private readonly oidcHook?: OidcHook,\n ) {\n super();\n }\n\n // webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\n override async filter(\n meta: MethodMeta,\n nextFilter: Service<MethodMeta, WpResponse<unknown>>,\n ): Promise<WpResponse<unknown>> {\n const mode = meta.routeMeta.authMeta?.mode;\n const authHeader = RequestContext.getRequest()?.getHeader(AUTHORIZATION_HEADER);\n\n if (!mode || mode.kind === 'public') {\n // Public: best-effort parse so a logged-out page can still know the logged-in user.\n this.bestEffortJwt(authHeader);\n return nextFilter.invoke(meta);\n }\n\n switch (mode.kind) {\n case 'jwt':\n this.enforceJwt(authHeader, mode.requirement);\n break;\n case 'oidc':\n await this.enforceOidc(authHeader, mode.callers);\n break;\n case 'shared-secret':\n this.enforceSharedSecret(this.credential(authHeader, SHARED_SECRET_SCHEME), mode.secretKey);\n break;\n }\n return nextFilter.invoke(meta);\n }\n\n private enforceJwt(header: string | undefined, requirement: JwtRequirement): void {\n const token = this.credential(header, BEARER_SCHEME);\n if (!token) {\n throw new HttpUnauthorizedError('Authentication required');\n }\n if (!this.jwtHook) {\n throw new HttpUnauthorizedError('User-JWT auth is not enabled on this server');\n }\n const values = this.jwtHook.parseJwt(token); // AUTHENTICATE — throws HttpUnauthorizedError if invalid\n this.applyAuthValues(values);\n this.jwtHook.authorizeJwt(values, requirement); // AUTHORIZE — app policy; throws HttpForbiddenError to deny\n }\n\n private async enforceOidc(header: string | undefined, callers: string[]): Promise<void> {\n const token = this.credential(header, BEARER_SCHEME);\n if (!token) {\n throw new HttpUnauthorizedError('Missing OIDC bearer token for @AuthOidc endpoint');\n }\n // App-bound OidcHook overrides the caller policy; otherwise the framework default runs directly.\n if (this.oidcHook) {\n await this.oidcHook.verifyOidc(token, callers);\n } else {\n await this.oidcVerifier.verify(token, callers);\n }\n }\n\n /** `provided` is the Authorization bearer value — the secret itself, same header as a JWT. */\n private enforceSharedSecret(provided: string | undefined, secretKey: string): void {\n const accepted = this.authConfig?.sharedSecrets[secretKey];\n if (!accepted || !provided || !this.matchesEither(provided, accepted)) {\n throw new HttpUnauthorizedError('Invalid shared secret for @AuthSharedSecret endpoint');\n }\n }\n\n /** EITHER secret1 or secret2 passes — the rotation window. Constant-time on each non-empty slot. */\n private matchesEither(provided: string, accepted: SharedSecrets): boolean {\n return (\n (accepted.secret1 !== '' && this.constantTimeEquals(provided, accepted.secret1)) ||\n (accepted.secret2 !== '' && this.constantTimeEquals(provided, accepted.secret2))\n );\n }\n\n /** Parse a JWT if one is present, else do nothing — used on public routes; never throws. */\n private bestEffortJwt(header: string | undefined): void {\n const token = this.credential(header, BEARER_SCHEME);\n if (!this.jwtHook || !token) {\n return;\n }\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- best-effort on a public route: a bad/absent token just means \"not logged in\", must not fail the request\n try {\n this.applyAuthValues(this.jwtHook.parseJwt(token));\n } catch (err: unknown) {\n const error = toError(err);\n log.debug('Best-effort JWT parse on a public endpoint failed (treating as anonymous): ', error);\n }\n }\n\n /** Stamp the parsed user's context entries + the principal into the RequestContext. */\n private applyAuthValues(values: AuthValues): void {\n for (const entry of values.entries) {\n RequestContext.putHeader(entry.key, entry.value);\n }\n RequestContext.put(PRINCIPAL_KEY, values);\n }\n\n /**\n * The credential value IF the header carries the expected scheme, else undefined.\n *\n * Strict: a bare value with no scheme, or a value under the WRONG scheme (a shared secret sent\n * where a JWT is expected), yields undefined and the caller 401s.\n */\n private credential(header: string | undefined, scheme: string): string | undefined {\n if (!header) {\n return undefined;\n }\n const prefix = `${scheme} `;\n return header.startsWith(prefix) ? header.substring(prefix.length) : undefined;\n }\n\n private constantTimeEquals(a: string, b: string): boolean {\n const bufA = Buffer.from(a, 'utf8');\n const bufB = Buffer.from(b, 'utf8');\n if (bufA.length !== bufB.length) {\n return false;\n }\n return timingSafeEqual(bufA, bufB);\n }\n}\n"]}
1
+ {"version":3,"file":"AuthFilter.js","sourceRoot":"","sources":["../../../../../../packages/http/http-routing/src/filters/AuthFilter.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject, optional } from 'inversify';\nimport { timingSafeEqual } from 'crypto';\nimport { provideFrameworkSingleton, PendingWireTrust, PendingTrustedValue, RequestContext } from '@webpieces/core-context';\nimport { HttpUnauthorizedError, JwtRequirement, LogManager, toError } from '@webpieces/core-util';\nimport { Filter, WpResponse, Service } from '../Filter';\nimport { MethodMeta } from '../MethodMeta';\nimport { AuthConfig, AUTH_CONFIG, AuthValues, SharedSecrets } from '../AuthConfig';\nimport { JwtHook, JWT_HOOK, OidcHook, OIDC_HOOK } from '../AuthHooks';\nimport { DefaultOidcVerifier } from '../DefaultOidcVerifier';\n\nconst log = LogManager.getLogger('AuthFilter');\n\n/**\n * The ONE credential header, read straight off the inbound HttpRequest.\n *\n * Deliberately NOT a ContextKey: a ContextKey with an httpHeader is a TRANSFERRED key, which would\n * put the caller's credential into RequestContext and hence onto every outbound call this service\n * makes, and onto every Cloud Task it enqueues. A credential belongs to ONE request hop.\n */\nconst AUTHORIZATION_HEADER = 'authorization';\n\n/**\n * The scheme (first word of the Authorization value) names WHICH credential follows, so a secret\n * can never be mistaken for a token, nor accepted where the other was expected:\n *\n * Authorization: Bearer <user JWT | service OIDC token>\n * Authorization: Webpieces <@AuthSharedSecret value>\n *\n * The scheme is REQUIRED. A bare value with no scheme is rejected.\n */\nconst BEARER_SCHEME = 'Bearer';\nconst SHARED_SECRET_SCHEME = 'Webpieces';\n\n/** Reserved context key holding the authenticated {@link AuthValues} (stamped after a jwt parse). */\nconst PRINCIPAL_KEY = '__webpieces_principal__';\n\n/**\n * AuthFilter - the ONE framework auth filter, auto-installed just below the error filter on every\n * route. It is TRANSPORT-NEUTRAL: it reads the raw credential from the {@link HttpRequest} in\n * RequestContext (never express), so the SAME check runs over HTTP and via createApiClient.\n *\n * It enforces the endpoint's AuthMode from separately-bound pieces, each OPTIONAL except the OIDC\n * default:\n * - shared-secret → constant-time compare vs the {@link AuthConfig} secret VALUE (state). No\n * AuthConfig bound → no accepted secret → fail fast (401).\n * - jwt → the bound {@link JwtHook} (`parseJwt` + `authorizeJwt`). No JwtHook bound →\n * \"not enabled\" (401): JWT needs an app secret + payload shape.\n * - oidc → the bound {@link OidcHook} if any, else the framework {@link DefaultOidcVerifier}\n * run DIRECTLY — so a server that wires NOTHING still verifies Google OIDC.\n * - public → BEST-EFFORT jwt parse (only if a JwtHook is bound): stamp the user's context so\n * a logged-out page still knows who is logged in; never fails.\n *\n * Zero wiring = OIDC just works; an app only binds the hooks it actually uses.\n */\n@provideFrameworkSingleton()\n// webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\nexport class AuthFilter extends Filter<MethodMeta, WpResponse<unknown>> {\n constructor(\n // Framework default, always available — verifies Google OIDC with zero app wiring.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- AuthFilter is DI-resolved via the esbuild/vitest path, which elides type-only imports (no design:paramtypes), so every param needs its explicit token\n @inject(DefaultOidcVerifier) private readonly oidcVerifier: DefaultOidcVerifier,\n // @optional: only bind an AuthConfig to enable @AuthSharedSecret endpoints.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- see above: explicit token required for DI-resolved param\n @optional() @inject(AUTH_CONFIG) private readonly authConfig?: AuthConfig,\n // @optional: only bind a JwtHook to enable @AuthJwt endpoints.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- see above: explicit token required for DI-resolved param\n @optional() @inject(JWT_HOOK) private readonly jwtHook?: JwtHook,\n // @optional: only bind an OidcHook to OVERRIDE the DefaultOidcVerifier caller policy.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- see above: explicit token required for DI-resolved param\n @optional() @inject(OIDC_HOOK) private readonly oidcHook?: OidcHook,\n ) {\n super();\n }\n\n // webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\n override async filter(\n meta: MethodMeta,\n nextFilter: Service<MethodMeta, WpResponse<unknown>>,\n ): Promise<WpResponse<unknown>> {\n const mode = meta.routeMeta.authMeta?.mode;\n const authHeader = RequestContext.getRequest()?.getHeader(AUTHORIZATION_HEADER);\n\n if (!mode || mode.kind === 'public') {\n // Public: best-effort parse so a logged-out page can still know the logged-in user.\n this.bestEffortJwt(authHeader);\n this.reconcileWireTrust(/*callerVerified*/ false);\n return nextFilter.invoke(meta);\n }\n\n switch (mode.kind) {\n case 'jwt':\n this.enforceJwt(authHeader, mode.requirement);\n break;\n case 'oidc':\n await this.enforceOidc(authHeader, mode.callers);\n break;\n case 'shared-secret':\n this.enforceSharedSecret(this.credential(authHeader, SHARED_SECRET_SCHEME), mode.secretKey);\n break;\n }\n // OIDC and shared-secret both authenticate the CALLER ITSELF, so an internal service is on\n // the other end and the trusted context it forwarded may be believed. A user JWT proves only\n // who the USER is — the sender is still whoever holds the token, i.e. a browser.\n this.reconcileWireTrust(/*callerVerified*/ mode.kind === 'oidc' || mode.kind === 'shared-secret');\n return nextFilter.invoke(meta);\n }\n\n /**\n * Decide what happens to the trusted keys that arrived on the WIRE and were held back by\n * {@link PendingWireTrust} (read that class for why they are held rather than written).\n *\n * `callerVerified` — the endpoint authenticated the SENDER (`@AuthOidc`, `@AuthSharedSecret`).\n * The sender is a service we trust, this is the service-to-service hop, and its forwarded\n * identity is admitted as-is. This is the case that makes propagating a verified userId across\n * internal services work.\n *\n * Otherwise the sender is a browser or anyone else with curl, and the ONLY acceptable inbound\n * trusted value is one the authenticator independently derived to the same value. Everything\n * else is rejected — see {@link requireVouched}.\n *\n * Runs AFTER the mode enforcement above, because that is what stamps the authenticator's own\n * values (`applyAuthValues`); comparing before it ran would compare against nothing.\n */\n private reconcileWireTrust(callerVerified: boolean): void {\n const pending = PendingWireTrust.takeAll();\n for (const item of pending) {\n if (callerVerified) {\n RequestContext.putTrusted(item.key, item.value);\n } else {\n this.requireVouched(item);\n }\n }\n }\n\n /**\n * On a browser-reachable route, an inbound trusted header must match what the authenticator\n * itself derived, or the request dies. Both failure shapes are rejections, not repairs:\n *\n * - DIFFERENT value — the caller said `alice`, the credential says `bob`. Silently letting the\n * credential win is not safe, because upstream rate limiters commonly bucket on the header\n * rather than the token: the request was already counted against the wrong principal, so\n * every forged header would be a free rate-limit bypass. No honest caller contradicts its own\n * credential.\n * - NOTHING vouched for it — nobody derived this key at all, so there is no evidence behind a\n * value a stranger typed. This is the common case, not the exotic one: the framework's\n * {@link DefaultJwtHook} stamps NO entries, and an app hook only stamps the keys it can prove,\n * so any other trusted key a caller sends lands here.\n *\n * The pending value is discarded either way — the throw is what leaves the request.\n */\n private requireVouched(item: PendingTrustedValue): void {\n const vouched = RequestContext.getTrusted(item.key);\n if (vouched === item.value) {\n return;\n }\n log.error(\n `Rejecting inbound '${item.key.httpHeader}': it is a TRUSTED context key, this route does ` +\n `not authenticate its caller, and the credential ` +\n (vouched === undefined ? 'vouched for no such value' : 'derived a different value') + '.',\n );\n throw new HttpUnauthorizedError(\n `Header '${item.key.httpHeader}' cannot be supplied by the caller on this endpoint`,\n );\n }\n\n private enforceJwt(header: string | undefined, requirement: JwtRequirement): void {\n const token = this.credential(header, BEARER_SCHEME);\n if (!token) {\n throw new HttpUnauthorizedError('Authentication required');\n }\n if (!this.jwtHook) {\n throw new HttpUnauthorizedError('User-JWT auth is not enabled on this server');\n }\n const values = this.jwtHook.parseJwt(token); // AUTHENTICATE — throws HttpUnauthorizedError if invalid\n this.applyAuthValues(values);\n this.jwtHook.authorizeJwt(values, requirement); // AUTHORIZE — app policy; throws HttpForbiddenError to deny\n }\n\n private async enforceOidc(header: string | undefined, callers: string[]): Promise<void> {\n const token = this.credential(header, BEARER_SCHEME);\n if (!token) {\n throw new HttpUnauthorizedError('Missing OIDC bearer token for @AuthOidc endpoint');\n }\n // App-bound OidcHook overrides the caller policy; otherwise the framework default runs directly.\n if (this.oidcHook) {\n await this.oidcHook.verifyOidc(token, callers);\n } else {\n await this.oidcVerifier.verify(token, callers);\n }\n }\n\n /** `provided` is the Authorization bearer value — the secret itself, same header as a JWT. */\n private enforceSharedSecret(provided: string | undefined, secretKey: string): void {\n const accepted = this.authConfig?.sharedSecrets[secretKey];\n if (!accepted || !provided || !this.matchesEither(provided, accepted)) {\n throw new HttpUnauthorizedError('Invalid shared secret for @AuthSharedSecret endpoint');\n }\n }\n\n /** EITHER secret1 or secret2 passes — the rotation window. Constant-time on each non-empty slot. */\n private matchesEither(provided: string, accepted: SharedSecrets): boolean {\n return (\n (accepted.secret1 !== '' && this.constantTimeEquals(provided, accepted.secret1)) ||\n (accepted.secret2 !== '' && this.constantTimeEquals(provided, accepted.secret2))\n );\n }\n\n /** Parse a JWT if one is present, else do nothing — used on public routes; never throws. */\n private bestEffortJwt(header: string | undefined): void {\n const token = this.credential(header, BEARER_SCHEME);\n if (!this.jwtHook || !token) {\n return;\n }\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- best-effort on a public route: a bad/absent token just means \"not logged in\", must not fail the request\n try {\n this.applyAuthValues(this.jwtHook.parseJwt(token));\n } catch (err: unknown) {\n const error = toError(err);\n log.debug('Best-effort JWT parse on a public endpoint failed (treating as anonymous): ', error);\n }\n }\n\n /** Stamp the parsed user's context entries + the principal into the RequestContext. */\n private applyAuthValues(values: AuthValues): void {\n for (const entry of values.entries) {\n // ContextTuple.key is a TRUSTED key by type, so this is the one sanctioned write of a\n // proven identity: the app's JwtHook derived it from a credential we just verified.\n RequestContext.putTrusted(entry.key, entry.value);\n }\n RequestContext.put(PRINCIPAL_KEY, values);\n }\n\n /**\n * The credential value IF the header carries the expected scheme, else undefined.\n *\n * Strict: a bare value with no scheme, or a value under the WRONG scheme (a shared secret sent\n * where a JWT is expected), yields undefined and the caller 401s.\n */\n private credential(header: string | undefined, scheme: string): string | undefined {\n if (!header) {\n return undefined;\n }\n const prefix = `${scheme} `;\n return header.startsWith(prefix) ? header.substring(prefix.length) : undefined;\n }\n\n private constantTimeEquals(a: string, b: string): boolean {\n const bufA = Buffer.from(a, 'utf8');\n const bufB = Buffer.from(b, 'utf8');\n if (bufA.length !== bufB.length) {\n return false;\n }\n return timingSafeEqual(bufA, bufB);\n }\n}\n"]}
@@ -52,10 +52,10 @@ let LogApiFilter = class LogApiFilter extends Filter_1.Filter {
52
52
  // gets them as separate jsonPayload.controller / jsonPayload.method; the local console formatters
53
53
  // render them together as a compact [Controller.method] bracket. They clear with the request scope.
54
54
  if (rm.controllerClassName) {
55
- core_context_1.RequestContext.putHeader(core_util_1.WebpiecesCoreHeaders.CONTROLLER, rm.controllerClassName);
55
+ core_context_1.RequestContext.putUntrusted(core_util_1.WebpiecesCoreHeaders.CONTROLLER, rm.controllerClassName);
56
56
  }
57
57
  if (rm.methodName) {
58
- core_context_1.RequestContext.putHeader(core_util_1.WebpiecesCoreHeaders.METHOD, rm.methodName);
58
+ core_context_1.RequestContext.putUntrusted(core_util_1.WebpiecesCoreHeaders.METHOD, rm.methodName);
59
59
  }
60
60
  const info = new core_util_1.ApiMethodInfo('server', rm.apiName ?? rm.controllerClassName ?? 'Unknown', rm.methodName, rm.controllerClassName, rm.mask);
61
61
  const response = await core_util_1.LogApiCall.execute(info, meta.requestDto, method);
@@ -1 +1 @@
1
- {"version":3,"file":"LogApiFilter.js","sourceRoot":"","sources":["../../../../../../packages/http/http-routing/src/filters/LogApiFilter.ts"],"names":[],"mappings":";;;;AAAA,0DAAoF;AACpF,oDAAmG;AACnG,sCAAwD;AAGxD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;AAI1C,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,eAAuC;IAErE,iGAAiG;IACjG,KAAK,CAAC,MAAM,CACR,IAAgB,EAChB,UAAoD;QAEpD,+DAA+D;QAC/D,uFAAuF;QACvF,MAAM,MAAM,GAAG,KAAK,IAAsB,EAAE;YACxC,MAAM,UAAU,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACjD,OAAO,UAAU,CAAC,QAAQ,CAAC;QAC/B,CAAC,CAAC;QAEF,+FAA+F;QAC/F,+FAA+F;QAC/F,+FAA+F;QAC/F,iGAAiG;QACjG,sGAAsG;QACtG,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC;QAE1B,iGAAiG;QACjG,4FAA4F;QAC5F,kGAAkG;QAClG,kGAAkG;QAClG,oGAAoG;QACpG,IAAI,EAAE,CAAC,mBAAmB,EAAE,CAAC;YACzB,6BAAc,CAAC,SAAS,CAAC,gCAAoB,CAAC,UAAU,EAAE,EAAE,CAAC,mBAAmB,CAAC,CAAC;QACtF,CAAC;QACD,IAAI,EAAE,CAAC,UAAU,EAAE,CAAC;YAChB,6BAAc,CAAC,SAAS,CAAC,gCAAoB,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC;QACzE,CAAC;QAED,MAAM,IAAI,GAAG,IAAI,yBAAa,CAC1B,QAAQ,EACR,EAAE,CAAC,OAAO,IAAI,EAAE,CAAC,mBAAmB,IAAI,SAAS,EACjD,EAAE,CAAC,UAAU,EACb,EAAE,CAAC,mBAAmB,EACtB,EAAE,CAAC,IAAI,CACV,CAAC;QACF,MAAM,QAAQ,GAAG,MAAM,sBAAU,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QACzE,OAAO,IAAI,mBAAU,CAAC,QAAQ,CAAC,CAAC;IACpC,CAAC;CACJ,CAAA;AA3CY,oCAAY;uBAAZ,YAAY;IAFxB,IAAA,wCAAyB,GAAE;IAC5B,iGAAiG;GACpF,YAAY,CA2CxB","sourcesContent":["import { provideFrameworkSingleton, RequestContext } from '@webpieces/core-context';\nimport { LogManager, WebpiecesCoreHeaders, LogApiCall, ApiMethodInfo } from '@webpieces/core-util';\nimport { Filter, WpResponse, Service } from '../Filter';\nimport { MethodMeta } from '../MethodMeta';\n\n/**\n * LogApiFilter - the OUTERMOST fixed framework filter (auto-installed at priority 1,000,000 on\n * every route, above AuthFilter). It logs the request AND the response/failure for EVERY call —\n * over HTTP or via createApiClient — and stamps the routed controller identity so every log line\n * of the request carries [Controller.method].\n *\n * Being outermost is deliberate: a request rejected by AuthFilter (401), or any other below-it\n * filter, is STILL logged here with its request body and controller identity. (The former\n * ErrorLogFilter sat above auth but logged only a bare error line — no request, no identity;\n * LogApiFilter replaces it and subsumes its error-logging via LogApiCall.)\n *\n * Logging patterns (via LogApiCall):\n * - [API-server-req] Class.method request={...}\n * - [API-server-resp-SUCCESS] Class.method response={...}\n * - [API-server-resp-FAIL] Class.method error=... (server errors: 500, 502, 504)\n * - [API-server-resp-OTHER] Class.method errorType=... (user errors: 400, 401, 403, 404, 266)\n *\n * User errors (HttpUnauthorizedError, HttpBadRequestError, etc.) are logged as OTHER, not FAIL,\n * because they are expected behavior from the server's perspective. LogApiCall re-throws the\n * error unchanged; the transport (express adapter, or another framework's adapter) maps\n * HttpError subclasses → HTTP status, so in-process and HTTP paths log identically.\n *\n * Headers are read from RequestContext (NOT from meta.requestHeaders which is undefined\n * after ContextFilter runs).\n */\nconst log = LogManager.getLogger('LogApiFilter');\n\n@provideFrameworkSingleton()\n// webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\nexport class LogApiFilter extends Filter<MethodMeta, WpResponse<unknown>> {\n\n // webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\n async filter(\n meta: MethodMeta,\n nextFilter: Service<MethodMeta, WpResponse<unknown>>,\n ): Promise<WpResponse<unknown>> {\n // Wrap nextFilter.invoke in a method that returns the response\n // webpieces-disable no-any-unknown -- response DTO is erased at the api/proxy boundary\n const method = async (): Promise<unknown> => {\n const wpResponse = await nextFilter.invoke(meta);\n return wpResponse.response;\n };\n\n // LogApiCall is a singleton (use it directly, no `new`). It logs the text lines AND stamps the\n // structured `api={method:{side:'server',...},...}` tag into RequestContext, so every log line\n // during the request carries jsonPayload.api. Correlation fields (requestId, ...) are added by\n // the backend. apiClass is the CONTRACT name (routeMeta.apiName, e.g. 'SaveApi') so a server log\n // line MATCHES the client's for the same call; controllerName keeps the impl (e.g. 'SaveController').\n const rm = meta.routeMeta;\n\n // Stamp the routed endpoint's IMPLEMENTATION identity onto the request context so EVERY log line\n // of this request (not just the api req/resp lines) carries the concrete controller class +\n // handler method name — what you actually grep for, and more useful than the raw requestPath. GCP\n // gets them as separate jsonPayload.controller / jsonPayload.method; the local console formatters\n // render them together as a compact [Controller.method] bracket. They clear with the request scope.\n if (rm.controllerClassName) {\n RequestContext.putHeader(WebpiecesCoreHeaders.CONTROLLER, rm.controllerClassName);\n }\n if (rm.methodName) {\n RequestContext.putHeader(WebpiecesCoreHeaders.METHOD, rm.methodName);\n }\n\n const info = new ApiMethodInfo(\n 'server',\n rm.apiName ?? rm.controllerClassName ?? 'Unknown',\n rm.methodName,\n rm.controllerClassName,\n rm.mask,\n );\n const response = await LogApiCall.execute(info, meta.requestDto, method);\n return new WpResponse(response);\n }\n}\n"]}
1
+ {"version":3,"file":"LogApiFilter.js","sourceRoot":"","sources":["../../../../../../packages/http/http-routing/src/filters/LogApiFilter.ts"],"names":[],"mappings":";;;;AAAA,0DAAoF;AACpF,oDAAmG;AACnG,sCAAwD;AAGxD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;AAI1C,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,eAAuC;IAErE,iGAAiG;IACjG,KAAK,CAAC,MAAM,CACR,IAAgB,EAChB,UAAoD;QAEpD,+DAA+D;QAC/D,uFAAuF;QACvF,MAAM,MAAM,GAAG,KAAK,IAAsB,EAAE;YACxC,MAAM,UAAU,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACjD,OAAO,UAAU,CAAC,QAAQ,CAAC;QAC/B,CAAC,CAAC;QAEF,+FAA+F;QAC/F,+FAA+F;QAC/F,+FAA+F;QAC/F,iGAAiG;QACjG,sGAAsG;QACtG,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC;QAE1B,iGAAiG;QACjG,4FAA4F;QAC5F,kGAAkG;QAClG,kGAAkG;QAClG,oGAAoG;QACpG,IAAI,EAAE,CAAC,mBAAmB,EAAE,CAAC;YACzB,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,UAAU,EAAE,EAAE,CAAC,mBAAmB,CAAC,CAAC;QACzF,CAAC;QACD,IAAI,EAAE,CAAC,UAAU,EAAE,CAAC;YAChB,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC;QAC5E,CAAC;QAED,MAAM,IAAI,GAAG,IAAI,yBAAa,CAC1B,QAAQ,EACR,EAAE,CAAC,OAAO,IAAI,EAAE,CAAC,mBAAmB,IAAI,SAAS,EACjD,EAAE,CAAC,UAAU,EACb,EAAE,CAAC,mBAAmB,EACtB,EAAE,CAAC,IAAI,CACV,CAAC;QACF,MAAM,QAAQ,GAAG,MAAM,sBAAU,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QACzE,OAAO,IAAI,mBAAU,CAAC,QAAQ,CAAC,CAAC;IACpC,CAAC;CACJ,CAAA;AA3CY,oCAAY;uBAAZ,YAAY;IAFxB,IAAA,wCAAyB,GAAE;IAC5B,iGAAiG;GACpF,YAAY,CA2CxB","sourcesContent":["import { provideFrameworkSingleton, RequestContext } from '@webpieces/core-context';\nimport { LogManager, WebpiecesCoreHeaders, LogApiCall, ApiMethodInfo } from '@webpieces/core-util';\nimport { Filter, WpResponse, Service } from '../Filter';\nimport { MethodMeta } from '../MethodMeta';\n\n/**\n * LogApiFilter - the OUTERMOST fixed framework filter (auto-installed at priority 1,000,000 on\n * every route, above AuthFilter). It logs the request AND the response/failure for EVERY call —\n * over HTTP or via createApiClient — and stamps the routed controller identity so every log line\n * of the request carries [Controller.method].\n *\n * Being outermost is deliberate: a request rejected by AuthFilter (401), or any other below-it\n * filter, is STILL logged here with its request body and controller identity. (The former\n * ErrorLogFilter sat above auth but logged only a bare error line — no request, no identity;\n * LogApiFilter replaces it and subsumes its error-logging via LogApiCall.)\n *\n * Logging patterns (via LogApiCall):\n * - [API-server-req] Class.method request={...}\n * - [API-server-resp-SUCCESS] Class.method response={...}\n * - [API-server-resp-FAIL] Class.method error=... (server errors: 500, 502, 504)\n * - [API-server-resp-OTHER] Class.method errorType=... (user errors: 400, 401, 403, 404, 266)\n *\n * User errors (HttpUnauthorizedError, HttpBadRequestError, etc.) are logged as OTHER, not FAIL,\n * because they are expected behavior from the server's perspective. LogApiCall re-throws the\n * error unchanged; the transport (express adapter, or another framework's adapter) maps\n * HttpError subclasses → HTTP status, so in-process and HTTP paths log identically.\n *\n * Headers are read from RequestContext (NOT from meta.requestHeaders which is undefined\n * after ContextFilter runs).\n */\nconst log = LogManager.getLogger('LogApiFilter');\n\n@provideFrameworkSingleton()\n// webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\nexport class LogApiFilter extends Filter<MethodMeta, WpResponse<unknown>> {\n\n // webpieces-disable no-any-unknown -- Filter generic params use unknown for response flexibility\n async filter(\n meta: MethodMeta,\n nextFilter: Service<MethodMeta, WpResponse<unknown>>,\n ): Promise<WpResponse<unknown>> {\n // Wrap nextFilter.invoke in a method that returns the response\n // webpieces-disable no-any-unknown -- response DTO is erased at the api/proxy boundary\n const method = async (): Promise<unknown> => {\n const wpResponse = await nextFilter.invoke(meta);\n return wpResponse.response;\n };\n\n // LogApiCall is a singleton (use it directly, no `new`). It logs the text lines AND stamps the\n // structured `api={method:{side:'server',...},...}` tag into RequestContext, so every log line\n // during the request carries jsonPayload.api. Correlation fields (requestId, ...) are added by\n // the backend. apiClass is the CONTRACT name (routeMeta.apiName, e.g. 'SaveApi') so a server log\n // line MATCHES the client's for the same call; controllerName keeps the impl (e.g. 'SaveController').\n const rm = meta.routeMeta;\n\n // Stamp the routed endpoint's IMPLEMENTATION identity onto the request context so EVERY log line\n // of this request (not just the api req/resp lines) carries the concrete controller class +\n // handler method name — what you actually grep for, and more useful than the raw requestPath. GCP\n // gets them as separate jsonPayload.controller / jsonPayload.method; the local console formatters\n // render them together as a compact [Controller.method] bracket. They clear with the request scope.\n if (rm.controllerClassName) {\n RequestContext.putUntrusted(WebpiecesCoreHeaders.CONTROLLER, rm.controllerClassName);\n }\n if (rm.methodName) {\n RequestContext.putUntrusted(WebpiecesCoreHeaders.METHOD, rm.methodName);\n }\n\n const info = new ApiMethodInfo(\n 'server',\n rm.apiName ?? rm.controllerClassName ?? 'Unknown',\n rm.methodName,\n rm.controllerClassName,\n rm.mask,\n );\n const response = await LogApiCall.execute(info, meta.requestDto, method);\n return new WpResponse(response);\n }\n}\n"]}