@webpieces/http-client-node 0.4.700 → 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 (50) hide show
  1. package/README.md +82 -51
  2. package/package.json +5 -5
  3. package/src/AddressResolver.d.ts +11 -1
  4. package/src/AddressResolver.js +12 -2
  5. package/src/AddressResolver.js.map +1 -1
  6. package/src/ClientConfig.d.ts +30 -36
  7. package/src/ClientConfig.js +21 -21
  8. package/src/ClientConfig.js.map +1 -1
  9. package/src/ClientHttpFactory.d.ts +30 -18
  10. package/src/ClientHttpFactory.js +29 -17
  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/MissingRuntimeBaseUrlError.d.ts +20 -0
  19. package/src/MissingRuntimeBaseUrlError.js +28 -0
  20. package/src/MissingRuntimeBaseUrlError.js.map +1 -0
  21. package/src/NodeProxyClient.d.ts +26 -27
  22. package/src/NodeProxyClient.js +60 -47
  23. package/src/NodeProxyClient.js.map +1 -1
  24. package/src/OutboundAuthErrors.d.ts +42 -0
  25. package/src/OutboundAuthErrors.js +54 -0
  26. package/src/OutboundAuthErrors.js.map +1 -0
  27. package/src/OutboundAuthFilter.d.ts +56 -0
  28. package/src/OutboundAuthFilter.js +103 -0
  29. package/src/OutboundAuthFilter.js.map +1 -0
  30. package/src/SsrfGuardFilter.d.ts +11 -0
  31. package/src/SsrfGuardFilter.js +23 -4
  32. package/src/SsrfGuardFilter.js.map +1 -1
  33. package/src/SsrfPolicy.d.ts +39 -45
  34. package/src/SsrfPolicy.js +49 -34
  35. package/src/SsrfPolicy.js.map +1 -1
  36. package/src/WebhookSignerCallback.d.ts +114 -0
  37. package/src/WebhookSignerCallback.js +106 -0
  38. package/src/WebhookSignerCallback.js.map +1 -0
  39. package/src/index.d.ts +13 -10
  40. package/src/index.js +30 -24
  41. package/src/index.js.map +1 -1
  42. package/src/ContextBaseUrlOverrideFilter.d.ts +0 -38
  43. package/src/ContextBaseUrlOverrideFilter.js +0 -57
  44. package/src/ContextBaseUrlOverrideFilter.js.map +0 -1
  45. package/src/HostPolicy.d.ts +0 -118
  46. package/src/HostPolicy.js +0 -169
  47. package/src/HostPolicy.js.map +0 -1
  48. package/src/RuntimeHostErrors.d.ts +0 -42
  49. package/src/RuntimeHostErrors.js +0 -54
  50. package/src/RuntimeHostErrors.js.map +0 -1
package/README.md CHANGED
@@ -5,18 +5,10 @@ the Cloud Tasks twin — calling a method makes the HTTP request that contract d
5
5
 
6
6
  ```ts
7
7
  // inject the factory (a framework singleton), then one client per contract
8
- const server2 = factory.createRpcClient(
9
- Server2Api,
10
- new ClientConfig('server2', new DeployedServiceHost()),
11
- [], // this client's outbound filters
12
- );
8
+ const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));
13
9
  const res = await server2.fetchValue(req); // inside a RequestContext
14
10
  ```
15
11
 
16
- Every `ClientConfig` states WHERE its requests go, because there are two kinds of destination and
17
- they carry very different risk. `DeployedServiceHost` is the one above and is what almost every
18
- client wants. See "A destination supplied at RUNTIME" below for the other.
19
-
20
12
  - `svcName` becomes a URL through `ClientRegistry.resolve` — ONE chain, the same one the browser
21
13
  client and Cloud Tasks run:
22
14
  1. a registered mapping wins: `ClientRegistry.addMapping(svcName, port)` (localhost) or
@@ -41,19 +33,53 @@ top-level server filter normally establishes the scope for you.
41
33
 
42
34
  The browser twin is [@webpieces/http-client-browser](../http-client-browser).
43
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
+
44
63
  ## A destination supplied at RUNTIME
45
64
 
46
65
  Some destinations are DATA, not deployment: a URL a partner registered (`OrganizationWebhook.url`),
47
66
  an OAuth callback, a per-tenant or self-hosted host. There is no `svcName` to resolve and nothing to
48
- register, but there IS a contract — the payload is fully specified and published to customers. Name
49
- a runtime host policy and the base URL arrives per call:
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:
50
70
 
51
71
  ```ts
52
- const partner = factory.createRpcClient(
53
- PartnerWebhookApi,
54
- new ClientConfig('partner-webhooks', new RuntimeHostFromContext(new DnsAddressResolver())),
55
- [new ClientFilterDefinition(500, new HmacSigningFilter(secret))],
56
- );
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
+ ]);
57
83
 
58
84
  for (const webhook of webhooks) {
59
85
  await RequestContext.run(() => {
@@ -63,46 +89,51 @@ for (const webhook of webhooks) {
63
89
  }
64
90
  ```
65
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.
66
96
  - **It cannot leak.** The override lives on the per-call request, never on the client, so one client
67
97
  fans out across N partner URLs and each call goes exactly where its own scope said.
68
- - **A `DeployedServiceHost` client IGNORES the key.** That is the point of making the policy a named
69
- class: an ambient URL set for a partner delivery cannot re-point every other client in the same
70
- request. `grep -rn RuntimeHostFromContext` lists every client that can be re-pointed at all.
71
- - **SSRF policy is ON.** https only; loopback / RFC1918 / link-local / cloud-metadata refused, by
72
- name AND by every address the name resolves to; redirects are not followed by the transport but
73
- re-judged hop by hop, so a partner URL that 302s at `169.254.169.254` is refused rather than
74
- obeyed. The ONLY way to relax it is to say so out loud:
75
- `new RuntimeHostFromContextAllowingInternalAddresses('local emulator on 127.0.0.1', resolver)`.
76
- - **`@AuthOidc` / `@AuthSharedSecret` endpoints are refused at bind time.** Both mint a credential
77
- for a peer we chose; a destination that arrives per call has no honest audience, and minting one
78
- would hand our credential to whoever registered the URL.
79
- - **The hop is VISIBLE.** `svcName` is still required under a runtime policy: it is the identity the
80
- destination gets on the runtime architecture graph, drawn as an external node of kind `runtime`.
81
-
82
- ## Outbound filters
83
-
84
- `createRpcClient`'s third argument is this client's OUTBOUND filter chain — the same `Filter` /
85
- `Service` abstraction (from `@webpieces/core-util`) the server's inbound chain uses, pointed the
86
- other way. A filter receives a mutable `ClientRequest` and returns the `Response`:
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:
87
121
 
88
122
  ```ts
89
- class HmacSigningFilter extends Filter<ClientRequest, Response> {
90
- constructor(private readonly secret: string) { super(); }
91
-
92
- async filter(request: ClientRequest, next: Service<ClientRequest, Response>): Promise<Response> {
93
- // request.body is the EXACT serialized bytes the transport will send — sign those.
94
- const mac = createHmac('sha256', this.secret).update(request.body ?? '').digest('hex');
95
- request.headers.set('x-signature', `sha256=${mac}`);
96
- return next.invoke(request);
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}`]]);
97
128
  }
98
129
  }
99
- ```
100
130
 
101
- Highest priority runs OUTERMOST, matching the server's `FilterMatcher`. The framework's own
102
- built-ins occupy 1000 (the runtime base-URL override) and 900 (the SSRF guard), so an app filter
103
- below them sees the destination already settled.
131
+ // AppModule.ts
132
+ options.bind(WEBHOOK_SIGNER_CALLBACK).to(PartnerHmacSigner);
133
+ ```
104
134
 
105
- Signing over `request.body` is the whole reason this seam exists: the bytes a filter signs are the
106
- bytes transmitted, byte for byte. Without it a sender has to hand-serialize and post the payload
107
- itself, because a raw HTTP library that re-serializes internally signs one sequence and sends
108
- another.
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.700",
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.700",
26
- "@webpieces/core-util": "0.4.700",
27
- "@webpieces/gcp-identity": "0.4.700",
28
- "@webpieces/http-client-core": "0.4.700",
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
  }
@@ -5,6 +5,12 @@
5
5
  *
6
6
  * ABSTRACT rather than an interface because it is a collaborator with behavior, and because
7
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.
8
14
  */
9
15
  export declare abstract class AddressResolver {
10
16
  /**
@@ -17,7 +23,11 @@ export declare abstract class AddressResolver {
17
23
  */
18
24
  abstract resolve(hostname: string): Promise<string[]>;
19
25
  }
20
- /** The real one: node's DNS resolver, asking for every address family. */
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
+ */
21
31
  export declare class DnsAddressResolver extends AddressResolver {
22
32
  resolve(hostname: string): Promise<string[]>;
23
33
  }
@@ -11,11 +11,21 @@ const core_context_1 = require("@webpieces/core-context");
11
11
  *
12
12
  * ABSTRACT rather than an interface because it is a collaborator with behavior, and because
13
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.
14
20
  */
15
21
  class AddressResolver {
16
22
  }
17
23
  exports.AddressResolver = AddressResolver;
18
- /** The real one: node's DNS resolver, asking for every address family. */
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
+ */
19
29
  let DnsAddressResolver = class DnsAddressResolver extends AddressResolver {
20
30
  async resolve(hostname) {
21
31
  const answers = await (0, promises_1.lookup)(hostname, { all: true, verbatim: true });
@@ -24,6 +34,6 @@ let DnsAddressResolver = class DnsAddressResolver extends AddressResolver {
24
34
  };
25
35
  exports.DnsAddressResolver = DnsAddressResolver;
26
36
  exports.DnsAddressResolver = DnsAddressResolver = tslib_1.__decorate([
27
- (0, core_context_1.provideFrameworkSingleton)()
37
+ (0, core_context_1.provideFrameworkSingletonDefaultForApi)(AddressResolver)
28
38
  ], DnsAddressResolver);
29
39
  //# sourceMappingURL=AddressResolver.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AddressResolver.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/AddressResolver.ts"],"names":[],"mappings":";;;;AACA,gDAA2C;AAC3C,0DAAoE;AAEpE;;;;;;;GAOG;AACH,MAAsB,eAAe;CAUpC;AAVD,0CAUC;AAED,0EAA0E;AAEnE,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,wCAAyB,GAAE;GACf,kBAAkB,CAK9B","sourcesContent":["import type { LookupAddress } from 'node:dns';\nimport { lookup } from 'node:dns/promises';\nimport { provideFrameworkSingleton } 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 */\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/** The real one: node's DNS resolver, asking for every address family. */\n@provideFrameworkSingleton()\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"]}
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"]}
@@ -1,4 +1,3 @@
1
- import { HostPolicy } from './HostPolicy';
2
1
  /**
3
2
  * Per-client STATE for a server-side HTTP client — nothing else. A plain class; it extends nothing
4
3
  * and is unrelated to the browser package's ClientConfig, because the two answer "what URL?"
@@ -6,51 +5,46 @@ import { HostPolicy } from './HostPolicy';
6
5
  *
7
6
  * Collaborators (RequestContextHeaders, Secrets) are NOT config: they are dependencies of
8
7
  * {@link NodeProxyClient} and are shared by every client the factory builds. Outbound FILTERS are
9
- * not config either — they are per-client collaborators an app constructs, so they are the third
10
- * argument to `createRpcClient` rather than a field here.
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.
11
19
  */
12
20
  export declare class ClientConfig {
13
21
  /**
14
- * The service name.
22
+ * The service name, and the ONE thing that decides where this client points.
15
23
  *
16
- * Under {@link DeployedServiceHost} the URL is DERIVED from it (on GCP: same project, same
17
- * region — the Cloud Run service name, so you maintain no URL table), which works across
18
- * demo/qa/prod. Anything the derivation cannot describe — a localhost port, another
19
- * region/project, a non-Cloud-Run host — is a `ClientRegistry` mapping registered at
20
- * startup, NOT a per-client URL.
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
27
+ * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.
21
28
  *
22
- * Under a runtime host policy nothing is derived from it, but it is still required and still
23
- * load-bearing: it is the IDENTITY this outbound hop gets on the runtime architecture graph
24
- * ('partner-webhooks'), which is what stops a partner delivery being an invisible edge.
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.
25
33
  */
26
34
  readonly svcName: string;
27
- /**
28
- * WHERE this client's requests go: a service we deploy, or a host supplied per call at
29
- * runtime. REQUIRED — see {@link HostPolicy} for why there is no default. The old
30
- * one-argument `new ClientConfig('svc')` no longer compiles; write
31
- * `new ClientConfig('svc', new DeployedServiceHost())` for the behaviour it used to have.
32
- */
33
- readonly hostPolicy: HostPolicy;
34
35
  constructor(
35
36
  /**
36
- * The service name.
37
+ * The service name, and the ONE thing that decides where this client points.
37
38
  *
38
- * Under {@link DeployedServiceHost} the URL is DERIVED from it (on GCP: same project, same
39
- * region — the Cloud Run service name, so you maintain no URL table), which works across
40
- * demo/qa/prod. Anything the derivation cannot describe — a localhost port, another
41
- * region/project, a non-Cloud-Run host — is a `ClientRegistry` mapping registered at
42
- * startup, NOT a per-client URL.
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
42
+ * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.
43
43
  *
44
- * Under a runtime host policy nothing is derived from it, but it is still required and still
45
- * load-bearing: it is the IDENTITY this outbound hop gets on the runtime architecture graph
46
- * ('partner-webhooks'), which is what stops a partner delivery being an invisible edge.
47
- */
48
- svcName: string,
49
- /**
50
- * WHERE this client's requests go: a service we deploy, or a host supplied per call at
51
- * runtime. REQUIRED — see {@link HostPolicy} for why there is no default. The old
52
- * one-argument `new ClientConfig('svc')` no longer compiles; write
53
- * `new ClientConfig('svc', new DeployedServiceHost())` for the behaviour it used to have.
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.
54
48
  */
55
- hostPolicy: HostPolicy);
49
+ svcName: string);
56
50
  }
@@ -8,36 +8,36 @@ exports.ClientConfig = void 0;
8
8
  *
9
9
  * Collaborators (RequestContextHeaders, Secrets) are NOT config: they are dependencies of
10
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 third
12
- * argument to `createRpcClient` rather than a field here.
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.
13
22
  */
14
23
  class ClientConfig {
15
24
  svcName;
16
- hostPolicy;
17
25
  constructor(
18
26
  /**
19
- * The service name.
27
+ * The service name, and the ONE thing that decides where this client points.
20
28
  *
21
- * Under {@link DeployedServiceHost} the URL is DERIVED from it (on GCP: same project, same
22
- * region — the Cloud Run service name, so you maintain no URL table), which works across
23
- * demo/qa/prod. Anything the derivation cannot describe — a localhost port, another
24
- * region/project, a non-Cloud-Run host — is a `ClientRegistry` mapping registered at
25
- * startup, NOT a per-client URL.
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
32
+ * host — is a `ClientRegistry` mapping registered at startup, NOT a per-client URL.
26
33
  *
27
- * Under a runtime host policy nothing is derived from it, but it is still required and still
28
- * load-bearing: it is the IDENTITY this outbound hop gets on the runtime architecture graph
29
- * ('partner-webhooks'), which is what stops a partner delivery being an invisible edge.
30
- */
31
- svcName,
32
- /**
33
- * WHERE this client's requests go: a service we deploy, or a host supplied per call at
34
- * runtime. REQUIRED — see {@link HostPolicy} for why there is no default. The old
35
- * one-argument `new ClientConfig('svc')` no longer compiles; write
36
- * `new ClientConfig('svc', new DeployedServiceHost())` for the behaviour it used to have.
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.
37
38
  */
38
- hostPolicy) {
39
+ svcName) {
39
40
  this.svcName = svcName;
40
- this.hostPolicy = hostPolicy;
41
41
  }
42
42
  }
43
43
  exports.ClientConfig = ClientConfig;
@@ -1 +1 @@
1
- {"version":3,"file":"ClientConfig.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientConfig.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;GASG;AACH,MAAa,YAAY;IAeD;IAOA;IArBpB;IACI;;;;;;;;;;;;OAYG;IACa,OAAe;IAC/B;;;;;OAKG;IACa,UAAsB;QAPtB,YAAO,GAAP,OAAO,CAAQ;QAOf,eAAU,GAAV,UAAU,CAAY;IACvC,CAAC;CACP;AAxBD,oCAwBC","sourcesContent":["import { HostPolicy } from './HostPolicy';\n\n/**\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 third\n * argument to `createRpcClient` rather than a field here.\n */\nexport class ClientConfig {\n constructor(\n /**\n * The service name.\n *\n * Under {@link DeployedServiceHost} the URL is DERIVED from it (on GCP: same project, same\n * region — the Cloud Run service name, so you maintain no URL table), which works across\n * demo/qa/prod. Anything the derivation cannot describe — a localhost port, another\n * region/project, a non-Cloud-Run host — is a `ClientRegistry` mapping registered at\n * startup, NOT a per-client URL.\n *\n * Under a runtime host policy nothing is derived from it, but it is still required and still\n * load-bearing: it is the IDENTITY this outbound hop gets on the runtime architecture graph\n * ('partner-webhooks'), which is what stops a partner delivery being an invisible edge.\n */\n public readonly svcName: string,\n /**\n * WHERE this client's requests go: a service we deploy, or a host supplied per call at\n * runtime. REQUIRED see {@link HostPolicy} for why there is no default. The old\n * one-argument `new ClientConfig('svc')` no longer compiles; write\n * `new ClientConfig('svc', new DeployedServiceHost())` for the behaviour it used to have.\n */\n public readonly hostPolicy: HostPolicy,\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,6 +1,6 @@
1
1
  import { Provider } from '@webpieces/core-context';
2
2
  import type { ApiPrototype } from '@webpieces/http-client-core';
3
- import { ClientFilterDefinition } from '@webpieces/http-client-core';
3
+ import type { ClientFilters } from '@webpieces/http-client-core';
4
4
  import { ClientConfig } from './ClientConfig';
5
5
  import { NodeProxyClient } from './NodeProxyClient';
6
6
  /**
@@ -14,25 +14,27 @@ import { NodeProxyClient } from './NodeProxyClient';
14
14
  * Inject it and ask for a typed client per contract:
15
15
  * ```typescript
16
16
  * // same project + region as this container; the URL is derived, you maintain nothing
17
- * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', new DeployedServiceHost()), []);
17
+ * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));
18
18
  *
19
19
  * // to reach somewhere derivation cannot describe (other region/project, non-Cloud-Run, localhost),
20
20
  * // register it once at startup — the client still carries only the svcName:
21
21
  * // ClientRegistry.addUrlMapping('legacy', 'https://legacy.corp');
22
- * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy', new DeployedServiceHost()), []);
22
+ * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy'));
23
23
  *
24
24
  * const response = await server2.fetchValue(req); // inside a RequestContext
25
25
  * ```
26
26
  *
27
- * A destination that is DATA rather than deployment — a URL a partner registered — names a runtime
28
- * host policy instead, and typically installs a signing filter over the exact bytes:
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:
29
30
  * ```typescript
30
- * const partner = factory.createRpcClient(
31
- * PartnerWebhookApi,
32
- * new ClientConfig('partner-webhooks', new RuntimeHostFromContext(new DnsAddressResolver())),
33
- * [new ClientFilterDefinition(500, new HmacSigningFilter(secret))],
34
- * );
31
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
32
+ * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
33
+ * ]);
35
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.
36
38
  *
37
39
  * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the
38
40
  * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`
@@ -50,13 +52,23 @@ export declare class ClientHttpFactory {
50
52
  * Create a type-safe RPC (HTTP) client for one API contract.
51
53
  *
52
54
  * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators
53
- * @param config - This client's state (its svcName and its host policy)
54
- * @param filters - This client's OUTBOUND filters, each with the priority it runs at (highest
55
- * OUTERMOST). They wrap the send, so a filter sees the final URL, may add headers, and
56
- * may replace `ClientRequest.body` — the exact bytes transmitted, which is what makes
57
- * signing possible without hand-serializing. Pass `[]` for a client that needs none;
58
- * the argument is REQUIRED so "this client signs nothing" is written down rather than
59
- * inferred from an omitted argument.
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}.
60
72
  */
61
- createRpcClient<T extends object>(apiPrototype: ApiPrototype<T>, config: ClientConfig, filters: ClientFilterDefinition[]): T;
73
+ createRpcClient<T extends object>(apiPrototype: ApiPrototype<T>, config: ClientConfig, filters?: ClientFilters): T;
62
74
  }
@@ -21,25 +21,27 @@ const NodeProxyClient_1 = require("./NodeProxyClient");
21
21
  * Inject it and ask for a typed client per contract:
22
22
  * ```typescript
23
23
  * // same project + region as this container; the URL is derived, you maintain nothing
24
- * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', new DeployedServiceHost()), []);
24
+ * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));
25
25
  *
26
26
  * // to reach somewhere derivation cannot describe (other region/project, non-Cloud-Run, localhost),
27
27
  * // register it once at startup — the client still carries only the svcName:
28
28
  * // ClientRegistry.addUrlMapping('legacy', 'https://legacy.corp');
29
- * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy', new DeployedServiceHost()), []);
29
+ * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy'));
30
30
  *
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 — names a runtime
35
- * host policy instead, and typically installs a signing filter over the exact bytes:
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:
36
37
  * ```typescript
37
- * const partner = factory.createRpcClient(
38
- * PartnerWebhookApi,
39
- * new ClientConfig('partner-webhooks', new RuntimeHostFromContext(new DnsAddressResolver())),
40
- * [new ClientFilterDefinition(500, new HmacSigningFilter(secret))],
41
- * );
38
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
39
+ * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
40
+ * ]);
42
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.
43
45
  *
44
46
  * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the
45
47
  * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`
@@ -59,19 +61,29 @@ let ClientHttpFactory = class ClientHttpFactory {
59
61
  * Create a type-safe RPC (HTTP) client for one API contract.
60
62
  *
61
63
  * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators
62
- * @param config - This client's state (its svcName and its host policy)
63
- * @param filters - This client's OUTBOUND filters, each with the priority it runs at (highest
64
- * OUTERMOST). They wrap the send, so a filter sees the final URL, may add headers, and
65
- * may replace `ClientRequest.body` — the exact bytes transmitted, which is what makes
66
- * signing possible without hand-serializing. Pass `[]` for a client that needs none;
67
- * the argument is REQUIRED so "this client signs nothing" is written down rather than
68
- * inferred from an omitted argument.
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}.
69
81
  */
70
82
  createRpcClient(apiPrototype, config, filters) {
71
83
  // Fresh instance per contract — NodeProxyClient is transient. init() binds it to this
72
84
  // contract + target; the collaborators already came from the container.
73
85
  const proxyClient = this.proxyClientProvider.get();
74
- proxyClient.init(apiPrototype, config, filters);
86
+ proxyClient.init(apiPrototype, config, filters === undefined ? [] : [...filters]);
75
87
  return (0, http_client_core_1.buildClientProxy)(apiPrototype, proxyClient);
76
88
  }
77
89
  };