@webpieces/http-client-node 0.4.700 → 0.4.702
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -51
- package/package.json +5 -5
- package/src/AddressResolver.d.ts +11 -1
- package/src/AddressResolver.js +12 -2
- package/src/AddressResolver.js.map +1 -1
- package/src/ClientConfig.d.ts +30 -36
- package/src/ClientConfig.js +21 -21
- package/src/ClientConfig.js.map +1 -1
- package/src/ClientHttpFactory.d.ts +30 -18
- package/src/ClientHttpFactory.js +29 -17
- package/src/ClientHttpFactory.js.map +1 -1
- package/src/ContextBaseUrlFilter.d.ts +80 -0
- package/src/ContextBaseUrlFilter.js +95 -0
- package/src/ContextBaseUrlFilter.js.map +1 -0
- package/src/CreateRpcClientCompileAssertions.d.ts +17 -0
- package/src/CreateRpcClientCompileAssertions.js +50 -0
- package/src/CreateRpcClientCompileAssertions.js.map +1 -0
- package/src/MissingRuntimeBaseUrlError.d.ts +20 -0
- package/src/MissingRuntimeBaseUrlError.js +28 -0
- package/src/MissingRuntimeBaseUrlError.js.map +1 -0
- package/src/NodeProxyClient.d.ts +26 -27
- package/src/NodeProxyClient.js +60 -47
- package/src/NodeProxyClient.js.map +1 -1
- package/src/OutboundAuthErrors.d.ts +42 -0
- package/src/OutboundAuthErrors.js +54 -0
- package/src/OutboundAuthErrors.js.map +1 -0
- package/src/OutboundAuthFilter.d.ts +56 -0
- package/src/OutboundAuthFilter.js +103 -0
- package/src/OutboundAuthFilter.js.map +1 -0
- package/src/SsrfGuardFilter.d.ts +11 -0
- package/src/SsrfGuardFilter.js +23 -4
- package/src/SsrfGuardFilter.js.map +1 -1
- package/src/SsrfPolicy.d.ts +39 -45
- package/src/SsrfPolicy.js +49 -34
- package/src/SsrfPolicy.js.map +1 -1
- package/src/WebhookSignerCallback.d.ts +114 -0
- package/src/WebhookSignerCallback.js +106 -0
- package/src/WebhookSignerCallback.js.map +1 -0
- package/src/index.d.ts +13 -10
- package/src/index.js +30 -24
- package/src/index.js.map +1 -1
- package/src/ContextBaseUrlOverrideFilter.d.ts +0 -38
- package/src/ContextBaseUrlOverrideFilter.js +0 -57
- package/src/ContextBaseUrlOverrideFilter.js.map +0 -1
- package/src/HostPolicy.d.ts +0 -118
- package/src/HostPolicy.js +0 -169
- package/src/HostPolicy.js.map +0 -1
- package/src/RuntimeHostErrors.d.ts +0 -42
- package/src/RuntimeHostErrors.js +0 -54
- 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.
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
- **
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
-
|
|
72
|
-
name AND by every address the name resolves to; redirects
|
|
73
|
-
re-judged hop by hop, so a partner URL that 302s at `169.254.169.254` is
|
|
74
|
-
obeyed. The
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
// AppModule.ts
|
|
132
|
+
options.bind(WEBHOOK_SIGNER_CALLBACK).to(PartnerHmacSigner);
|
|
133
|
+
```
|
|
104
134
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.702",
|
|
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.
|
|
26
|
-
"@webpieces/core-util": "0.4.
|
|
27
|
-
"@webpieces/gcp-identity": "0.4.
|
|
28
|
-
"@webpieces/http-client-core": "0.4.
|
|
25
|
+
"@webpieces/core-context": "0.4.702",
|
|
26
|
+
"@webpieces/core-util": "0.4.702",
|
|
27
|
+
"@webpieces/gcp-identity": "0.4.702",
|
|
28
|
+
"@webpieces/http-client-core": "0.4.702",
|
|
29
29
|
"inversify": "7.10.4",
|
|
30
30
|
"reflect-metadata": "0.2.2"
|
|
31
31
|
}
|
package/src/AddressResolver.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
}
|
package/src/AddressResolver.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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,
|
|
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"]}
|
package/src/ClientConfig.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
49
|
+
svcName: string);
|
|
56
50
|
}
|
package/src/ClientConfig.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
39
|
+
svcName) {
|
|
39
40
|
this.svcName = svcName;
|
|
40
|
-
this.hostPolicy = hostPolicy;
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
exports.ClientConfig = ClientConfig;
|
package/src/ClientConfig.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ClientConfig.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientConfig.ts"],"names":[],"mappings":";;;
|
|
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 {
|
|
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'
|
|
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'
|
|
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 —
|
|
28
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
54
|
-
* @param filters - This client's OUTBOUND filters, each with the priority it runs at
|
|
55
|
-
* OUTERMOST). They wrap the send, so a filter
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
73
|
+
createRpcClient<T extends object>(apiPrototype: ApiPrototype<T>, config: ClientConfig, filters?: ClientFilters): T;
|
|
62
74
|
}
|
package/src/ClientHttpFactory.js
CHANGED
|
@@ -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'
|
|
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'
|
|
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 —
|
|
35
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
|
63
|
-
* @param filters - This client's OUTBOUND filters, each with the priority it runs at
|
|
64
|
-
* OUTERMOST). They wrap the send, so a filter
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
};
|