@webpieces/core-util 0.4.664 → 0.4.666
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
|
@@ -97,9 +97,9 @@ class WebpiecesCoreHeaders {
|
|
|
97
97
|
* hook does NOT stamp will be rejected when a caller supplies it, which is the correct and loud
|
|
98
98
|
* outcome: nothing is vouching for it.
|
|
99
99
|
*/
|
|
100
|
-
static ORG_ID = ContextKey_1.ContextKey.trusted('orgId', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in
|
|
101
|
-
static USER_ID = ContextKey_1.ContextKey.trusted('userId', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in
|
|
102
|
-
static USER_ROLES = ContextKey_1.ContextKey.trusted('roles', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in
|
|
100
|
+
static ORG_ID = ContextKey_1.ContextKey.trusted('orgId', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)', 'x-org-id');
|
|
101
|
+
static USER_ID = ContextKey_1.ContextKey.trusted('userId', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)', 'x-user-id');
|
|
102
|
+
static USER_ROLES = ContextKey_1.ContextKey.trusted('roles', 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)', 'x-webpieces-roles');
|
|
103
103
|
/**
|
|
104
104
|
* Turns on test-case recording for this request (Java: x-webpieces-recording).
|
|
105
105
|
* Transferred so recording follows the request across service hops.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WebpiecesCoreHeaders.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/WebpiecesCoreHeaders.ts"],"names":[],"mappings":";;;AAAA,8CAA0D;AAG1D;;;;;;;;;;;;;;;GAeG;AACH,MAAa,oBAAoB;IAC7B;;;OAGG;IACH,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,SAAS,CAAS,WAAW,EAAE,cAAc,CAAC,CAAC;IAEvF;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAU,iBAAiB,GAAG,uBAAU,CAAC,SAAS,CACpD,iBAAiB;IACjB,cAAc,CAAC,SAAS,CAC3B,CAAC;IAEF;;;;;;;;;;;;OAYG;IACH,MAAM,CAAU,cAAc,GAAG,uBAAU,CAAC,SAAS,CAAS,eAAe,EAAE,4BAA4B,CAAC,CAAC;IAE7G;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,MAAM,CAAU,SAAS,GAAG,uBAAU,CAAC,SAAS,CAAS,UAAU,EAAE,sBAAsB,CAAC,CAAC;IAE7F;;;;;;;;;;;;;;;;;;OAkBG;IACH,MAAM,CAAU,MAAM,GAAG,uBAAU,CAAC,OAAO,CACvC,OAAO,EACP,2FAA2F,EAC3F,UAAU,CACb,CAAC;IAEF,MAAM,CAAU,OAAO,GAAG,uBAAU,CAAC,OAAO,CACxC,QAAQ,EACR,2FAA2F,EAC3F,WAAW,CACd,CAAC;IAEF,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,OAAO,CAC3C,OAAO,EACP,2FAA2F,EAC3F,mBAAmB,CACtB,CAAC;IAEF;;;OAGG;IACH,MAAM,CAAU,SAAS,GAAG,uBAAU,CAAC,SAAS,CAAS,WAAW,EAAE,uBAAuB,CAAC,CAAC;IAE/F;;;;;;;;;;;OAWG;IACH,MAAM,CAAU,aAAa,GAAG,uBAAU,CAAC,SAAS,CAAc,KAAK,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE5I;;;;;;;;;;OAUG;IACH,MAAM,CAAU,WAAW,GAAG,uBAAU,CAAC,SAAS,CAAS,YAAY,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE5I,MAAM,CAAU,YAAY,GAAG,uBAAU,CAAC,SAAS,CAAS,aAAa,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE9I;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,SAAS,CAAS,YAAY,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE3I,MAAM,CAAU,MAAM,GAAG,uBAAU,CAAC,SAAS,CAAS,QAAQ,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAEnI;;;;;;;;;;;;;;;OAeG;IAEH;;;;;OAKG;IACH,MAAM,CAAU,WAAW,GAAoB;QAC3C,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,iBAAiB;QACtC,oBAAoB,CAAC,cAAc;QACnC,oBAAoB,CAAC,SAAS;QAC9B,oBAAoB,CAAC,OAAO;QAC5B,oBAAoB,CAAC,MAAM;QAC3B,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,SAAS;QAC9B,oBAAoB,CAAC,aAAa;QAClC,oBAAoB,CAAC,WAAW;QAChC,oBAAoB,CAAC,YAAY;QACjC,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,MAAM;KAC9B,CAAC;;AArMN,oDAsMC","sourcesContent":["import { ContextKey, AnyContextKey } from '../ContextKey';\nimport { ApiCallInfo } from './ApiCallInfo';\n\n/**\n * Core framework context keys — the minimum the WebPieces framework needs to correlate one\n * request across every service it touches, and across every log line each of them writes.\n *\n * ONE id, propagated unchanged. The first service to see a request without an `x-request-id`\n * generates one (RequestContextHeaders.fillFromRequest); every hop copies it onward verbatim. Grep that id and you\n * have the whole call tree. There is no per-hop id and no parent pointer: a chain of ids you must\n * stitch back together buys nothing a single shared id does not already give you.\n *\n * Lives in core-util (browser-safe) so both the http clients and http-server can reference it.\n *\n * Exposed as {@link HeaderRegistry.DEFAULT_HEADERS} — a service opts into these by\n * passing `platformHeaders=true` to `HeaderRegistry.configure(...)`.\n *\n * Each key's `name` is the logical/log name; `httpHeader` is the wire name.\n */\nexport class WebpiecesCoreHeaders {\n /**\n * The id that correlates every hop of one request, and every log line of every hop.\n * Generated by the first service to see a request without one; propagated unchanged after that.\n */\n static readonly REQUEST_ID = ContextKey.untrusted<string>('requestId', 'x-request-id');\n\n /**\n * WHICH SERVICE MINTED {@link REQUEST_ID} — the name from {@link ServiceInfo}, stamped by\n * `RequestContextHeaders.fillFromRequest` ONLY on the branch that generates a new id (i.e. when\n * the inbound request carried no `x-request-id`). It answers the question the id alone cannot:\n * \"this trace starts here — is that right?\" An id appearing with no source means it came from\n * outside; an id sourced by a service that should never be an entry point is a routing bug.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire, and that is the WHOLE POINT. If it\n * travelled, hop 2 would inherit it, hop 3 would inherit it, and \"who started this trace\"\n * would be indistinguishable from \"who passed it along\" — the origin, the one fact this key\n * carries, would be lost. It is absent on every hop that did NOT mint the id, which is exactly\n * the signal: present == I am the origin.\n * - `isLogged` TRUE → emitted as a plain string at `jsonPayload.requestIdSource`.\n */\n static readonly REQUEST_ID_SOURCE = ContextKey.untrusted<string>(\n 'requestIdSource',\n /*httpHeader*/ undefined\n );\n\n /**\n * The CALLER's build version — so a downstream server's logs record which build of the client\n * called it (surfaces as `jsonPayload.clientVersion`). Distinct from the log line's own `version`\n * (this service's build): `version` answers \"which build wrote this line?\", `clientVersion`\n * answers \"which build asked us to?\".\n *\n * - `httpHeader` SET → transferred over the wire, BUT unlike a normal transferred key it is NOT\n * copied from the context onward. Each hop OVERWRITES it with its OWN `ServiceInfo.getVersion()`\n * as it becomes the client to the next hop (see `buildOutboundHeaders`), so on any given server\n * `clientVersion` is always the IMMEDIATE caller's version, never a stale grand-caller's.\n * - `isLogged` TRUE → the inbound value lands in the context and flows through the normal log\n * field map; no backend change needed.\n */\n static readonly CLIENT_VERSION = ContextKey.untrusted<string>('clientVersion', 'x-webpieces-client-version');\n\n /**\n * A frontend/app-minted correlation id that groups every request triggered by ONE user ACTION.\n *\n * An \"action\" is a single thing the user did in the GUI — a CLICK on a button/link, or TYPING in a\n * field — or a background poller tick: anything that may fan out into MULTIPLE remote calls. That one\n * action fires 1..N browser HTTP calls, each of which gets its own framework-minted {@link REQUEST_ID}\n * (one per HTTP call, shared within that call's server→server subtree). `actionId` sits ABOVE\n * `requestId` and is what stitches those N requests back to the single action that caused them:\n *\n * actionId (app-minted, ONE per user action, rides EVERY call of that action)\n * └── 1..N requestId (framework-minted, ONE per HTTP call)\n *\n * Grep one `actionId` in the logs → every `requestId` it spawned, and every log line of the whole\n * action. Minted and refreshed by the app (a UI concern), carried under `x-webpieces-actionid`.\n *\n * Browser/app-minted ONLY: unlike {@link REQUEST_ID}, the framework transfers and logs it but must\n * NOT auto-mint one server-side. Absent `actionId` ⇒ a non-action flow (system / cron / task), which\n * is the correct signal.\n *\n * - `httpHeader` SET → transferred: copied off the inbound request into context and re-emitted on\n * outbound hops, so the id follows the action across services.\n * - `isLogged` TRUE → emitted as a plain string on every log line of the request.\n */\n static readonly ACTION_ID = ContextKey.untrusted<string>('actionId', 'x-webpieces-actionid');\n\n /**\n * WHO the request is acting as, and WHAT they may do. All three are TRUSTED keys: they are the\n * inputs to authorization decisions, so a reader must be able to tell \"the framework proved\n * this\" from \"the caller typed this\" — see the trust section of the {@link ContextKey} doc.\n *\n * They keep their `httpHeader`, because propagating a verified identity to the next internal\n * service is the point. What makes that safe is not the header being absent, it is WHO is\n * allowed to have set it: an inbound value is held PENDING by\n * `RequestContextHeaders.fillFromRequest` and admitted by `AuthFilter` only on a route that\n * verified its CALLER (`@AuthOidc` / `@AuthSharedSecret`). On a browser-reachable route\n * (`@AuthJwt` / public) the value must match what the authenticator itself derived, or the\n * request is rejected.\n *\n * `provenance` says \"an app-bound JwtHook\" rather than naming one hook, because the framework\n * default ({@link DefaultJwtHook}) stamps NO context entries at all — an app supplies a hook that\n * returns {@link ContextTuple}s for the keys it can vouch for. Any of these three that an app's\n * hook does NOT stamp will be rejected when a caller supplies it, which is the correct and loud\n * outcome: nothing is vouching for it.\n */\n static readonly ORG_ID = ContextKey.trusted<string>(\n 'orgId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthValues)',\n 'x-org-id',\n );\n\n static readonly USER_ID = ContextKey.trusted<string>(\n 'userId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthValues)',\n 'x-user-id',\n );\n\n static readonly USER_ROLES = ContextKey.trusted<string>(\n 'roles',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthValues)',\n 'x-webpieces-roles',\n );\n\n /**\n * Turns on test-case recording for this request (Java: x-webpieces-recording).\n * Transferred so recording follows the request across service hops.\n */\n static readonly RECORDING = ContextKey.untrusted<string>('recording', 'x-webpieces-recording');\n\n /**\n * The structured API-call tag ({@link ApiCallInfo}) stamped by {@link LogApiCall} around every\n * outbound (client) / inbound (server) call. It rides the magic context so EVERY log line emitted\n * during the call inherits a filterable `api` object, surfacing in GCP as nested\n * `jsonPayload.api.{side,type,result,path,method}`.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire. Per-hop only: each server/client hop\n * stamps its own tag, so a downstream server records `side:'server'`, never the caller's `side:'client'`.\n * - `isLogged` TRUE → emitted by the logging backends. It carries an OBJECT value, so the backends\n * read it via {@link HeaderRegistry.buildStructuredLogFields} (object-aware); the flat\n * `buildLogFields()` string map deliberately skips it (typeof-string guard).\n */\n static readonly API_CALL_INFO = ContextKey.untrusted<ApiCallInfo>('api', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n /**\n * The inbound request's HTTP method and path, stamped ONCE from the {@link HttpRequest} by\n * `RequestContextHeaders.fillFromRequest` (the atomic inbound choke point every transport funnels\n * through). They surface as top-level `jsonPayload.httpMethod` / `jsonPayload.requestPath` so every\n * log line of the request carries them — they used to ride inside {@link ApiCallInfo} (`api.path` /\n * `api.method`) but that coupled a per-CALL logger to the per-REQUEST transport shape.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire: a downstream hop stamps its OWN inbound\n * method/path, never the caller's. Outbound client calls never set these (no inbound path).\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly HTTP_METHOD = ContextKey.untrusted<string>('httpMethod', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n static readonly REQUEST_PATH = ContextKey.untrusted<string>('requestPath', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n /**\n * The routed endpoint's IMPLEMENTATION identity: the concrete controller class name\n * ({@link RouteMetadata.controllerClassName}, e.g. `LoginController`) and the handler method NAME\n * ({@link RouteMetadata.methodName}, e.g. `login`), stamped once per request by {@link LogApiFilter}\n * after route matching so every subsequent log line of the request carries them.\n *\n * These say WHICH CODE ran, which is what you actually grep for — far more useful than the raw\n * `requestPath`. They are the top-level, filterable twin of what previously only lived nested in\n * {@link ApiCallInfo} (`api.method.controllerName` / `api.method.methodName`). The local console\n * formatters render them together as a compact `[Controller.method]` bracket; GCP keeps them as two\n * separate `jsonPayload.controller` / `jsonPayload.method` fields.\n *\n * NOTE: `method` here is the CODE method name (e.g. `login`), NOT the HTTP verb — that is\n * {@link HTTP_METHOD} (`httpMethod`).\n *\n * - `httpHeader` UNDEFINED → NOT transferred: each hop stamps its OWN routed controller/method.\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly CONTROLLER = ContextKey.untrusted<string>('controller', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n static readonly METHOD = ContextKey.untrusted<string>('method', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n /**\n * NO CREDENTIAL KEYS LIVE HERE.\n *\n * `authorization` and `x-webpieces-shared-secret` used to be ContextKeys. That made them\n * TRANSFERRED keys, so the inbound transfer copied them off the request into the\n * RequestContext, and every outbound RPC call and enqueued Cloud Task then carried the\n * caller's credential onward — to services that had no business seeing it.\n *\n * A credential belongs to ONE request hop. It is read straight off the {@link HttpRequest}\n * by the framework AuthFilter, and written straight onto the outbound request by the client\n * that mints it (NodeProxyClient, GcpTaskInvoker, InMemoryTaskInvoker). It never enters the\n * magic context, so nothing can propagate it by accident.\n *\n * An app that genuinely wants a credential to travel can still register its own ContextKey for\n * it — but that is now an explicit, visible decision rather than the default.\n */\n\n /**\n * All core context keys (the platform DEFAULT_HEADERS set). A `static readonly` CONSTANT, not a\n * method: it is compile-time data — a list of the key definitions above — read once at the startup\n * composition root (`HeaderRegistry.configure` / `HeaderRegistry.DEFAULT_HEADERS`). A method here\n * would be un-injectable behavior the DI design graph can't reach; a constant is honest data.\n */\n static readonly ALL_HEADERS: AnyContextKey[] = [\n WebpiecesCoreHeaders.REQUEST_ID,\n WebpiecesCoreHeaders.REQUEST_ID_SOURCE,\n WebpiecesCoreHeaders.CLIENT_VERSION,\n WebpiecesCoreHeaders.ACTION_ID,\n WebpiecesCoreHeaders.USER_ID,\n WebpiecesCoreHeaders.ORG_ID,\n WebpiecesCoreHeaders.USER_ROLES,\n WebpiecesCoreHeaders.RECORDING,\n WebpiecesCoreHeaders.API_CALL_INFO,\n WebpiecesCoreHeaders.HTTP_METHOD,\n WebpiecesCoreHeaders.REQUEST_PATH,\n WebpiecesCoreHeaders.CONTROLLER,\n WebpiecesCoreHeaders.METHOD,\n ];\n}\n"]}
|
|
1
|
+
{"version":3,"file":"WebpiecesCoreHeaders.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/WebpiecesCoreHeaders.ts"],"names":[],"mappings":";;;AAAA,8CAA0D;AAG1D;;;;;;;;;;;;;;;GAeG;AACH,MAAa,oBAAoB;IAC7B;;;OAGG;IACH,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,SAAS,CAAS,WAAW,EAAE,cAAc,CAAC,CAAC;IAEvF;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAU,iBAAiB,GAAG,uBAAU,CAAC,SAAS,CACpD,iBAAiB;IACjB,cAAc,CAAC,SAAS,CAC3B,CAAC;IAEF;;;;;;;;;;;;OAYG;IACH,MAAM,CAAU,cAAc,GAAG,uBAAU,CAAC,SAAS,CAAS,eAAe,EAAE,4BAA4B,CAAC,CAAC;IAE7G;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,MAAM,CAAU,SAAS,GAAG,uBAAU,CAAC,SAAS,CAAS,UAAU,EAAE,sBAAsB,CAAC,CAAC;IAE7F;;;;;;;;;;;;;;;;;;OAkBG;IACH,MAAM,CAAU,MAAM,GAAG,uBAAU,CAAC,OAAO,CACvC,OAAO,EACP,oGAAoG,EACpG,UAAU,CACb,CAAC;IAEF,MAAM,CAAU,OAAO,GAAG,uBAAU,CAAC,OAAO,CACxC,QAAQ,EACR,oGAAoG,EACpG,WAAW,CACd,CAAC;IAEF,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,OAAO,CAC3C,OAAO,EACP,oGAAoG,EACpG,mBAAmB,CACtB,CAAC;IAEF;;;OAGG;IACH,MAAM,CAAU,SAAS,GAAG,uBAAU,CAAC,SAAS,CAAS,WAAW,EAAE,uBAAuB,CAAC,CAAC;IAE/F;;;;;;;;;;;OAWG;IACH,MAAM,CAAU,aAAa,GAAG,uBAAU,CAAC,SAAS,CAAc,KAAK,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE5I;;;;;;;;;;OAUG;IACH,MAAM,CAAU,WAAW,GAAG,uBAAU,CAAC,SAAS,CAAS,YAAY,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE5I,MAAM,CAAU,YAAY,GAAG,uBAAU,CAAC,SAAS,CAAS,aAAa,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE9I;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAU,UAAU,GAAG,uBAAU,CAAC,SAAS,CAAS,YAAY,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAE3I,MAAM,CAAU,MAAM,GAAG,uBAAU,CAAC,SAAS,CAAS,QAAQ,EAAE,cAAc,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IAEnI;;;;;;;;;;;;;;;OAeG;IAEH;;;;;OAKG;IACH,MAAM,CAAU,WAAW,GAAoB;QAC3C,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,iBAAiB;QACtC,oBAAoB,CAAC,cAAc;QACnC,oBAAoB,CAAC,SAAS;QAC9B,oBAAoB,CAAC,OAAO;QAC5B,oBAAoB,CAAC,MAAM;QAC3B,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,SAAS;QAC9B,oBAAoB,CAAC,aAAa;QAClC,oBAAoB,CAAC,WAAW;QAChC,oBAAoB,CAAC,YAAY;QACjC,oBAAoB,CAAC,UAAU;QAC/B,oBAAoB,CAAC,MAAM;KAC9B,CAAC;;AArMN,oDAsMC","sourcesContent":["import { ContextKey, AnyContextKey } from '../ContextKey';\nimport { ApiCallInfo } from './ApiCallInfo';\n\n/**\n * Core framework context keys — the minimum the WebPieces framework needs to correlate one\n * request across every service it touches, and across every log line each of them writes.\n *\n * ONE id, propagated unchanged. The first service to see a request without an `x-request-id`\n * generates one (RequestContextHeaders.fillFromRequest); every hop copies it onward verbatim. Grep that id and you\n * have the whole call tree. There is no per-hop id and no parent pointer: a chain of ids you must\n * stitch back together buys nothing a single shared id does not already give you.\n *\n * Lives in core-util (browser-safe) so both the http clients and http-server can reference it.\n *\n * Exposed as {@link HeaderRegistry.DEFAULT_HEADERS} — a service opts into these by\n * passing `platformHeaders=true` to `HeaderRegistry.configure(...)`.\n *\n * Each key's `name` is the logical/log name; `httpHeader` is the wire name.\n */\nexport class WebpiecesCoreHeaders {\n /**\n * The id that correlates every hop of one request, and every log line of every hop.\n * Generated by the first service to see a request without one; propagated unchanged after that.\n */\n static readonly REQUEST_ID = ContextKey.untrusted<string>('requestId', 'x-request-id');\n\n /**\n * WHICH SERVICE MINTED {@link REQUEST_ID} — the name from {@link ServiceInfo}, stamped by\n * `RequestContextHeaders.fillFromRequest` ONLY on the branch that generates a new id (i.e. when\n * the inbound request carried no `x-request-id`). It answers the question the id alone cannot:\n * \"this trace starts here — is that right?\" An id appearing with no source means it came from\n * outside; an id sourced by a service that should never be an entry point is a routing bug.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire, and that is the WHOLE POINT. If it\n * travelled, hop 2 would inherit it, hop 3 would inherit it, and \"who started this trace\"\n * would be indistinguishable from \"who passed it along\" — the origin, the one fact this key\n * carries, would be lost. It is absent on every hop that did NOT mint the id, which is exactly\n * the signal: present == I am the origin.\n * - `isLogged` TRUE → emitted as a plain string at `jsonPayload.requestIdSource`.\n */\n static readonly REQUEST_ID_SOURCE = ContextKey.untrusted<string>(\n 'requestIdSource',\n /*httpHeader*/ undefined\n );\n\n /**\n * The CALLER's build version — so a downstream server's logs record which build of the client\n * called it (surfaces as `jsonPayload.clientVersion`). Distinct from the log line's own `version`\n * (this service's build): `version` answers \"which build wrote this line?\", `clientVersion`\n * answers \"which build asked us to?\".\n *\n * - `httpHeader` SET → transferred over the wire, BUT unlike a normal transferred key it is NOT\n * copied from the context onward. Each hop OVERWRITES it with its OWN `ServiceInfo.getVersion()`\n * as it becomes the client to the next hop (see `buildOutboundHeaders`), so on any given server\n * `clientVersion` is always the IMMEDIATE caller's version, never a stale grand-caller's.\n * - `isLogged` TRUE → the inbound value lands in the context and flows through the normal log\n * field map; no backend change needed.\n */\n static readonly CLIENT_VERSION = ContextKey.untrusted<string>('clientVersion', 'x-webpieces-client-version');\n\n /**\n * A frontend/app-minted correlation id that groups every request triggered by ONE user ACTION.\n *\n * An \"action\" is a single thing the user did in the GUI — a CLICK on a button/link, or TYPING in a\n * field — or a background poller tick: anything that may fan out into MULTIPLE remote calls. That one\n * action fires 1..N browser HTTP calls, each of which gets its own framework-minted {@link REQUEST_ID}\n * (one per HTTP call, shared within that call's server→server subtree). `actionId` sits ABOVE\n * `requestId` and is what stitches those N requests back to the single action that caused them:\n *\n * actionId (app-minted, ONE per user action, rides EVERY call of that action)\n * └── 1..N requestId (framework-minted, ONE per HTTP call)\n *\n * Grep one `actionId` in the logs → every `requestId` it spawned, and every log line of the whole\n * action. Minted and refreshed by the app (a UI concern), carried under `x-webpieces-actionid`.\n *\n * Browser/app-minted ONLY: unlike {@link REQUEST_ID}, the framework transfers and logs it but must\n * NOT auto-mint one server-side. Absent `actionId` ⇒ a non-action flow (system / cron / task), which\n * is the correct signal.\n *\n * - `httpHeader` SET → transferred: copied off the inbound request into context and re-emitted on\n * outbound hops, so the id follows the action across services.\n * - `isLogged` TRUE → emitted as a plain string on every log line of the request.\n */\n static readonly ACTION_ID = ContextKey.untrusted<string>('actionId', 'x-webpieces-actionid');\n\n /**\n * WHO the request is acting as, and WHAT they may do. All three are TRUSTED keys: they are the\n * inputs to authorization decisions, so a reader must be able to tell \"the framework proved\n * this\" from \"the caller typed this\" — see the trust section of the {@link ContextKey} doc.\n *\n * They keep their `httpHeader`, because propagating a verified identity to the next internal\n * service is the point. What makes that safe is not the header being absent, it is WHO is\n * allowed to have set it: an inbound value is held PENDING by\n * `RequestContextHeaders.fillFromRequest` and admitted by `AuthFilter` only on a route that\n * verified its CALLER (`@AuthOidc` / `@AuthSharedSecret`). On a browser-reachable route\n * (`@AuthJwt` / public) the value must match what the authenticator itself derived, or the\n * request is rejected.\n *\n * `provenance` says \"an app-bound JwtHook\" rather than naming one hook, because the framework\n * default ({@link DefaultJwtHook}) stamps NO context entries at all — an app supplies a hook that\n * returns {@link ContextTuple}s for the keys it can vouch for. Any of these three that an app's\n * hook does NOT stamp will be rejected when a caller supplies it, which is the correct and loud\n * outcome: nothing is vouching for it.\n */\n static readonly ORG_ID = ContextKey.trusted<string>(\n 'orgId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-org-id',\n );\n\n static readonly USER_ID = ContextKey.trusted<string>(\n 'userId',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-user-id',\n );\n\n static readonly USER_ROLES = ContextKey.trusted<string>(\n 'roles',\n 'derived from a verified credential by an app-bound JwtHook (a ContextTuple in AuthenticatedCaller)',\n 'x-webpieces-roles',\n );\n\n /**\n * Turns on test-case recording for this request (Java: x-webpieces-recording).\n * Transferred so recording follows the request across service hops.\n */\n static readonly RECORDING = ContextKey.untrusted<string>('recording', 'x-webpieces-recording');\n\n /**\n * The structured API-call tag ({@link ApiCallInfo}) stamped by {@link LogApiCall} around every\n * outbound (client) / inbound (server) call. It rides the magic context so EVERY log line emitted\n * during the call inherits a filterable `api` object, surfacing in GCP as nested\n * `jsonPayload.api.{side,type,result,path,method}`.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire. Per-hop only: each server/client hop\n * stamps its own tag, so a downstream server records `side:'server'`, never the caller's `side:'client'`.\n * - `isLogged` TRUE → emitted by the logging backends. It carries an OBJECT value, so the backends\n * read it via {@link HeaderRegistry.buildStructuredLogFields} (object-aware); the flat\n * `buildLogFields()` string map deliberately skips it (typeof-string guard).\n */\n static readonly API_CALL_INFO = ContextKey.untrusted<ApiCallInfo>('api', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n /**\n * The inbound request's HTTP method and path, stamped ONCE from the {@link HttpRequest} by\n * `RequestContextHeaders.fillFromRequest` (the atomic inbound choke point every transport funnels\n * through). They surface as top-level `jsonPayload.httpMethod` / `jsonPayload.requestPath` so every\n * log line of the request carries them — they used to ride inside {@link ApiCallInfo} (`api.path` /\n * `api.method`) but that coupled a per-CALL logger to the per-REQUEST transport shape.\n *\n * - `httpHeader` UNDEFINED → NOT transferred over the wire: a downstream hop stamps its OWN inbound\n * method/path, never the caller's. Outbound client calls never set these (no inbound path).\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly HTTP_METHOD = ContextKey.untrusted<string>('httpMethod', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n static readonly REQUEST_PATH = ContextKey.untrusted<string>('requestPath', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n /**\n * The routed endpoint's IMPLEMENTATION identity: the concrete controller class name\n * ({@link RouteMetadata.controllerClassName}, e.g. `LoginController`) and the handler method NAME\n * ({@link RouteMetadata.methodName}, e.g. `login`), stamped once per request by {@link LogApiFilter}\n * after route matching so every subsequent log line of the request carries them.\n *\n * These say WHICH CODE ran, which is what you actually grep for — far more useful than the raw\n * `requestPath`. They are the top-level, filterable twin of what previously only lived nested in\n * {@link ApiCallInfo} (`api.method.controllerName` / `api.method.methodName`). The local console\n * formatters render them together as a compact `[Controller.method]` bracket; GCP keeps them as two\n * separate `jsonPayload.controller` / `jsonPayload.method` fields.\n *\n * NOTE: `method` here is the CODE method name (e.g. `login`), NOT the HTTP verb — that is\n * {@link HTTP_METHOD} (`httpMethod`).\n *\n * - `httpHeader` UNDEFINED → NOT transferred: each hop stamps its OWN routed controller/method.\n * - `isLogged` TRUE → emitted by the logging backends as plain strings.\n */\n static readonly CONTROLLER = ContextKey.untrusted<string>('controller', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n static readonly METHOD = ContextKey.untrusted<string>('method', /*httpHeader*/ undefined, /*maskInLogs*/ false, /*isLogged*/ true);\n\n /**\n * NO CREDENTIAL KEYS LIVE HERE.\n *\n * `authorization` and `x-webpieces-shared-secret` used to be ContextKeys. That made them\n * TRANSFERRED keys, so the inbound transfer copied them off the request into the\n * RequestContext, and every outbound RPC call and enqueued Cloud Task then carried the\n * caller's credential onward — to services that had no business seeing it.\n *\n * A credential belongs to ONE request hop. It is read straight off the {@link HttpRequest}\n * by the framework AuthFilter, and written straight onto the outbound request by the client\n * that mints it (NodeProxyClient, GcpTaskInvoker, InMemoryTaskInvoker). It never enters the\n * magic context, so nothing can propagate it by accident.\n *\n * An app that genuinely wants a credential to travel can still register its own ContextKey for\n * it — but that is now an explicit, visible decision rather than the default.\n */\n\n /**\n * All core context keys (the platform DEFAULT_HEADERS set). A `static readonly` CONSTANT, not a\n * method: it is compile-time data — a list of the key definitions above — read once at the startup\n * composition root (`HeaderRegistry.configure` / `HeaderRegistry.DEFAULT_HEADERS`). A method here\n * would be un-injectable behavior the DI design graph can't reach; a constant is honest data.\n */\n static readonly ALL_HEADERS: AnyContextKey[] = [\n WebpiecesCoreHeaders.REQUEST_ID,\n WebpiecesCoreHeaders.REQUEST_ID_SOURCE,\n WebpiecesCoreHeaders.CLIENT_VERSION,\n WebpiecesCoreHeaders.ACTION_ID,\n WebpiecesCoreHeaders.USER_ID,\n WebpiecesCoreHeaders.ORG_ID,\n WebpiecesCoreHeaders.USER_ROLES,\n WebpiecesCoreHeaders.RECORDING,\n WebpiecesCoreHeaders.API_CALL_INFO,\n WebpiecesCoreHeaders.HTTP_METHOD,\n WebpiecesCoreHeaders.REQUEST_PATH,\n WebpiecesCoreHeaders.CONTROLLER,\n WebpiecesCoreHeaders.METHOD,\n ];\n}\n"]}
|
package/src/http/decorators.d.ts
CHANGED
|
@@ -350,11 +350,11 @@ export declare function AuthWebhook(name: string): ClassDecorator & MethodDecora
|
|
|
350
350
|
* with `jwt` on the caller-NOT-verified side, where an inbound trusted header is admitted only when
|
|
351
351
|
* the hook independently derived the SAME value.
|
|
352
352
|
*
|
|
353
|
-
* WHY THE HOOK SEES THE
|
|
353
|
+
* WHY THE HOOK SEES THE REQUEST, NOT ONE TOKEN. A real key regime checks the key TOGETHER WITH a
|
|
354
354
|
* second header (the organization it is acting for), and `JwtHook.parseJwt` — handed one pre-extracted
|
|
355
|
-
* token from one header — physically cannot. `ApiKeyHook.verifyApiKey(name,
|
|
356
|
-
* instead, so the app owns which headers carry the credential and validates them as a
|
|
357
|
-
* framework deliberately configures no header name: that cross-check is the entire point.
|
|
355
|
+
* token from one header — physically cannot. `ApiKeyHook.verifyApiKey(name, request)` gets the whole
|
|
356
|
+
* inbound request instead, so the app owns which headers carry the credential and validates them as a
|
|
357
|
+
* PAIR. The framework deliberately configures no header name: that cross-check is the entire point.
|
|
358
358
|
*
|
|
359
359
|
* FAILS CLOSED: with no `ApiKeyHook` bound, every `@AuthApiKey` endpoint 401s, matching `JwtHook` and
|
|
360
360
|
* `WebhookAuthCallback`.
|
package/src/http/decorators.js
CHANGED
|
@@ -288,11 +288,11 @@ function AuthWebhook(name) {
|
|
|
288
288
|
* with `jwt` on the caller-NOT-verified side, where an inbound trusted header is admitted only when
|
|
289
289
|
* the hook independently derived the SAME value.
|
|
290
290
|
*
|
|
291
|
-
* WHY THE HOOK SEES THE
|
|
291
|
+
* WHY THE HOOK SEES THE REQUEST, NOT ONE TOKEN. A real key regime checks the key TOGETHER WITH a
|
|
292
292
|
* second header (the organization it is acting for), and `JwtHook.parseJwt` — handed one pre-extracted
|
|
293
|
-
* token from one header — physically cannot. `ApiKeyHook.verifyApiKey(name,
|
|
294
|
-
* instead, so the app owns which headers carry the credential and validates them as a
|
|
295
|
-
* framework deliberately configures no header name: that cross-check is the entire point.
|
|
293
|
+
* token from one header — physically cannot. `ApiKeyHook.verifyApiKey(name, request)` gets the whole
|
|
294
|
+
* inbound request instead, so the app owns which headers carry the credential and validates them as a
|
|
295
|
+
* PAIR. The framework deliberately configures no header name: that cross-check is the entire point.
|
|
296
296
|
*
|
|
297
297
|
* FAILS CLOSED: with no `ApiKeyHook` bound, every `@AuthApiKey` endpoint 401s, matching `JwtHook` and
|
|
298
298
|
* `WebhookAuthCallback`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decorators.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/decorators.ts"],"names":[],"mappings":"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","sourcesContent":["import 'reflect-metadata';\nimport { MaskSpec, MaskMode } from './LogFieldMask';\nimport { DEFAULT_CALLER_KIND, ENDPOINT_CALLER_KEY, ExternalCaller, ExternalSystemKind, getEndpointCaller } from './external-caller';\n\n/**\n * Metadata keys for storing API routing information.\n * These keys are used by both server-side (routing) and client-side (client generation).\n */\nexport const METADATA_KEYS = {\n API_PATH: 'webpieces:api-path',\n ENDPOINTS: 'webpieces:endpoints',\n AUTH_META: 'webpieces:auth-meta',\n /** 'rpc' (default, sync request/response) vs 'pubsub' (fire-and-forget cloud task). */\n API_KIND: 'webpieces:api-kind',\n /** Per-method Cloud Tasks queue-name override (set via @Queue). */\n QUEUE_OVERRIDE: 'webpieces:queue-override',\n /** Per-method @Endpoint options (e.g. formPost), parallel to ENDPOINTS. */\n ENDPOINT_OPTIONS: 'webpieces:endpoint-options',\n /** Per-method @Endpoint trigger kind (rpc | cloudtasks | cron | external), parallel to ENDPOINTS. */\n ENDPOINT_KIND: 'webpieces:endpoint-kind',\n /** Per-method declared external CALLER (only for kind 'external'), parallel to ENDPOINTS. */\n ENDPOINT_CALLER: ENDPOINT_CALLER_KEY,\n /** Per-method @MaskLog spec (which DTO fields the LogApiCall path masks). */\n MASK_LOG: 'webpieces:mask-log',\n};\n\n/**\n * WHAT TRIGGERS an endpoint at runtime — the single fact that decides how the runtime architecture\n * graph draws it, and which Terraform resource must exist for it to ever fire:\n *\n * - `rpc` — a caller in this repo (or a browser) calls it synchronously. A direct arrow.\n * - `cloudtasks` — a producer ENQUEUES it; Cloud Tasks delivers it later. Drawn producer → queue →\n * consumer, one queue node per METHOD (see {@link Queue}). Producer and consumer\n * being the SAME service is legal and common — the queue decouples them.\n * - `cron` — a scheduler fires it on a clock. Nothing in-repo calls it; drawn hanging off a\n * clock symbol. Backed by a Cloud Scheduler job.\n * - `external` — a system OUTSIDE this repo drives it (a GCP Pub/Sub push subscription, a Twilio\n * or Gmail webhook). Drawn as an inbound dashed arrow from that system.\n *\n * Declared PER METHOD, because one api class routinely mixes them: an admin contract can have\n * caller-driven endpoints AND a nightly cron sweep. A class-level marker cannot express that, which\n * is exactly why the graph could not tell these apart before.\n */\nexport type EndpointKind = 'rpc' | 'cloudtasks' | 'cron' | 'external';\n\n/**\n * Options for a single @Endpoint. Kept in a metadata map PARALLEL to ENDPOINTS so the existing\n * `Record<methodName, path>` shape every consumer iterates stays unchanged.\n */\nexport interface EndpointOptions {\n /**\n * Parse the request body as application/x-www-form-urlencoded (flat key→value) instead of JSON.\n * For EXTERNAL webhooks (e.g. Twilio) that post form-encoded. The request DTO must be FLAT —\n * urlencoded has no nesting (unlike JSON). Default false = JSON.\n */\n formPost?: boolean;\n\n /**\n * RETAIN the verbatim request bytes + the absolute url the sender addressed, so an\n * {@link AuthWebhook} hook can verify a vendor signature over them (see `RawRequest`).\n *\n * Opt-in PER ENDPOINT, sitting beside `formPost` and for the same reason: the cost lands on the\n * handful of webhook routes rather than on every request in the process. It is retention, not\n * new buffering — the express adapter already accumulates the whole body, it simply threw it\n * away once it had parsed a DTO.\n *\n * REQUIRED by `@AuthWebhook`, checked at wiring time (see\n * {@link assertEveryWebhookEndpointRetainsRawBody}) rather than left to fail as a 401 in\n * production: a hook with nothing to verify is a misconfiguration, not a bad request.\n *\n * Combines with `formPost` — `{ formPost: true, rawBody: true }` is the Twilio case, where the\n * hook needs the bytes and the url while the controller still wants the flat parsed DTO.\n */\n rawBody?: boolean;\n}\n\n/**\n * Options for an `external` @Endpoint: everything {@link EndpointOptions} carries, PLUS a REQUIRED\n * declaration of WHO is calling. See {@link Endpoint} for why, `external-caller.ts` for identity.\n */\nexport interface ExternalEndpointOptions extends EndpointOptions {\n /** The outside system that posts here (`'twilio'`) — the graph node IDENTITY, not display text. */\n calledBy: string;\n /** What that caller IS; picks the node's shape. Defaults to `'saas'` (see DEFAULT_CALLER_KIND). */\n callerKind?: ExternalSystemKind;\n}\n\n/**\n * The role decision for a JWT endpoint, as a union the COMPILER enforces — one spelling per decision,\n * every broken combination a compile error:\n *\n * ```typescript\n * @AuthJwt({ roles: ['admin'] }) // ✅ role-gated (any-of)\n * @AuthJwt({ allRolesAllowed: true }) // ✅ every authenticated user, said out loud\n * @AuthJwt({}) // ❌ pick a branch\n * @AuthJwt({ roles: [] }) // ❌ needs at least one role\n * ```\n *\n * `allRolesAllowed` exists ONLY on the wide branch (the dangerous half must be a greppable token, and\n * the narrow branch rejects it as a redundant second spelling); `roles` is a NON-EMPTY tuple so\n * \"declared roles, passed none\" — the old optional `string[]`'s silent widest grant — cannot be written.\n * All six bad cases are pinned in `AuthJwtCompileAssertions.ts` — a COMPILED file, not a spec: tsc\n * fails the build (TS2578) if any starts compiling. A spec cannot do this (see that file's header).\n *\n * WHY a type rather than the runtime `throw` this replaced: `.claude/review/backwards-compatibility.md`\n * shim shapes #4 and #5. Not restated here — three copies of one rationale is three things to drift.\n */\nexport type JwtRoles =\n | { allRolesAllowed: true; roles?: never }\n | { roles: readonly [string, ...string[]]; allRolesAllowed?: never };\n\n/**\n * JwtRequirement - the {@link JwtRoles} decision PLUS any app-defined authorization fields, e.g.\n * `@AuthJwt({ allRolesAllowed: true, inOrg: true })`. The framework authenticates (JwtHook.parseJwt)\n * and enforces the roles any-of; the app overrides JwtHook.authorizeJwt to enforce its own fields.\n * Both hook methods are ASYNC, so an app field like `inOrg` may be answered from a datastore.\n *\n * This was a SECOND decorator (`@Auth`) whose `roles` was optional — so `@Auth({})` reached the exact\n * widest grant that {@link JwtRoles} exists to make un-typeable. Folding it in leaves one decorator per\n * credential kind and closes that route by construction.\n */\n// webpieces-disable no-any-unknown -- app-defined authorization fields (inOrg, tenant, ...)\nexport type JwtRequirement = JwtRoles & { [field: string]: unknown };\n\n/**\n * The service-to-service / user auth mode of an endpoint. Discriminated union so a filter can\n * `switch (mode.kind)` and get the data it needs, exhaustively.\n *\n * - `public` → no auth check\n * - `jwt` → user JWT; `requirement` carries the compiler-enforced role decision\n * ({@link JwtRoles}) plus any app-defined authorization fields\n * - `oidc` → Google OIDC service-to-service (Cloud Tasks delivery / cross-service RPC);\n * `callers` is the allow-list of caller SAs ('self' = this service's SA)\n * - `shared-secret` → constant-time compare of a header against the secret bound for `secretKey`\n * - `webhook` → an OUTSIDE vendor signed this request its own way; the app's bound `WebhookAuthCallback`\n * verifies it, selected by `name`. The framework ships NO vendor crypto (see\n * {@link AuthWebhook}).\n * - `apikey` → a CUSTOMER holds the credential; the app's bound `ApiKeyHook` looks it up\n * (async, over the whole header set) and returns the context to seed, selected\n * by `name`. NOT a peer service — see {@link AuthApiKey}.\n * - `local-only` → exists ONLY on a developer's machine; not registered and never served when\n * {@link RuntimeLocality} says this process is deployed. Authenticates NOBODY —\n * it is a deployment gate, not a credential.\n */\nexport type AuthMode =\n | { kind: 'public' }\n | { kind: 'jwt'; requirement: JwtRequirement }\n | { kind: 'oidc'; callers: string[] }\n | { kind: 'shared-secret'; secretKey: string }\n | { kind: 'webhook'; name: string }\n | { kind: 'apikey'; name: string }\n | { kind: 'local-only' };\n\n/**\n * Auth metadata attached to a class or method via one of the auth decorators\n * (@Public / @AuthJwt / @AuthOidc / @AuthSharedSecret / @AuthWebhook / @AuthApiKey / @AuthLocalOnly) —\n * one per credential kind.\n *\n * Carries a discriminated {@link AuthMode} and nothing else. It USED to also expose\n * `authenticated`/`roles` getters \"for back-compat with readers that only understand the user-JWT\n * model\" — deleted, because nothing read them: every reader (AuthFilter, BrowserProxyClient,\n * ProxyClient) switches on `mode.kind`, which is the whole point of the discriminated union. A\n * flattened view of a union is a second spelling of it, and the flattened one silently answers\n * `authenticated: true` for oidc and shared-secret too.\n */\nexport class AuthMeta {\n mode: AuthMode;\n\n constructor(mode: AuthMode) {\n this.mode = mode;\n }\n}\n\n/**\n * @ApiPath(basePath) - Class decorator that marks a class as an API definition\n * and sets the base path for all endpoints.\n *\n * Usage:\n * ```typescript\n * @AuthJwt({ roles: ['admin'] })\n * @ApiPath('/api/save')\n * abstract class SaveApi {\n * @Endpoint('/item', 'rpc')\n * save(request: SaveRequest): Promise<SaveResponse> { ... }\n * }\n * ```\n */\nexport function ApiPath(basePath: string): ClassDecorator {\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any) => {\n Reflect.defineMetadata(METADATA_KEYS.API_PATH, basePath, target);\n\n // Initialize endpoints map if not exists\n if (!Reflect.hasMetadata(METADATA_KEYS.ENDPOINTS, target)) {\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINTS, {}, target);\n }\n };\n}\n\n/**\n * @Endpoint(path, kind, options?) - Method decorator that registers a POST endpoint at the given\n * path and declares WHAT TRIGGERS it.\n *\n * All endpoints are POST-only (matching gRPC/thrift style).\n *\n * Usage:\n * ```typescript\n * @Endpoint('/item', 'rpc')\n * save(request: SaveRequest): Promise<SaveResponse> { ... }\n *\n * // enqueued by a producer, delivered later by Cloud Tasks:\n * @Endpoint('/send', 'cloudtasks')\n * send(request: SendRequest): Promise<void> { ... }\n *\n * // fired by Cloud Scheduler on a clock, called by nobody in this repo:\n * @Endpoint('/nightly', 'cron')\n * nightly(request: NightlyRequest): Promise<void> { ... }\n *\n * // EXTERNAL webhook posting application/x-www-form-urlencoded (e.g. Twilio):\n * @Endpoint('/hook', 'external', { formPost: true, calledBy: 'twilio' })\n * inbound(request: InboundRequest): Promise<InboundResponse> { ... }\n * ```\n *\n * `kind` is REQUIRED and deliberately positional: it makes every pre-existing single-argument\n * `@Endpoint('/x')` a COMPILE error rather than something a lint rule has to chase, so no endpoint\n * can slip into the runtime architecture graph with its trigger left to guesswork. See\n * {@link EndpointKind} for what each value draws and which Terraform resource backs it.\n *\n * `calledBy` is REQUIRED for `external` FOR EXACTLY THE SAME REASON, enforced by the overloads below:\n * the one box on the runtime graph whose whole job is to say who calls us from outside could only\n * restate OUR OWN contract name, because nothing in the source ever said who the caller was. This is\n * BREAKING for published consumers, intentionally — an existing `@Endpoint(p, 'external', {...})`\n * stops compiling until it names its caller. Migration is one property; see the migration note in\n * `external-caller.ts`. Non-`external` endpoints are completely unaffected.\n *\n * The path write to ENDPOINTS is UNCHANGED (every consumer iterates `[methodName, path]`); kind,\n * options and caller ride PARALLEL ENDPOINT_KIND / ENDPOINT_OPTIONS / ENDPOINT_CALLER maps.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function Endpoint(path: string, kind: 'external', options: ExternalEndpointOptions): MethodDecorator;\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function Endpoint(path: string, kind: Exclude<EndpointKind, 'external'>, options?: EndpointOptions): MethodDecorator;\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function Endpoint(path: string, kind: EndpointKind, options: EndpointOptions = {}): MethodDecorator {\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any, propertyKey: string | symbol, _descriptor: PropertyDescriptor) => {\n const metadataTarget = typeof target === 'function' ? target : target.constructor;\n\n const endpoints: Record<string, string> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINTS, metadataTarget) || {};\n\n endpoints[propertyKey as string] = path;\n\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINTS, endpoints, metadataTarget);\n\n const kinds: Record<string, EndpointKind> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINT_KIND, metadataTarget) || {};\n kinds[propertyKey as string] = kind;\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINT_KIND, kinds, metadataTarget);\n\n const opts: Record<string, EndpointOptions> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINT_OPTIONS, metadataTarget) || {};\n opts[propertyKey as string] = options;\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINT_OPTIONS, opts, metadataTarget);\n\n // ONLY for 'external', mirroring how a queue name is recorded only for the kinds that HAVE\n // a queue: a caller on an rpc endpoint would be a fact about nothing.\n const declared = options as ExternalEndpointOptions;\n if (kind !== 'external' || typeof declared.calledBy !== 'string' || declared.calledBy === '') return;\n const callers: Record<string, ExternalCaller> = Reflect.getMetadata(METADATA_KEYS.ENDPOINT_CALLER, metadataTarget) || {};\n callers[propertyKey as string] = new ExternalCaller(declared.callerKind ?? DEFAULT_CALLER_KIND, declared.calledBy);\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINT_CALLER, callers, metadataTarget);\n };\n}\n\n/**\n * @MaskLog(fields) - declare which fields of THIS method's request/response DTOs the\n * {@link LogApiCall} logging path must mask, so a secret riding on a DTO (an OAuth refresh token, an\n * id-token JWT) is never written to the logs in cleartext. The REAL value still travels on the wire\n * untouched — masking lives in the logging path only.\n *\n * ```typescript\n * @Endpoint('/account', 'rpc')\n * @MaskLog({ refreshToken: 'full', accessToken: 'last4', credential: 'full' })\n * getEmailAccount(request: GetEmailAccountRequest): Promise<GetEmailAccountResponse> { ... }\n * ```\n *\n * Matching is by field NAME at any depth (nested objects + array elements), so\n * `response.account.refreshToken` is masked. Declared on the SHARED api contract, so BOTH the client\n * `[API-client-*]` and server `[API-server-*]` lines mask it. The spec is read ONCE at route-build\n * time and rides {@link RouteMetadata.mask}, so an unmasked method pays nothing at call time.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function MaskLog(fields: Record<string, MaskMode>): MethodDecorator {\n const spec = new MaskSpec(fields);\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any, propertyKey: string | symbol, _descriptor: PropertyDescriptor) => {\n const metadataTarget = typeof target === 'function' ? target : target.constructor;\n const specs: Record<string, MaskSpec> =\n Reflect.getMetadata(METADATA_KEYS.MASK_LOG, metadataTarget) || {};\n specs[propertyKey as string] = spec;\n Reflect.defineMetadata(METADATA_KEYS.MASK_LOG, specs, metadataTarget);\n };\n}\n\n/**\n * The @MaskLog spec for one method, or undefined if the method declared none (the common case — the\n * caller then logs the DTO verbatim on the plain JSON.stringify fast path).\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpointOptions\nexport function getMaskSpec(apiClass: Function, methodName: string): MaskSpec | undefined {\n const specs: Record<string, MaskSpec> =\n Reflect.getMetadata(METADATA_KEYS.MASK_LOG, apiClass) || {};\n return specs[methodName];\n}\n\n/**\n * Shared implementation for every auth decorator: stores an {@link AuthMeta} for\n * the given {@link AuthMode} at class- or method-level, rejecting a second auth\n * decorator on the same target.\n */\nfunction defineAuthMode(mode: AuthMode): ClassDecorator & MethodDecorator {\n const authMeta = new AuthMeta(mode);\n\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any, propertyKey?: string | symbol, _descriptor?: PropertyDescriptor) => {\n if (propertyKey !== undefined) {\n // Method decorator\n const metadataTarget = typeof target === 'function' ? target : target.constructor;\n validateNoConflictingDecorators(metadataTarget, propertyKey as string);\n Reflect.defineMetadata(METADATA_KEYS.AUTH_META, authMeta, metadataTarget, propertyKey);\n } else {\n // Class decorator\n validateNoConflictingDecorators(target, undefined);\n Reflect.defineMetadata(METADATA_KEYS.AUTH_META, authMeta, target);\n }\n };\n}\n\n/**\n * @Public() - endpoint requires no authentication. Class- or method-level.\n */\nexport function Public(): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'public' });\n}\n\n/**\n * @AuthJwt(requirement) - THE user-facing JWT decorator, covering the whole user-JWT axis: the\n * compiler-enforced role decision ({@link JwtRoles}) plus app-defined fields ({@link JwtRequirement}).\n *\n * ```typescript\n * @AuthJwt({ roles: ['admin', 'editor'] }) // any-of\n * @AuthJwt({ allRolesAllowed: true, inOrg: true }) // wide + an app rule enforced by authorizeJwt\n * ```\n *\n * It absorbed the former `@Auth(requirement)` — same argument, same AuthMode, so two spellings of one\n * decision. One decorator per credential kind now: `@Public` / `@AuthJwt` / `@AuthOidc` /\n * `@AuthSharedSecret` / `@AuthLocalOnly`.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthJwt(requirement: JwtRequirement): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'jwt', requirement });\n}\n\n/**\n * The roles an endpoint accepts, or [] when it accepts every authenticated user. The ONE reader of\n * the {@link JwtRoles} union, so no caller has to re-derive \"does absent mean wide?\" — a question\n * whose two plausible answers is how the widest grant kept hiding behind an absent field.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getAuthMode\nexport function rolesRequired(requirement: JwtRequirement): readonly string[] {\n return requirement.allRolesAllowed === true ? [] : requirement.roles;\n}\n\n/**\n * @AuthOidc(...callers) - Google OIDC service-to-service auth (Cloud Tasks delivery / cross-service\n * RPC). `callers` is an OPTIONAL app-level allow-list of caller service accounts.\n *\n * NO args = TRUST THE EDGE: accept any genuine Google-signed OIDC caller, because a PRIVATE Cloud\n * Run service's edge already gates WHO via `run.invoker` IAM (managed in terraform — one source of\n * truth, no hand-synced list in code). If the service is actually PUBLIC, the verifier logs a loud\n * warning (it can't be the gate then). Pass explicit SAs (`@AuthOidc('svc-a')`) only when you want\n * an additional app-level allow-list as defense-in-depth.\n */\nexport function AuthOidc(...callers: string[]): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'oidc', callers });\n}\n\n/**\n * @AuthSharedSecret(key) - constant-time compare of an inbound header against the secret bound for\n * `key`. `key` is a LOOKUP KEY (not an env var): the server looks up its accepted {@link SharedSecrets}\n * by this key, and each client looks up the value it sends by the SAME key (see {@link Secrets}).\n * For internal callers that cannot mint OIDC tokens.\n */\nexport function AuthSharedSecret(key: string): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'shared-secret', secretKey: key });\n}\n\n/**\n * @AuthWebhook(name) - an OUTSIDE vendor signed this request in its OWN scheme; the app's bound\n * `WebhookAuthCallback` proves it. THE mode for every signed inbound webhook — Sentry, GitHub, Stripe, Slack,\n * Twilio — none of which fits the other kinds: no vendor mints Google OIDC tokens, and none sends its\n * secret (they all send a DERIVATION over the request), so `@Public` was the only reachable posture\n * and `calledBy: 'sentry'` stayed a claim rather than a fact.\n *\n * ```typescript\n * @AuthWebhook('sentry')\n * @Endpoint('/hook/sentry/issue', 'external', { calledBy: 'sentry', rawBody: true })\n * abstract notify(request: SentryIssueHook): Promise<HookAck>;\n * ```\n *\n * `name` is a bare STRING resolved through DI in the server's container, exactly as\n * `@AuthOidc('gmail-push')` already is — never a function reference. An api contract is level 0: a\n * direct reference to a verifier would invert the dependency graph and drag a vendor SDK into the\n * browser bundle that imports the same contract.\n *\n * THE FRAMEWORK IMPLEMENTS NO VENDOR CRYPTO, deliberately. Every vendor ships an official validator\n * (`twilio.validateRequest`, `stripe.webhooks.constructEvent`, `@octokit/webhooks-methods`) and every\n * vendor revises its scheme (Twilio added `bodySHA256` for JSON bodies; Stripe versions its header).\n * Reimplementing five of those is signing up to track five security changelogs forever and to be\n * wrong at the moment being wrong matters. The framework hands the hook enough of the raw request to\n * call the vendor's own library — hence the REQUIRED `{ rawBody: true }` (see\n * {@link EndpointOptions.rawBody}), which is checked at wiring time.\n *\n * FAILS CLOSED: with no `WebhookAuthCallback` bound, every `@AuthWebhook` endpoint 401s, matching `JwtHook`.\n * Silently allowing an unverified webhook is the one default that must not exist.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthWebhook(name: string): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'webhook', name });\n}\n\n/**\n * @AuthApiKey(name) - a CUSTOMER holds the credential. The app's bound `ApiKeyHook` authenticates the\n * inbound request against its own datastore and returns the `ContextTuple` entries the framework\n * seeds into `RequestContext`. THE mode for a partner-facing contract consumed by other companies'\n * codebases (POS vendors, back-office platforms, ETL pipelines).\n *\n * ```typescript\n * @AuthApiKey('onetablet-partner')\n * @ApiPath('/management/v1')\n * abstract class ManagementApi { ... }\n * ```\n *\n * `name` is a bare STRING selecting WHICH key regime this route belongs to, exactly as\n * `@AuthSharedSecret(key)` and `@AuthWebhook(vendor)` already are — one hook serves several regimes,\n * and an api contract is level 0, so it never references a verifier directly.\n *\n * WHY IT IS NOT `@AuthSharedSecret`. Shared-secret declares that AN INTERNAL SERVICE is on the other\n * end, so the framework BELIEVES the trusted context headers that caller forwarded (see\n * `DestinationTrust.forAuthMode` and `AuthFilter.verifiesCaller`). A customer is not an internal\n * service: declaring a partner endpoint `@AuthSharedSecret` would let that partner assert someone\n * else's org id on the wire and have it admitted — a privilege escalation. `apikey` therefore sits\n * with `jwt` on the caller-NOT-verified side, where an inbound trusted header is admitted only when\n * the hook independently derived the SAME value.\n *\n * WHY THE HOOK SEES THE HEADERS, NOT ONE TOKEN. A real key regime checks the key TOGETHER WITH a\n * second header (the organization it is acting for), and `JwtHook.parseJwt` — handed one pre-extracted\n * token from one header — physically cannot. `ApiKeyHook.verifyApiKey(name, headers)` gets a reader\n * instead, so the app owns which headers carry the credential and validates them as a PAIR. The\n * framework deliberately configures no header name: that cross-check is the entire point.\n *\n * FAILS CLOSED: with no `ApiKeyHook` bound, every `@AuthApiKey` endpoint 401s, matching `JwtHook` and\n * `WebhookAuthCallback`.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthApiKey(name: string): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'apikey', name });\n}\n\n/**\n * @AuthLocalOnly() - this endpoint exists ONLY on a developer's machine. Off-local it is not\n * registered as a route at all, and if it is somehow reached it 404s. Class- or method-level.\n *\n * ```typescript\n * @AuthLocalOnly()\n * @Endpoint('/logs', 'rpc')\n * sendBatch(request: SendLogBatchRequest): Promise<SendLogBatchResponse> { ... }\n * ```\n *\n * WHY IT IS AN AUTH MODE AND NOT A ROUTE-MODULE `if`. Apps hand-rolled this in TWO places kept in\n * sync by a comment: a route module that registered the route only locally, PLUS a\n * `if (env !== 'local') throw new HttpForbiddenError(...)` at the top of the handler. Neither half\n * was visible on the CONTRACT, so nothing reading the api — a human, a generated client, or an\n * agent — could tell this endpoint from a `@Public` one. Both halves are the framework's job now,\n * driven by this ONE declaration on the contract, which is where every other \"who may call this\"\n * fact already lives.\n *\n * It is DELIBERATELY a peer of @Public / @AuthJwt / @AuthOidc / @AuthSharedSecret / @AuthApiKey rather than an\n * option on one of them: one decorator per credential kind, and \"local-only\" is a different kind of\n * gate — it authenticates nobody, it excludes an entire environment.\n *\n * HOW \"local\" IS DECIDED: {@link RuntimeLocality}, declared once at startup (a REQUIRED input to\n * `RuntimeSetupOptions`). Undeclared means DEPLOYED, so a forgotten wiring call refuses the endpoint\n * rather than exposing it.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthLocalOnly(): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'local-only' });\n}\n\n// ============================================================\n// Helper functions\n// ============================================================\n\n/**\n * Get the base path from @ApiPath decorator.\n */\nexport function getApiPath(apiClass: Function): string | undefined {\n return Reflect.getMetadata(METADATA_KEYS.API_PATH, apiClass);\n}\n\n/**\n * Get all endpoints from @Endpoint decorators.\n * Returns a record of methodName -> endpoint path.\n */\nexport function getEndpoints(apiClass: Function): Record<string, string> | undefined {\n return Reflect.getMetadata(METADATA_KEYS.ENDPOINTS, apiClass);\n}\n\n/**\n * Every method's declared trigger kind, as `methodName -> kind`. Parallel to {@link getEndpoints}.\n * Empty for a class carrying no @Endpoint at all.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function getEndpointKinds(apiClass: Function): Record<string, EndpointKind> {\n return Reflect.getMetadata(METADATA_KEYS.ENDPOINT_KIND, apiClass) || {};\n}\n\n/**\n * What triggers ONE method, or undefined when the method carries no @Endpoint.\n *\n * Defaults to nothing rather than to 'rpc': `kind` is a required argument, so a missing entry means\n * \"this is not an endpoint\", never \"an endpoint that forgot to say\". Silently defaulting here would\n * put an undeclared cron or webhook back into the graph as a normal rpc call — the exact blindness\n * the required argument exists to remove.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function getEndpointKind(apiClass: Function, methodName: string): EndpointKind | undefined {\n return getEndpointKinds(apiClass)[methodName];\n}\n\n/**\n * Get the @Endpoint options for one method (empty object if the method had no options).\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function getEndpointOptions(apiClass: Function, methodName: string): EndpointOptions {\n const opts: Record<string, EndpointOptions> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINT_OPTIONS, apiClass) || {};\n return opts[methodName] ?? {};\n}\n\n/**\n * Fail-fast at wiring time when an `external` endpoint declared no caller. The {@link Endpoint}\n * overloads already make that a COMPILE error; this is the backstop for the ways TS is bypassed —\n * a JS caller, an `as any` options object, a hand-rolled Reflect.defineMetadata.\n * @throws Error naming the first external endpoint with no `calledBy`.\n */\n// webpieces-disable no-function-outside-class -- wiring-time assert, sibling of assertEveryEndpointHasAuthMode\nexport function assertEveryExternalEndpointDeclaresCaller(apiClass: Function): void {\n const kinds = getEndpointKinds(apiClass);\n for (const methodName of Object.keys(kinds)) {\n if (kinds[methodName] !== 'external' || getEndpointCaller(apiClass, methodName) !== undefined) continue;\n throw new Error(\n `External endpoint '${methodName}' in ${apiClass.name || 'Unknown'} declares no caller. Say WHO ` +\n `posts to it: @Endpoint(path, 'external', { calledBy: '<vendor>' }) — the runtime architecture ` +\n `graph cannot name an inbound caller it was never told about.`,\n );\n }\n}\n\n/**\n * True when the method's @Endpoint declared `{ formPost: true }` — its body is\n * application/x-www-form-urlencoded (flat), not JSON.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function isFormPost(apiClass: Function, methodName: string): boolean {\n return getEndpointOptions(apiClass, methodName).formPost === true;\n}\n\n/**\n * True when the method's @Endpoint declared `{ rawBody: true }` — the transport must retain the\n * verbatim bytes + absolute url for an {@link AuthWebhook} hook to verify.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of isFormPost\nexport function isRawBody(apiClass: Function, methodName: string): boolean {\n return getEndpointOptions(apiClass, methodName).rawBody === true;\n}\n\n/**\n * Fail-fast at wiring time when an `@AuthWebhook` endpoint did not ask the transport to keep the\n * bytes it is supposed to verify. A hook with nothing to verify is a MISCONFIGURATION, and it must\n * surface at startup, naming the fix — not as a 401 in production on exactly the traffic the endpoint\n * exists for.\n *\n * This pairing is a runtime assert rather than a type because the two halves live on DIFFERENT\n * decorators (`@AuthWebhook` and `@Endpoint`), and no union over one decorator's argument can say\n * anything about the other's.\n *\n * @throws Error naming the first `@AuthWebhook` endpoint missing `{ rawBody: true }`.\n */\n// webpieces-disable no-function-outside-class -- wiring-time assert, sibling of assertEveryEndpointHasAuthMode\nexport function assertEveryWebhookEndpointRetainsRawBody(apiClass: Function): void {\n const endpoints = getEndpoints(apiClass) || {};\n for (const methodName of Object.keys(endpoints)) {\n if (getAuthMode(apiClass, methodName)?.kind !== 'webhook' || isRawBody(apiClass, methodName)) continue;\n throw new Error(\n `Endpoint '${methodName}' in ${apiClass.name || 'Unknown'} is @AuthWebhook but its @Endpoint ` +\n `does not declare { rawBody: true }. A webhook hook verifies a signature over the bytes and ` +\n `the url the SENDER transmitted, and without that option the transport parses the body and ` +\n `throws them away — leaving the hook nothing to check.`,\n );\n }\n}\n\n/**\n * Check if a class has @ApiPath decorator.\n */\nexport function isApiPath(apiClass: Function): boolean {\n return Reflect.hasMetadata(METADATA_KEYS.API_PATH, apiClass);\n}\n\n/**\n * Get auth metadata for a specific method, falling back to class-level auth.\n * Method-level auth takes precedence over class-level auth.\n */\nexport function getAuthMeta(apiClass: Function, methodName?: string): AuthMeta | undefined {\n // Check method-level first\n if (methodName) {\n const methodAuth = Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass, methodName);\n if (methodAuth) {\n return methodAuth;\n }\n }\n\n // Fall back to class-level\n return Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass);\n}\n\n/**\n * Get the auth mode for a method (falling back to class-level), or undefined.\n * Convenience wrapper over getAuthMeta for callers that only want the mode.\n */\nexport function getAuthMode(apiClass: Function, methodName?: string): AuthMode | undefined {\n return getAuthMeta(apiClass, methodName)?.mode;\n}\n\n/**\n * The ONE prescription for \"this endpoint declares no auth\", shared by the two places that raise it\n * (here and http-routing's ApiRoutingFactory) because they had drifted into teaching different menus.\n * A message teaching an incomplete API is the same defect as an API with two spellings: whichever menu\n * the caller hits becomes the API they believe exists. It leads with the ROLE-GATED member on purpose —\n * the first thing offered should not be the widest grant.\n */\nexport const MISSING_AUTH_DECORATOR_FIX =\n \"Add one of @AuthJwt({roles: ['admin']}) / @AuthJwt({allRolesAllowed: true}) / @Public() / \" +\n \"@AuthOidc(...callers) / @AuthSharedSecret(key) / @AuthWebhook('vendor') / @AuthApiKey('regime') / \" +\n '@AuthLocalOnly() to ' +\n 'the class or method.';\n\n/**\n * Fail-fast at wiring time if any endpoint lacks an auth mode. Both the server\n * (ApiRoutingFactory) and the task/rpc clients call this so a missing auth\n * decorator is a startup error, never a silent open endpoint.\n * @throws Error naming the first endpoint with no auth decorator, via {@link MISSING_AUTH_DECORATOR_FIX}.\n */\nexport function assertEveryEndpointHasAuthMode(apiClass: Function): void {\n const apiName = apiClass.name || 'Unknown';\n const endpoints = getEndpoints(apiClass) || {};\n for (const methodName of Object.keys(endpoints)) {\n if (!getAuthMeta(apiClass, methodName)) {\n throw new Error(\n `Endpoint '${methodName}' in ${apiName} has no auth decorator. ` +\n MISSING_AUTH_DECORATOR_FIX,\n );\n }\n }\n}\n\n/**\n * Validate that a class/method doesn't have conflicting auth decorators.\n * @throws Error if multiple auth decorators are found on the same target.\n */\nexport function validateNoConflictingDecorators(apiClass: Function, methodName: string | undefined): void {\n const existing = methodName\n ? Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass, methodName)\n : Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass);\n\n if (existing) {\n const targetName = apiClass.name || 'Unknown';\n const location = methodName ? `method '${methodName}' of ${targetName}` : `class ${targetName}`;\n throw new Error(\n `Conflicting auth decorator on ${location}. ` +\n `Only one of @Public() / @AuthJwt({...}) / @AuthOidc(...) / @AuthSharedSecret(...) / ` +\n `@AuthWebhook(...) / @AuthApiKey(...) / @AuthLocalOnly() is allowed per target.`\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"decorators.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/decorators.ts"],"names":[],"mappings":";;;AA2LA,0BAUC;AA8CD,4BA8BC;AAoBD,0BAUC;AAOD,kCAIC;AA4BD,wBAEC;AAgBD,0BAEC;AAQD,sCAEC;AAYD,4BAEC;AAQD,4CAEC;AAgCD,kCAEC;AAoCD,gCAEC;AA6BD,sCAEC;AASD,gCAEC;AAMD,oCAEC;AAOD,4CAEC;AAWD,0CAEC;AAMD,gDAIC;AASD,8FAUC;AAOD,gCAEC;AAOD,8BAEC;AAeD,4FAWC;AAKD,8BAEC;AAMD,kCAWC;AAMD,kCAEC;AAqBD,wEAWC;AAMD,0EAcC;AAzrBD,4BAA0B;AAC1B,iDAAoD;AACpD,uDAAoI;AAEpI;;;GAGG;AACU,QAAA,aAAa,GAAG;IACzB,QAAQ,EAAE,oBAAoB;IAC9B,SAAS,EAAE,qBAAqB;IAChC,SAAS,EAAE,qBAAqB;IAChC,uFAAuF;IACvF,QAAQ,EAAE,oBAAoB;IAC9B,mEAAmE;IACnE,cAAc,EAAE,0BAA0B;IAC1C,2EAA2E;IAC3E,gBAAgB,EAAE,4BAA4B;IAC9C,qGAAqG;IACrG,aAAa,EAAE,yBAAyB;IACxC,6FAA6F;IAC7F,eAAe,EAAE,qCAAmB;IACpC,6EAA6E;IAC7E,QAAQ,EAAE,oBAAoB;CACjC,CAAC;AAiIF;;;;;;;;;;;GAWG;AACH,MAAa,QAAQ;IACjB,IAAI,CAAW;IAEf,YAAY,IAAc;QACtB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ;AAND,4BAMC;AAED;;;;;;;;;;;;;GAaG;AACH,SAAgB,OAAO,CAAC,QAAgB;IACpC,kFAAkF;IAClF,OAAO,CAAC,MAAW,EAAE,EAAE;QACnB,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;QAEjE,yCAAyC;QACzC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,CAAC;YACxD,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,SAAS,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;QAChE,CAAC;IACL,CAAC,CAAC;AACN,CAAC;AA6CD,2GAA2G;AAC3G,SAAgB,QAAQ,CAAC,IAAY,EAAE,IAAkB,EAAE,UAA2B,EAAE;IACpF,kFAAkF;IAClF,OAAO,CAAC,MAAW,EAAE,WAA4B,EAAE,WAA+B,EAAE,EAAE;QAClF,MAAM,cAAc,GAAG,OAAO,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC;QAElF,MAAM,SAAS,GACX,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,cAAc,CAAC,IAAI,EAAE,CAAC;QAEvE,SAAS,CAAC,WAAqB,CAAC,GAAG,IAAI,CAAC;QAExC,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,SAAS,EAAE,SAAS,EAAE,cAAc,CAAC,CAAC;QAE3E,MAAM,KAAK,GACP,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,aAAa,EAAE,cAAc,CAAC,IAAI,EAAE,CAAC;QAC3E,KAAK,CAAC,WAAqB,CAAC,GAAG,IAAI,CAAC;QACpC,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,aAAa,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC;QAE3E,MAAM,IAAI,GACN,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,gBAAgB,EAAE,cAAc,CAAC,IAAI,EAAE,CAAC;QAC9E,IAAI,CAAC,WAAqB,CAAC,GAAG,OAAO,CAAC;QACtC,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,gBAAgB,EAAE,IAAI,EAAE,cAAc,CAAC,CAAC;QAE7E,2FAA2F;QAC3F,sEAAsE;QACtE,MAAM,QAAQ,GAAG,OAAkC,CAAC;QACpD,IAAI,IAAI,KAAK,UAAU,IAAI,OAAO,QAAQ,CAAC,QAAQ,KAAK,QAAQ,IAAI,QAAQ,CAAC,QAAQ,KAAK,EAAE;YAAE,OAAO;QACrG,MAAM,OAAO,GAAmC,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,eAAe,EAAE,cAAc,CAAC,IAAI,EAAE,CAAC;QACzH,OAAO,CAAC,WAAqB,CAAC,GAAG,IAAI,gCAAc,CAAC,QAAQ,CAAC,UAAU,IAAI,qCAAmB,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;QACnH,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,eAAe,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;IACnF,CAAC,CAAC;AACN,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,2GAA2G;AAC3G,SAAgB,OAAO,CAAC,MAAgC;IACpD,MAAM,IAAI,GAAG,IAAI,uBAAQ,CAAC,MAAM,CAAC,CAAC;IAClC,kFAAkF;IAClF,OAAO,CAAC,MAAW,EAAE,WAA4B,EAAE,WAA+B,EAAE,EAAE;QAClF,MAAM,cAAc,GAAG,OAAO,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC;QAClF,MAAM,KAAK,GACP,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,QAAQ,EAAE,cAAc,CAAC,IAAI,EAAE,CAAC;QACtE,KAAK,CAAC,WAAqB,CAAC,GAAG,IAAI,CAAC;QACpC,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,QAAQ,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC;IAC1E,CAAC,CAAC;AACN,CAAC;AAED;;;GAGG;AACH,wGAAwG;AACxG,SAAgB,WAAW,CAAC,QAAkB,EAAE,UAAkB;IAC9D,MAAM,KAAK,GACP,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChE,OAAO,KAAK,CAAC,UAAU,CAAC,CAAC;AAC7B,CAAC;AAED;;;;GAIG;AACH,SAAS,cAAc,CAAC,IAAc;IAClC,MAAM,QAAQ,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;IAEpC,kFAAkF;IAClF,OAAO,CAAC,MAAW,EAAE,WAA6B,EAAE,WAAgC,EAAE,EAAE;QACpF,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;YAC5B,mBAAmB;YACnB,MAAM,cAAc,GAAG,OAAO,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC;YAClF,+BAA+B,CAAC,cAAc,EAAE,WAAqB,CAAC,CAAC;YACvE,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,cAAc,EAAE,WAAW,CAAC,CAAC;QAC3F,CAAC;aAAM,CAAC;YACJ,kBAAkB;YAClB,+BAA+B,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;YACnD,OAAO,CAAC,cAAc,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;QACtE,CAAC;IACL,CAAC,CAAC;AACN,CAAC;AAED;;GAEG;AACH,SAAgB,MAAM;IAClB,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,2GAA2G;AAC3G,SAAgB,OAAO,CAAC,WAA2B;IAC/C,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;AACxD,CAAC;AAED;;;;GAIG;AACH,iGAAiG;AACjG,SAAgB,aAAa,CAAC,WAA2B;IACrD,OAAO,WAAW,CAAC,eAAe,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC;AACzE,CAAC;AAED;;;;;;;;;GASG;AACH,SAAgB,QAAQ,CAAC,GAAG,OAAiB;IACzC,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;AACrD,CAAC;AAED;;;;;GAKG;AACH,SAAgB,gBAAgB,CAAC,GAAW;IACxC,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC;AACrE,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,2GAA2G;AAC3G,SAAgB,WAAW,CAAC,IAAY;IACpC,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;AACrD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,2GAA2G;AAC3G,SAAgB,UAAU,CAAC,IAAY;IACnC,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,2GAA2G;AAC3G,SAAgB,aAAa;IACzB,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC;AAClD,CAAC;AAED,+DAA+D;AAC/D,mBAAmB;AACnB,+DAA+D;AAE/D;;GAEG;AACH,SAAgB,UAAU,CAAC,QAAkB;IACzC,OAAO,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AACjE,CAAC;AAED;;;GAGG;AACH,SAAgB,YAAY,CAAC,QAAkB;IAC3C,OAAO,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAClE,CAAC;AAED;;;GAGG;AACH,kGAAkG;AAClG,SAAgB,gBAAgB,CAAC,QAAkB;IAC/C,OAAO,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,aAAa,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;AAC5E,CAAC;AAED;;;;;;;GAOG;AACH,kGAAkG;AAClG,SAAgB,eAAe,CAAC,QAAkB,EAAE,UAAkB;IAClE,OAAO,gBAAgB,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,CAAC;AAClD,CAAC;AAED;;GAEG;AACH,kGAAkG;AAClG,SAAgB,kBAAkB,CAAC,QAAkB,EAAE,UAAkB;IACrE,MAAM,IAAI,GACN,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;IACxE,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;AAClC,CAAC;AAED;;;;;GAKG;AACH,+GAA+G;AAC/G,SAAgB,yCAAyC,CAAC,QAAkB;IACxE,MAAM,KAAK,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IACzC,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1C,IAAI,KAAK,CAAC,UAAU,CAAC,KAAK,UAAU,IAAI,IAAA,mCAAiB,EAAC,QAAQ,EAAE,UAAU,CAAC,KAAK,SAAS;YAAE,SAAS;QACxG,MAAM,IAAI,KAAK,CACX,sBAAsB,UAAU,QAAQ,QAAQ,CAAC,IAAI,IAAI,SAAS,+BAA+B;YACjG,gGAAgG;YAChG,8DAA8D,CACjE,CAAC;IACN,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,kGAAkG;AAClG,SAAgB,UAAU,CAAC,QAAkB,EAAE,UAAkB;IAC7D,OAAO,kBAAkB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC,QAAQ,KAAK,IAAI,CAAC;AACtE,CAAC;AAED;;;GAGG;AACH,gGAAgG;AAChG,SAAgB,SAAS,CAAC,QAAkB,EAAE,UAAkB;IAC5D,OAAO,kBAAkB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC;AACrE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,+GAA+G;AAC/G,SAAgB,wCAAwC,CAAC,QAAkB;IACvE,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC/C,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9C,IAAI,WAAW,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,IAAI,KAAK,SAAS,IAAI,SAAS,CAAC,QAAQ,EAAE,UAAU,CAAC;YAAE,SAAS;QACvG,MAAM,IAAI,KAAK,CACX,aAAa,UAAU,QAAQ,QAAQ,CAAC,IAAI,IAAI,SAAS,qCAAqC;YAC9F,6FAA6F;YAC7F,4FAA4F;YAC5F,uDAAuD,CAC1D,CAAC;IACN,CAAC;AACL,CAAC;AAED;;GAEG;AACH,SAAgB,SAAS,CAAC,QAAkB;IACxC,OAAO,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AACjE,CAAC;AAED;;;GAGG;AACH,SAAgB,WAAW,CAAC,QAAkB,EAAE,UAAmB;IAC/D,2BAA2B;IAC3B,IAAI,UAAU,EAAE,CAAC;QACb,MAAM,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;QACtF,IAAI,UAAU,EAAE,CAAC;YACb,OAAO,UAAU,CAAC;QACtB,CAAC;IACL,CAAC;IAED,2BAA2B;IAC3B,OAAO,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAClE,CAAC;AAED;;;GAGG;AACH,SAAgB,WAAW,CAAC,QAAkB,EAAE,UAAmB;IAC/D,OAAO,WAAW,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,IAAI,CAAC;AACnD,CAAC;AAED;;;;;;GAMG;AACU,QAAA,0BAA0B,GACnC,4FAA4F;IAC5F,oGAAoG;IACpG,sBAAsB;IACtB,sBAAsB,CAAC;AAE3B;;;;;GAKG;AACH,SAAgB,8BAA8B,CAAC,QAAkB;IAC7D,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,IAAI,SAAS,CAAC;IAC3C,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC/C,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9C,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CACX,aAAa,UAAU,QAAQ,OAAO,0BAA0B;gBAChE,kCAA0B,CAC7B,CAAC;QACN,CAAC;IACL,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,SAAgB,+BAA+B,CAAC,QAAkB,EAAE,UAA8B;IAC9F,MAAM,QAAQ,GAAG,UAAU;QACvB,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC;QACpE,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,qBAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAE7D,IAAI,QAAQ,EAAE,CAAC;QACX,MAAM,UAAU,GAAG,QAAQ,CAAC,IAAI,IAAI,SAAS,CAAC;QAC9C,MAAM,QAAQ,GAAG,UAAU,CAAC,CAAC,CAAC,WAAW,UAAU,QAAQ,UAAU,EAAE,CAAC,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC;QAChG,MAAM,IAAI,KAAK,CACX,iCAAiC,QAAQ,IAAI;YAC7C,sFAAsF;YACtF,gFAAgF,CACnF,CAAC;IACN,CAAC;AACL,CAAC","sourcesContent":["import 'reflect-metadata';\nimport { MaskSpec, MaskMode } from './LogFieldMask';\nimport { DEFAULT_CALLER_KIND, ENDPOINT_CALLER_KEY, ExternalCaller, ExternalSystemKind, getEndpointCaller } from './external-caller';\n\n/**\n * Metadata keys for storing API routing information.\n * These keys are used by both server-side (routing) and client-side (client generation).\n */\nexport const METADATA_KEYS = {\n API_PATH: 'webpieces:api-path',\n ENDPOINTS: 'webpieces:endpoints',\n AUTH_META: 'webpieces:auth-meta',\n /** 'rpc' (default, sync request/response) vs 'pubsub' (fire-and-forget cloud task). */\n API_KIND: 'webpieces:api-kind',\n /** Per-method Cloud Tasks queue-name override (set via @Queue). */\n QUEUE_OVERRIDE: 'webpieces:queue-override',\n /** Per-method @Endpoint options (e.g. formPost), parallel to ENDPOINTS. */\n ENDPOINT_OPTIONS: 'webpieces:endpoint-options',\n /** Per-method @Endpoint trigger kind (rpc | cloudtasks | cron | external), parallel to ENDPOINTS. */\n ENDPOINT_KIND: 'webpieces:endpoint-kind',\n /** Per-method declared external CALLER (only for kind 'external'), parallel to ENDPOINTS. */\n ENDPOINT_CALLER: ENDPOINT_CALLER_KEY,\n /** Per-method @MaskLog spec (which DTO fields the LogApiCall path masks). */\n MASK_LOG: 'webpieces:mask-log',\n};\n\n/**\n * WHAT TRIGGERS an endpoint at runtime — the single fact that decides how the runtime architecture\n * graph draws it, and which Terraform resource must exist for it to ever fire:\n *\n * - `rpc` — a caller in this repo (or a browser) calls it synchronously. A direct arrow.\n * - `cloudtasks` — a producer ENQUEUES it; Cloud Tasks delivers it later. Drawn producer → queue →\n * consumer, one queue node per METHOD (see {@link Queue}). Producer and consumer\n * being the SAME service is legal and common — the queue decouples them.\n * - `cron` — a scheduler fires it on a clock. Nothing in-repo calls it; drawn hanging off a\n * clock symbol. Backed by a Cloud Scheduler job.\n * - `external` — a system OUTSIDE this repo drives it (a GCP Pub/Sub push subscription, a Twilio\n * or Gmail webhook). Drawn as an inbound dashed arrow from that system.\n *\n * Declared PER METHOD, because one api class routinely mixes them: an admin contract can have\n * caller-driven endpoints AND a nightly cron sweep. A class-level marker cannot express that, which\n * is exactly why the graph could not tell these apart before.\n */\nexport type EndpointKind = 'rpc' | 'cloudtasks' | 'cron' | 'external';\n\n/**\n * Options for a single @Endpoint. Kept in a metadata map PARALLEL to ENDPOINTS so the existing\n * `Record<methodName, path>` shape every consumer iterates stays unchanged.\n */\nexport interface EndpointOptions {\n /**\n * Parse the request body as application/x-www-form-urlencoded (flat key→value) instead of JSON.\n * For EXTERNAL webhooks (e.g. Twilio) that post form-encoded. The request DTO must be FLAT —\n * urlencoded has no nesting (unlike JSON). Default false = JSON.\n */\n formPost?: boolean;\n\n /**\n * RETAIN the verbatim request bytes + the absolute url the sender addressed, so an\n * {@link AuthWebhook} hook can verify a vendor signature over them (see `RawRequest`).\n *\n * Opt-in PER ENDPOINT, sitting beside `formPost` and for the same reason: the cost lands on the\n * handful of webhook routes rather than on every request in the process. It is retention, not\n * new buffering — the express adapter already accumulates the whole body, it simply threw it\n * away once it had parsed a DTO.\n *\n * REQUIRED by `@AuthWebhook`, checked at wiring time (see\n * {@link assertEveryWebhookEndpointRetainsRawBody}) rather than left to fail as a 401 in\n * production: a hook with nothing to verify is a misconfiguration, not a bad request.\n *\n * Combines with `formPost` — `{ formPost: true, rawBody: true }` is the Twilio case, where the\n * hook needs the bytes and the url while the controller still wants the flat parsed DTO.\n */\n rawBody?: boolean;\n}\n\n/**\n * Options for an `external` @Endpoint: everything {@link EndpointOptions} carries, PLUS a REQUIRED\n * declaration of WHO is calling. See {@link Endpoint} for why, `external-caller.ts` for identity.\n */\nexport interface ExternalEndpointOptions extends EndpointOptions {\n /** The outside system that posts here (`'twilio'`) — the graph node IDENTITY, not display text. */\n calledBy: string;\n /** What that caller IS; picks the node's shape. Defaults to `'saas'` (see DEFAULT_CALLER_KIND). */\n callerKind?: ExternalSystemKind;\n}\n\n/**\n * The role decision for a JWT endpoint, as a union the COMPILER enforces — one spelling per decision,\n * every broken combination a compile error:\n *\n * ```typescript\n * @AuthJwt({ roles: ['admin'] }) // ✅ role-gated (any-of)\n * @AuthJwt({ allRolesAllowed: true }) // ✅ every authenticated user, said out loud\n * @AuthJwt({}) // ❌ pick a branch\n * @AuthJwt({ roles: [] }) // ❌ needs at least one role\n * ```\n *\n * `allRolesAllowed` exists ONLY on the wide branch (the dangerous half must be a greppable token, and\n * the narrow branch rejects it as a redundant second spelling); `roles` is a NON-EMPTY tuple so\n * \"declared roles, passed none\" — the old optional `string[]`'s silent widest grant — cannot be written.\n * All six bad cases are pinned in `AuthJwtCompileAssertions.ts` — a COMPILED file, not a spec: tsc\n * fails the build (TS2578) if any starts compiling. A spec cannot do this (see that file's header).\n *\n * WHY a type rather than the runtime `throw` this replaced: `.claude/review/backwards-compatibility.md`\n * shim shapes #4 and #5. Not restated here — three copies of one rationale is three things to drift.\n */\nexport type JwtRoles =\n | { allRolesAllowed: true; roles?: never }\n | { roles: readonly [string, ...string[]]; allRolesAllowed?: never };\n\n/**\n * JwtRequirement - the {@link JwtRoles} decision PLUS any app-defined authorization fields, e.g.\n * `@AuthJwt({ allRolesAllowed: true, inOrg: true })`. The framework authenticates (JwtHook.parseJwt)\n * and enforces the roles any-of; the app overrides JwtHook.authorizeJwt to enforce its own fields.\n * Both hook methods are ASYNC, so an app field like `inOrg` may be answered from a datastore.\n *\n * This was a SECOND decorator (`@Auth`) whose `roles` was optional — so `@Auth({})` reached the exact\n * widest grant that {@link JwtRoles} exists to make un-typeable. Folding it in leaves one decorator per\n * credential kind and closes that route by construction.\n */\n// webpieces-disable no-any-unknown -- app-defined authorization fields (inOrg, tenant, ...)\nexport type JwtRequirement = JwtRoles & { [field: string]: unknown };\n\n/**\n * The service-to-service / user auth mode of an endpoint. Discriminated union so a filter can\n * `switch (mode.kind)` and get the data it needs, exhaustively.\n *\n * - `public` → no auth check\n * - `jwt` → user JWT; `requirement` carries the compiler-enforced role decision\n * ({@link JwtRoles}) plus any app-defined authorization fields\n * - `oidc` → Google OIDC service-to-service (Cloud Tasks delivery / cross-service RPC);\n * `callers` is the allow-list of caller SAs ('self' = this service's SA)\n * - `shared-secret` → constant-time compare of a header against the secret bound for `secretKey`\n * - `webhook` → an OUTSIDE vendor signed this request its own way; the app's bound `WebhookAuthCallback`\n * verifies it, selected by `name`. The framework ships NO vendor crypto (see\n * {@link AuthWebhook}).\n * - `apikey` → a CUSTOMER holds the credential; the app's bound `ApiKeyHook` looks it up\n * (async, over the whole header set) and returns the context to seed, selected\n * by `name`. NOT a peer service — see {@link AuthApiKey}.\n * - `local-only` → exists ONLY on a developer's machine; not registered and never served when\n * {@link RuntimeLocality} says this process is deployed. Authenticates NOBODY —\n * it is a deployment gate, not a credential.\n */\nexport type AuthMode =\n | { kind: 'public' }\n | { kind: 'jwt'; requirement: JwtRequirement }\n | { kind: 'oidc'; callers: string[] }\n | { kind: 'shared-secret'; secretKey: string }\n | { kind: 'webhook'; name: string }\n | { kind: 'apikey'; name: string }\n | { kind: 'local-only' };\n\n/**\n * Auth metadata attached to a class or method via one of the auth decorators\n * (@Public / @AuthJwt / @AuthOidc / @AuthSharedSecret / @AuthWebhook / @AuthApiKey / @AuthLocalOnly) —\n * one per credential kind.\n *\n * Carries a discriminated {@link AuthMode} and nothing else. It USED to also expose\n * `authenticated`/`roles` getters \"for back-compat with readers that only understand the user-JWT\n * model\" — deleted, because nothing read them: every reader (AuthFilter, BrowserProxyClient,\n * ProxyClient) switches on `mode.kind`, which is the whole point of the discriminated union. A\n * flattened view of a union is a second spelling of it, and the flattened one silently answers\n * `authenticated: true` for oidc and shared-secret too.\n */\nexport class AuthMeta {\n mode: AuthMode;\n\n constructor(mode: AuthMode) {\n this.mode = mode;\n }\n}\n\n/**\n * @ApiPath(basePath) - Class decorator that marks a class as an API definition\n * and sets the base path for all endpoints.\n *\n * Usage:\n * ```typescript\n * @AuthJwt({ roles: ['admin'] })\n * @ApiPath('/api/save')\n * abstract class SaveApi {\n * @Endpoint('/item', 'rpc')\n * save(request: SaveRequest): Promise<SaveResponse> { ... }\n * }\n * ```\n */\nexport function ApiPath(basePath: string): ClassDecorator {\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any) => {\n Reflect.defineMetadata(METADATA_KEYS.API_PATH, basePath, target);\n\n // Initialize endpoints map if not exists\n if (!Reflect.hasMetadata(METADATA_KEYS.ENDPOINTS, target)) {\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINTS, {}, target);\n }\n };\n}\n\n/**\n * @Endpoint(path, kind, options?) - Method decorator that registers a POST endpoint at the given\n * path and declares WHAT TRIGGERS it.\n *\n * All endpoints are POST-only (matching gRPC/thrift style).\n *\n * Usage:\n * ```typescript\n * @Endpoint('/item', 'rpc')\n * save(request: SaveRequest): Promise<SaveResponse> { ... }\n *\n * // enqueued by a producer, delivered later by Cloud Tasks:\n * @Endpoint('/send', 'cloudtasks')\n * send(request: SendRequest): Promise<void> { ... }\n *\n * // fired by Cloud Scheduler on a clock, called by nobody in this repo:\n * @Endpoint('/nightly', 'cron')\n * nightly(request: NightlyRequest): Promise<void> { ... }\n *\n * // EXTERNAL webhook posting application/x-www-form-urlencoded (e.g. Twilio):\n * @Endpoint('/hook', 'external', { formPost: true, calledBy: 'twilio' })\n * inbound(request: InboundRequest): Promise<InboundResponse> { ... }\n * ```\n *\n * `kind` is REQUIRED and deliberately positional: it makes every pre-existing single-argument\n * `@Endpoint('/x')` a COMPILE error rather than something a lint rule has to chase, so no endpoint\n * can slip into the runtime architecture graph with its trigger left to guesswork. See\n * {@link EndpointKind} for what each value draws and which Terraform resource backs it.\n *\n * `calledBy` is REQUIRED for `external` FOR EXACTLY THE SAME REASON, enforced by the overloads below:\n * the one box on the runtime graph whose whole job is to say who calls us from outside could only\n * restate OUR OWN contract name, because nothing in the source ever said who the caller was. This is\n * BREAKING for published consumers, intentionally — an existing `@Endpoint(p, 'external', {...})`\n * stops compiling until it names its caller. Migration is one property; see the migration note in\n * `external-caller.ts`. Non-`external` endpoints are completely unaffected.\n *\n * The path write to ENDPOINTS is UNCHANGED (every consumer iterates `[methodName, path]`); kind,\n * options and caller ride PARALLEL ENDPOINT_KIND / ENDPOINT_OPTIONS / ENDPOINT_CALLER maps.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function Endpoint(path: string, kind: 'external', options: ExternalEndpointOptions): MethodDecorator;\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function Endpoint(path: string, kind: Exclude<EndpointKind, 'external'>, options?: EndpointOptions): MethodDecorator;\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function Endpoint(path: string, kind: EndpointKind, options: EndpointOptions = {}): MethodDecorator {\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any, propertyKey: string | symbol, _descriptor: PropertyDescriptor) => {\n const metadataTarget = typeof target === 'function' ? target : target.constructor;\n\n const endpoints: Record<string, string> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINTS, metadataTarget) || {};\n\n endpoints[propertyKey as string] = path;\n\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINTS, endpoints, metadataTarget);\n\n const kinds: Record<string, EndpointKind> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINT_KIND, metadataTarget) || {};\n kinds[propertyKey as string] = kind;\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINT_KIND, kinds, metadataTarget);\n\n const opts: Record<string, EndpointOptions> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINT_OPTIONS, metadataTarget) || {};\n opts[propertyKey as string] = options;\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINT_OPTIONS, opts, metadataTarget);\n\n // ONLY for 'external', mirroring how a queue name is recorded only for the kinds that HAVE\n // a queue: a caller on an rpc endpoint would be a fact about nothing.\n const declared = options as ExternalEndpointOptions;\n if (kind !== 'external' || typeof declared.calledBy !== 'string' || declared.calledBy === '') return;\n const callers: Record<string, ExternalCaller> = Reflect.getMetadata(METADATA_KEYS.ENDPOINT_CALLER, metadataTarget) || {};\n callers[propertyKey as string] = new ExternalCaller(declared.callerKind ?? DEFAULT_CALLER_KIND, declared.calledBy);\n Reflect.defineMetadata(METADATA_KEYS.ENDPOINT_CALLER, callers, metadataTarget);\n };\n}\n\n/**\n * @MaskLog(fields) - declare which fields of THIS method's request/response DTOs the\n * {@link LogApiCall} logging path must mask, so a secret riding on a DTO (an OAuth refresh token, an\n * id-token JWT) is never written to the logs in cleartext. The REAL value still travels on the wire\n * untouched — masking lives in the logging path only.\n *\n * ```typescript\n * @Endpoint('/account', 'rpc')\n * @MaskLog({ refreshToken: 'full', accessToken: 'last4', credential: 'full' })\n * getEmailAccount(request: GetEmailAccountRequest): Promise<GetEmailAccountResponse> { ... }\n * ```\n *\n * Matching is by field NAME at any depth (nested objects + array elements), so\n * `response.account.refreshToken` is masked. Declared on the SHARED api contract, so BOTH the client\n * `[API-client-*]` and server `[API-server-*]` lines mask it. The spec is read ONCE at route-build\n * time and rides {@link RouteMetadata.mask}, so an unmasked method pays nothing at call time.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function MaskLog(fields: Record<string, MaskMode>): MethodDecorator {\n const spec = new MaskSpec(fields);\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any, propertyKey: string | symbol, _descriptor: PropertyDescriptor) => {\n const metadataTarget = typeof target === 'function' ? target : target.constructor;\n const specs: Record<string, MaskSpec> =\n Reflect.getMetadata(METADATA_KEYS.MASK_LOG, metadataTarget) || {};\n specs[propertyKey as string] = spec;\n Reflect.defineMetadata(METADATA_KEYS.MASK_LOG, specs, metadataTarget);\n };\n}\n\n/**\n * The @MaskLog spec for one method, or undefined if the method declared none (the common case — the\n * caller then logs the DTO verbatim on the plain JSON.stringify fast path).\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpointOptions\nexport function getMaskSpec(apiClass: Function, methodName: string): MaskSpec | undefined {\n const specs: Record<string, MaskSpec> =\n Reflect.getMetadata(METADATA_KEYS.MASK_LOG, apiClass) || {};\n return specs[methodName];\n}\n\n/**\n * Shared implementation for every auth decorator: stores an {@link AuthMeta} for\n * the given {@link AuthMode} at class- or method-level, rejecting a second auth\n * decorator on the same target.\n */\nfunction defineAuthMode(mode: AuthMode): ClassDecorator & MethodDecorator {\n const authMeta = new AuthMeta(mode);\n\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any, propertyKey?: string | symbol, _descriptor?: PropertyDescriptor) => {\n if (propertyKey !== undefined) {\n // Method decorator\n const metadataTarget = typeof target === 'function' ? target : target.constructor;\n validateNoConflictingDecorators(metadataTarget, propertyKey as string);\n Reflect.defineMetadata(METADATA_KEYS.AUTH_META, authMeta, metadataTarget, propertyKey);\n } else {\n // Class decorator\n validateNoConflictingDecorators(target, undefined);\n Reflect.defineMetadata(METADATA_KEYS.AUTH_META, authMeta, target);\n }\n };\n}\n\n/**\n * @Public() - endpoint requires no authentication. Class- or method-level.\n */\nexport function Public(): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'public' });\n}\n\n/**\n * @AuthJwt(requirement) - THE user-facing JWT decorator, covering the whole user-JWT axis: the\n * compiler-enforced role decision ({@link JwtRoles}) plus app-defined fields ({@link JwtRequirement}).\n *\n * ```typescript\n * @AuthJwt({ roles: ['admin', 'editor'] }) // any-of\n * @AuthJwt({ allRolesAllowed: true, inOrg: true }) // wide + an app rule enforced by authorizeJwt\n * ```\n *\n * It absorbed the former `@Auth(requirement)` — same argument, same AuthMode, so two spellings of one\n * decision. One decorator per credential kind now: `@Public` / `@AuthJwt` / `@AuthOidc` /\n * `@AuthSharedSecret` / `@AuthLocalOnly`.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthJwt(requirement: JwtRequirement): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'jwt', requirement });\n}\n\n/**\n * The roles an endpoint accepts, or [] when it accepts every authenticated user. The ONE reader of\n * the {@link JwtRoles} union, so no caller has to re-derive \"does absent mean wide?\" — a question\n * whose two plausible answers is how the widest grant kept hiding behind an absent field.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getAuthMode\nexport function rolesRequired(requirement: JwtRequirement): readonly string[] {\n return requirement.allRolesAllowed === true ? [] : requirement.roles;\n}\n\n/**\n * @AuthOidc(...callers) - Google OIDC service-to-service auth (Cloud Tasks delivery / cross-service\n * RPC). `callers` is an OPTIONAL app-level allow-list of caller service accounts.\n *\n * NO args = TRUST THE EDGE: accept any genuine Google-signed OIDC caller, because a PRIVATE Cloud\n * Run service's edge already gates WHO via `run.invoker` IAM (managed in terraform — one source of\n * truth, no hand-synced list in code). If the service is actually PUBLIC, the verifier logs a loud\n * warning (it can't be the gate then). Pass explicit SAs (`@AuthOidc('svc-a')`) only when you want\n * an additional app-level allow-list as defense-in-depth.\n */\nexport function AuthOidc(...callers: string[]): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'oidc', callers });\n}\n\n/**\n * @AuthSharedSecret(key) - constant-time compare of an inbound header against the secret bound for\n * `key`. `key` is a LOOKUP KEY (not an env var): the server looks up its accepted {@link SharedSecrets}\n * by this key, and each client looks up the value it sends by the SAME key (see {@link Secrets}).\n * For internal callers that cannot mint OIDC tokens.\n */\nexport function AuthSharedSecret(key: string): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'shared-secret', secretKey: key });\n}\n\n/**\n * @AuthWebhook(name) - an OUTSIDE vendor signed this request in its OWN scheme; the app's bound\n * `WebhookAuthCallback` proves it. THE mode for every signed inbound webhook — Sentry, GitHub, Stripe, Slack,\n * Twilio — none of which fits the other kinds: no vendor mints Google OIDC tokens, and none sends its\n * secret (they all send a DERIVATION over the request), so `@Public` was the only reachable posture\n * and `calledBy: 'sentry'` stayed a claim rather than a fact.\n *\n * ```typescript\n * @AuthWebhook('sentry')\n * @Endpoint('/hook/sentry/issue', 'external', { calledBy: 'sentry', rawBody: true })\n * abstract notify(request: SentryIssueHook): Promise<HookAck>;\n * ```\n *\n * `name` is a bare STRING resolved through DI in the server's container, exactly as\n * `@AuthOidc('gmail-push')` already is — never a function reference. An api contract is level 0: a\n * direct reference to a verifier would invert the dependency graph and drag a vendor SDK into the\n * browser bundle that imports the same contract.\n *\n * THE FRAMEWORK IMPLEMENTS NO VENDOR CRYPTO, deliberately. Every vendor ships an official validator\n * (`twilio.validateRequest`, `stripe.webhooks.constructEvent`, `@octokit/webhooks-methods`) and every\n * vendor revises its scheme (Twilio added `bodySHA256` for JSON bodies; Stripe versions its header).\n * Reimplementing five of those is signing up to track five security changelogs forever and to be\n * wrong at the moment being wrong matters. The framework hands the hook enough of the raw request to\n * call the vendor's own library — hence the REQUIRED `{ rawBody: true }` (see\n * {@link EndpointOptions.rawBody}), which is checked at wiring time.\n *\n * FAILS CLOSED: with no `WebhookAuthCallback` bound, every `@AuthWebhook` endpoint 401s, matching `JwtHook`.\n * Silently allowing an unverified webhook is the one default that must not exist.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthWebhook(name: string): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'webhook', name });\n}\n\n/**\n * @AuthApiKey(name) - a CUSTOMER holds the credential. The app's bound `ApiKeyHook` authenticates the\n * inbound request against its own datastore and returns the `ContextTuple` entries the framework\n * seeds into `RequestContext`. THE mode for a partner-facing contract consumed by other companies'\n * codebases (POS vendors, back-office platforms, ETL pipelines).\n *\n * ```typescript\n * @AuthApiKey('onetablet-partner')\n * @ApiPath('/management/v1')\n * abstract class ManagementApi { ... }\n * ```\n *\n * `name` is a bare STRING selecting WHICH key regime this route belongs to, exactly as\n * `@AuthSharedSecret(key)` and `@AuthWebhook(vendor)` already are — one hook serves several regimes,\n * and an api contract is level 0, so it never references a verifier directly.\n *\n * WHY IT IS NOT `@AuthSharedSecret`. Shared-secret declares that AN INTERNAL SERVICE is on the other\n * end, so the framework BELIEVES the trusted context headers that caller forwarded (see\n * `DestinationTrust.forAuthMode` and `AuthFilter.verifiesCaller`). A customer is not an internal\n * service: declaring a partner endpoint `@AuthSharedSecret` would let that partner assert someone\n * else's org id on the wire and have it admitted — a privilege escalation. `apikey` therefore sits\n * with `jwt` on the caller-NOT-verified side, where an inbound trusted header is admitted only when\n * the hook independently derived the SAME value.\n *\n * WHY THE HOOK SEES THE REQUEST, NOT ONE TOKEN. A real key regime checks the key TOGETHER WITH a\n * second header (the organization it is acting for), and `JwtHook.parseJwt` — handed one pre-extracted\n * token from one header — physically cannot. `ApiKeyHook.verifyApiKey(name, request)` gets the whole\n * inbound request instead, so the app owns which headers carry the credential and validates them as a\n * PAIR. The framework deliberately configures no header name: that cross-check is the entire point.\n *\n * FAILS CLOSED: with no `ApiKeyHook` bound, every `@AuthApiKey` endpoint 401s, matching `JwtHook` and\n * `WebhookAuthCallback`.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthApiKey(name: string): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'apikey', name });\n}\n\n/**\n * @AuthLocalOnly() - this endpoint exists ONLY on a developer's machine. Off-local it is not\n * registered as a route at all, and if it is somehow reached it 404s. Class- or method-level.\n *\n * ```typescript\n * @AuthLocalOnly()\n * @Endpoint('/logs', 'rpc')\n * sendBatch(request: SendLogBatchRequest): Promise<SendLogBatchResponse> { ... }\n * ```\n *\n * WHY IT IS AN AUTH MODE AND NOT A ROUTE-MODULE `if`. Apps hand-rolled this in TWO places kept in\n * sync by a comment: a route module that registered the route only locally, PLUS a\n * `if (env !== 'local') throw new HttpForbiddenError(...)` at the top of the handler. Neither half\n * was visible on the CONTRACT, so nothing reading the api — a human, a generated client, or an\n * agent — could tell this endpoint from a `@Public` one. Both halves are the framework's job now,\n * driven by this ONE declaration on the contract, which is where every other \"who may call this\"\n * fact already lives.\n *\n * It is DELIBERATELY a peer of @Public / @AuthJwt / @AuthOidc / @AuthSharedSecret / @AuthApiKey rather than an\n * option on one of them: one decorator per credential kind, and \"local-only\" is a different kind of\n * gate — it authenticates nobody, it excludes an entire environment.\n *\n * HOW \"local\" IS DECIDED: {@link RuntimeLocality}, declared once at startup (a REQUIRED input to\n * `RuntimeSetupOptions`). Undeclared means DEPLOYED, so a forgotten wiring call refuses the endpoint\n * rather than exposing it.\n */\n// webpieces-disable no-function-outside-class -- decorator factory; decorators are inherently module-scope\nexport function AuthLocalOnly(): ClassDecorator & MethodDecorator {\n return defineAuthMode({ kind: 'local-only' });\n}\n\n// ============================================================\n// Helper functions\n// ============================================================\n\n/**\n * Get the base path from @ApiPath decorator.\n */\nexport function getApiPath(apiClass: Function): string | undefined {\n return Reflect.getMetadata(METADATA_KEYS.API_PATH, apiClass);\n}\n\n/**\n * Get all endpoints from @Endpoint decorators.\n * Returns a record of methodName -> endpoint path.\n */\nexport function getEndpoints(apiClass: Function): Record<string, string> | undefined {\n return Reflect.getMetadata(METADATA_KEYS.ENDPOINTS, apiClass);\n}\n\n/**\n * Every method's declared trigger kind, as `methodName -> kind`. Parallel to {@link getEndpoints}.\n * Empty for a class carrying no @Endpoint at all.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function getEndpointKinds(apiClass: Function): Record<string, EndpointKind> {\n return Reflect.getMetadata(METADATA_KEYS.ENDPOINT_KIND, apiClass) || {};\n}\n\n/**\n * What triggers ONE method, or undefined when the method carries no @Endpoint.\n *\n * Defaults to nothing rather than to 'rpc': `kind` is a required argument, so a missing entry means\n * \"this is not an endpoint\", never \"an endpoint that forgot to say\". Silently defaulting here would\n * put an undeclared cron or webhook back into the graph as a normal rpc call — the exact blindness\n * the required argument exists to remove.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function getEndpointKind(apiClass: Function, methodName: string): EndpointKind | undefined {\n return getEndpointKinds(apiClass)[methodName];\n}\n\n/**\n * Get the @Endpoint options for one method (empty object if the method had no options).\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function getEndpointOptions(apiClass: Function, methodName: string): EndpointOptions {\n const opts: Record<string, EndpointOptions> =\n Reflect.getMetadata(METADATA_KEYS.ENDPOINT_OPTIONS, apiClass) || {};\n return opts[methodName] ?? {};\n}\n\n/**\n * Fail-fast at wiring time when an `external` endpoint declared no caller. The {@link Endpoint}\n * overloads already make that a COMPILE error; this is the backstop for the ways TS is bypassed —\n * a JS caller, an `as any` options object, a hand-rolled Reflect.defineMetadata.\n * @throws Error naming the first external endpoint with no `calledBy`.\n */\n// webpieces-disable no-function-outside-class -- wiring-time assert, sibling of assertEveryEndpointHasAuthMode\nexport function assertEveryExternalEndpointDeclaresCaller(apiClass: Function): void {\n const kinds = getEndpointKinds(apiClass);\n for (const methodName of Object.keys(kinds)) {\n if (kinds[methodName] !== 'external' || getEndpointCaller(apiClass, methodName) !== undefined) continue;\n throw new Error(\n `External endpoint '${methodName}' in ${apiClass.name || 'Unknown'} declares no caller. Say WHO ` +\n `posts to it: @Endpoint(path, 'external', { calledBy: '<vendor>' }) — the runtime architecture ` +\n `graph cannot name an inbound caller it was never told about.`,\n );\n }\n}\n\n/**\n * True when the method's @Endpoint declared `{ formPost: true }` — its body is\n * application/x-www-form-urlencoded (flat), not JSON.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of getEndpoints\nexport function isFormPost(apiClass: Function, methodName: string): boolean {\n return getEndpointOptions(apiClass, methodName).formPost === true;\n}\n\n/**\n * True when the method's @Endpoint declared `{ rawBody: true }` — the transport must retain the\n * verbatim bytes + absolute url for an {@link AuthWebhook} hook to verify.\n */\n// webpieces-disable no-function-outside-class -- reflect-metadata reader, sibling of isFormPost\nexport function isRawBody(apiClass: Function, methodName: string): boolean {\n return getEndpointOptions(apiClass, methodName).rawBody === true;\n}\n\n/**\n * Fail-fast at wiring time when an `@AuthWebhook` endpoint did not ask the transport to keep the\n * bytes it is supposed to verify. A hook with nothing to verify is a MISCONFIGURATION, and it must\n * surface at startup, naming the fix — not as a 401 in production on exactly the traffic the endpoint\n * exists for.\n *\n * This pairing is a runtime assert rather than a type because the two halves live on DIFFERENT\n * decorators (`@AuthWebhook` and `@Endpoint`), and no union over one decorator's argument can say\n * anything about the other's.\n *\n * @throws Error naming the first `@AuthWebhook` endpoint missing `{ rawBody: true }`.\n */\n// webpieces-disable no-function-outside-class -- wiring-time assert, sibling of assertEveryEndpointHasAuthMode\nexport function assertEveryWebhookEndpointRetainsRawBody(apiClass: Function): void {\n const endpoints = getEndpoints(apiClass) || {};\n for (const methodName of Object.keys(endpoints)) {\n if (getAuthMode(apiClass, methodName)?.kind !== 'webhook' || isRawBody(apiClass, methodName)) continue;\n throw new Error(\n `Endpoint '${methodName}' in ${apiClass.name || 'Unknown'} is @AuthWebhook but its @Endpoint ` +\n `does not declare { rawBody: true }. A webhook hook verifies a signature over the bytes and ` +\n `the url the SENDER transmitted, and without that option the transport parses the body and ` +\n `throws them away — leaving the hook nothing to check.`,\n );\n }\n}\n\n/**\n * Check if a class has @ApiPath decorator.\n */\nexport function isApiPath(apiClass: Function): boolean {\n return Reflect.hasMetadata(METADATA_KEYS.API_PATH, apiClass);\n}\n\n/**\n * Get auth metadata for a specific method, falling back to class-level auth.\n * Method-level auth takes precedence over class-level auth.\n */\nexport function getAuthMeta(apiClass: Function, methodName?: string): AuthMeta | undefined {\n // Check method-level first\n if (methodName) {\n const methodAuth = Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass, methodName);\n if (methodAuth) {\n return methodAuth;\n }\n }\n\n // Fall back to class-level\n return Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass);\n}\n\n/**\n * Get the auth mode for a method (falling back to class-level), or undefined.\n * Convenience wrapper over getAuthMeta for callers that only want the mode.\n */\nexport function getAuthMode(apiClass: Function, methodName?: string): AuthMode | undefined {\n return getAuthMeta(apiClass, methodName)?.mode;\n}\n\n/**\n * The ONE prescription for \"this endpoint declares no auth\", shared by the two places that raise it\n * (here and http-routing's ApiRoutingFactory) because they had drifted into teaching different menus.\n * A message teaching an incomplete API is the same defect as an API with two spellings: whichever menu\n * the caller hits becomes the API they believe exists. It leads with the ROLE-GATED member on purpose —\n * the first thing offered should not be the widest grant.\n */\nexport const MISSING_AUTH_DECORATOR_FIX =\n \"Add one of @AuthJwt({roles: ['admin']}) / @AuthJwt({allRolesAllowed: true}) / @Public() / \" +\n \"@AuthOidc(...callers) / @AuthSharedSecret(key) / @AuthWebhook('vendor') / @AuthApiKey('regime') / \" +\n '@AuthLocalOnly() to ' +\n 'the class or method.';\n\n/**\n * Fail-fast at wiring time if any endpoint lacks an auth mode. Both the server\n * (ApiRoutingFactory) and the task/rpc clients call this so a missing auth\n * decorator is a startup error, never a silent open endpoint.\n * @throws Error naming the first endpoint with no auth decorator, via {@link MISSING_AUTH_DECORATOR_FIX}.\n */\nexport function assertEveryEndpointHasAuthMode(apiClass: Function): void {\n const apiName = apiClass.name || 'Unknown';\n const endpoints = getEndpoints(apiClass) || {};\n for (const methodName of Object.keys(endpoints)) {\n if (!getAuthMeta(apiClass, methodName)) {\n throw new Error(\n `Endpoint '${methodName}' in ${apiName} has no auth decorator. ` +\n MISSING_AUTH_DECORATOR_FIX,\n );\n }\n }\n}\n\n/**\n * Validate that a class/method doesn't have conflicting auth decorators.\n * @throws Error if multiple auth decorators are found on the same target.\n */\nexport function validateNoConflictingDecorators(apiClass: Function, methodName: string | undefined): void {\n const existing = methodName\n ? Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass, methodName)\n : Reflect.getMetadata(METADATA_KEYS.AUTH_META, apiClass);\n\n if (existing) {\n const targetName = apiClass.name || 'Unknown';\n const location = methodName ? `method '${methodName}' of ${targetName}` : `class ${targetName}`;\n throw new Error(\n `Conflicting auth decorator on ${location}. ` +\n `Only one of @Public() / @AuthJwt({...}) / @AuthOidc(...) / @AuthSharedSecret(...) / ` +\n `@AuthWebhook(...) / @AuthApiKey(...) / @AuthLocalOnly() is allowed per target.`\n );\n }\n}\n"]}
|