@webpieces/core-util 0.4.606 → 0.4.608

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/core-util",
3
- "version": "0.4.606",
3
+ "version": "0.4.608",
4
4
  "description": "Utility functions for WebPieces - works in browser and Node.js",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -175,8 +175,32 @@ export declare class ContextKey<V, T extends Trust = Trust> {
175
175
  * True for a key built by {@link trusted}. The RUNTIME check used by the inbound fill and the
176
176
  * AuthFilter reconciliation; ordinary application code should never need it, because the typed
177
177
  * verbs already made the decision at compile time.
178
+ *
179
+ * It is a TYPE PREDICATE, so the branch that takes it holds a `ContextKey<V, 'trusted'>` and can
180
+ * hand the key straight to `putTrusted` / `PendingWireTrust.stash` with NO cast. Before this, every
181
+ * such site wrote `key as AnyTrustedContextKey` — a cast is exactly the thing an agent copies to
182
+ * the one place it is not warranted, so the runtime check now produces the type it proves.
183
+ */
184
+ isTrusted(): this is ContextKey<V, 'trusted'>;
185
+ /**
186
+ * The other branch — true for a key built by {@link untrusted}, narrowing to
187
+ * `ContextKey<V, 'untrusted'>` so the key can go straight to `putUntrusted`.
188
+ *
189
+ * NOT a second spelling of `!isTrusted()`: that expression cannot narrow anything. TypeScript
190
+ * narrows the NEGATIVE of a `this is X` predicate by `Exclude`, and an {@link AnyContextKey} is
191
+ * `ContextKey<unknown, Trust>` — a single type, not a union of the two branches — so there is
192
+ * nothing to exclude and the key stays mixed. A positive predicate per branch is the only form that
193
+ * types the branch it guards, which is why both exist and why neither takes an argument saying
194
+ * which one you meant.
195
+ *
196
+ * This is what makes a loop over a mixed `AnyContextKey[]` — the {@link HeaderRegistry} arrays, a
197
+ * browser-log payload re-stated into a detached scope — safe BY CONSTRUCTION: the loop can only
198
+ * write a key whose trust it has just tested, and a trusted key cannot reach `putUntrusted` at all,
199
+ * so a loop fed by a source that proves nothing cannot fabricate a proven value, and nobody has to
200
+ * remember to filter. That is a limit on the SOURCE, not on the key: a trusted key is written all
201
+ * the time via `putTrusted`, by an authenticator or by app code that proved the value out of band.
178
202
  */
179
- isTrusted(): boolean;
203
+ isUntrusted(): this is ContextKey<V, 'untrusted'>;
180
204
  /**
181
205
  * The value as it should appear in a log line: returned as-is for a normal
182
206
  * key, partially masked when this key sets `maskInLogs`. Masking is length-based:
package/src/ContextKey.js CHANGED
@@ -155,10 +155,36 @@ class ContextKey {
155
155
  * True for a key built by {@link trusted}. The RUNTIME check used by the inbound fill and the
156
156
  * AuthFilter reconciliation; ordinary application code should never need it, because the typed
157
157
  * verbs already made the decision at compile time.
158
+ *
159
+ * It is a TYPE PREDICATE, so the branch that takes it holds a `ContextKey<V, 'trusted'>` and can
160
+ * hand the key straight to `putTrusted` / `PendingWireTrust.stash` with NO cast. Before this, every
161
+ * such site wrote `key as AnyTrustedContextKey` — a cast is exactly the thing an agent copies to
162
+ * the one place it is not warranted, so the runtime check now produces the type it proves.
158
163
  */
159
164
  isTrusted() {
160
165
  return this.trust === 'trusted';
161
166
  }
167
+ /**
168
+ * The other branch — true for a key built by {@link untrusted}, narrowing to
169
+ * `ContextKey<V, 'untrusted'>` so the key can go straight to `putUntrusted`.
170
+ *
171
+ * NOT a second spelling of `!isTrusted()`: that expression cannot narrow anything. TypeScript
172
+ * narrows the NEGATIVE of a `this is X` predicate by `Exclude`, and an {@link AnyContextKey} is
173
+ * `ContextKey<unknown, Trust>` — a single type, not a union of the two branches — so there is
174
+ * nothing to exclude and the key stays mixed. A positive predicate per branch is the only form that
175
+ * types the branch it guards, which is why both exist and why neither takes an argument saying
176
+ * which one you meant.
177
+ *
178
+ * This is what makes a loop over a mixed `AnyContextKey[]` — the {@link HeaderRegistry} arrays, a
179
+ * browser-log payload re-stated into a detached scope — safe BY CONSTRUCTION: the loop can only
180
+ * write a key whose trust it has just tested, and a trusted key cannot reach `putUntrusted` at all,
181
+ * so a loop fed by a source that proves nothing cannot fabricate a proven value, and nobody has to
182
+ * remember to filter. That is a limit on the SOURCE, not on the key: a trusted key is written all
183
+ * the time via `putTrusted`, by an authenticator or by app code that proved the value out of band.
184
+ */
185
+ isUntrusted() {
186
+ return this.trust === 'untrusted';
187
+ }
162
188
  /**
163
189
  * The value as it should appear in a log line: returned as-is for a normal
164
190
  * key, partially masked when this key sets `maskInLogs`. Masking is length-based:
@@ -1 +1 @@
1
- {"version":3,"file":"ContextKey.js","sourceRoot":"","sources":["../../../../../packages/core/core-util/src/ContextKey.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;;;AAkCH,MAAa,UAAU;IAgBnB,qEAAqE;IAC5D,IAAI,CAAS;IAEtB;;;OAGG;IACM,UAAU,CAAU;IAE7B,0EAA0E;IACjE,KAAK,CAAQ;IAEtB;;;;;OAKG;IACM,UAAU,CAAU;IAE7B,4FAA4F;IACnF,UAAU,CAAU;IAE7B,6EAA6E;IACpE,QAAQ,CAAU;IAE3B;;;;OAIG;IACH,YACI,IAAY,EACZ,KAAY,EACZ,UAA8B,EAC9B,UAA8B,EAC9B,UAAmB,EACnB,QAAiB;QAEjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC7B,CAAC;IAED;;;;;;;;OAQG;IACH,6KAA6K;IAC7K,MAAM,CAAC,OAAO,CACV,IAAY,EACZ,UAAkB,EAClB,UAAmB,EACnB,UAAU,GAAG,KAAK,EAClB,QAAQ,GAAG,IAAI;QAEf,OAAO,IAAI,UAAU,CAAe,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;IACvG,CAAC;IAED;;;;;;;OAOG;IACH,uHAAuH;IACvH,MAAM,CAAC,SAAS,CACZ,IAAY,EACZ,UAAmB,EACnB,UAAU,GAAG,KAAK,EAClB,QAAQ,GAAG,IAAI;QAEf,OAAO,IAAI,UAAU,CAAiB,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC1G,CAAC;IAED,uEAAuE;IACvE,aAAa;QACT,OAAO,IAAI,CAAC,UAAU,KAAK,SAAS,CAAC;IACzC,CAAC;IAED;;;;OAIG;IACH,SAAS;QACL,OAAO,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC;IACpC,CAAC;IAED;;;;;;OAMG;IACH,WAAW,CAAC,KAAa;QACrB,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;YACnB,OAAO,KAAK,CAAC;QACjB,CAAC;QACD,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC;QACzB,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;YACV,OAAO,+BAA+B,CAAC;QAC3C,CAAC;aAAM,IAAI,GAAG,IAAI,EAAE,EAAE,CAAC;YACnB,OAAO,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC;QACzC,CAAC;aAAM,CAAC;YACJ,OAAO,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC;QACpE,CAAC;IACL,CAAC;CACJ;AAvID,gCAuIC","sourcesContent":["/**\n * ContextKey - a single key that travels in the request's \"magic context\"\n * (RequestContext on the server, MutableContextStore in the browser).\n *\n * This ONE class replaces the old split of `Header` (interface) + `PlatformHeader`\n * (class) + `ContextKey` (class). Every context value — whether it rides over HTTP\n * (request-id, tenant, authorization) or stays in-process (method-meta, the\n * TestCaseRecorder) — is a `ContextKey`.\n *\n * The fields are named for what they DO (flipped from the old model):\n * - `name` ALWAYS set. The context storage key, the log/MDC key, and the\n * recorder name. e.g. 'requestId', 'tenantId', 'authorization'.\n * - `httpHeader` OPTIONAL. When set, this key is transferred over the wire under\n * this HTTP header name (inbound request -> context, and context ->\n * outbound request). e.g. 'x-request-id'. When UNSET, the key is\n * context-only and never leaves the process (method-meta, recorder).\n * - `trust` REQUIRED, and stated by WHICH FACTORY you call. See below.\n * - `maskInLogs` When true, the value is masked (partially) in logs. This is about\n * LOG REDACTION and has NOTHING to do with `trust` — a userId is\n * trusted AND fully logged; a bearer token is untrusted AND masked.\n * (Formerly `isSecured`, renamed because sitting next to `trust` the\n * old name read as \"this value is secure\", which it never meant.)\n * - `isLogged` Defaults to true. When false, the value is NEVER logged (used for\n * object-valued/internal keys like the recorder or method-meta that\n * must not be serialized into log lines).\n *\n * ## TRUST — the whole point of this class\n *\n * A context value is either something the framework PROVED (`trusted`) or something a\n * caller merely ASSERTED (`untrusted`). That distinction is invisible in a `Map<string,\n * string>`, so code — human- or AI-written — routinely reads a spoofable header as if it\n * were an authenticated fact. `userId` is the canonical example: it is a verified JWT\n * claim on one route and an attacker-supplied `x-user-id` on the next, and nothing in the\n * old API told them apart.\n *\n * So trust is declared ON THE KEY, at the single place the key is defined, and it is\n * enforced at BOTH ends:\n *\n * ContextKey.trusted<string>('userId', 'jwt claim `sub`, stamped by AuthFilter', 'x-user-id')\n * ContextKey.untrusted<string>('actionId', 'x-webpieces-actionid')\n *\n * `grep -rn \"ContextKey.trusted\"` therefore enumerates every high-assurance field in the\n * codebase, each with its `provenance` string on the same line saying WHY it is trusted.\n * That is the AI-facing payoff: the answer is one grep, not an audit.\n *\n * The constructor is PRIVATE — you cannot make a key without picking a branch, and there\n * is no default. A default would make the permissive branch the shortest thing to type,\n * which is exactly the \"widening that is an ABSENCE rather than a token\" that CLAUDE.md\n * rejects. `provenance` is a REQUIRED positional argument on the trusted factory only, so\n * \"trusted with no stated reason\" cannot be written down.\n *\n * ## What makes the `trusted` label HONEST at runtime\n *\n * The label would be a lie if anything could write a trusted key from an unverified\n * source. Three enforced facts prevent that:\n *\n * 1. WRITES are typed: only `RequestContext.putTrusted(key, value)` accepts a trusted key,\n * and it is a distinct, greppable verb an app has to type on purpose.\n * 2. INBOUND wire values for trusted keys never enter the context directly.\n * `RequestContextHeaders.fillFromRequest` stashes them as PENDING, and `AuthFilter`\n * admits them only after it knows who the caller is (see {@link PendingWireTrust}).\n * 3. READS are typed: `getTrusted(key)` does not compile for an untrusted key, and\n * `getUntrusted(key)` does not compile for a trusted one. Picking a verb is\n * unavoidable, so a reader always knows which kind of value it is holding.\n *\n * Trusted keys DO keep their `httpHeader` — service-to-service propagation of a verified\n * userId is a first-class requirement, not a hole. It is safe because rule 2 gates it on\n * the endpoint's own auth mode: a route that verified WHO called it (`@AuthOidc`,\n * `@AuthSharedSecret`) accepts the caller's trusted headers; a route reachable by a\n * browser (`@AuthJwt`, public) does not.\n *\n * Per CLAUDE.md: data-only structures are classes, not interfaces.\n *\n * The type parameter `V` is the TYPE OF THE VALUE stored under this key — `string` for the wire/log\n * keys (requestId, tenantId, ...), `ApiCallInfo` for the structured api tag, `TestCaseRecorder` for\n * the recorder. It is REQUIRED (no default): every key must state what it holds. A heterogeneous\n * store CANNOT be a `Record<string, string>` — the recorder and the api payload are not strings — so\n * instead each KEY carries its own value type, and the typed accessors INFER it from the key. That\n * keeps the backing Map honestly type-erased while the public surface stays fully typed: a caller\n * never asserts a value type, the key already declares it. A genuinely mixed collection of keys is\n * spelled explicitly as `AnyContextKey[]`, so \"I mean a mixed bag\" is a visible, deliberate\n * statement, never a default.\n *\n * The type parameter `T` is the TRUST LEVEL, carried as a phantom type so the accessor verbs can\n * reject the wrong kind of key at COMPILE time rather than throwing at runtime (CLAUDE.md treats a\n * runtime throw standing in for an expressible type as a defect).\n */\n\n/** The two kinds of context value. See the {@link ContextKey} class doc. */\nexport type Trust = 'trusted' | 'untrusted';\n\n/**\n * A ContextKey whose value type is intentionally UNCONSTRAINED — a \"key of any value type\". Use this\n * (never a bare `ContextKey`, which no longer compiles) for genuinely mixed-bag collections and\n * key-agnostic code: `ALL_HEADERS: AnyContextKey[]`, the {@link HeaderRegistry}'s key arrays, a\n * reader that takes whatever key it is handed. Naming the mixed case makes \"I mean any key\" a visible,\n * deliberate statement, and confines the one sanctioned `unknown` to this single alias instead of\n * scattering `ContextKey<unknown>` — and its disable comment — across the codebase.\n *\n * NOTE this is mixed in TRUST as well as in value type, so it is READ-ONLY territory: `getAny(key)`\n * takes one, but no WRITE verb does. A write must name the trust level, which is what keeps the\n * `trusted` label honest.\n */\n// webpieces-disable no-any-unknown -- the ONE sanctioned `unknown`: a key whose value type is deliberately unconstrained (mixed-bag collections / key-agnostic code). Every other site names AnyContextKey instead of repeating this.\nexport type AnyContextKey = ContextKey<unknown>;\n\n/**\n * A trusted key of any value type — what {@link ContextTuple} carries, and what the trusted write\n * verb accepts when the value type is not statically known.\n */\n// webpieces-disable no-any-unknown -- same sanctioned mixed-bag alias as AnyContextKey, narrowed to the trusted branch\nexport type AnyTrustedContextKey = ContextKey<unknown, 'trusted'>;\n\n/**\n * An untrusted key of any value type — what the {@link ApiCallContext} seam stamps, so that seam\n * cannot be used as a side door to forge a trusted value.\n */\n// webpieces-disable no-any-unknown -- same sanctioned mixed-bag alias as AnyContextKey, narrowed to the untrusted branch\nexport type AnyUntrustedContextKey = ContextKey<unknown, 'untrusted'>;\n\nexport class ContextKey<V, T extends Trust = Trust> {\n /**\n * Phantom marker carrying the value type {@link V}. It has no runtime existence (`declare`, never\n * assigned) — it exists ONLY so the read verbs return `V` and the write verbs check `value`\n * against `V`, both inferred straight from the key. Optional, so `ContextKey<A>` stays assignable\n * to `AnyContextKey` (i.e. `ContextKey<unknown>`) — arrays of mixed keys keep working.\n */\n declare readonly __valueType?: V;\n\n /**\n * Phantom marker carrying the trust level {@link T} — the reason `getTrusted(SOME_UNTRUSTED_KEY)`\n * is a COMPILE error and not a runtime throw. Like `__valueType` it never exists at runtime; the\n * runtime answer is the {@link trust} field below, which the fill/reconcile path reads.\n */\n declare readonly __trust?: T;\n\n /** Context storage key + log/MDC key + recorder name. Always set. */\n readonly name: string;\n\n /**\n * HTTP header name when this key is transferred over the wire (e.g.\n * 'x-request-id'). Undefined = context-only, never transferred.\n */\n readonly httpHeader?: string;\n\n /** The runtime twin of the phantom {@link __trust}. See the class doc. */\n readonly trust: Trust;\n\n /**\n * WHY this key is trusted, in prose — 'jwt claim `sub`, stamped by AuthFilter', or\n * 'whatsapp webhook phone number -> user lookup'. Required on a trusted key, absent on an\n * untrusted one. It exists so that grepping the trusted keys also tells you what proves each\n * one, without opening another file.\n */\n readonly provenance?: string;\n\n /** Mask this value (partially) in logs. Log redaction only — unrelated to {@link trust}. */\n readonly maskInLogs: boolean;\n\n /** Whether this key is logged at all. Default true; false = never logged. */\n readonly isLogged: boolean;\n\n /**\n * PRIVATE — use {@link trusted} or {@link untrusted}. There is deliberately no way to build a key\n * without stating its trust level: a defaulted trust argument would make the permissive branch\n * the shortest thing to type and impossible to grep.\n */\n private constructor(\n name: string,\n trust: Trust,\n provenance: string | undefined,\n httpHeader: string | undefined,\n maskInLogs: boolean,\n isLogged: boolean,\n ) {\n this.name = name;\n this.trust = trust;\n this.provenance = provenance;\n this.httpHeader = httpHeader;\n this.maskInLogs = maskInLogs;\n this.isLogged = isLogged;\n }\n\n /**\n * A key whose value the framework PROVED — a verified JWT claim, or a fact an app derived from a\n * verified credential (a Twilio/WhatsApp webhook's signed phone number looked up to a userId).\n *\n * Only `RequestContext.putTrusted` can write one, only `RequestContext.getTrusted` can read one,\n * and an inbound wire value for one is held PENDING until `AuthFilter` knows who the caller is.\n *\n * @param provenance WHY it is trusted, in prose. Required — see {@link ContextKey.provenance}.\n */\n // webpieces-disable no-function-outside-class -- static factory replacing the (now private) constructor; the trust branch must be part of the call, not a defaulted argument\n static trusted<V>(\n name: string,\n provenance: string,\n httpHeader?: string,\n maskInLogs = false,\n isLogged = true,\n ): ContextKey<V, 'trusted'> {\n return new ContextKey<V, 'trusted'>(name, 'trusted', provenance, httpHeader, maskInLogs, isLogged);\n }\n\n /**\n * A key whose value is merely ASSERTED by whoever sent it — a browser-minted actionId, a\n * client-supplied recording flag, an in-process log tag. Perfectly fine to use; just never\n * an input to an authorization decision.\n *\n * This is the DEFAULT choice in the sense that most keys are this — but it is never the default\n * VALUE: you still type the word, so reading a key definition always tells you which it is.\n */\n // webpieces-disable no-function-outside-class -- static factory replacing the (now private) constructor; see trusted()\n static untrusted<V>(\n name: string,\n httpHeader?: string,\n maskInLogs = false,\n isLogged = true,\n ): ContextKey<V, 'untrusted'> {\n return new ContextKey<V, 'untrusted'>(name, 'untrusted', undefined, httpHeader, maskInLogs, isLogged);\n }\n\n /** True when this key is transferred over HTTP (has an httpHeader). */\n isTransferred(): boolean {\n return this.httpHeader !== undefined;\n }\n\n /**\n * True for a key built by {@link trusted}. The RUNTIME check used by the inbound fill and the\n * AuthFilter reconciliation; ordinary application code should never need it, because the typed\n * verbs already made the decision at compile time.\n */\n isTrusted(): boolean {\n return this.trust === 'trusted';\n }\n\n /**\n * The value as it should appear in a log line: returned as-is for a normal\n * key, partially masked when this key sets `maskInLogs`. Masking is length-based:\n * - Length > 15: first 3 + \"...\" + last 3\n * - Length 8-15: first 2 + \"...\"\n * - Length < 8: \"<secure key too short to log>\"\n */\n maskForLogs(value: string): string {\n if (!this.maskInLogs) {\n return value;\n }\n const len = value.length;\n if (len < 8) {\n return '<secure key too short to log>';\n } else if (len <= 15) {\n return `${value.substring(0, 2)}...`;\n } else {\n return `${value.substring(0, 3)}...${value.substring(len - 3)}`;\n }\n }\n}\n"]}
1
+ {"version":3,"file":"ContextKey.js","sourceRoot":"","sources":["../../../../../packages/core/core-util/src/ContextKey.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;;;AAkCH,MAAa,UAAU;IAgBnB,qEAAqE;IAC5D,IAAI,CAAS;IAEtB;;;OAGG;IACM,UAAU,CAAU;IAE7B,0EAA0E;IACjE,KAAK,CAAQ;IAEtB;;;;;OAKG;IACM,UAAU,CAAU;IAE7B,4FAA4F;IACnF,UAAU,CAAU;IAE7B,6EAA6E;IACpE,QAAQ,CAAU;IAE3B;;;;OAIG;IACH,YACI,IAAY,EACZ,KAAY,EACZ,UAA8B,EAC9B,UAA8B,EAC9B,UAAmB,EACnB,QAAiB;QAEjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC7B,CAAC;IAED;;;;;;;;OAQG;IACH,6KAA6K;IAC7K,MAAM,CAAC,OAAO,CACV,IAAY,EACZ,UAAkB,EAClB,UAAmB,EACnB,UAAU,GAAG,KAAK,EAClB,QAAQ,GAAG,IAAI;QAEf,OAAO,IAAI,UAAU,CAAe,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;IACvG,CAAC;IAED;;;;;;;OAOG;IACH,uHAAuH;IACvH,MAAM,CAAC,SAAS,CACZ,IAAY,EACZ,UAAmB,EACnB,UAAU,GAAG,KAAK,EAClB,QAAQ,GAAG,IAAI;QAEf,OAAO,IAAI,UAAU,CAAiB,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC1G,CAAC;IAED,uEAAuE;IACvE,aAAa;QACT,OAAO,IAAI,CAAC,UAAU,KAAK,SAAS,CAAC;IACzC,CAAC;IAED;;;;;;;;;OASG;IACH,SAAS;QACL,OAAO,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC;IACpC,CAAC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,WAAW;QACP,OAAO,IAAI,CAAC,KAAK,KAAK,WAAW,CAAC;IACtC,CAAC;IAED;;;;;;OAMG;IACH,WAAW,CAAC,KAAa;QACrB,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;YACnB,OAAO,KAAK,CAAC;QACjB,CAAC;QACD,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC;QACzB,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;YACV,OAAO,+BAA+B,CAAC;QAC3C,CAAC;aAAM,IAAI,GAAG,IAAI,EAAE,EAAE,CAAC;YACnB,OAAO,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC;QACzC,CAAC;aAAM,CAAC;YACJ,OAAO,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC;QACpE,CAAC;IACL,CAAC;CACJ;AAlKD,gCAkKC","sourcesContent":["/**\n * ContextKey - a single key that travels in the request's \"magic context\"\n * (RequestContext on the server, MutableContextStore in the browser).\n *\n * This ONE class replaces the old split of `Header` (interface) + `PlatformHeader`\n * (class) + `ContextKey` (class). Every context value — whether it rides over HTTP\n * (request-id, tenant, authorization) or stays in-process (method-meta, the\n * TestCaseRecorder) — is a `ContextKey`.\n *\n * The fields are named for what they DO (flipped from the old model):\n * - `name` ALWAYS set. The context storage key, the log/MDC key, and the\n * recorder name. e.g. 'requestId', 'tenantId', 'authorization'.\n * - `httpHeader` OPTIONAL. When set, this key is transferred over the wire under\n * this HTTP header name (inbound request -> context, and context ->\n * outbound request). e.g. 'x-request-id'. When UNSET, the key is\n * context-only and never leaves the process (method-meta, recorder).\n * - `trust` REQUIRED, and stated by WHICH FACTORY you call. See below.\n * - `maskInLogs` When true, the value is masked (partially) in logs. This is about\n * LOG REDACTION and has NOTHING to do with `trust` — a userId is\n * trusted AND fully logged; a bearer token is untrusted AND masked.\n * (Formerly `isSecured`, renamed because sitting next to `trust` the\n * old name read as \"this value is secure\", which it never meant.)\n * - `isLogged` Defaults to true. When false, the value is NEVER logged (used for\n * object-valued/internal keys like the recorder or method-meta that\n * must not be serialized into log lines).\n *\n * ## TRUST — the whole point of this class\n *\n * A context value is either something the framework PROVED (`trusted`) or something a\n * caller merely ASSERTED (`untrusted`). That distinction is invisible in a `Map<string,\n * string>`, so code — human- or AI-written — routinely reads a spoofable header as if it\n * were an authenticated fact. `userId` is the canonical example: it is a verified JWT\n * claim on one route and an attacker-supplied `x-user-id` on the next, and nothing in the\n * old API told them apart.\n *\n * So trust is declared ON THE KEY, at the single place the key is defined, and it is\n * enforced at BOTH ends:\n *\n * ContextKey.trusted<string>('userId', 'jwt claim `sub`, stamped by AuthFilter', 'x-user-id')\n * ContextKey.untrusted<string>('actionId', 'x-webpieces-actionid')\n *\n * `grep -rn \"ContextKey.trusted\"` therefore enumerates every high-assurance field in the\n * codebase, each with its `provenance` string on the same line saying WHY it is trusted.\n * That is the AI-facing payoff: the answer is one grep, not an audit.\n *\n * The constructor is PRIVATE — you cannot make a key without picking a branch, and there\n * is no default. A default would make the permissive branch the shortest thing to type,\n * which is exactly the \"widening that is an ABSENCE rather than a token\" that CLAUDE.md\n * rejects. `provenance` is a REQUIRED positional argument on the trusted factory only, so\n * \"trusted with no stated reason\" cannot be written down.\n *\n * ## What makes the `trusted` label HONEST at runtime\n *\n * The label would be a lie if anything could write a trusted key from an unverified\n * source. Three enforced facts prevent that:\n *\n * 1. WRITES are typed: only `RequestContext.putTrusted(key, value)` accepts a trusted key,\n * and it is a distinct, greppable verb an app has to type on purpose.\n * 2. INBOUND wire values for trusted keys never enter the context directly.\n * `RequestContextHeaders.fillFromRequest` stashes them as PENDING, and `AuthFilter`\n * admits them only after it knows who the caller is (see {@link PendingWireTrust}).\n * 3. READS are typed: `getTrusted(key)` does not compile for an untrusted key, and\n * `getUntrusted(key)` does not compile for a trusted one. Picking a verb is\n * unavoidable, so a reader always knows which kind of value it is holding.\n *\n * Trusted keys DO keep their `httpHeader` — service-to-service propagation of a verified\n * userId is a first-class requirement, not a hole. It is safe because rule 2 gates it on\n * the endpoint's own auth mode: a route that verified WHO called it (`@AuthOidc`,\n * `@AuthSharedSecret`) accepts the caller's trusted headers; a route reachable by a\n * browser (`@AuthJwt`, public) does not.\n *\n * Per CLAUDE.md: data-only structures are classes, not interfaces.\n *\n * The type parameter `V` is the TYPE OF THE VALUE stored under this key — `string` for the wire/log\n * keys (requestId, tenantId, ...), `ApiCallInfo` for the structured api tag, `TestCaseRecorder` for\n * the recorder. It is REQUIRED (no default): every key must state what it holds. A heterogeneous\n * store CANNOT be a `Record<string, string>` — the recorder and the api payload are not strings — so\n * instead each KEY carries its own value type, and the typed accessors INFER it from the key. That\n * keeps the backing Map honestly type-erased while the public surface stays fully typed: a caller\n * never asserts a value type, the key already declares it. A genuinely mixed collection of keys is\n * spelled explicitly as `AnyContextKey[]`, so \"I mean a mixed bag\" is a visible, deliberate\n * statement, never a default.\n *\n * The type parameter `T` is the TRUST LEVEL, carried as a phantom type so the accessor verbs can\n * reject the wrong kind of key at COMPILE time rather than throwing at runtime (CLAUDE.md treats a\n * runtime throw standing in for an expressible type as a defect).\n */\n\n/** The two kinds of context value. See the {@link ContextKey} class doc. */\nexport type Trust = 'trusted' | 'untrusted';\n\n/**\n * A ContextKey whose value type is intentionally UNCONSTRAINED — a \"key of any value type\". Use this\n * (never a bare `ContextKey`, which no longer compiles) for genuinely mixed-bag collections and\n * key-agnostic code: `ALL_HEADERS: AnyContextKey[]`, the {@link HeaderRegistry}'s key arrays, a\n * reader that takes whatever key it is handed. Naming the mixed case makes \"I mean any key\" a visible,\n * deliberate statement, and confines the one sanctioned `unknown` to this single alias instead of\n * scattering `ContextKey<unknown>` — and its disable comment — across the codebase.\n *\n * NOTE this is mixed in TRUST as well as in value type, so it is READ-ONLY territory: `getAny(key)`\n * takes one, but no WRITE verb does. A write must name the trust level, which is what keeps the\n * `trusted` label honest.\n */\n// webpieces-disable no-any-unknown -- the ONE sanctioned `unknown`: a key whose value type is deliberately unconstrained (mixed-bag collections / key-agnostic code). Every other site names AnyContextKey instead of repeating this.\nexport type AnyContextKey = ContextKey<unknown>;\n\n/**\n * A trusted key of any value type — what {@link ContextTuple} carries, and what the trusted write\n * verb accepts when the value type is not statically known.\n */\n// webpieces-disable no-any-unknown -- same sanctioned mixed-bag alias as AnyContextKey, narrowed to the trusted branch\nexport type AnyTrustedContextKey = ContextKey<unknown, 'trusted'>;\n\n/**\n * An untrusted key of any value type — what the {@link ApiCallContext} seam stamps, so that seam\n * cannot be used as a side door to forge a trusted value.\n */\n// webpieces-disable no-any-unknown -- same sanctioned mixed-bag alias as AnyContextKey, narrowed to the untrusted branch\nexport type AnyUntrustedContextKey = ContextKey<unknown, 'untrusted'>;\n\nexport class ContextKey<V, T extends Trust = Trust> {\n /**\n * Phantom marker carrying the value type {@link V}. It has no runtime existence (`declare`, never\n * assigned) — it exists ONLY so the read verbs return `V` and the write verbs check `value`\n * against `V`, both inferred straight from the key. Optional, so `ContextKey<A>` stays assignable\n * to `AnyContextKey` (i.e. `ContextKey<unknown>`) — arrays of mixed keys keep working.\n */\n declare readonly __valueType?: V;\n\n /**\n * Phantom marker carrying the trust level {@link T} — the reason `getTrusted(SOME_UNTRUSTED_KEY)`\n * is a COMPILE error and not a runtime throw. Like `__valueType` it never exists at runtime; the\n * runtime answer is the {@link trust} field below, which the fill/reconcile path reads.\n */\n declare readonly __trust?: T;\n\n /** Context storage key + log/MDC key + recorder name. Always set. */\n readonly name: string;\n\n /**\n * HTTP header name when this key is transferred over the wire (e.g.\n * 'x-request-id'). Undefined = context-only, never transferred.\n */\n readonly httpHeader?: string;\n\n /** The runtime twin of the phantom {@link __trust}. See the class doc. */\n readonly trust: Trust;\n\n /**\n * WHY this key is trusted, in prose — 'jwt claim `sub`, stamped by AuthFilter', or\n * 'whatsapp webhook phone number -> user lookup'. Required on a trusted key, absent on an\n * untrusted one. It exists so that grepping the trusted keys also tells you what proves each\n * one, without opening another file.\n */\n readonly provenance?: string;\n\n /** Mask this value (partially) in logs. Log redaction only — unrelated to {@link trust}. */\n readonly maskInLogs: boolean;\n\n /** Whether this key is logged at all. Default true; false = never logged. */\n readonly isLogged: boolean;\n\n /**\n * PRIVATE — use {@link trusted} or {@link untrusted}. There is deliberately no way to build a key\n * without stating its trust level: a defaulted trust argument would make the permissive branch\n * the shortest thing to type and impossible to grep.\n */\n private constructor(\n name: string,\n trust: Trust,\n provenance: string | undefined,\n httpHeader: string | undefined,\n maskInLogs: boolean,\n isLogged: boolean,\n ) {\n this.name = name;\n this.trust = trust;\n this.provenance = provenance;\n this.httpHeader = httpHeader;\n this.maskInLogs = maskInLogs;\n this.isLogged = isLogged;\n }\n\n /**\n * A key whose value the framework PROVED — a verified JWT claim, or a fact an app derived from a\n * verified credential (a Twilio/WhatsApp webhook's signed phone number looked up to a userId).\n *\n * Only `RequestContext.putTrusted` can write one, only `RequestContext.getTrusted` can read one,\n * and an inbound wire value for one is held PENDING until `AuthFilter` knows who the caller is.\n *\n * @param provenance WHY it is trusted, in prose. Required — see {@link ContextKey.provenance}.\n */\n // webpieces-disable no-function-outside-class -- static factory replacing the (now private) constructor; the trust branch must be part of the call, not a defaulted argument\n static trusted<V>(\n name: string,\n provenance: string,\n httpHeader?: string,\n maskInLogs = false,\n isLogged = true,\n ): ContextKey<V, 'trusted'> {\n return new ContextKey<V, 'trusted'>(name, 'trusted', provenance, httpHeader, maskInLogs, isLogged);\n }\n\n /**\n * A key whose value is merely ASSERTED by whoever sent it — a browser-minted actionId, a\n * client-supplied recording flag, an in-process log tag. Perfectly fine to use; just never\n * an input to an authorization decision.\n *\n * This is the DEFAULT choice in the sense that most keys are this — but it is never the default\n * VALUE: you still type the word, so reading a key definition always tells you which it is.\n */\n // webpieces-disable no-function-outside-class -- static factory replacing the (now private) constructor; see trusted()\n static untrusted<V>(\n name: string,\n httpHeader?: string,\n maskInLogs = false,\n isLogged = true,\n ): ContextKey<V, 'untrusted'> {\n return new ContextKey<V, 'untrusted'>(name, 'untrusted', undefined, httpHeader, maskInLogs, isLogged);\n }\n\n /** True when this key is transferred over HTTP (has an httpHeader). */\n isTransferred(): boolean {\n return this.httpHeader !== undefined;\n }\n\n /**\n * True for a key built by {@link trusted}. The RUNTIME check used by the inbound fill and the\n * AuthFilter reconciliation; ordinary application code should never need it, because the typed\n * verbs already made the decision at compile time.\n *\n * It is a TYPE PREDICATE, so the branch that takes it holds a `ContextKey<V, 'trusted'>` and can\n * hand the key straight to `putTrusted` / `PendingWireTrust.stash` with NO cast. Before this, every\n * such site wrote `key as AnyTrustedContextKey` — a cast is exactly the thing an agent copies to\n * the one place it is not warranted, so the runtime check now produces the type it proves.\n */\n isTrusted(): this is ContextKey<V, 'trusted'> {\n return this.trust === 'trusted';\n }\n\n /**\n * The other branch — true for a key built by {@link untrusted}, narrowing to\n * `ContextKey<V, 'untrusted'>` so the key can go straight to `putUntrusted`.\n *\n * NOT a second spelling of `!isTrusted()`: that expression cannot narrow anything. TypeScript\n * narrows the NEGATIVE of a `this is X` predicate by `Exclude`, and an {@link AnyContextKey} is\n * `ContextKey<unknown, Trust>` — a single type, not a union of the two branches — so there is\n * nothing to exclude and the key stays mixed. A positive predicate per branch is the only form that\n * types the branch it guards, which is why both exist and why neither takes an argument saying\n * which one you meant.\n *\n * This is what makes a loop over a mixed `AnyContextKey[]` — the {@link HeaderRegistry} arrays, a\n * browser-log payload re-stated into a detached scope — safe BY CONSTRUCTION: the loop can only\n * write a key whose trust it has just tested, and a trusted key cannot reach `putUntrusted` at all,\n * so a loop fed by a source that proves nothing cannot fabricate a proven value, and nobody has to\n * remember to filter. That is a limit on the SOURCE, not on the key: a trusted key is written all\n * the time via `putTrusted`, by an authenticator or by app code that proved the value out of band.\n */\n isUntrusted(): this is ContextKey<V, 'untrusted'> {\n return this.trust === 'untrusted';\n }\n\n /**\n * The value as it should appear in a log line: returned as-is for a normal\n * key, partially masked when this key sets `maskInLogs`. Masking is length-based:\n * - Length > 15: first 3 + \"...\" + last 3\n * - Length 8-15: first 2 + \"...\"\n * - Length < 8: \"<secure key too short to log>\"\n */\n maskForLogs(value: string): string {\n if (!this.maskInLogs) {\n return value;\n }\n const len = value.length;\n if (len < 8) {\n return '<secure key too short to log>';\n } else if (len <= 15) {\n return `${value.substring(0, 2)}...`;\n } else {\n return `${value.substring(0, 3)}...${value.substring(len - 3)}`;\n }\n }\n}\n"]}
@@ -38,8 +38,8 @@ export declare class DestinationTrust {
38
38
  */
39
39
  private static readonly VERIFIES_CALLER;
40
40
  /**
41
- * The destination cannot tell us from a browser with curl (@AuthJwt / @Public / an endpoint with
42
- * no declared mode), so trusted keys are omitted. Untrusted keys still travel.
41
+ * The destination cannot tell us from a browser with curl (@AuthJwt / @Public / @AuthLocalOnly /
42
+ * an endpoint with no declared mode), so trusted keys are omitted. Untrusted keys still travel.
43
43
  */
44
44
  private static readonly CANNOT_VERIFY_CALLER;
45
45
  private constructor();
@@ -39,8 +39,8 @@ class DestinationTrust {
39
39
  */
40
40
  static VERIFIES_CALLER = new DestinationTrust(true);
41
41
  /**
42
- * The destination cannot tell us from a browser with curl (@AuthJwt / @Public / an endpoint with
43
- * no declared mode), so trusted keys are omitted. Untrusted keys still travel.
42
+ * The destination cannot tell us from a browser with curl (@AuthJwt / @Public / @AuthLocalOnly /
43
+ * an endpoint with no declared mode), so trusted keys are omitted. Untrusted keys still travel.
44
44
  */
45
45
  static CANNOT_VERIFY_CALLER = new DestinationTrust(false);
46
46
  constructor(verifiesCaller) {
@@ -62,6 +62,11 @@ class DestinationTrust {
62
62
  return DestinationTrust.VERIFIES_CALLER;
63
63
  case 'jwt':
64
64
  case 'public':
65
+ // @AuthLocalOnly authenticates NOBODY — it gates on the environment, not on a
66
+ // credential — so a browser with curl on the same laptop is indistinguishable from us.
67
+ // Same bucket as public/jwt. (This switch has NO `default` on purpose: adding a kind to
68
+ // AuthMode is a compile error here rather than a silent permissive fallthrough.)
69
+ case 'local-only':
65
70
  return DestinationTrust.CANNOT_VERIFY_CALLER;
66
71
  }
67
72
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DestinationTrust.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/DestinationTrust.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAa,gBAAgB;IAcY;IAbrC;;;;OAIG;IACK,MAAM,CAAU,eAAe,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAErE;;;OAGG;IACK,MAAM,CAAU,oBAAoB,GAAG,IAAI,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAE3E,YAAqC,cAAuB;QAAvB,mBAAc,GAAd,cAAc,CAAS;IAAG,CAAC;IAEhE;;;;OAIG;IACH,qSAAqS;IACrS,MAAM,CAAC,WAAW,CAAC,IAA0B;QACzC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACrB,OAAO,gBAAgB,CAAC,oBAAoB,CAAC;QACjD,CAAC;QACD,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;YAChB,KAAK,MAAM,CAAC;YACZ,KAAK,eAAe;gBAChB,OAAO,gBAAgB,CAAC,eAAe,CAAC;YAC5C,KAAK,KAAK,CAAC;YACX,KAAK,QAAQ;gBACT,OAAO,gBAAgB,CAAC,oBAAoB,CAAC;QACrD,CAAC;IACL,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAkB;QACrB,OAAO,IAAI,CAAC,cAAc,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;IACnD,CAAC;;AA3CL,4CA4CC","sourcesContent":["import { AnyContextKey } from '../ContextKey';\nimport { AuthMode } from './decorators';\n\n/**\n * DestinationTrust - the OUTBOUND half of the trust model: may a TRUSTED context key\n * ({@link ContextKey.trusted}) ride to the endpoint we are about to call?\n *\n * ## Why the client has to answer this at all\n *\n * The server already decided (see `PendingWireTrust`): an inbound `x-user-id` is admitted only on a\n * route that verified WHO called it — `@AuthOidc` / `@AuthSharedSecret`. On a `@AuthJwt` or `@Public`\n * route the same header must match what the authenticator independently derived, or the request is\n * REJECTED with a 401.\n *\n * That rule is correct, and it means a client that ships `x-user-id` to a `@Public` endpoint is\n * building a request the callee is obliged to reject. Before this class the outbound builders\n * forwarded EVERY transferred key with no idea what the destination was, so an internal service\n * calling another service's public or JWT endpoint 401'd itself. The fix belongs on the producing\n * side: don't send what cannot possibly be believed.\n *\n * ## Why it is a class with a private constructor and no boolean parameter\n *\n * `buildOutboundHeaders(sendTrusted = true)` would have been three characters of work and exactly the\n * \"widening that is an ABSENCE rather than a token\" CLAUDE.md rejects — the permissive answer would be\n * what you get by not typing anything. There is no way to build a DestinationTrust except from the\n * destination endpoint's own {@link AuthMode}, so the caller cannot assert a posture the route does\n * not actually have, and `grep -rn DestinationTrust.forAuthMode` lists every place the question is\n * asked. The two instances are PRIVATE for the same reason: exposing them would be a second spelling\n * that skips the derivation.\n *\n * Per CLAUDE.md: data-only structure, so a class rather than an interface or a bare boolean.\n */\nexport class DestinationTrust {\n /**\n * The destination authenticates its CALLER (@AuthOidc / @AuthSharedSecret), so it is entitled to\n * believe context WE vouch for — this is the service-to-service identity propagation that trusted\n * keys keep an `httpHeader` for.\n */\n private static readonly VERIFIES_CALLER = new DestinationTrust(true);\n\n /**\n * The destination cannot tell us from a browser with curl (@AuthJwt / @Public / an endpoint with\n * no declared mode), so trusted keys are omitted. Untrusted keys still travel.\n */\n private static readonly CANNOT_VERIFY_CALLER = new DestinationTrust(false);\n\n private constructor(private readonly verifiesCaller: boolean) {}\n\n /**\n * The ONLY way to obtain one: state the destination endpoint's auth mode. `undefined` (an\n * endpoint that declared no mode) is treated as un-verifying, i.e. the SAFE answer — an absent\n * declaration must never be the widest one.\n */\n // webpieces-disable no-function-outside-class -- static factory replacing the (now private) constructor, exactly as ContextKey.trusted/untrusted do: the destination's auth mode must be part of the CALL, and a DI-injected instance method would let a caller hold one without ever naming a route\n static forAuthMode(mode: AuthMode | undefined): DestinationTrust {\n if (mode === undefined) {\n return DestinationTrust.CANNOT_VERIFY_CALLER;\n }\n switch (mode.kind) {\n case 'oidc':\n case 'shared-secret':\n return DestinationTrust.VERIFIES_CALLER;\n case 'jwt':\n case 'public':\n return DestinationTrust.CANNOT_VERIFY_CALLER;\n }\n }\n\n /**\n * May this key go on the wire to this destination? Untrusted keys always may — nobody was ever\n * going to make a security decision on them. A trusted key may only when the destination will\n * authenticate US, because that is the only case its `AuthFilter` will admit it.\n */\n allows(key: AnyContextKey): boolean {\n return this.verifiesCaller || !key.isTrusted();\n }\n}\n"]}
1
+ {"version":3,"file":"DestinationTrust.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/DestinationTrust.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAa,gBAAgB;IAcY;IAbrC;;;;OAIG;IACK,MAAM,CAAU,eAAe,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAErE;;;OAGG;IACK,MAAM,CAAU,oBAAoB,GAAG,IAAI,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAE3E,YAAqC,cAAuB;QAAvB,mBAAc,GAAd,cAAc,CAAS;IAAG,CAAC;IAEhE;;;;OAIG;IACH,qSAAqS;IACrS,MAAM,CAAC,WAAW,CAAC,IAA0B;QACzC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACrB,OAAO,gBAAgB,CAAC,oBAAoB,CAAC;QACjD,CAAC;QACD,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;YAChB,KAAK,MAAM,CAAC;YACZ,KAAK,eAAe;gBAChB,OAAO,gBAAgB,CAAC,eAAe,CAAC;YAC5C,KAAK,KAAK,CAAC;YACX,KAAK,QAAQ,CAAC;YACd,8EAA8E;YAC9E,uFAAuF;YACvF,wFAAwF;YACxF,iFAAiF;YACjF,KAAK,YAAY;gBACb,OAAO,gBAAgB,CAAC,oBAAoB,CAAC;QACrD,CAAC;IACL,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAkB;QACrB,OAAO,IAAI,CAAC,cAAc,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;IACnD,CAAC;;AAhDL,4CAiDC","sourcesContent":["import { AnyContextKey } from '../ContextKey';\nimport { AuthMode } from './decorators';\n\n/**\n * DestinationTrust - the OUTBOUND half of the trust model: may a TRUSTED context key\n * ({@link ContextKey.trusted}) ride to the endpoint we are about to call?\n *\n * ## Why the client has to answer this at all\n *\n * The server already decided (see `PendingWireTrust`): an inbound `x-user-id` is admitted only on a\n * route that verified WHO called it — `@AuthOidc` / `@AuthSharedSecret`. On a `@AuthJwt` or `@Public`\n * route the same header must match what the authenticator independently derived, or the request is\n * REJECTED with a 401.\n *\n * That rule is correct, and it means a client that ships `x-user-id` to a `@Public` endpoint is\n * building a request the callee is obliged to reject. Before this class the outbound builders\n * forwarded EVERY transferred key with no idea what the destination was, so an internal service\n * calling another service's public or JWT endpoint 401'd itself. The fix belongs on the producing\n * side: don't send what cannot possibly be believed.\n *\n * ## Why it is a class with a private constructor and no boolean parameter\n *\n * `buildOutboundHeaders(sendTrusted = true)` would have been three characters of work and exactly the\n * \"widening that is an ABSENCE rather than a token\" CLAUDE.md rejects — the permissive answer would be\n * what you get by not typing anything. There is no way to build a DestinationTrust except from the\n * destination endpoint's own {@link AuthMode}, so the caller cannot assert a posture the route does\n * not actually have, and `grep -rn DestinationTrust.forAuthMode` lists every place the question is\n * asked. The two instances are PRIVATE for the same reason: exposing them would be a second spelling\n * that skips the derivation.\n *\n * Per CLAUDE.md: data-only structure, so a class rather than an interface or a bare boolean.\n */\nexport class DestinationTrust {\n /**\n * The destination authenticates its CALLER (@AuthOidc / @AuthSharedSecret), so it is entitled to\n * believe context WE vouch for — this is the service-to-service identity propagation that trusted\n * keys keep an `httpHeader` for.\n */\n private static readonly VERIFIES_CALLER = new DestinationTrust(true);\n\n /**\n * The destination cannot tell us from a browser with curl (@AuthJwt / @Public / @AuthLocalOnly /\n * an endpoint with no declared mode), so trusted keys are omitted. Untrusted keys still travel.\n */\n private static readonly CANNOT_VERIFY_CALLER = new DestinationTrust(false);\n\n private constructor(private readonly verifiesCaller: boolean) {}\n\n /**\n * The ONLY way to obtain one: state the destination endpoint's auth mode. `undefined` (an\n * endpoint that declared no mode) is treated as un-verifying, i.e. the SAFE answer — an absent\n * declaration must never be the widest one.\n */\n // webpieces-disable no-function-outside-class -- static factory replacing the (now private) constructor, exactly as ContextKey.trusted/untrusted do: the destination's auth mode must be part of the CALL, and a DI-injected instance method would let a caller hold one without ever naming a route\n static forAuthMode(mode: AuthMode | undefined): DestinationTrust {\n if (mode === undefined) {\n return DestinationTrust.CANNOT_VERIFY_CALLER;\n }\n switch (mode.kind) {\n case 'oidc':\n case 'shared-secret':\n return DestinationTrust.VERIFIES_CALLER;\n case 'jwt':\n case 'public':\n // @AuthLocalOnly authenticates NOBODY — it gates on the environment, not on a\n // credential — so a browser with curl on the same laptop is indistinguishable from us.\n // Same bucket as public/jwt. (This switch has NO `default` on purpose: adding a kind to\n // AuthMode is a compile error here rather than a silent permissive fallthrough.)\n case 'local-only':\n return DestinationTrust.CANNOT_VERIFY_CALLER;\n }\n }\n\n /**\n * May this key go on the wire to this destination? Untrusted keys always may — nobody was ever\n * going to make a security decision on them. A trusted key may only when the destination will\n * authenticate US, because that is the only case its `AuthFilter` will admit it.\n */\n allows(key: AnyContextKey): boolean {\n return this.verifiesCaller || !key.isTrusted();\n }\n}\n"]}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Where this process is running, as a NAMED token rather than a boolean:
3
+ *
4
+ * - `'local'` — a developer's machine. `@AuthLocalOnly` endpoints exist and serve.
5
+ * - `'deployed'` — anywhere else (staging, prod, CI, a container). `@AuthLocalOnly` endpoints are
6
+ * not registered and, if reached anyway, 404.
7
+ *
8
+ * A `boolean` would have made the DANGEROUS half (`true`) unnameable and ungreppable — see CLAUDE.md
9
+ * shim shape #5. `grep -rn "'local'" ` over a repo's startup now lists every place that claims to be a
10
+ * developer's machine.
11
+ */
12
+ export type Locality = 'local' | 'deployed';
13
+ /**
14
+ * RuntimeLocality - the ONE answer to "am I running on a developer's machine?", for the one part of
15
+ * webpieces that needs it: {@link AuthLocalOnly}.
16
+ *
17
+ * ## Why this is a seam and not a `process.env` read
18
+ *
19
+ * The framework cannot compute this itself and must not try. "Local" is a fact about the DEPLOYMENT
20
+ * PLATFORM: Cloud Run derives it from `K_SERVICE`, ECS from `ECS_CONTAINER_METADATA_URI`, a laptop
21
+ * from the absence of both. Baking any one of those into core-util would hardcode a cloud vendor into
22
+ * the framework core, and core-util is browser-safe (it may not read `process.env` at all). So the
23
+ * ENVIRONMENT tells the framework, exactly as it tells it the logging backend
24
+ * ({@link LogManager.setFactory}), the header set ({@link HeaderRegistry.configure}), the context seam
25
+ * ({@link ApiCallContextHolder.install}) and its own identity ({@link ServiceInfo.setInfo}).
26
+ *
27
+ * It is a VALUE holder rather than an interface-plus-impl (the `ApiCallContext` shape) because there
28
+ * is no behavior to plug in — the answer is one token fixed at startup. Per CLAUDE.md, data is a
29
+ * class; only behavior is an interface.
30
+ *
31
+ * ## Where it is declared
32
+ *
33
+ * `RuntimeSetupOptions` takes it as a REQUIRED, positional constructor argument, so `setupRuntime`
34
+ * declares it on every server and no server can boot without having stated it. That is the same
35
+ * forcing function `@Endpoint(path, kind)` uses: a required positional argument turns "we forgot" into
36
+ * a compile error instead of a runtime guess.
37
+ *
38
+ * ## FAIL SAFE when nothing declared it
39
+ *
40
+ * {@link isLocalDevelopment} returns `false` until {@link declare} is called. An undeclared process is
41
+ * treated as DEPLOYED, so the failure mode of a forgotten wiring call is "my local-only endpoint 404s
42
+ * on my laptop" — annoying and instantly visible — never "my local-only endpoint is live in
43
+ * production". The permissive answer is never the one you get by not typing anything.
44
+ */
45
+ export declare class RuntimeLocality {
46
+ /** Process-global; set once at startup. `undefined` = never declared = treated as deployed. */
47
+ private static locality;
48
+ /**
49
+ * State where this process is running. Call it at startup — `setupRuntime` does it for you from
50
+ * `RuntimeSetupOptions.locality`.
51
+ *
52
+ * LAST CALL WINS, mirroring {@link ServiceInfo.setInfo}: an in-process test can legitimately boot
53
+ * two servers back-to-back.
54
+ */
55
+ static declare(locality: Locality): void;
56
+ /**
57
+ * True ONLY when a startup explicitly declared `'local'`. Undeclared reads as deployed — see the
58
+ * fail-safe note on the class. Does not throw: a wrong answer here must refuse an endpoint, never
59
+ * 500 unrelated traffic.
60
+ */
61
+ static isLocalDevelopment(): boolean;
62
+ /**
63
+ * Whether anything declared a locality at all. Used ONLY to make the refusal log say which of the
64
+ * two reasons applies — "you are deployed" vs "nobody ever told me" — because those have very
65
+ * different fixes and a developer staring at a 404 on their own laptop needs to know which.
66
+ */
67
+ static isDeclared(): boolean;
68
+ /** Reset — for tests, mirroring {@link ServiceInfo.clear}. */
69
+ static clear(): void;
70
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RuntimeLocality = void 0;
4
+ /**
5
+ * RuntimeLocality - the ONE answer to "am I running on a developer's machine?", for the one part of
6
+ * webpieces that needs it: {@link AuthLocalOnly}.
7
+ *
8
+ * ## Why this is a seam and not a `process.env` read
9
+ *
10
+ * The framework cannot compute this itself and must not try. "Local" is a fact about the DEPLOYMENT
11
+ * PLATFORM: Cloud Run derives it from `K_SERVICE`, ECS from `ECS_CONTAINER_METADATA_URI`, a laptop
12
+ * from the absence of both. Baking any one of those into core-util would hardcode a cloud vendor into
13
+ * the framework core, and core-util is browser-safe (it may not read `process.env` at all). So the
14
+ * ENVIRONMENT tells the framework, exactly as it tells it the logging backend
15
+ * ({@link LogManager.setFactory}), the header set ({@link HeaderRegistry.configure}), the context seam
16
+ * ({@link ApiCallContextHolder.install}) and its own identity ({@link ServiceInfo.setInfo}).
17
+ *
18
+ * It is a VALUE holder rather than an interface-plus-impl (the `ApiCallContext` shape) because there
19
+ * is no behavior to plug in — the answer is one token fixed at startup. Per CLAUDE.md, data is a
20
+ * class; only behavior is an interface.
21
+ *
22
+ * ## Where it is declared
23
+ *
24
+ * `RuntimeSetupOptions` takes it as a REQUIRED, positional constructor argument, so `setupRuntime`
25
+ * declares it on every server and no server can boot without having stated it. That is the same
26
+ * forcing function `@Endpoint(path, kind)` uses: a required positional argument turns "we forgot" into
27
+ * a compile error instead of a runtime guess.
28
+ *
29
+ * ## FAIL SAFE when nothing declared it
30
+ *
31
+ * {@link isLocalDevelopment} returns `false` until {@link declare} is called. An undeclared process is
32
+ * treated as DEPLOYED, so the failure mode of a forgotten wiring call is "my local-only endpoint 404s
33
+ * on my laptop" — annoying and instantly visible — never "my local-only endpoint is live in
34
+ * production". The permissive answer is never the one you get by not typing anything.
35
+ */
36
+ class RuntimeLocality {
37
+ /** Process-global; set once at startup. `undefined` = never declared = treated as deployed. */
38
+ static locality;
39
+ /**
40
+ * State where this process is running. Call it at startup — `setupRuntime` does it for you from
41
+ * `RuntimeSetupOptions.locality`.
42
+ *
43
+ * LAST CALL WINS, mirroring {@link ServiceInfo.setInfo}: an in-process test can legitimately boot
44
+ * two servers back-to-back.
45
+ */
46
+ // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected
47
+ static declare(locality) {
48
+ RuntimeLocality.locality = locality;
49
+ }
50
+ /**
51
+ * True ONLY when a startup explicitly declared `'local'`. Undeclared reads as deployed — see the
52
+ * fail-safe note on the class. Does not throw: a wrong answer here must refuse an endpoint, never
53
+ * 500 unrelated traffic.
54
+ */
55
+ // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected
56
+ static isLocalDevelopment() {
57
+ return RuntimeLocality.locality === 'local';
58
+ }
59
+ /**
60
+ * Whether anything declared a locality at all. Used ONLY to make the refusal log say which of the
61
+ * two reasons applies — "you are deployed" vs "nobody ever told me" — because those have very
62
+ * different fixes and a developer staring at a 404 on their own laptop needs to know which.
63
+ */
64
+ // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected
65
+ static isDeclared() {
66
+ return RuntimeLocality.locality !== undefined;
67
+ }
68
+ /** Reset — for tests, mirroring {@link ServiceInfo.clear}. */
69
+ // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected
70
+ static clear() {
71
+ RuntimeLocality.locality = undefined;
72
+ }
73
+ }
74
+ exports.RuntimeLocality = RuntimeLocality;
75
+ //# sourceMappingURL=RuntimeLocality.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RuntimeLocality.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/RuntimeLocality.ts"],"names":[],"mappings":";;;AAaA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAa,eAAe;IACxB,+FAA+F;IACvF,MAAM,CAAC,QAAQ,CAAuB;IAE9C;;;;;;OAMG;IACH,yJAAyJ;IACzJ,MAAM,CAAC,OAAO,CAAC,QAAkB;QAC7B,eAAe,CAAC,QAAQ,GAAG,QAAQ,CAAC;IACxC,CAAC;IAED;;;;OAIG;IACH,yJAAyJ;IACzJ,MAAM,CAAC,kBAAkB;QACrB,OAAO,eAAe,CAAC,QAAQ,KAAK,OAAO,CAAC;IAChD,CAAC;IAED;;;;OAIG;IACH,yJAAyJ;IACzJ,MAAM,CAAC,UAAU;QACb,OAAO,eAAe,CAAC,QAAQ,KAAK,SAAS,CAAC;IAClD,CAAC;IAED,8DAA8D;IAC9D,yJAAyJ;IACzJ,MAAM,CAAC,KAAK;QACR,eAAe,CAAC,QAAQ,GAAG,SAAS,CAAC;IACzC,CAAC;CACJ;AAzCD,0CAyCC","sourcesContent":["/**\n * Where this process is running, as a NAMED token rather than a boolean:\n *\n * - `'local'` — a developer's machine. `@AuthLocalOnly` endpoints exist and serve.\n * - `'deployed'` — anywhere else (staging, prod, CI, a container). `@AuthLocalOnly` endpoints are\n * not registered and, if reached anyway, 404.\n *\n * A `boolean` would have made the DANGEROUS half (`true`) unnameable and ungreppable — see CLAUDE.md\n * shim shape #5. `grep -rn \"'local'\" ` over a repo's startup now lists every place that claims to be a\n * developer's machine.\n */\nexport type Locality = 'local' | 'deployed';\n\n/**\n * RuntimeLocality - the ONE answer to \"am I running on a developer's machine?\", for the one part of\n * webpieces that needs it: {@link AuthLocalOnly}.\n *\n * ## Why this is a seam and not a `process.env` read\n *\n * The framework cannot compute this itself and must not try. \"Local\" is a fact about the DEPLOYMENT\n * PLATFORM: Cloud Run derives it from `K_SERVICE`, ECS from `ECS_CONTAINER_METADATA_URI`, a laptop\n * from the absence of both. Baking any one of those into core-util would hardcode a cloud vendor into\n * the framework core, and core-util is browser-safe (it may not read `process.env` at all). So the\n * ENVIRONMENT tells the framework, exactly as it tells it the logging backend\n * ({@link LogManager.setFactory}), the header set ({@link HeaderRegistry.configure}), the context seam\n * ({@link ApiCallContextHolder.install}) and its own identity ({@link ServiceInfo.setInfo}).\n *\n * It is a VALUE holder rather than an interface-plus-impl (the `ApiCallContext` shape) because there\n * is no behavior to plug in — the answer is one token fixed at startup. Per CLAUDE.md, data is a\n * class; only behavior is an interface.\n *\n * ## Where it is declared\n *\n * `RuntimeSetupOptions` takes it as a REQUIRED, positional constructor argument, so `setupRuntime`\n * declares it on every server and no server can boot without having stated it. That is the same\n * forcing function `@Endpoint(path, kind)` uses: a required positional argument turns \"we forgot\" into\n * a compile error instead of a runtime guess.\n *\n * ## FAIL SAFE when nothing declared it\n *\n * {@link isLocalDevelopment} returns `false` until {@link declare} is called. An undeclared process is\n * treated as DEPLOYED, so the failure mode of a forgotten wiring call is \"my local-only endpoint 404s\n * on my laptop\" — annoying and instantly visible — never \"my local-only endpoint is live in\n * production\". The permissive answer is never the one you get by not typing anything.\n */\nexport class RuntimeLocality {\n /** Process-global; set once at startup. `undefined` = never declared = treated as deployed. */\n private static locality: Locality | undefined;\n\n /**\n * State where this process is running. Call it at startup — `setupRuntime` does it for you from\n * `RuntimeSetupOptions.locality`.\n *\n * LAST CALL WINS, mirroring {@link ServiceInfo.setInfo}: an in-process test can legitimately boot\n * two servers back-to-back.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected\n static declare(locality: Locality): void {\n RuntimeLocality.locality = locality;\n }\n\n /**\n * True ONLY when a startup explicitly declared `'local'`. Undeclared reads as deployed — see the\n * fail-safe note on the class. Does not throw: a wrong answer here must refuse an endpoint, never\n * 500 unrelated traffic.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected\n static isLocalDevelopment(): boolean {\n return RuntimeLocality.locality === 'local';\n }\n\n /**\n * Whether anything declared a locality at all. Used ONLY to make the refusal log say which of the\n * two reasons applies — \"you are deployed\" vs \"nobody ever told me\" — because those have very\n * different fixes and a developer staring at a 404 on their own laptop needs to know which.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected\n static isDeclared(): boolean {\n return RuntimeLocality.locality !== undefined;\n }\n\n /** Reset — for tests, mirroring {@link ServiceInfo.clear}. */\n // webpieces-disable no-function-outside-class -- static global singleton (like ServiceInfo/HeaderRegistry); populated once at startup, never DI-injected\n static clear(): void {\n RuntimeLocality.locality = undefined;\n }\n}\n"]}
@@ -0,0 +1,65 @@
1
+ import 'reflect-metadata';
2
+ import { EndpointKind } from './decorators';
3
+ /**
4
+ * API KIND — whether a contract is synchronous RPC or fire-and-forget over a queue — plus the
5
+ * queue-naming rules that only a @PubSub contract has.
6
+ *
7
+ * Split out of `decorators.ts` purely for size (max-file-lines); the dependency runs ONE way,
8
+ * api-kind -> decorators, so there is no cycle. Auth modes and endpoint shape stay in
9
+ * `decorators.ts`; everything here is re-exported from the package barrel, so no consumer import
10
+ * changes and there is no second spelling of anything.
11
+ */
12
+ /**
13
+ * API kind. 'rpc' = synchronous request/response (http-client ↔ ApiRoutingFactory).
14
+ * 'pubsub' = fire-and-forget cloud task; the enqueue client (cloudtasks-client)
15
+ * schedules a Cloud Task that is later delivered to the SAME controller endpoint.
16
+ */
17
+ export type ApiKind = 'rpc' | 'pubsub';
18
+ /**
19
+ * @Rpc() - marks an API class as synchronous request/response (the default kind).
20
+ * Present mostly for symmetry/readability; an undecorated API is treated as 'rpc'.
21
+ */
22
+ export declare function Rpc(): ClassDecorator;
23
+ /**
24
+ * @PubSub() - marks an API class as fire-and-forget over Cloud Tasks. Every method
25
+ * MUST return Promise<void> (a compile-time contract on the abstract API). The
26
+ * enqueue client and the controller share this one class, exactly like RPC.
27
+ */
28
+ export declare function PubSub(): ClassDecorator;
29
+ /**
30
+ * @Queue(name) - override the Cloud Tasks queue name for a @PubSub method. Default
31
+ * (no decorator) is `${ApiClassName}-${methodName}`, matched 1:1 by Terraform.
32
+ */
33
+ export declare function Queue(name: string): MethodDecorator;
34
+ /**
35
+ * Get the API kind. Defaults to 'rpc' when neither @Rpc nor @PubSub is present.
36
+ */
37
+ export declare function getApiKind(apiClass: Function): ApiKind;
38
+ /**
39
+ * Assert the API class is of the expected kind (used by the clients: the RPC
40
+ * client rejects a @PubSub api and vice-versa).
41
+ * @throws Error if the kind doesn't match.
42
+ */
43
+ export declare function assertApiKind(apiClass: Function, expected: ApiKind): void;
44
+ /**
45
+ * Which {@link EndpointKind}s each {@link ApiKind} may declare. A @PubSub contract is delivered
46
+ * asynchronously by definition, so `rpc` is meaningless on it; an @Rpc contract has no queue, so
47
+ * `cloudtasks`/`cron` on it would name a queue/schedule nothing could ever deliver to. `external`
48
+ * is legal on both — a webhook posts synchronously, a push subscription does not.
49
+ *
50
+ * Shared so the wiring-time assert below and the build-time architecture scan enforce ONE rule.
51
+ */
52
+ export declare const ENDPOINT_KINDS_BY_API_KIND: Record<ApiKind, readonly EndpointKind[]>;
53
+ /**
54
+ * Validate @PubSub conventions at wiring time: the class must be @ApiPath + @PubSub, declare at
55
+ * least one endpoint, and every endpoint must declare a kind this api kind can actually deliver.
56
+ * (Return-type is Promise<void>, a compile-time contract — TS erases types at runtime so it cannot
57
+ * be re-checked here.)
58
+ * @throws Error if conventions are violated.
59
+ */
60
+ export declare function assertPubSubConventions(apiClass: Function): void;
61
+ /**
62
+ * Resolve the Cloud Tasks queue name for a @PubSub method: the @Queue override if
63
+ * present, else `${ApiClassName}-${methodName}`.
64
+ */
65
+ export declare function getQueueName(apiClass: Function, methodName: string): string;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ENDPOINT_KINDS_BY_API_KIND = void 0;
4
+ exports.Rpc = Rpc;
5
+ exports.PubSub = PubSub;
6
+ exports.Queue = Queue;
7
+ exports.getApiKind = getApiKind;
8
+ exports.assertApiKind = assertApiKind;
9
+ exports.assertPubSubConventions = assertPubSubConventions;
10
+ exports.getQueueName = getQueueName;
11
+ require("reflect-metadata");
12
+ const decorators_1 = require("./decorators");
13
+ /**
14
+ * @Rpc() - marks an API class as synchronous request/response (the default kind).
15
+ * Present mostly for symmetry/readability; an undecorated API is treated as 'rpc'.
16
+ */
17
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
18
+ function Rpc() {
19
+ // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any
20
+ return (target) => {
21
+ Reflect.defineMetadata(decorators_1.METADATA_KEYS.API_KIND, 'rpc', target);
22
+ };
23
+ }
24
+ /**
25
+ * @PubSub() - marks an API class as fire-and-forget over Cloud Tasks. Every method
26
+ * MUST return Promise<void> (a compile-time contract on the abstract API). The
27
+ * enqueue client and the controller share this one class, exactly like RPC.
28
+ */
29
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
30
+ function PubSub() {
31
+ // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any
32
+ return (target) => {
33
+ Reflect.defineMetadata(decorators_1.METADATA_KEYS.API_KIND, 'pubsub', target);
34
+ };
35
+ }
36
+ /**
37
+ * @Queue(name) - override the Cloud Tasks queue name for a @PubSub method. Default
38
+ * (no decorator) is `${ApiClassName}-${methodName}`, matched 1:1 by Terraform.
39
+ */
40
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
41
+ function Queue(name) {
42
+ // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any
43
+ return (target, propertyKey, _descriptor) => {
44
+ const metadataTarget = typeof target === 'function' ? target : target.constructor;
45
+ const overrides = Reflect.getMetadata(decorators_1.METADATA_KEYS.QUEUE_OVERRIDE, metadataTarget) || {};
46
+ overrides[propertyKey] = name;
47
+ Reflect.defineMetadata(decorators_1.METADATA_KEYS.QUEUE_OVERRIDE, overrides, metadataTarget);
48
+ };
49
+ }
50
+ /**
51
+ * Get the API kind. Defaults to 'rpc' when neither @Rpc nor @PubSub is present.
52
+ */
53
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
54
+ function getApiKind(apiClass) {
55
+ return Reflect.getMetadata(decorators_1.METADATA_KEYS.API_KIND, apiClass) ?? 'rpc';
56
+ }
57
+ /**
58
+ * Assert the API class is of the expected kind (used by the clients: the RPC
59
+ * client rejects a @PubSub api and vice-versa).
60
+ * @throws Error if the kind doesn't match.
61
+ */
62
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
63
+ function assertApiKind(apiClass, expected) {
64
+ const actual = getApiKind(apiClass);
65
+ if (actual !== expected) {
66
+ const apiName = apiClass.name || 'Unknown';
67
+ throw new Error(`API ${apiName} is @${actual === 'pubsub' ? 'PubSub' : 'Rpc'} but a ` +
68
+ `${expected === 'pubsub' ? '@PubSub (cloud task)' : '@Rpc'} API was required here.`);
69
+ }
70
+ }
71
+ /**
72
+ * Which {@link EndpointKind}s each {@link ApiKind} may declare. A @PubSub contract is delivered
73
+ * asynchronously by definition, so `rpc` is meaningless on it; an @Rpc contract has no queue, so
74
+ * `cloudtasks`/`cron` on it would name a queue/schedule nothing could ever deliver to. `external`
75
+ * is legal on both — a webhook posts synchronously, a push subscription does not.
76
+ *
77
+ * Shared so the wiring-time assert below and the build-time architecture scan enforce ONE rule.
78
+ */
79
+ exports.ENDPOINT_KINDS_BY_API_KIND = {
80
+ rpc: ['rpc', 'external'],
81
+ pubsub: ['cloudtasks', 'cron', 'external'],
82
+ };
83
+ /**
84
+ * Validate @PubSub conventions at wiring time: the class must be @ApiPath + @PubSub, declare at
85
+ * least one endpoint, and every endpoint must declare a kind this api kind can actually deliver.
86
+ * (Return-type is Promise<void>, a compile-time contract — TS erases types at runtime so it cannot
87
+ * be re-checked here.)
88
+ * @throws Error if conventions are violated.
89
+ */
90
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
91
+ function assertPubSubConventions(apiClass) {
92
+ assertApiKind(apiClass, 'pubsub');
93
+ const apiName = apiClass.name || 'Unknown';
94
+ if (!(0, decorators_1.isApiPath)(apiClass)) {
95
+ throw new Error(`@PubSub API ${apiName} must also be decorated with @ApiPath()`);
96
+ }
97
+ const endpoints = (0, decorators_1.getEndpoints)(apiClass) || {};
98
+ if (Object.keys(endpoints).length === 0) {
99
+ throw new Error(`@PubSub API ${apiName} declares no @Endpoint methods`);
100
+ }
101
+ const allowed = exports.ENDPOINT_KINDS_BY_API_KIND.pubsub;
102
+ const kinds = (0, decorators_1.getEndpointKinds)(apiClass);
103
+ for (const methodName of Object.keys(endpoints)) {
104
+ const kind = kinds[methodName];
105
+ if (kind !== undefined && allowed.includes(kind))
106
+ continue;
107
+ throw new Error(`@PubSub API ${apiName}.${methodName} declares @Endpoint(..., '${kind ?? 'missing'}') — a ` +
108
+ `@PubSub contract is delivered through a queue, so it must be one of: ${allowed.join(' | ')}.`);
109
+ }
110
+ }
111
+ /**
112
+ * Resolve the Cloud Tasks queue name for a @PubSub method: the @Queue override if
113
+ * present, else `${ApiClassName}-${methodName}`.
114
+ */
115
+ // webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there
116
+ function getQueueName(apiClass, methodName) {
117
+ const overrides = Reflect.getMetadata(decorators_1.METADATA_KEYS.QUEUE_OVERRIDE, apiClass) || {};
118
+ return overrides[methodName] ?? `${apiClass.name || 'Unknown'}-${methodName}`;
119
+ }
120
+ //# sourceMappingURL=api-kind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-kind.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/api-kind.ts"],"names":[],"mappings":";;;AA6BA,kBAKC;AAQD,wBAKC;AAOD,sBASC;AAMD,gCAEC;AAQD,sCASC;AAuBD,0DAoBC;AAOD,oCAIC;AA9ID,4BAA0B;AAC1B,6CAAsG;AAuBtG;;;GAGG;AACH,6LAA6L;AAC7L,SAAgB,GAAG;IACf,kFAAkF;IAClF,OAAO,CAAC,MAAW,EAAE,EAAE;QACnB,OAAO,CAAC,cAAc,CAAC,0BAAa,CAAC,QAAQ,EAAE,KAAgB,EAAE,MAAM,CAAC,CAAC;IAC7E,CAAC,CAAC;AACN,CAAC;AAED;;;;GAIG;AACH,6LAA6L;AAC7L,SAAgB,MAAM;IAClB,kFAAkF;IAClF,OAAO,CAAC,MAAW,EAAE,EAAE;QACnB,OAAO,CAAC,cAAc,CAAC,0BAAa,CAAC,QAAQ,EAAE,QAAmB,EAAE,MAAM,CAAC,CAAC;IAChF,CAAC,CAAC;AACN,CAAC;AAED;;;GAGG;AACH,6LAA6L;AAC7L,SAAgB,KAAK,CAAC,IAAY;IAC9B,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,SAAS,GACX,OAAO,CAAC,WAAW,CAAC,0BAAa,CAAC,cAAc,EAAE,cAAc,CAAC,IAAI,EAAE,CAAC;QAC5E,SAAS,CAAC,WAAqB,CAAC,GAAG,IAAI,CAAC;QACxC,OAAO,CAAC,cAAc,CAAC,0BAAa,CAAC,cAAc,EAAE,SAAS,EAAE,cAAc,CAAC,CAAC;IACpF,CAAC,CAAC;AACN,CAAC;AAED;;GAEG;AACH,6LAA6L;AAC7L,SAAgB,UAAU,CAAC,QAAkB;IACzC,OAAQ,OAAO,CAAC,WAAW,CAAC,0BAAa,CAAC,QAAQ,EAAE,QAAQ,CAAa,IAAI,KAAK,CAAC;AACvF,CAAC;AAED;;;;GAIG;AACH,6LAA6L;AAC7L,SAAgB,aAAa,CAAC,QAAkB,EAAE,QAAiB;IAC/D,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IACpC,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;QACtB,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,IAAI,SAAS,CAAC;QAC3C,MAAM,IAAI,KAAK,CACX,OAAO,OAAO,QAAQ,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,SAAS;YACrE,GAAG,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,MAAM,yBAAyB,CACtF,CAAC;IACN,CAAC;AACL,CAAC;AAED;;;;;;;GAOG;AACU,QAAA,0BAA0B,GAA6C;IAChF,GAAG,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC;IACxB,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,CAAC;CAC7C,CAAC;AAEF;;;;;;GAMG;AACH,6LAA6L;AAC7L,SAAgB,uBAAuB,CAAC,QAAkB;IACtD,aAAa,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,IAAI,SAAS,CAAC;IAC3C,IAAI,CAAC,IAAA,sBAAS,EAAC,QAAQ,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,eAAe,OAAO,yCAAyC,CAAC,CAAC;IACrF,CAAC;IACD,MAAM,SAAS,GAAG,IAAA,yBAAY,EAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC/C,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,KAAK,CAAC,eAAe,OAAO,gCAAgC,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,OAAO,GAAG,kCAA0B,CAAC,MAAM,CAAC;IAClD,MAAM,KAAK,GAAG,IAAA,6BAAgB,EAAC,QAAQ,CAAC,CAAC;IACzC,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC;QAC/B,IAAI,IAAI,KAAK,SAAS,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,SAAS;QAC3D,MAAM,IAAI,KAAK,CACX,eAAe,OAAO,IAAI,UAAU,6BAA6B,IAAI,IAAI,SAAS,SAAS;YAC3F,wEAAwE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CACjG,CAAC;IACN,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,6LAA6L;AAC7L,SAAgB,YAAY,CAAC,QAAkB,EAAE,UAAkB;IAC/D,MAAM,SAAS,GACX,OAAO,CAAC,WAAW,CAAC,0BAAa,CAAC,cAAc,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;IACtE,OAAO,SAAS,CAAC,UAAU,CAAC,IAAI,GAAG,QAAQ,CAAC,IAAI,IAAI,SAAS,IAAI,UAAU,EAAE,CAAC;AAClF,CAAC","sourcesContent":["import 'reflect-metadata';\nimport { EndpointKind, METADATA_KEYS, getEndpoints, getEndpointKinds, isApiPath } from './decorators';\n\n/**\n * API KIND — whether a contract is synchronous RPC or fire-and-forget over a queue — plus the\n * queue-naming rules that only a @PubSub contract has.\n *\n * Split out of `decorators.ts` purely for size (max-file-lines); the dependency runs ONE way,\n * api-kind -> decorators, so there is no cycle. Auth modes and endpoint shape stay in\n * `decorators.ts`; everything here is re-exported from the package barrel, so no consumer import\n * changes and there is no second spelling of anything.\n */\n\n// ============================================================\n// API kind (RPC vs PubSub/Cloud Tasks) + queue naming\n// ============================================================\n\n/**\n * API kind. 'rpc' = synchronous request/response (http-client ↔ ApiRoutingFactory).\n * 'pubsub' = fire-and-forget cloud task; the enqueue client (cloudtasks-client)\n * schedules a Cloud Task that is later delivered to the SAME controller endpoint.\n */\nexport type ApiKind = 'rpc' | 'pubsub';\n\n/**\n * @Rpc() - marks an API class as synchronous request/response (the default kind).\n * Present mostly for symmetry/readability; an undecorated API is treated as 'rpc'.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function Rpc(): ClassDecorator {\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any) => {\n Reflect.defineMetadata(METADATA_KEYS.API_KIND, 'rpc' as ApiKind, target);\n };\n}\n\n/**\n * @PubSub() - marks an API class as fire-and-forget over Cloud Tasks. Every method\n * MUST return Promise<void> (a compile-time contract on the abstract API). The\n * enqueue client and the controller share this one class, exactly like RPC.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function PubSub(): ClassDecorator {\n // webpieces-disable no-any-unknown -- reflect-metadata decorator API requires any\n return (target: any) => {\n Reflect.defineMetadata(METADATA_KEYS.API_KIND, 'pubsub' as ApiKind, target);\n };\n}\n\n/**\n * @Queue(name) - override the Cloud Tasks queue name for a @PubSub method. Default\n * (no decorator) is `${ApiClassName}-${methodName}`, matched 1:1 by Terraform.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function Queue(name: string): 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 const overrides: Record<string, string> =\n Reflect.getMetadata(METADATA_KEYS.QUEUE_OVERRIDE, metadataTarget) || {};\n overrides[propertyKey as string] = name;\n Reflect.defineMetadata(METADATA_KEYS.QUEUE_OVERRIDE, overrides, metadataTarget);\n };\n}\n\n/**\n * Get the API kind. Defaults to 'rpc' when neither @Rpc nor @PubSub is present.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function getApiKind(apiClass: Function): ApiKind {\n return (Reflect.getMetadata(METADATA_KEYS.API_KIND, apiClass) as ApiKind) ?? 'rpc';\n}\n\n/**\n * Assert the API class is of the expected kind (used by the clients: the RPC\n * client rejects a @PubSub api and vice-versa).\n * @throws Error if the kind doesn't match.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function assertApiKind(apiClass: Function, expected: ApiKind): void {\n const actual = getApiKind(apiClass);\n if (actual !== expected) {\n const apiName = apiClass.name || 'Unknown';\n throw new Error(\n `API ${apiName} is @${actual === 'pubsub' ? 'PubSub' : 'Rpc'} but a ` +\n `${expected === 'pubsub' ? '@PubSub (cloud task)' : '@Rpc'} API was required here.`,\n );\n }\n}\n\n/**\n * Which {@link EndpointKind}s each {@link ApiKind} may declare. A @PubSub contract is delivered\n * asynchronously by definition, so `rpc` is meaningless on it; an @Rpc contract has no queue, so\n * `cloudtasks`/`cron` on it would name a queue/schedule nothing could ever deliver to. `external`\n * is legal on both — a webhook posts synchronously, a push subscription does not.\n *\n * Shared so the wiring-time assert below and the build-time architecture scan enforce ONE rule.\n */\nexport const ENDPOINT_KINDS_BY_API_KIND: Record<ApiKind, readonly EndpointKind[]> = {\n rpc: ['rpc', 'external'],\n pubsub: ['cloudtasks', 'cron', 'external'],\n};\n\n/**\n * Validate @PubSub conventions at wiring time: the class must be @ApiPath + @PubSub, declare at\n * least one endpoint, and every endpoint must declare a kind this api kind can actually deliver.\n * (Return-type is Promise<void>, a compile-time contract — TS erases types at runtime so it cannot\n * be re-checked here.)\n * @throws Error if conventions are violated.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function assertPubSubConventions(apiClass: Function): void {\n assertApiKind(apiClass, 'pubsub');\n const apiName = apiClass.name || 'Unknown';\n if (!isApiPath(apiClass)) {\n throw new Error(`@PubSub API ${apiName} must also be decorated with @ApiPath()`);\n }\n const endpoints = getEndpoints(apiClass) || {};\n if (Object.keys(endpoints).length === 0) {\n throw new Error(`@PubSub API ${apiName} declares no @Endpoint methods`);\n }\n const allowed = ENDPOINT_KINDS_BY_API_KIND.pubsub;\n const kinds = getEndpointKinds(apiClass);\n for (const methodName of Object.keys(endpoints)) {\n const kind = kinds[methodName];\n if (kind !== undefined && allowed.includes(kind)) continue;\n throw new Error(\n `@PubSub API ${apiName}.${methodName} declares @Endpoint(..., '${kind ?? 'missing'}') — a ` +\n `@PubSub contract is delivered through a queue, so it must be one of: ${allowed.join(' | ')}.`,\n );\n }\n}\n\n/**\n * Resolve the Cloud Tasks queue name for a @PubSub method: the @Queue override if\n * present, else `${ApiClassName}-${methodName}`.\n */\n// webpieces-disable no-function-outside-class -- decorator factory / reflect-metadata reader; moved verbatim from decorators.ts for file size, same module-scope shape as its siblings there\nexport function getQueueName(apiClass: Function, methodName: string): string {\n const overrides: Record<string, string> =\n Reflect.getMetadata(METADATA_KEYS.QUEUE_OVERRIDE, apiClass) || {};\n return overrides[methodName] ?? `${apiClass.name || 'Unknown'}-${methodName}`;\n}\n"]}