@webpieces/http-client-node 0.4.699 → 0.4.701

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +105 -0
  2. package/package.json +5 -5
  3. package/src/AddressResolver.d.ts +33 -0
  4. package/src/AddressResolver.js +39 -0
  5. package/src/AddressResolver.js.map +1 -0
  6. package/src/ClientConfig.d.ts +32 -8
  7. package/src/ClientConfig.js +22 -5
  8. package/src/ClientConfig.js.map +1 -1
  9. package/src/ClientHttpFactory.d.ts +31 -2
  10. package/src/ClientHttpFactory.js +31 -3
  11. package/src/ClientHttpFactory.js.map +1 -1
  12. package/src/ContextBaseUrlFilter.d.ts +80 -0
  13. package/src/ContextBaseUrlFilter.js +95 -0
  14. package/src/ContextBaseUrlFilter.js.map +1 -0
  15. package/src/CreateRpcClientCompileAssertions.d.ts +17 -0
  16. package/src/CreateRpcClientCompileAssertions.js +50 -0
  17. package/src/CreateRpcClientCompileAssertions.js.map +1 -0
  18. package/src/InternalAddressRules.d.ts +37 -0
  19. package/src/InternalAddressRules.js +132 -0
  20. package/src/InternalAddressRules.js.map +1 -0
  21. package/src/MissingRuntimeBaseUrlError.d.ts +20 -0
  22. package/src/MissingRuntimeBaseUrlError.js +28 -0
  23. package/src/MissingRuntimeBaseUrlError.js.map +1 -0
  24. package/src/NodeProxyClient.d.ts +33 -16
  25. package/src/NodeProxyClient.js +67 -32
  26. package/src/NodeProxyClient.js.map +1 -1
  27. package/src/OutboundAuthErrors.d.ts +42 -0
  28. package/src/OutboundAuthErrors.js +54 -0
  29. package/src/OutboundAuthErrors.js.map +1 -0
  30. package/src/OutboundAuthFilter.d.ts +56 -0
  31. package/src/OutboundAuthFilter.js +103 -0
  32. package/src/OutboundAuthFilter.js.map +1 -0
  33. package/src/SsrfGuardFilter.d.ts +61 -0
  34. package/src/SsrfGuardFilter.js +165 -0
  35. package/src/SsrfGuardFilter.js.map +1 -0
  36. package/src/SsrfPolicy.d.ts +58 -0
  37. package/src/SsrfPolicy.js +67 -0
  38. package/src/SsrfPolicy.js.map +1 -0
  39. package/src/SsrfRefusedError.d.ts +20 -0
  40. package/src/SsrfRefusedError.js +28 -0
  41. package/src/SsrfRefusedError.js.map +1 -0
  42. package/src/WebhookSignerCallback.d.ts +114 -0
  43. package/src/WebhookSignerCallback.js +106 -0
  44. package/src/WebhookSignerCallback.js.map +1 -0
  45. package/src/index.d.ts +17 -0
  46. package/src/index.js +43 -1
  47. package/src/index.js.map +1 -1
package/README.md CHANGED
@@ -32,3 +32,108 @@ request-id chain loses the trace, and finding that out in production is worse th
32
32
  top-level server filter normally establishes the scope for you.
33
33
 
34
34
  The browser twin is [@webpieces/http-client-browser](../http-client-browser).
35
+
36
+ ## Outbound filters
37
+
38
+ `createRpcClient`'s OPTIONAL third argument is this client's outbound filter chain — the same
39
+ `Filter` / `Service` abstraction (from `@webpieces/core-util`) the server's inbound chain uses,
40
+ pointed the other way. A filter receives a mutable `ClientRequest` and returns the `Response`, so it
41
+ can rewrite the url, add or remove headers, log, or replace the serialized body:
42
+
43
+ ```ts
44
+ class TenantHeaderFilter extends Filter<ClientRequest, Response> {
45
+ async filter(request: ClientRequest, next: Service<ClientRequest, Response>): Promise<Response> {
46
+ request.headers.set('x-tenant', currentTenant());
47
+ return next.invoke(request);
48
+ }
49
+ }
50
+ ```
51
+
52
+ Highest priority runs OUTERMOST, matching the server's `FilterMatcher`.
53
+
54
+ **Priority orders YOUR filters against each other, and nothing else.** The framework's own built-ins
55
+ — the SSRF guard, then the outbound credential minter — are appended BENEATH every app filter,
56
+ structurally rather than by number, so no priority (not `Number.MAX_SAFE_INTEGER`) puts an app filter
57
+ under them. They have to judge, and sign for, the URL that is actually about to be fetched.
58
+
59
+ `request.body` is the EXACT serialized bytes the transport will send. That is the whole reason this
60
+ seam exists: without it a webhook sender has to hand-serialize and post the payload itself, because a
61
+ raw HTTP library that re-serializes internally signs one byte sequence and sends another.
62
+
63
+ ## A destination supplied at RUNTIME
64
+
65
+ Some destinations are DATA, not deployment: a URL a partner registered (`OrganizationWebhook.url`),
66
+ an OAuth callback, a per-tenant or self-hosted host. There is no `svcName` to resolve and nothing to
67
+ register, but there IS a contract — the payload is fully specified and published to customers.
68
+
69
+ It is not a different kind of client. It is ONE filter:
70
+
71
+ ```ts
72
+ /** @externalSystem runtime partner-webhooks */
73
+ @ApiPath('/ot-webhook')
74
+ export class PartnerWebhookApi {
75
+ @Endpoint('/deliver')
76
+ @AuthWebhook('partner-hmac')
77
+ deliver(envelope: WebhookEnvelope): Promise<DeliveryAck>;
78
+ }
79
+
80
+ const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
81
+ new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
82
+ ]);
83
+
84
+ for (const webhook of webhooks) {
85
+ await RequestContext.run(() => {
86
+ RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
87
+ return partner.deliver(envelope);
88
+ });
89
+ }
90
+ ```
91
+
92
+ - **Installing the filter IS the opt-in.** A client without a `ContextBaseUrlFilter` ignores an
93
+ ambient `OVERRIDE_BASE_URL` entirely, so a URL set for a partner delivery cannot re-point every
94
+ other client in the same request. `grep -rn ContextBaseUrlFilter` lists every client that can be
95
+ re-pointed at all.
96
+ - **It cannot leak.** The override lives on the per-call request, never on the client, so one client
97
+ fans out across N partner URLs and each call goes exactly where its own scope said.
98
+ - **SSRF is automatic, and it is the ACT of re-pointing that arms it.** A URL that came out of
99
+ `ClientRegistry` is an address we chose and is never judged — so a `localhost` emulator registered
100
+ with `ClientRegistry.addMapping` needs no opt-out of any kind, and an ordinary RPC costs nothing.
101
+ A re-pointed URL gets the full policy: https only; loopback / RFC1918 / CGNAT / link-local /
102
+ cloud-metadata refused, by name AND by every address the name resolves to; redirects taken away
103
+ from the transport and re-judged hop by hop, so a partner URL that 302s at `169.254.169.254` is
104
+ refused rather than obeyed. The one relaxation — testing the partner path against a local fake —
105
+ has to be said out loud, with a reason:
106
+ `new ContextBaseUrlFilter(new SsrfTestingPolicy('local fake in the delivery e2e'))`.
107
+ - **Every auth mode still works.** `@AuthOidc` mints for the FINAL base URL, `@AuthSharedSecret`
108
+ sends the value this client holds (N services implementing one contract behind one agreed secret is
109
+ a real topology), and `@AuthWebhook(name)` calls your bound `WebhookSignerCallback`. The minter runs
110
+ BELOW the SSRF guard, so a destination that is going to be refused never causes a credential to be
111
+ created.
112
+ - **The hop is VISIBLE.** `@externalSystem runtime <identity>` on the contract draws the destination
113
+ as its own node on the runtime architecture graph, and two services delivering over the same
114
+ contract converge on one box.
115
+
116
+ ## Signing an OUTBOUND webhook — `@AuthWebhook`, the other way round
117
+
118
+ `@AuthWebhook(name)` names a signing SCHEME, not a direction. Inbound, a vendor signs and your bound
119
+ `WebhookAuthCallback` (in `@webpieces/http-routing`) verifies. Outbound, WE are the vendor, so your
120
+ bound `WebhookSignerCallback` produces the signature over the final URL and the exact wire bytes:
121
+
122
+ ```ts
123
+ @provideSingleton()
124
+ export class PartnerHmacSigner implements WebhookSignerCallback {
125
+ async sign(name: string, request: SignableRequest): Promise<Map<string, string>> {
126
+ const mac = createHmac('sha256', secretFor(name)).update(request.body ?? '').digest('hex');
127
+ return new Map([['x-partner-signature', `sha256=${mac}`]]);
128
+ }
129
+ }
130
+
131
+ // AppModule.ts
132
+ options.bind(WEBHOOK_SIGNER_CALLBACK).to(PartnerHmacSigner);
133
+ ```
134
+
135
+ The framework ships no vendor crypto, deliberately: Twilio signs the full URL with sorted params,
136
+ Slack signs `v0:{ts}:{body}`, Meta signs the raw body. The scheme lives in your hook and the vendor on
137
+ the contract. With **no** `WebhookSignerCallback` bound, every outbound `@AuthWebhook` call THROWS
138
+ rather than delivering unsigned — the mirror of an unbound `WebhookAuthCallback` 401ing every inbound
139
+ one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/http-client-node",
3
- "version": "0.4.699",
3
+ "version": "0.4.701",
4
4
  "description": "Server-side HTTP client for webpieces: inversify-wired, reads RequestContext directly, mints OIDC/shared-secret delivery auth, resolves Cloud Run URLs from a service name",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,10 +22,10 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@webpieces/core-context": "0.4.699",
26
- "@webpieces/core-util": "0.4.699",
27
- "@webpieces/gcp-identity": "0.4.699",
28
- "@webpieces/http-client-core": "0.4.699",
25
+ "@webpieces/core-context": "0.4.701",
26
+ "@webpieces/core-util": "0.4.701",
27
+ "@webpieces/gcp-identity": "0.4.701",
28
+ "@webpieces/http-client-core": "0.4.701",
29
29
  "inversify": "7.10.4",
30
30
  "reflect-metadata": "0.2.2"
31
31
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Turns a hostname into the IP addresses it actually resolves to — the half of the SSRF check that
3
+ * has to touch the network, kept behind its own class so the policy itself is unit-testable without
4
+ * DNS and a test can hand the guard a resolver that answers whatever the case needs.
5
+ *
6
+ * ABSTRACT rather than an interface because it is a collaborator with behavior, and because
7
+ * @webpieces/core-mock and app tests substitute it by TYPE.
8
+ *
9
+ * DI-BOUND, not threaded through constructors: {@link DnsAddressResolver} registers itself as the
10
+ * DEFAULT for this base, so every `NodeProxyClient` gets a working resolver with nobody writing a
11
+ * line, and an app or a test rebinds THIS type to substitute one. Passing a resolver in at each
12
+ * client construction site — which is what the deleted host-policy classes did — made the framework's
13
+ * own dependency the app's to remember, on every single client, forever.
14
+ */
15
+ export declare abstract class AddressResolver {
16
+ /**
17
+ * Every address `hostname` resolves to, as strings. ALL of them matter: a hostname that answers
18
+ * with one public address and one 127.0.0.1 is the classic DNS-rebinding shape, and a guard that
19
+ * checks only the first answer waves it through.
20
+ *
21
+ * @throws Error when the name does not resolve. The guard treats that as a refusal, not as a
22
+ * pass — a destination we cannot even name is not one we should POST a payload to.
23
+ */
24
+ abstract resolve(hostname: string): Promise<string[]>;
25
+ }
26
+ /**
27
+ * The real one: node's DNS resolver, asking for every address family. Registered as the OVERRIDABLE
28
+ * default for {@link AddressResolver}, so injecting the base type just works and a test rebinding
29
+ * that type wins.
30
+ */
31
+ export declare class DnsAddressResolver extends AddressResolver {
32
+ resolve(hostname: string): Promise<string[]>;
33
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DnsAddressResolver = exports.AddressResolver = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const promises_1 = require("node:dns/promises");
6
+ const core_context_1 = require("@webpieces/core-context");
7
+ /**
8
+ * Turns a hostname into the IP addresses it actually resolves to — the half of the SSRF check that
9
+ * has to touch the network, kept behind its own class so the policy itself is unit-testable without
10
+ * DNS and a test can hand the guard a resolver that answers whatever the case needs.
11
+ *
12
+ * ABSTRACT rather than an interface because it is a collaborator with behavior, and because
13
+ * @webpieces/core-mock and app tests substitute it by TYPE.
14
+ *
15
+ * DI-BOUND, not threaded through constructors: {@link DnsAddressResolver} registers itself as the
16
+ * DEFAULT for this base, so every `NodeProxyClient` gets a working resolver with nobody writing a
17
+ * line, and an app or a test rebinds THIS type to substitute one. Passing a resolver in at each
18
+ * client construction site — which is what the deleted host-policy classes did — made the framework's
19
+ * own dependency the app's to remember, on every single client, forever.
20
+ */
21
+ class AddressResolver {
22
+ }
23
+ exports.AddressResolver = AddressResolver;
24
+ /**
25
+ * The real one: node's DNS resolver, asking for every address family. Registered as the OVERRIDABLE
26
+ * default for {@link AddressResolver}, so injecting the base type just works and a test rebinding
27
+ * that type wins.
28
+ */
29
+ let DnsAddressResolver = class DnsAddressResolver extends AddressResolver {
30
+ async resolve(hostname) {
31
+ const answers = await (0, promises_1.lookup)(hostname, { all: true, verbatim: true });
32
+ return answers.map((answer) => answer.address);
33
+ }
34
+ };
35
+ exports.DnsAddressResolver = DnsAddressResolver;
36
+ exports.DnsAddressResolver = DnsAddressResolver = tslib_1.__decorate([
37
+ (0, core_context_1.provideFrameworkSingletonDefaultForApi)(AddressResolver)
38
+ ], DnsAddressResolver);
39
+ //# sourceMappingURL=AddressResolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AddressResolver.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/AddressResolver.ts"],"names":[],"mappings":";;;;AACA,gDAA2C;AAC3C,0DAAiF;AAEjF;;;;;;;;;;;;;GAaG;AACH,MAAsB,eAAe;CAUpC;AAVD,0CAUC;AAED;;;;GAIG;AAEI,IAAM,kBAAkB,GAAxB,MAAM,kBAAmB,SAAQ,eAAe;IAC1C,KAAK,CAAC,OAAO,CAAC,QAAgB;QACnC,MAAM,OAAO,GAAoB,MAAM,IAAA,iBAAM,EAAC,QAAQ,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QACvF,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAqB,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAClE,CAAC;CACJ,CAAA;AALY,gDAAkB;6BAAlB,kBAAkB;IAD9B,IAAA,qDAAsC,EAAC,eAAe,CAAC;GAC3C,kBAAkB,CAK9B","sourcesContent":["import type { LookupAddress } from 'node:dns';\nimport { lookup } from 'node:dns/promises';\nimport { provideFrameworkSingletonDefaultForApi } from '@webpieces/core-context';\n\n/**\n * Turns a hostname into the IP addresses it actually resolves to — the half of the SSRF check that\n * has to touch the network, kept behind its own class so the policy itself is unit-testable without\n * DNS and a test can hand the guard a resolver that answers whatever the case needs.\n *\n * ABSTRACT rather than an interface because it is a collaborator with behavior, and because\n * @webpieces/core-mock and app tests substitute it by TYPE.\n *\n * DI-BOUND, not threaded through constructors: {@link DnsAddressResolver} registers itself as the\n * DEFAULT for this base, so every `NodeProxyClient` gets a working resolver with nobody writing a\n * line, and an app or a test rebinds THIS type to substitute one. Passing a resolver in at each\n * client construction site — which is what the deleted host-policy classes did — made the framework's\n * own dependency the app's to remember, on every single client, forever.\n */\nexport abstract class AddressResolver {\n /**\n * Every address `hostname` resolves to, as strings. ALL of them matter: a hostname that answers\n * with one public address and one 127.0.0.1 is the classic DNS-rebinding shape, and a guard that\n * checks only the first answer waves it through.\n *\n * @throws Error when the name does not resolve. The guard treats that as a refusal, not as a\n * pass — a destination we cannot even name is not one we should POST a payload to.\n */\n abstract resolve(hostname: string): Promise<string[]>;\n}\n\n/**\n * The real one: node's DNS resolver, asking for every address family. Registered as the OVERRIDABLE\n * default for {@link AddressResolver}, so injecting the base type just works and a test rebinding\n * that type wins.\n */\n@provideFrameworkSingletonDefaultForApi(AddressResolver)\nexport class DnsAddressResolver extends AddressResolver {\n override async resolve(hostname: string): Promise<string[]> {\n const answers: LookupAddress[] = await lookup(hostname, { all: true, verbatim: true });\n return answers.map((answer: LookupAddress) => answer.address);\n }\n}\n"]}
@@ -4,23 +4,47 @@
4
4
  * differently and share nothing worth a base class.
5
5
  *
6
6
  * Collaborators (RequestContextHeaders, Secrets) are NOT config: they are dependencies of
7
- * {@link NodeProxyClient} and are shared by every client the factory builds. This is the RPC twin
8
- * of cloudtasks-client's TaskClientConfig, and takes the same single field.
7
+ * {@link NodeProxyClient} and are shared by every client the factory builds. Outbound FILTERS are
8
+ * not config either — they are per-client collaborators an app constructs, so they are the optional
9
+ * third argument to `createRpcClient` rather than a field here.
10
+ *
11
+ * ## Why WHERE a client points is not stated here
12
+ *
13
+ * A client resolves ONE address, out of {@link ClientRegistry}, from this `svcName`. A destination
14
+ * that is DATA instead — a URL a partner registered, a per-tenant host, an OAuth callback — is not
15
+ * a second KIND of config; it is a per-call edit made by a filter (`ContextBaseUrlFilter`), through
16
+ * the same seam an app's own header-rewriting or logging filter uses. Naming the two as alternative
17
+ * config shapes made a second extension mechanism sitting beside the filter chain and doing the
18
+ * same job, which is exactly the shape this repo rejects.
9
19
  */
10
20
  export declare class ClientConfig {
11
21
  /**
12
- * The service name. On GCP the URL is DERIVED from it (same project, same region — the Cloud
13
- * Run service name, so you maintain no URL table), which works across demo/qa/prod. Anything
14
- * the derivation cannot describe a localhost port, another region/project, a non-Cloud-Run
22
+ * The service name, and the ONE thing that decides where this client points.
23
+ *
24
+ * The URL is DERIVED from it (on GCP: same project, same region — the Cloud Run service
25
+ * name, so you maintain no URL table), which works across demo/qa/prod. Anything the
26
+ * derivation cannot describe — a localhost port, another region/project, a non-Cloud-Run
15
27
  * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.
28
+ *
29
+ * It is also this client's IDENTITY on the runtime architecture graph. For a client that a
30
+ * `ContextBaseUrlFilter` re-points per call, the graph identity comes from the CONTRACT's
31
+ * `@externalSystem` tag instead, which is where the fact "this hop leaves our estate"
32
+ * belongs — on the contract every caller of it shares, not on one construction site.
16
33
  */
17
34
  readonly svcName: string;
18
35
  constructor(
19
36
  /**
20
- * The service name. On GCP the URL is DERIVED from it (same project, same region — the Cloud
21
- * Run service name, so you maintain no URL table), which works across demo/qa/prod. Anything
22
- * the derivation cannot describe a localhost port, another region/project, a non-Cloud-Run
37
+ * The service name, and the ONE thing that decides where this client points.
38
+ *
39
+ * The URL is DERIVED from it (on GCP: same project, same region — the Cloud Run service
40
+ * name, so you maintain no URL table), which works across demo/qa/prod. Anything the
41
+ * derivation cannot describe — a localhost port, another region/project, a non-Cloud-Run
23
42
  * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.
43
+ *
44
+ * It is also this client's IDENTITY on the runtime architecture graph. For a client that a
45
+ * `ContextBaseUrlFilter` re-points per call, the graph identity comes from the CONTRACT's
46
+ * `@externalSystem` tag instead, which is where the fact "this hop leaves our estate"
47
+ * belongs — on the contract every caller of it shares, not on one construction site.
24
48
  */
25
49
  svcName: string);
26
50
  }
@@ -7,17 +7,34 @@ exports.ClientConfig = void 0;
7
7
  * differently and share nothing worth a base class.
8
8
  *
9
9
  * Collaborators (RequestContextHeaders, Secrets) are NOT config: they are dependencies of
10
- * {@link NodeProxyClient} and are shared by every client the factory builds. This is the RPC twin
11
- * of cloudtasks-client's TaskClientConfig, and takes the same single field.
10
+ * {@link NodeProxyClient} and are shared by every client the factory builds. Outbound FILTERS are
11
+ * not config either — they are per-client collaborators an app constructs, so they are the optional
12
+ * third argument to `createRpcClient` rather than a field here.
13
+ *
14
+ * ## Why WHERE a client points is not stated here
15
+ *
16
+ * A client resolves ONE address, out of {@link ClientRegistry}, from this `svcName`. A destination
17
+ * that is DATA instead — a URL a partner registered, a per-tenant host, an OAuth callback — is not
18
+ * a second KIND of config; it is a per-call edit made by a filter (`ContextBaseUrlFilter`), through
19
+ * the same seam an app's own header-rewriting or logging filter uses. Naming the two as alternative
20
+ * config shapes made a second extension mechanism sitting beside the filter chain and doing the
21
+ * same job, which is exactly the shape this repo rejects.
12
22
  */
13
23
  class ClientConfig {
14
24
  svcName;
15
25
  constructor(
16
26
  /**
17
- * The service name. On GCP the URL is DERIVED from it (same project, same region — the Cloud
18
- * Run service name, so you maintain no URL table), which works across demo/qa/prod. Anything
19
- * the derivation cannot describe a localhost port, another region/project, a non-Cloud-Run
27
+ * The service name, and the ONE thing that decides where this client points.
28
+ *
29
+ * The URL is DERIVED from it (on GCP: same project, same region — the Cloud Run service
30
+ * name, so you maintain no URL table), which works across demo/qa/prod. Anything the
31
+ * derivation cannot describe — a localhost port, another region/project, a non-Cloud-Run
20
32
  * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.
33
+ *
34
+ * It is also this client's IDENTITY on the runtime architecture graph. For a client that a
35
+ * `ContextBaseUrlFilter` re-points per call, the graph identity comes from the CONTRACT's
36
+ * `@externalSystem` tag instead, which is where the fact "this hop leaves our estate"
37
+ * belongs — on the contract every caller of it shares, not on one construction site.
21
38
  */
22
39
  svcName) {
23
40
  this.svcName = svcName;
@@ -1 +1 @@
1
- {"version":3,"file":"ClientConfig.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientConfig.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;GAQG;AACH,MAAa,YAAY;IAQD;IAPpB;IACI;;;;;OAKG;IACa,OAAe;QAAf,YAAO,GAAP,OAAO,CAAQ;IAChC,CAAC;CACP;AAVD,oCAUC","sourcesContent":["/**\n * Per-client STATE for a server-side HTTP client — nothing else. A plain class; it extends nothing\n * and is unrelated to the browser package's ClientConfig, because the two answer \"what URL?\"\n * differently and share nothing worth a base class.\n *\n * Collaborators (RequestContextHeaders, Secrets) are NOT config: they are dependencies of\n * {@link NodeProxyClient} and are shared by every client the factory builds. This is the RPC twin\n * of cloudtasks-client's TaskClientConfig, and takes the same single field.\n */\nexport class ClientConfig {\n constructor(\n /**\n * The service name. On GCP the URL is DERIVED from it (same project, same region — the Cloud\n * Run service name, so you maintain no URL table), which works across demo/qa/prod. Anything\n * the derivation cannot describe — a localhost port, another region/project, a non-Cloud-Run\n * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.\n */\n public readonly svcName: string,\n ) {}\n}\n"]}
1
+ {"version":3,"file":"ClientConfig.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientConfig.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,YAAY;IAeD;IAdpB;IACI;;;;;;;;;;;;OAYG;IACa,OAAe;QAAf,YAAO,GAAP,OAAO,CAAQ;IAChC,CAAC;CACP;AAjBD,oCAiBC","sourcesContent":["/**\n * Per-client STATE for a server-side HTTP client — nothing else. A plain class; it extends nothing\n * and is unrelated to the browser package's ClientConfig, because the two answer \"what URL?\"\n * differently and share nothing worth a base class.\n *\n * Collaborators (RequestContextHeaders, Secrets) are NOT config: they are dependencies of\n * {@link NodeProxyClient} and are shared by every client the factory builds. Outbound FILTERS are\n * not config either — they are per-client collaborators an app constructs, so they are the optional\n * third argument to `createRpcClient` rather than a field here.\n *\n * ## Why WHERE a client points is not stated here\n *\n * A client resolves ONE address, out of {@link ClientRegistry}, from this `svcName`. A destination\n * that is DATA instead — a URL a partner registered, a per-tenant host, an OAuth callback — is not\n * a second KIND of config; it is a per-call edit made by a filter (`ContextBaseUrlFilter`), through\n * the same seam an app's own header-rewriting or logging filter uses. Naming the two as alternative\n * config shapes made a second extension mechanism sitting beside the filter chain and doing the\n * same job, which is exactly the shape this repo rejects.\n */\nexport class ClientConfig {\n constructor(\n /**\n * The service name, and the ONE thing that decides where this client points.\n *\n * The URL is DERIVED from it (on GCP: same project, same region — the Cloud Run service\n * name, so you maintain no URL table), which works across demo/qa/prod. Anything the\n * derivation cannot describe — a localhost port, another region/project, a non-Cloud-Run\n * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.\n *\n * It is also this client's IDENTITY on the runtime architecture graph. For a client that a\n * `ContextBaseUrlFilter` re-points per call, the graph identity comes from the CONTRACT's\n * `@externalSystem` tag instead, which is where the fact \"this hop leaves our estate\"\n * belongs — on the contract every caller of it shares, not on one construction site.\n */\n public readonly svcName: string,\n ) {}\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  import { Provider } from '@webpieces/core-context';
2
2
  import type { ApiPrototype } from '@webpieces/http-client-core';
3
+ import type { ClientFilters } from '@webpieces/http-client-core';
3
4
  import { ClientConfig } from './ClientConfig';
4
5
  import { NodeProxyClient } from './NodeProxyClient';
5
6
  /**
@@ -23,6 +24,18 @@ import { NodeProxyClient } from './NodeProxyClient';
23
24
  * const response = await server2.fetchValue(req); // inside a RequestContext
24
25
  * ```
25
26
  *
27
+ * A destination that is DATA rather than deployment — a URL a partner registered — is a FILTER, not
28
+ * a different kind of client. Install `ContextBaseUrlFilter` on the one client that may be
29
+ * re-pointed, and set the URL per call:
30
+ * ```typescript
31
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
32
+ * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
33
+ * ]);
34
+ * ```
35
+ * The SSRF guard arms itself the moment that filter re-points a request, and the contract's
36
+ * `@AuthWebhook(name)` selects the app's bound `WebhookSignerCallback` to sign the exact bytes —
37
+ * neither is something the app registers, orders, or can displace.
38
+ *
26
39
  * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the
27
40
  * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`
28
41
  * then `init`s. Their collaborators (RequestContextHeaders, Secrets) come from the container, so
@@ -39,7 +52,23 @@ export declare class ClientHttpFactory {
39
52
  * Create a type-safe RPC (HTTP) client for one API contract.
40
53
  *
41
54
  * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators
42
- * @param config - This client's state (its svcName)
55
+ * @param config - This client's state: its svcName, which is what `ClientRegistry` resolves
56
+ * @param filters - This client's own OUTBOUND filters, each with the priority it runs at
57
+ * (highest OUTERMOST). They wrap the send, so a filter may rewrite the URL, add or remove
58
+ * headers, log, or replace `ClientRequest.body` — the exact bytes transmitted. What goes
59
+ * here is APP behaviour: url rewriting, headers, logging, and `ContextBaseUrlFilter` when
60
+ * this client's destination arrives per call.
61
+ *
62
+ * OPTIONAL, and omitting it is not a statement about security: the framework's own SSRF
63
+ * guard and credential minter are installed on every client regardless, BENEATH anything
64
+ * passed here, so there is nothing an app can decline by writing nothing.
65
+ *
66
+ * ONE SPELLING PER DECISION. It is a NON-EMPTY tuple, so `createRpcClient(Api, cfg, [])`
67
+ * does not compile: "this client has no app filters" is said by omitting the argument, and
68
+ * `[]` would be a second way to say the identical thing. That is the same device
69
+ * {@link JwtRoles}'s `roles` uses, for the same reason — the bad case is deleted by the
70
+ * TYPE rather than left available and discouraged in a docstring. Pinned in
71
+ * {@link CreateRpcClientCompileAssertions}.
43
72
  */
44
- createRpcClient<T extends object>(apiPrototype: ApiPrototype<T>, config: ClientConfig): T;
73
+ createRpcClient<T extends object>(apiPrototype: ApiPrototype<T>, config: ClientConfig, filters?: ClientFilters): T;
45
74
  }
@@ -31,6 +31,18 @@ const NodeProxyClient_1 = require("./NodeProxyClient");
31
31
  * const response = await server2.fetchValue(req); // inside a RequestContext
32
32
  * ```
33
33
  *
34
+ * A destination that is DATA rather than deployment — a URL a partner registered — is a FILTER, not
35
+ * a different kind of client. Install `ContextBaseUrlFilter` on the one client that may be
36
+ * re-pointed, and set the URL per call:
37
+ * ```typescript
38
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
39
+ * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
40
+ * ]);
41
+ * ```
42
+ * The SSRF guard arms itself the moment that filter re-points a request, and the contract's
43
+ * `@AuthWebhook(name)` selects the app's bound `WebhookSignerCallback` to sign the exact bytes —
44
+ * neither is something the app registers, orders, or can displace.
45
+ *
34
46
  * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the
35
47
  * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`
36
48
  * then `init`s. Their collaborators (RequestContextHeaders, Secrets) come from the container, so
@@ -49,13 +61,29 @@ let ClientHttpFactory = class ClientHttpFactory {
49
61
  * Create a type-safe RPC (HTTP) client for one API contract.
50
62
  *
51
63
  * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators
52
- * @param config - This client's state (its svcName)
64
+ * @param config - This client's state: its svcName, which is what `ClientRegistry` resolves
65
+ * @param filters - This client's own OUTBOUND filters, each with the priority it runs at
66
+ * (highest OUTERMOST). They wrap the send, so a filter may rewrite the URL, add or remove
67
+ * headers, log, or replace `ClientRequest.body` — the exact bytes transmitted. What goes
68
+ * here is APP behaviour: url rewriting, headers, logging, and `ContextBaseUrlFilter` when
69
+ * this client's destination arrives per call.
70
+ *
71
+ * OPTIONAL, and omitting it is not a statement about security: the framework's own SSRF
72
+ * guard and credential minter are installed on every client regardless, BENEATH anything
73
+ * passed here, so there is nothing an app can decline by writing nothing.
74
+ *
75
+ * ONE SPELLING PER DECISION. It is a NON-EMPTY tuple, so `createRpcClient(Api, cfg, [])`
76
+ * does not compile: "this client has no app filters" is said by omitting the argument, and
77
+ * `[]` would be a second way to say the identical thing. That is the same device
78
+ * {@link JwtRoles}'s `roles` uses, for the same reason — the bad case is deleted by the
79
+ * TYPE rather than left available and discouraged in a docstring. Pinned in
80
+ * {@link CreateRpcClientCompileAssertions}.
53
81
  */
54
- createRpcClient(apiPrototype, config) {
82
+ createRpcClient(apiPrototype, config, filters) {
55
83
  // Fresh instance per contract — NodeProxyClient is transient. init() binds it to this
56
84
  // contract + target; the collaborators already came from the container.
57
85
  const proxyClient = this.proxyClientProvider.get();
58
- proxyClient.init(apiPrototype, config);
86
+ proxyClient.init(apiPrototype, config, filters === undefined ? [] : [...filters]);
59
87
  return (0, http_client_core_1.buildClientProxy)(apiPrototype, proxyClient);
60
88
  }
61
89
  };
@@ -1 +1 @@
1
- {"version":3,"file":"ClientHttpFactory.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientHttpFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAAsD;AACtD,0DAAqG;AAErG,kEAA+D;AAE/D,uDAAgF;AAEhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGI,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAE+B;IADzD,YACyD,mBAA8C;QAA9C,wBAAmB,GAAnB,mBAAmB,CAA2B;IACpG,CAAC;IAEJ;;;;;OAKG;IACH,eAAe,CAAmB,YAA6B,EAAE,MAAoB;QACjF,sFAAsF;QACtF,wEAAwE;QACxE,MAAM,WAAW,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,EAAE,CAAC;QACnD,WAAW,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;QACvC,OAAO,IAAA,mCAAgB,EAAC,YAAY,EAAE,WAAW,CAAC,CAAC;IACvD,CAAC;CACJ,CAAA;AAlBY,8CAAiB;4BAAjB,iBAAiB;IAF7B,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CAAuC,uBAAQ;GAF7E,iBAAiB,CAkB7B","sourcesContent":["import { inject } from 'inversify';\nimport { DocumentDesign } from '@webpieces/core-util';\nimport { Provider, bindFrameworkProvider, provideFrameworkSingleton } from '@webpieces/core-context';\nimport type { ApiPrototype } from '@webpieces/http-client-core';\nimport { buildClientProxy } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\nimport { NODE_PROXY_CLIENT_PROVIDER, NodeProxyClient } from './NodeProxyClient';\n\n// Teach the container how to hand out fresh NodeProxyClients. NodeProxyClient is bound TRANSIENT\n// (@provideFrameworkTransient), so each provider.get() constructs a new one.\nbindFrameworkProvider(NODE_PROXY_CLIENT_PROVIDER, NodeProxyClient);\n\n/**\n * ClientHttpFactory - builds type-safe HTTP clients from API prototypes carrying\n * @ApiPath/@Endpoint decorators. The SERVER-side factory.\n *\n * This is the client-side equivalent of ApiRoutingFactory:\n * - Server routing: ApiRoutingFactory reads decorators -> routes HTTP requests to controllers\n * - Server client: ClientHttpFactory reads decorators -> generates HTTP requests from method calls\n *\n * Inject it and ask for a typed client per contract:\n * ```typescript\n * // same project + region as this container; the URL is derived, you maintain nothing\n * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));\n *\n * // to reach somewhere derivation cannot describe (other region/project, non-Cloud-Run, localhost),\n * // register it once at startup — the client still carries only the svcName:\n * // ClientRegistry.addUrlMapping('legacy', 'https://legacy.corp');\n * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy'));\n *\n * const response = await server2.fetchValue(req); // inside a RequestContext\n * ```\n *\n * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the\n * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`\n * then `init`s. Their collaborators (RequestContextHeaders, Secrets) come from the container, so\n * the whole dependency graph is visible in this package's design.html.\n *\n * Unlike @webpieces/http-client-browser this package is Node-only, so the factory IS the inversify\n * entry point and the magic context is read straight from the RequestContext. A call made outside\n * `RequestContext.run(...)` throws rather than silently dropping the trace.\n */\n@DocumentDesign()\n@provideFrameworkSingleton()\nexport class ClientHttpFactory {\n constructor(\n @inject(NODE_PROXY_CLIENT_PROVIDER) private readonly proxyClientProvider: Provider<NodeProxyClient>,\n ) {}\n\n /**\n * Create a type-safe RPC (HTTP) client for one API contract.\n *\n * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators\n * @param config - This client's state (its svcName)\n */\n createRpcClient<T extends object>(apiPrototype: ApiPrototype<T>, config: ClientConfig): T {\n // Fresh instance per contract — NodeProxyClient is transient. init() binds it to this\n // contract + target; the collaborators already came from the container.\n const proxyClient = this.proxyClientProvider.get();\n proxyClient.init(apiPrototype, config);\n return buildClientProxy(apiPrototype, proxyClient);\n }\n}\n"]}
1
+ {"version":3,"file":"ClientHttpFactory.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientHttpFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAAsD;AACtD,0DAAqG;AAErG,kEAA+D;AAG/D,uDAAgF;AAEhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGI,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAE+B;IADzD,YACyD,mBAA8C;QAA9C,wBAAmB,GAAnB,mBAAmB,CAA2B;IACpG,CAAC;IAEJ;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,eAAe,CACX,YAA6B,EAC7B,MAAoB,EACpB,OAAuB;QAEvB,sFAAsF;QACtF,wEAAwE;QACxE,MAAM,WAAW,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,EAAE,CAAC;QACnD,WAAW,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;QAClF,OAAO,IAAA,mCAAgB,EAAC,YAAY,EAAE,WAAW,CAAC,CAAC;IACvD,CAAC;CACJ,CAAA;AAtCY,8CAAiB;4BAAjB,iBAAiB;IAF7B,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CAAuC,uBAAQ;GAF7E,iBAAiB,CAsC7B","sourcesContent":["import { inject } from 'inversify';\nimport { DocumentDesign } from '@webpieces/core-util';\nimport { Provider, bindFrameworkProvider, provideFrameworkSingleton } from '@webpieces/core-context';\nimport type { ApiPrototype } from '@webpieces/http-client-core';\nimport { buildClientProxy } from '@webpieces/http-client-core';\nimport type { ClientFilters } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\nimport { NODE_PROXY_CLIENT_PROVIDER, NodeProxyClient } from './NodeProxyClient';\n\n// Teach the container how to hand out fresh NodeProxyClients. NodeProxyClient is bound TRANSIENT\n// (@provideFrameworkTransient), so each provider.get() constructs a new one.\nbindFrameworkProvider(NODE_PROXY_CLIENT_PROVIDER, NodeProxyClient);\n\n/**\n * ClientHttpFactory - builds type-safe HTTP clients from API prototypes carrying\n * @ApiPath/@Endpoint decorators. The SERVER-side factory.\n *\n * This is the client-side equivalent of ApiRoutingFactory:\n * - Server routing: ApiRoutingFactory reads decorators -> routes HTTP requests to controllers\n * - Server client: ClientHttpFactory reads decorators -> generates HTTP requests from method calls\n *\n * Inject it and ask for a typed client per contract:\n * ```typescript\n * // same project + region as this container; the URL is derived, you maintain nothing\n * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));\n *\n * // to reach somewhere derivation cannot describe (other region/project, non-Cloud-Run, localhost),\n * // register it once at startup — the client still carries only the svcName:\n * // ClientRegistry.addUrlMapping('legacy', 'https://legacy.corp');\n * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy'));\n *\n * const response = await server2.fetchValue(req); // inside a RequestContext\n * ```\n *\n * A destination that is DATA rather than deployment — a URL a partner registered — is a FILTER, not\n * a different kind of client. Install `ContextBaseUrlFilter` on the one client that may be\n * re-pointed, and set the URL per call:\n * ```typescript\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n * ```\n * The SSRF guard arms itself the moment that filter re-points a request, and the contract's\n * `@AuthWebhook(name)` selects the app's bound `WebhookSignerCallback` to sign the exact bytes —\n * neither is something the app registers, orders, or can displace.\n *\n * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the\n * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`\n * then `init`s. Their collaborators (RequestContextHeaders, Secrets) come from the container, so\n * the whole dependency graph is visible in this package's design.html.\n *\n * Unlike @webpieces/http-client-browser this package is Node-only, so the factory IS the inversify\n * entry point and the magic context is read straight from the RequestContext. A call made outside\n * `RequestContext.run(...)` throws rather than silently dropping the trace.\n */\n@DocumentDesign()\n@provideFrameworkSingleton()\nexport class ClientHttpFactory {\n constructor(\n @inject(NODE_PROXY_CLIENT_PROVIDER) private readonly proxyClientProvider: Provider<NodeProxyClient>,\n ) {}\n\n /**\n * Create a type-safe RPC (HTTP) client for one API contract.\n *\n * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators\n * @param config - This client's state: its svcName, which is what `ClientRegistry` resolves\n * @param filters - This client's own OUTBOUND filters, each with the priority it runs at\n * (highest OUTERMOST). They wrap the send, so a filter may rewrite the URL, add or remove\n * headers, log, or replace `ClientRequest.body` — the exact bytes transmitted. What goes\n * here is APP behaviour: url rewriting, headers, logging, and `ContextBaseUrlFilter` when\n * this client's destination arrives per call.\n *\n * OPTIONAL, and omitting it is not a statement about security: the framework's own SSRF\n * guard and credential minter are installed on every client regardless, BENEATH anything\n * passed here, so there is nothing an app can decline by writing nothing.\n *\n * ONE SPELLING PER DECISION. It is a NON-EMPTY tuple, so `createRpcClient(Api, cfg, [])`\n * does not compile: \"this client has no app filters\" is said by omitting the argument, and\n * `[]` would be a second way to say the identical thing. That is the same device\n * {@link JwtRoles}'s `roles` uses, for the same reason — the bad case is deleted by the\n * TYPE rather than left available and discouraged in a docstring. Pinned in\n * {@link CreateRpcClientCompileAssertions}.\n */\n createRpcClient<T extends object>(\n apiPrototype: ApiPrototype<T>,\n config: ClientConfig,\n filters?: ClientFilters,\n ): T {\n // Fresh instance per contract — NodeProxyClient is transient. init() binds it to this\n // contract + target; the collaborators already came from the container.\n const proxyClient = this.proxyClientProvider.get();\n proxyClient.init(apiPrototype, config, filters === undefined ? [] : [...filters]);\n return buildClientProxy(apiPrototype, proxyClient);\n }\n}\n"]}
@@ -0,0 +1,80 @@
1
+ import { Filter, Service } from '@webpieces/core-util';
2
+ import { ClientRequest } from '@webpieces/http-client-core';
3
+ import { SsrfPolicy } from './SsrfPolicy';
4
+ /**
5
+ * Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points
6
+ * THIS ONE CALL at it. Ships in the box, and is the whole of the runtime-base-URL feature:
7
+ *
8
+ * ```ts
9
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
10
+ * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
11
+ * ]);
12
+ *
13
+ * for (const webhook of webhooks) {
14
+ * await RequestContext.run(() => {
15
+ * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
16
+ * return partner.deliver(envelope);
17
+ * });
18
+ * }
19
+ * ```
20
+ *
21
+ * ## INSTALLING IT IS THE OPT-IN
22
+ *
23
+ * There is no client-level setting saying "this one may be re-pointed", because installing this
24
+ * filter IS that statement, written at the one place a reader looks. A client with no
25
+ * `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a
26
+ * value set for a partner delivery from silently re-pointing every other client in the same
27
+ * request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a
28
+ * codebase that can be re-pointed at all, which is the question a security review actually asks.
29
+ *
30
+ * ## PER-ENDPOINT, if a contract mixes them
31
+ *
32
+ * `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can
33
+ * re-point some endpoints of a contract and leave the rest on the configured host, with no API
34
+ * change:
35
+ *
36
+ * ```ts
37
+ * class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {
38
+ * override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {
39
+ * if (request.route.methodName !== 'deliver') return next.invoke(request);
40
+ * return super.filter(request, next);
41
+ * }
42
+ * }
43
+ * ```
44
+ *
45
+ * ## Scope, and why it cannot leak
46
+ *
47
+ * It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the
48
+ * next call through the same client starts from its configured host again; and the context entry is
49
+ * scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets
50
+ * a different URL per partner gets exactly the URL it set, per iteration.
51
+ *
52
+ * ## The SSRF guard is NOT registered here
53
+ *
54
+ * Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips
55
+ * `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads
56
+ * that. So this filter cannot forget to bring the guard along, and an app cannot install this one
57
+ * without it. The only thing this class carries is WHICH policy the guard applies, and only
58
+ * because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same
59
+ * construction site as the decision to be re-pointable at all.
60
+ */
61
+ export declare class ContextBaseUrlFilter extends Filter<ClientRequest, Response> {
62
+ /**
63
+ * What the framework's SSRF guard holds this client's re-pointed URLs to.
64
+ *
65
+ * Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the
66
+ * omitted argument can never be the permissive one — the widening has to be typed out, with
67
+ * a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.
68
+ */
69
+ readonly ssrfPolicy: SsrfPolicy;
70
+ constructor(
71
+ /**
72
+ * What the framework's SSRF guard holds this client's re-pointed URLs to.
73
+ *
74
+ * Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the
75
+ * omitted argument can never be the permissive one — the widening has to be typed out, with
76
+ * a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.
77
+ */
78
+ ssrfPolicy?: SsrfPolicy);
79
+ filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response>;
80
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContextBaseUrlFilter = void 0;
4
+ const core_util_1 = require("@webpieces/core-util");
5
+ const core_context_1 = require("@webpieces/core-context");
6
+ const MissingRuntimeBaseUrlError_1 = require("./MissingRuntimeBaseUrlError");
7
+ const SsrfPolicy_1 = require("./SsrfPolicy");
8
+ /**
9
+ * Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points
10
+ * THIS ONE CALL at it. Ships in the box, and is the whole of the runtime-base-URL feature:
11
+ *
12
+ * ```ts
13
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
14
+ * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
15
+ * ]);
16
+ *
17
+ * for (const webhook of webhooks) {
18
+ * await RequestContext.run(() => {
19
+ * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
20
+ * return partner.deliver(envelope);
21
+ * });
22
+ * }
23
+ * ```
24
+ *
25
+ * ## INSTALLING IT IS THE OPT-IN
26
+ *
27
+ * There is no client-level setting saying "this one may be re-pointed", because installing this
28
+ * filter IS that statement, written at the one place a reader looks. A client with no
29
+ * `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a
30
+ * value set for a partner delivery from silently re-pointing every other client in the same
31
+ * request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a
32
+ * codebase that can be re-pointed at all, which is the question a security review actually asks.
33
+ *
34
+ * ## PER-ENDPOINT, if a contract mixes them
35
+ *
36
+ * `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can
37
+ * re-point some endpoints of a contract and leave the rest on the configured host, with no API
38
+ * change:
39
+ *
40
+ * ```ts
41
+ * class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {
42
+ * override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {
43
+ * if (request.route.methodName !== 'deliver') return next.invoke(request);
44
+ * return super.filter(request, next);
45
+ * }
46
+ * }
47
+ * ```
48
+ *
49
+ * ## Scope, and why it cannot leak
50
+ *
51
+ * It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the
52
+ * next call through the same client starts from its configured host again; and the context entry is
53
+ * scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets
54
+ * a different URL per partner gets exactly the URL it set, per iteration.
55
+ *
56
+ * ## The SSRF guard is NOT registered here
57
+ *
58
+ * Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips
59
+ * `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads
60
+ * that. So this filter cannot forget to bring the guard along, and an app cannot install this one
61
+ * without it. The only thing this class carries is WHICH policy the guard applies, and only
62
+ * because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same
63
+ * construction site as the decision to be re-pointable at all.
64
+ */
65
+ class ContextBaseUrlFilter extends core_util_1.Filter {
66
+ ssrfPolicy;
67
+ constructor(
68
+ /**
69
+ * What the framework's SSRF guard holds this client's re-pointed URLs to.
70
+ *
71
+ * Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the
72
+ * omitted argument can never be the permissive one — the widening has to be typed out, with
73
+ * a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.
74
+ */
75
+ ssrfPolicy = new SsrfPolicy_1.SsrfPolicy()) {
76
+ super();
77
+ this.ssrfPolicy = ssrfPolicy;
78
+ }
79
+ async filter(request, nextFilter) {
80
+ const override = core_context_1.RequestContext.getUntrusted(core_util_1.WebpiecesCoreHeaders.OVERRIDE_BASE_URL);
81
+ if (override === undefined || override === '') {
82
+ throw new MissingRuntimeBaseUrlError_1.MissingRuntimeBaseUrlError(`${request.contractName}.${request.route.methodName} runs behind a ContextBaseUrlFilter, so ` +
83
+ `its destination must be supplied per call, but no ` +
84
+ `WebpiecesCoreHeaders.OVERRIDE_BASE_URL was found in the RequestContext. Set it around ` +
85
+ `the call:\n` +
86
+ ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\n` +
87
+ `Refusing rather than falling back to this client's configured service URL is ` +
88
+ `deliberate: a silent fallback would send a partner's payload to one of our own services.`, `${request.contractName}.${request.route.methodName}`);
89
+ }
90
+ request.pointAtBaseUrl(override);
91
+ return nextFilter.invoke(request);
92
+ }
93
+ }
94
+ exports.ContextBaseUrlFilter = ContextBaseUrlFilter;
95
+ //# sourceMappingURL=ContextBaseUrlFilter.js.map