@webpieces/http-client-node 0.4.878 → 0.4.880
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 +6 -2
- package/package.json +5 -5
- package/src/ClientConfig.d.ts +20 -1
- package/src/ClientConfig.js +12 -1
- package/src/ClientConfig.js.map +1 -1
- package/src/ClientHttpFactory.d.ts +3 -3
- package/src/ClientHttpFactory.js +3 -3
- package/src/ClientHttpFactory.js.map +1 -1
- package/src/ContextBaseUrlFilter.d.ts +1 -1
- package/src/ContextBaseUrlFilter.js +1 -1
- package/src/ContextBaseUrlFilter.js.map +1 -1
- package/src/ContextFullUrlFilter.d.ts +1 -1
- package/src/ContextFullUrlFilter.js +1 -1
- package/src/ContextFullUrlFilter.js.map +1 -1
- package/src/CreateRpcClientCompileAssertions.js +5 -5
- package/src/CreateRpcClientCompileAssertions.js.map +1 -1
- package/src/NodeProxyClient.d.ts +2 -1
- package/src/NodeProxyClient.js +3 -0
- package/src/NodeProxyClient.js.map +1 -1
- package/src/index.d.ts +3 -2
- package/src/index.js +6 -3
- package/src/index.js.map +1 -1
package/README.md
CHANGED
|
@@ -5,10 +5,14 @@ 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(Server2Api, new ClientConfig('server2'));
|
|
8
|
+
const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', ClientRole.SERVER));
|
|
9
9
|
const res = await server2.fetchValue(req); // inside a RequestContext
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
- `ClientRole` is REQUIRED, with no default: `ClientRole.SERVER` for a server calling a server (a
|
|
13
|
+
401 it receives is its own credential bug, `ApiImplementationError`), `ClientRole.END_USER_CLIENT`
|
|
14
|
+
for a node process acting for a person, such as a remote MCP client (a 401 is
|
|
15
|
+
`ApiUnauthorizedError`, "log in again"). See `docs/portable-ipc-and-errors.md`.
|
|
12
16
|
- `svcName` becomes a URL through `ClientRegistry.resolve` — ONE chain, the same one the browser
|
|
13
17
|
client and Cloud Tasks run:
|
|
14
18
|
1. a registered mapping wins: `ClientRegistry.addMapping(svcName, port)` (localhost) or
|
|
@@ -78,7 +82,7 @@ export class PartnerWebhookApi {
|
|
|
78
82
|
deliver(envelope: WebhookEnvelope): Promise<DeliveryAck>;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
85
|
+
const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
82
86
|
new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
|
|
83
87
|
]);
|
|
84
88
|
|
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.880",
|
|
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.880",
|
|
26
|
+
"@webpieces/core-util": "0.4.880",
|
|
27
|
+
"@webpieces/gcp-identity": "0.4.880",
|
|
28
|
+
"@webpieces/http-client-core": "0.4.880",
|
|
29
29
|
"inversify": "7.10.4",
|
|
30
30
|
"reflect-metadata": "0.2.2"
|
|
31
31
|
}
|
package/src/ClientConfig.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ClientRole } from '@webpieces/core-util';
|
|
1
2
|
/**
|
|
2
3
|
* Per-client STATE for a server-side HTTP client — nothing else. A plain class; it extends nothing
|
|
3
4
|
* and is unrelated to the browser package's ClientConfig, because the two answer "what URL?"
|
|
@@ -32,6 +33,15 @@ export declare class ClientConfig {
|
|
|
32
33
|
* belongs — on the contract every caller of it shares, not on one construction site.
|
|
33
34
|
*/
|
|
34
35
|
readonly svcName: string;
|
|
36
|
+
/**
|
|
37
|
+
* WHO receives this client's responses — {@link ClientRole.SERVER} for a server calling
|
|
38
|
+
* another server, {@link ClientRole.END_USER_CLIENT} for a client acting for a person (a
|
|
39
|
+
* browser bundle, an Expo shell, a remote MCP client). REQUIRED, with no default (#1173): it
|
|
40
|
+
* decides what a received 401 means — "the user must log in again" for an end-user client,
|
|
41
|
+
* "this service's own credential is broken" (a 500 to its caller) for a server — and the
|
|
42
|
+
* right answer is opposite for the two, so no default could be right for both.
|
|
43
|
+
*/
|
|
44
|
+
readonly role: ClientRole;
|
|
35
45
|
constructor(
|
|
36
46
|
/**
|
|
37
47
|
* The service name, and the ONE thing that decides where this client points.
|
|
@@ -46,5 +56,14 @@ export declare class ClientConfig {
|
|
|
46
56
|
* `@externalSystem` tag instead, which is where the fact "this hop leaves our estate"
|
|
47
57
|
* belongs — on the contract every caller of it shares, not on one construction site.
|
|
48
58
|
*/
|
|
49
|
-
svcName: string
|
|
59
|
+
svcName: string,
|
|
60
|
+
/**
|
|
61
|
+
* WHO receives this client's responses — {@link ClientRole.SERVER} for a server calling
|
|
62
|
+
* another server, {@link ClientRole.END_USER_CLIENT} for a client acting for a person (a
|
|
63
|
+
* browser bundle, an Expo shell, a remote MCP client). REQUIRED, with no default (#1173): it
|
|
64
|
+
* decides what a received 401 means — "the user must log in again" for an end-user client,
|
|
65
|
+
* "this service's own credential is broken" (a 500 to its caller) for a server — and the
|
|
66
|
+
* right answer is opposite for the two, so no default could be right for both.
|
|
67
|
+
*/
|
|
68
|
+
role: ClientRole);
|
|
50
69
|
}
|
package/src/ClientConfig.js
CHANGED
|
@@ -22,6 +22,7 @@ exports.ClientConfig = void 0;
|
|
|
22
22
|
*/
|
|
23
23
|
class ClientConfig {
|
|
24
24
|
svcName;
|
|
25
|
+
role;
|
|
25
26
|
constructor(
|
|
26
27
|
/**
|
|
27
28
|
* The service name, and the ONE thing that decides where this client points.
|
|
@@ -36,8 +37,18 @@ class ClientConfig {
|
|
|
36
37
|
* `@externalSystem` tag instead, which is where the fact "this hop leaves our estate"
|
|
37
38
|
* belongs — on the contract every caller of it shares, not on one construction site.
|
|
38
39
|
*/
|
|
39
|
-
svcName
|
|
40
|
+
svcName,
|
|
41
|
+
/**
|
|
42
|
+
* WHO receives this client's responses — {@link ClientRole.SERVER} for a server calling
|
|
43
|
+
* another server, {@link ClientRole.END_USER_CLIENT} for a client acting for a person (a
|
|
44
|
+
* browser bundle, an Expo shell, a remote MCP client). REQUIRED, with no default (#1173): it
|
|
45
|
+
* decides what a received 401 means — "the user must log in again" for an end-user client,
|
|
46
|
+
* "this service's own credential is broken" (a 500 to its caller) for a server — and the
|
|
47
|
+
* right answer is opposite for the two, so no default could be right for both.
|
|
48
|
+
*/
|
|
49
|
+
role) {
|
|
40
50
|
this.svcName = svcName;
|
|
51
|
+
this.role = role;
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
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":";;;AAEA;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,YAAY;IAeD;IASA;IAvBpB;IACI;;;;;;;;;;;;OAYG;IACa,OAAe;IAC/B;;;;;;;OAOG;IACa,IAAgB;QAThB,YAAO,GAAP,OAAO,CAAQ;QASf,SAAI,GAAJ,IAAI,CAAY;IACjC,CAAC;CACP;AA1BD,oCA0BC","sourcesContent":["import { ClientRole } from '@webpieces/core-util';\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 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 * WHO receives this client's responses — {@link ClientRole.SERVER} for a server calling\n * another server, {@link ClientRole.END_USER_CLIENT} for a client acting for a person (a\n * browser bundle, an Expo shell, a remote MCP client). REQUIRED, with no default (#1173): it\n * decides what a received 401 means — \"the user must log in again\" for an end-user client,\n * \"this service's own credential is broken\" (a 500 to its caller) for a server — and the\n * right answer is opposite for the two, so no default could be right for both.\n */\n public readonly role: ClientRole,\n ) {}\n}\n"]}
|
|
@@ -13,12 +13,12 @@ import { NodeProxyClient } from './NodeProxyClient';
|
|
|
13
13
|
* Inject it and ask for a typed client per contract:
|
|
14
14
|
* ```typescript
|
|
15
15
|
* // same project + region as this container; the URL is derived, you maintain nothing
|
|
16
|
-
* const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));
|
|
16
|
+
* const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', ClientRole.SERVER));
|
|
17
17
|
*
|
|
18
18
|
* // to reach somewhere derivation cannot describe (other region/project, non-Cloud-Run, localhost),
|
|
19
19
|
* // register it once at startup — the client still carries only the svcName:
|
|
20
20
|
* // ClientRegistry.addUrlMapping('legacy', 'https://legacy.corp');
|
|
21
|
-
* const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy'));
|
|
21
|
+
* const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy', ClientRole.SERVER));
|
|
22
22
|
*
|
|
23
23
|
* const response = await server2.fetchValue(req); // inside a RequestContext
|
|
24
24
|
* ```
|
|
@@ -27,7 +27,7 @@ import { NodeProxyClient } from './NodeProxyClient';
|
|
|
27
27
|
* a different kind of client. Install `ContextBaseUrlFilter` on the one client that may be
|
|
28
28
|
* re-pointed, and set the URL per call:
|
|
29
29
|
* ```typescript
|
|
30
|
-
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
30
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
31
31
|
* new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
|
|
32
32
|
* ]);
|
|
33
33
|
* ```
|
package/src/ClientHttpFactory.js
CHANGED
|
@@ -21,12 +21,12 @@ 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', ClientRole.SERVER));
|
|
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', ClientRole.SERVER));
|
|
30
30
|
*
|
|
31
31
|
* const response = await server2.fetchValue(req); // inside a RequestContext
|
|
32
32
|
* ```
|
|
@@ -35,7 +35,7 @@ const NodeProxyClient_1 = require("./NodeProxyClient");
|
|
|
35
35
|
* a different kind of client. Install `ContextBaseUrlFilter` on the one client that may be
|
|
36
36
|
* re-pointed, and set the URL per call:
|
|
37
37
|
* ```typescript
|
|
38
|
-
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
38
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
39
39
|
* new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
|
|
40
40
|
* ]);
|
|
41
41
|
* ```
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ClientHttpFactory.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientHttpFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAA4E;AAC5E,0DAIiC;AAEjC,kEAA+D;AAE/D,uDAAgF;AAEhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGI,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAGL;IAFrB,YAEqB,mBAA8C;QAA9C,wBAAmB,GAAnB,mBAAmB,CAA2B;IAChE,CAAC;IAEJ;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,eAAe,CACX,YAA6B,EAC7B,MAAoB,EACpB,OAA2C;QAE3C,IAAA,gCAAoB,EAAC,YAAY,EAAE,mBAAmB,CAAC,CAAC;QACxD,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;AA1CY,8CAAiB;4BAAjB,iBAAiB;IAF7B,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CACG,uBAAQ;GAHzC,iBAAiB,CA0C7B","sourcesContent":["import { inject } from 'inversify';\nimport { assertNotInternalApi, DocumentDesign } from '@webpieces/core-util';\nimport {\n Provider,\n bindFrameworkProvider,\n provideFrameworkSingleton,\n} from '@webpieces/core-context';\nimport type { ApiPrototype, ClientFilterDefinition } 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 * 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 * `webhook(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)\n 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 * A PLAIN OPTIONAL ARRAY, deliberately. Omitting it, passing `[]`, and passing a list you\n * built up conditionally all mean the same thing and all compile — because the normal way\n * an app arrives at a filter list is to declare a `ClientFilterDefinition[]` and push to\n * it under `if`s, and the declared type of such a local can never be non-empty. A\n * non-empty tuple here would reject that ordinary code and force a cast at the call site.\n * `filters === undefined ? [] : [...filters]` below normalizes the two empty spellings to\n * one value before anything downstream sees them. Pinned in\n * {@link CreateRpcClientCompileAssertions}.\n */\n createRpcClient<T extends object>(\n apiPrototype: ApiPrototype<T>,\n config: ClientConfig,\n filters?: readonly ClientFilterDefinition[],\n ): T {\n assertNotInternalApi(apiPrototype, 'ClientHttpFactory');\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"]}
|
|
1
|
+
{"version":3,"file":"ClientHttpFactory.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientHttpFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAA4E;AAC5E,0DAIiC;AAEjC,kEAA+D;AAE/D,uDAAgF;AAEhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGI,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAGL;IAFrB,YAEqB,mBAA8C;QAA9C,wBAAmB,GAAnB,mBAAmB,CAA2B;IAChE,CAAC;IAEJ;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,eAAe,CACX,YAA6B,EAC7B,MAAoB,EACpB,OAA2C;QAE3C,IAAA,gCAAoB,EAAC,YAAY,EAAE,mBAAmB,CAAC,CAAC;QACxD,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;AA1CY,8CAAiB;4BAAjB,iBAAiB;IAF7B,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CACG,uBAAQ;GAHzC,iBAAiB,CA0C7B","sourcesContent":["import { inject } from 'inversify';\nimport { assertNotInternalApi, DocumentDesign } from '@webpieces/core-util';\nimport {\n Provider,\n bindFrameworkProvider,\n provideFrameworkSingleton,\n} from '@webpieces/core-context';\nimport type { ApiPrototype, ClientFilterDefinition } 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', ClientRole.SERVER));\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', ClientRole.SERVER));\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', ClientRole.SERVER), [\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 * `webhook(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)\n 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 * A PLAIN OPTIONAL ARRAY, deliberately. Omitting it, passing `[]`, and passing a list you\n * built up conditionally all mean the same thing and all compile — because the normal way\n * an app arrives at a filter list is to declare a `ClientFilterDefinition[]` and push to\n * it under `if`s, and the declared type of such a local can never be non-empty. A\n * non-empty tuple here would reject that ordinary code and force a cast at the call site.\n * `filters === undefined ? [] : [...filters]` below normalizes the two empty spellings to\n * one value before anything downstream sees them. Pinned in\n * {@link CreateRpcClientCompileAssertions}.\n */\n createRpcClient<T extends object>(\n apiPrototype: ApiPrototype<T>,\n config: ClientConfig,\n filters?: readonly ClientFilterDefinition[],\n ): T {\n assertNotInternalApi(apiPrototype, 'ClientHttpFactory');\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"]}
|
|
@@ -8,7 +8,7 @@ import { SsrfPolicy } from './SsrfPolicy';
|
|
|
8
8
|
* implements OUR contract at their own base URL:
|
|
9
9
|
*
|
|
10
10
|
* ```ts
|
|
11
|
-
* const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc'), [
|
|
11
|
+
* const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc', ClientRole.SERVER), [
|
|
12
12
|
* new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
|
|
13
13
|
* ]);
|
|
14
14
|
*
|
|
@@ -12,7 +12,7 @@ const SsrfPolicy_1 = require("./SsrfPolicy");
|
|
|
12
12
|
* implements OUR contract at their own base URL:
|
|
13
13
|
*
|
|
14
14
|
* ```ts
|
|
15
|
-
* const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc'), [
|
|
15
|
+
* const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc', ClientRole.SERVER), [
|
|
16
16
|
* new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
|
|
17
17
|
* ]);
|
|
18
18
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContextBaseUrlFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ContextBaseUrlFilter.ts"],"names":[],"mappings":";;;AAAA,oDAA6E;AAC7E,0DAAyD;AAEzD,6EAA0E;AAC1E,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH,MAAa,oBAAqB,SAAQ,kBAA+B;IASxD;IARb;IACI;;;;;;OAMG;IACM,aAAyB,IAAI,uBAAU,EAAE;QAElD,KAAK,EAAE,CAAC;QAFC,eAAU,GAAV,UAAU,CAA+B;IAGtD,CAAC;IAEQ,KAAK,CAAC,MAAM,CAAC,OAAsB,EAAE,UAA4C;QACtF,MAAM,QAAQ,GAAG,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,iBAAiB,CAAC,CAAC;QACrF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,uDAA0B,CAChC,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,0CAA0C;gBACzF,oDAAoD;gBACpD,wFAAwF;gBACxF,aAAa;gBACb,yFAAyF;gBACzF,+EAA+E;gBAC/E,0FAA0F,EAC9F,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CACxD,CAAC;QACN,CAAC;QACD,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;CACJ;AA/BD,oDA+BC","sourcesContent":["import { Filter, Service, WebpiecesCoreHeaders } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\nimport { SsrfPolicy } from './SsrfPolicy';\n\n/**\n * Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points\n * THIS ONE CALL at that HOST, KEEPING this route's path: the url sent is\n * `override + @ApiPath + @Endpoint`. That is the shape for a tenant or self-hosted deployment that\n * implements OUR contract at their own base URL:\n *\n * ```ts\n * const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc'), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n *\n * await RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, tenantRow.baseUrl);\n * return tenant.sync(request);\n * });\n * ```\n *\n * ## A partner webhook url is NOT a base URL — use ContextFullUrlFilter\n *\n * A url a partner registered for webhooks is usually a COMPLETE destination\n * (`https://hooks.partner.example/in/abc?token=xyz`). Appending the contract's path to it produces\n * the wrong url. For that, install `ContextFullUrlFilter` and set\n * `WebpiecesCoreHeaders.OVERRIDE_FULL_URL`: the stored url is sent byte for byte.\n *\n * ## INSTALLING IT IS THE OPT-IN\n *\n * There is no client-level setting saying \"this one may be re-pointed\", because installing this\n * filter IS that statement, written at the one place a reader looks. A client with no\n * `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a\n * value set for a partner delivery from silently re-pointing every other client in the same\n * request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a\n * codebase that can be re-pointed at all, which is the question a security review actually asks.\n *\n * ## PER-ENDPOINT, if a contract mixes them\n *\n * `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can\n * re-point some endpoints of a contract and leave the rest on the configured host, with no API\n * change:\n *\n * ```ts\n * class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {\n * override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {\n * if (request.route.methodName !== 'deliver') return next.invoke(request);\n * return super.filter(request, next);\n * }\n * }\n * ```\n *\n * ## Scope, and why it cannot leak\n *\n * It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the\n * next call through the same client starts from its configured host again; and the context entry is\n * scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets\n * a different URL per partner gets exactly the URL it set, per iteration.\n *\n * ## The SSRF guard is NOT registered here\n *\n * Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips\n * `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads\n * that. So this filter cannot forget to bring the guard along, and an app cannot install this one\n * without it. The only thing this class carries is WHICH policy the guard applies, and only\n * because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same\n * construction site as the decision to be re-pointable at all.\n */\nexport class ContextBaseUrlFilter extends Filter<ClientRequest, Response> {\n constructor(\n /**\n * What the framework's SSRF guard holds this client's re-pointed URLs to.\n *\n * Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the\n * omitted argument can never be the permissive one — the widening has to be typed out, with\n * a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.\n */\n readonly ssrfPolicy: SsrfPolicy = new SsrfPolicy(),\n ) {\n super();\n }\n\n override async filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response> {\n const override = RequestContext.getUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL);\n if (override === undefined || override === '') {\n throw new MissingRuntimeBaseUrlError(\n `${request.contractName}.${request.route.methodName} runs behind a ContextBaseUrlFilter, so ` +\n `its destination must be supplied per call, but no ` +\n `WebpiecesCoreHeaders.OVERRIDE_BASE_URL was found in the RequestContext. Set it around ` +\n `the call:\\n` +\n ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\\n` +\n `Refusing rather than falling back to this client's configured service URL is ` +\n `deliberate: a silent fallback would send a partner's payload to one of our own services.`,\n `${request.contractName}.${request.route.methodName}`,\n );\n }\n request.pointAtBaseUrl(override);\n return nextFilter.invoke(request);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ContextBaseUrlFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ContextBaseUrlFilter.ts"],"names":[],"mappings":";;;AAAA,oDAA6E;AAC7E,0DAAyD;AAEzD,6EAA0E;AAC1E,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH,MAAa,oBAAqB,SAAQ,kBAA+B;IASxD;IARb;IACI;;;;;;OAMG;IACM,aAAyB,IAAI,uBAAU,EAAE;QAElD,KAAK,EAAE,CAAC;QAFC,eAAU,GAAV,UAAU,CAA+B;IAGtD,CAAC;IAEQ,KAAK,CAAC,MAAM,CAAC,OAAsB,EAAE,UAA4C;QACtF,MAAM,QAAQ,GAAG,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,iBAAiB,CAAC,CAAC;QACrF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,uDAA0B,CAChC,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,0CAA0C;gBACzF,oDAAoD;gBACpD,wFAAwF;gBACxF,aAAa;gBACb,yFAAyF;gBACzF,+EAA+E;gBAC/E,0FAA0F,EAC9F,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CACxD,CAAC;QACN,CAAC;QACD,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;CACJ;AA/BD,oDA+BC","sourcesContent":["import { Filter, Service, WebpiecesCoreHeaders } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\nimport { SsrfPolicy } from './SsrfPolicy';\n\n/**\n * Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points\n * THIS ONE CALL at that HOST, KEEPING this route's path: the url sent is\n * `override + @ApiPath + @Endpoint`. That is the shape for a tenant or self-hosted deployment that\n * implements OUR contract at their own base URL:\n *\n * ```ts\n * const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc', ClientRole.SERVER), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n *\n * await RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, tenantRow.baseUrl);\n * return tenant.sync(request);\n * });\n * ```\n *\n * ## A partner webhook url is NOT a base URL — use ContextFullUrlFilter\n *\n * A url a partner registered for webhooks is usually a COMPLETE destination\n * (`https://hooks.partner.example/in/abc?token=xyz`). Appending the contract's path to it produces\n * the wrong url. For that, install `ContextFullUrlFilter` and set\n * `WebpiecesCoreHeaders.OVERRIDE_FULL_URL`: the stored url is sent byte for byte.\n *\n * ## INSTALLING IT IS THE OPT-IN\n *\n * There is no client-level setting saying \"this one may be re-pointed\", because installing this\n * filter IS that statement, written at the one place a reader looks. A client with no\n * `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a\n * value set for a partner delivery from silently re-pointing every other client in the same\n * request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a\n * codebase that can be re-pointed at all, which is the question a security review actually asks.\n *\n * ## PER-ENDPOINT, if a contract mixes them\n *\n * `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can\n * re-point some endpoints of a contract and leave the rest on the configured host, with no API\n * change:\n *\n * ```ts\n * class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {\n * override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {\n * if (request.route.methodName !== 'deliver') return next.invoke(request);\n * return super.filter(request, next);\n * }\n * }\n * ```\n *\n * ## Scope, and why it cannot leak\n *\n * It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the\n * next call through the same client starts from its configured host again; and the context entry is\n * scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets\n * a different URL per partner gets exactly the URL it set, per iteration.\n *\n * ## The SSRF guard is NOT registered here\n *\n * Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips\n * `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads\n * that. So this filter cannot forget to bring the guard along, and an app cannot install this one\n * without it. The only thing this class carries is WHICH policy the guard applies, and only\n * because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same\n * construction site as the decision to be re-pointable at all.\n */\nexport class ContextBaseUrlFilter extends Filter<ClientRequest, Response> {\n constructor(\n /**\n * What the framework's SSRF guard holds this client's re-pointed URLs to.\n *\n * Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the\n * omitted argument can never be the permissive one — the widening has to be typed out, with\n * a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.\n */\n readonly ssrfPolicy: SsrfPolicy = new SsrfPolicy(),\n ) {\n super();\n }\n\n override async filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response> {\n const override = RequestContext.getUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL);\n if (override === undefined || override === '') {\n throw new MissingRuntimeBaseUrlError(\n `${request.contractName}.${request.route.methodName} runs behind a ContextBaseUrlFilter, so ` +\n `its destination must be supplied per call, but no ` +\n `WebpiecesCoreHeaders.OVERRIDE_BASE_URL was found in the RequestContext. Set it around ` +\n `the call:\\n` +\n ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\\n` +\n `Refusing rather than falling back to this client's configured service URL is ` +\n `deliberate: a silent fallback would send a partner's payload to one of our own services.`,\n `${request.contractName}.${request.route.methodName}`,\n );\n }\n request.pointAtBaseUrl(override);\n return nextFilter.invoke(request);\n }\n}\n"]}
|
|
@@ -8,7 +8,7 @@ import { SsrfPolicy } from './SsrfPolicy';
|
|
|
8
8
|
* gave us one opaque url (a database row) rather than implementing our contract at their host:
|
|
9
9
|
*
|
|
10
10
|
* ```ts
|
|
11
|
-
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
11
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
12
12
|
* new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
|
|
13
13
|
* ]);
|
|
14
14
|
*
|
|
@@ -12,7 +12,7 @@ const SsrfPolicy_1 = require("./SsrfPolicy");
|
|
|
12
12
|
* gave us one opaque url (a database row) rather than implementing our contract at their host:
|
|
13
13
|
*
|
|
14
14
|
* ```ts
|
|
15
|
-
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
15
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
16
16
|
* new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
|
|
17
17
|
* ]);
|
|
18
18
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContextFullUrlFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ContextFullUrlFilter.ts"],"names":[],"mappings":";;;AAAA,oDAA6E;AAC7E,0DAAyD;AAEzD,6EAA0E;AAC1E,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAa,oBAAqB,SAAQ,kBAA+B;IAOxD;IANb;IACI;;;;OAIG;IACM,aAAyB,IAAI,uBAAU,EAAE;QAElD,KAAK,EAAE,CAAC;QAFC,eAAU,GAAV,UAAU,CAA+B;IAGtD,CAAC;IAEQ,KAAK,CAAC,MAAM,CACjB,OAAsB,EACtB,UAA4C;QAE5C,MAAM,QAAQ,GAAG,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,iBAAiB,CAAC,CAAC;QACrF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,uDAA0B,CAChC,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,0CAA0C;gBACzF,iEAAiE;gBACjE,wFAAwF;gBACxF,aAAa;gBACb,yFAAyF;gBACzF,+EAA+E;gBAC/E,0FAA0F,EAC9F,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CACxD,CAAC;QACN,CAAC;QACD,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;CACJ;AAhCD,oDAgCC","sourcesContent":["import { Filter, Service, WebpiecesCoreHeaders } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\nimport { SsrfPolicy } from './SsrfPolicy';\n\n/**\n * Reads {@link WebpiecesCoreHeaders.OVERRIDE_FULL_URL} out of the ambient RequestContext and sends\n * THIS ONE CALL to that url, VERBATIM — host, path and query exactly as stored, with the contract's\n * own path NOT appended. This is the filter for the common partner-webhook shape, where the partner\n * gave us one opaque url (a database row) rather than implementing our contract at their host:\n *\n * ```ts\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),\n * ]);\n *\n * for (const webhook of webhooks) {\n * await RequestContext.run(() => {\n * // e.g. 'https://hooks.partner.example/in/abc?token=xyz' — sent byte for byte\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\n * return partner.deliver(envelope);\n * });\n * }\n * ```\n *\n * The contract can therefore declare an honest path (`@ApiPath('/webhook')` + `@Endpoint(POST, '/deliver', ...)`)\n * for documentation and the architecture graph, knowing it is replaced per call.\n *\n * ## Why a SEPARATE filter from ContextBaseUrlFilter\n *\n * The two answer different questions. `ContextBaseUrlFilter` swaps the HOST and keeps this route's\n * path (a tenant running our contract at their own server); this one swaps the WHOLE url. A flag on\n * one filter would be two behaviours behind one name, and `grep -rn ContextFullUrlFilter` would no\n * longer list exactly the clients whose destination is a stored url.\n *\n * ## Everything else is ContextBaseUrlFilter's contract, unchanged\n *\n * - **Installing it is the opt-in.** A client without it ignores an ambient `OVERRIDE_FULL_URL`.\n * - **It refuses rather than falls back.** No override in scope throws\n * {@link MissingRuntimeBaseUrlError}; it never sends to the client's configured service URL,\n * which would deliver a partner's payload to one of our own services.\n * - **It cannot leak.** Only the per-call {@link ClientRequest} is mutated.\n * - **The SSRF guard is armed by the act of re-pointing.** `ClientRequest.pointAtFullUrl` flips\n * `destinationCameFromData`; this class carries only WHICH policy applies, so the one relaxation\n * (`SsrfTestingPolicy`) is named at the same construction site as the decision to be re-pointable.\n */\nexport class ContextFullUrlFilter extends Filter<ClientRequest, Response> {\n constructor(\n /**\n * What the framework's SSRF guard holds this client's re-pointed URLs to. Defaulted to the\n * strict {@link SsrfPolicy}, so the omitted argument is never the permissive one — widening\n * has to be typed out as `new ContextFullUrlFilter(new SsrfTestingPolicy('<why>'))`.\n */\n readonly ssrfPolicy: SsrfPolicy = new SsrfPolicy(),\n ) {\n super();\n }\n\n override async filter(\n request: ClientRequest,\n nextFilter: Service<ClientRequest, Response>,\n ): Promise<Response> {\n const override = RequestContext.getUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL);\n if (override === undefined || override === '') {\n throw new MissingRuntimeBaseUrlError(\n `${request.contractName}.${request.route.methodName} runs behind a ContextFullUrlFilter, so ` +\n `its complete destination url must be supplied per call, but no ` +\n `WebpiecesCoreHeaders.OVERRIDE_FULL_URL was found in the RequestContext. Set it around ` +\n `the call:\\n` +\n ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\\n` +\n `Refusing rather than falling back to this client's configured service URL is ` +\n `deliberate: a silent fallback would send a partner's payload to one of our own services.`,\n `${request.contractName}.${request.route.methodName}`,\n );\n }\n request.pointAtFullUrl(override);\n return nextFilter.invoke(request);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ContextFullUrlFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ContextFullUrlFilter.ts"],"names":[],"mappings":";;;AAAA,oDAA6E;AAC7E,0DAAyD;AAEzD,6EAA0E;AAC1E,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAa,oBAAqB,SAAQ,kBAA+B;IAOxD;IANb;IACI;;;;OAIG;IACM,aAAyB,IAAI,uBAAU,EAAE;QAElD,KAAK,EAAE,CAAC;QAFC,eAAU,GAAV,UAAU,CAA+B;IAGtD,CAAC;IAEQ,KAAK,CAAC,MAAM,CACjB,OAAsB,EACtB,UAA4C;QAE5C,MAAM,QAAQ,GAAG,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,iBAAiB,CAAC,CAAC;QACrF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,uDAA0B,CAChC,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,0CAA0C;gBACzF,iEAAiE;gBACjE,wFAAwF;gBACxF,aAAa;gBACb,yFAAyF;gBACzF,+EAA+E;gBAC/E,0FAA0F,EAC9F,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CACxD,CAAC;QACN,CAAC;QACD,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;CACJ;AAhCD,oDAgCC","sourcesContent":["import { Filter, Service, WebpiecesCoreHeaders } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\nimport { SsrfPolicy } from './SsrfPolicy';\n\n/**\n * Reads {@link WebpiecesCoreHeaders.OVERRIDE_FULL_URL} out of the ambient RequestContext and sends\n * THIS ONE CALL to that url, VERBATIM — host, path and query exactly as stored, with the contract's\n * own path NOT appended. This is the filter for the common partner-webhook shape, where the partner\n * gave us one opaque url (a database row) rather than implementing our contract at their host:\n *\n * ```ts\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [\n * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),\n * ]);\n *\n * for (const webhook of webhooks) {\n * await RequestContext.run(() => {\n * // e.g. 'https://hooks.partner.example/in/abc?token=xyz' — sent byte for byte\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\n * return partner.deliver(envelope);\n * });\n * }\n * ```\n *\n * The contract can therefore declare an honest path (`@ApiPath('/webhook')` + `@Endpoint(POST, '/deliver', ...)`)\n * for documentation and the architecture graph, knowing it is replaced per call.\n *\n * ## Why a SEPARATE filter from ContextBaseUrlFilter\n *\n * The two answer different questions. `ContextBaseUrlFilter` swaps the HOST and keeps this route's\n * path (a tenant running our contract at their own server); this one swaps the WHOLE url. A flag on\n * one filter would be two behaviours behind one name, and `grep -rn ContextFullUrlFilter` would no\n * longer list exactly the clients whose destination is a stored url.\n *\n * ## Everything else is ContextBaseUrlFilter's contract, unchanged\n *\n * - **Installing it is the opt-in.** A client without it ignores an ambient `OVERRIDE_FULL_URL`.\n * - **It refuses rather than falls back.** No override in scope throws\n * {@link MissingRuntimeBaseUrlError}; it never sends to the client's configured service URL,\n * which would deliver a partner's payload to one of our own services.\n * - **It cannot leak.** Only the per-call {@link ClientRequest} is mutated.\n * - **The SSRF guard is armed by the act of re-pointing.** `ClientRequest.pointAtFullUrl` flips\n * `destinationCameFromData`; this class carries only WHICH policy applies, so the one relaxation\n * (`SsrfTestingPolicy`) is named at the same construction site as the decision to be re-pointable.\n */\nexport class ContextFullUrlFilter extends Filter<ClientRequest, Response> {\n constructor(\n /**\n * What the framework's SSRF guard holds this client's re-pointed URLs to. Defaulted to the\n * strict {@link SsrfPolicy}, so the omitted argument is never the permissive one — widening\n * has to be typed out as `new ContextFullUrlFilter(new SsrfTestingPolicy('<why>'))`.\n */\n readonly ssrfPolicy: SsrfPolicy = new SsrfPolicy(),\n ) {\n super();\n }\n\n override async filter(\n request: ClientRequest,\n nextFilter: Service<ClientRequest, Response>,\n ): Promise<Response> {\n const override = RequestContext.getUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL);\n if (override === undefined || override === '') {\n throw new MissingRuntimeBaseUrlError(\n `${request.contractName}.${request.route.methodName} runs behind a ContextFullUrlFilter, so ` +\n `its complete destination url must be supplied per call, but no ` +\n `WebpiecesCoreHeaders.OVERRIDE_FULL_URL was found in the RequestContext. Set it around ` +\n `the call:\\n` +\n ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\\n` +\n `Refusing rather than falling back to this client's configured service URL is ` +\n `deliberate: a silent fallback would send a partner's payload to one of our own services.`,\n `${request.contractName}.${request.route.methodName}`,\n );\n }\n request.pointAtFullUrl(override);\n return nextFilter.invoke(request);\n }\n}\n"]}
|
|
@@ -33,21 +33,21 @@ class OutboundLogFilter extends core_util_1.Filter {
|
|
|
33
33
|
class CreateRpcClientCompileAssertions {
|
|
34
34
|
/** No app filters, said by omitting the argument. */
|
|
35
35
|
noFilters() {
|
|
36
|
-
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('server2'));
|
|
36
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('server2', core_util_1.ClientRole.SERVER));
|
|
37
37
|
}
|
|
38
38
|
/** No app filters, said with an empty array — the same thing, normalized to the same value. */
|
|
39
39
|
emptyArray() {
|
|
40
|
-
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('server2'), []);
|
|
40
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('server2', core_util_1.ClientRole.SERVER), []);
|
|
41
41
|
}
|
|
42
42
|
/** One app filter. */
|
|
43
43
|
oneFilter() {
|
|
44
|
-
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks'), [
|
|
44
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks', core_util_1.ClientRole.SERVER), [
|
|
45
45
|
new http_client_core_1.ClientFilterDefinition(1000, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
|
|
46
46
|
]);
|
|
47
47
|
}
|
|
48
48
|
/** Several app filters. */
|
|
49
49
|
severalFilters() {
|
|
50
|
-
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks'), [
|
|
50
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks', core_util_1.ClientRole.SERVER), [
|
|
51
51
|
new http_client_core_1.ClientFilterDefinition(1000, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
|
|
52
52
|
new http_client_core_1.ClientFilterDefinition(500, new ContextFullUrlFilter_1.ContextFullUrlFilter()),
|
|
53
53
|
]);
|
|
@@ -63,7 +63,7 @@ class CreateRpcClientCompileAssertions {
|
|
|
63
63
|
filters.push(new http_client_core_1.ClientFilterDefinition(1000, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()));
|
|
64
64
|
if (verbose)
|
|
65
65
|
filters.push(new http_client_core_1.ClientFilterDefinition(500, new OutboundLogFilter()));
|
|
66
|
-
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks'), filters);
|
|
66
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks', core_util_1.ClientRole.SERVER), filters);
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
void CreateRpcClientCompileAssertions;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CreateRpcClientCompileAssertions.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/CreateRpcClientCompileAssertions.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;GAkBG;;AAEH,
|
|
1
|
+
{"version":3,"file":"CreateRpcClientCompileAssertions.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/CreateRpcClientCompileAssertions.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;GAkBG;;AAEH,oDAAmE;AACnE,kEAAoF;AACpF,iDAA8C;AAE9C,iEAA8D;AAC9D,iEAA8D;AAQ9D,iGAAiG;AACjG,MAAM,iBAAkB,SAAQ,kBAA+B;IAClD,MAAM,CAAC,OAAsB,EAAE,IAAsC;QAC1E,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAChC,CAAC;CACJ;AAED,MAAM,gCAAgC;IAClC,qDAAqD;IACrD,SAAS;QACL,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,EAAE,sBAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IACrF,CAAC;IAED,+FAA+F;IAC/F,UAAU;QACN,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,EAAE,sBAAU,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;IACzF,CAAC;IAED,sBAAsB;IACtB,SAAS;QACL,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,EAAE,sBAAU,CAAC,MAAM,CAAC,EAAE;YACtF,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC;SAC/D,CAAC,CAAC;IACP,CAAC;IAED,2BAA2B;IAC3B,cAAc;QACV,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,EAAE,sBAAU,CAAC,MAAM,CAAC,EAAE;YACtF,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC;YAC5D,IAAI,yCAAsB,CAAC,GAAG,EAAE,IAAI,2CAAoB,EAAE,CAAC;SAC9D,CAAC,CAAC;IACP,CAAC;IAED;;;;OAIG;IACH,sBAAsB;QAClB,MAAM,OAAO,GAA6B,EAAE,CAAC;QAC7C,IAAI,SAAS;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC,CAAC,CAAC;QAC1F,IAAI,OAAO;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,yCAAsB,CAAC,GAAG,EAAE,IAAI,iBAAiB,EAAE,CAAC,CAAC,CAAC;QACpF,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,EAAE,sBAAU,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;IACvG,CAAC;CACJ;AAED,KAAK,gCAAgC,CAAC","sourcesContent":["/**\n * COMPILE-TIME assertions that `createRpcClient`'s filters argument is GENUINELY optional.\n *\n * The argument is `readonly ClientFilterDefinition[] | undefined`, so all three ways an app says\n * \"here are this client's filters\" compile: omit it, pass `[]`, or pass a mutable\n * `ClientFilterDefinition[]` that was declared empty and pushed to under `if`s. That last one is the\n * whole reason this file exists — it is how a real app builds a filter list once the list is not a\n * hardcoded literal, and a non-empty tuple parameter rejects it, because the declared type of a\n * conditionally-populated local cannot be non-empty. The cure would be a cast at every call site,\n * which is worse than the case the tuple was protecting against.\n *\n * WHY THIS FILE AND NOT A SPEC. `tsconfig.lib.json` EXCLUDES `*.spec.ts`, and vitest strips types\n * with esbuild rather than checking them — so a type-level assertion in a spec is inert and the\n * suite passes whether or not the signature still admits these calls. Here it is compiled by the\n * build: if the signature is ever re-narrowed, tsc fails on the line below that stops compiling.\n * That failure IS the test. Mirrors `core-util/src/http/AuthJwtCompileAssertions.ts`.\n *\n * Nothing here runs. The class is never constructed and never exported from the barrel.\n */\n\nimport { ClientRole, Filter, Service } from '@webpieces/core-util';\nimport { ClientFilterDefinition, ClientRequest } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\nimport { ClientHttpFactory } from './ClientHttpFactory';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nimport { ContextFullUrlFilter } from './ContextFullUrlFilter';\n\n/** Stand-in for a real contract; only its TYPE is used, and only by tsc. */\ndeclare const someApi: Parameters<ClientHttpFactory['createRpcClient']>[0];\ndeclare const factory: ClientHttpFactory;\ndeclare const perTenant: boolean;\ndeclare const verbose: boolean;\n\n/** A second app filter, so the conditional-build case below has two branches like a real one. */\nclass OutboundLogFilter extends Filter<ClientRequest, Response> {\n override filter(request: ClientRequest, next: Service<ClientRequest, Response>): Promise<Response> {\n return next.invoke(request);\n }\n}\n\nclass CreateRpcClientCompileAssertions {\n /** No app filters, said by omitting the argument. */\n noFilters(): void {\n factory.createRpcClient(someApi, new ClientConfig('server2', ClientRole.SERVER));\n }\n\n /** No app filters, said with an empty array — the same thing, normalized to the same value. */\n emptyArray(): void {\n factory.createRpcClient(someApi, new ClientConfig('server2', ClientRole.SERVER), []);\n }\n\n /** One app filter. */\n oneFilter(): void {\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [\n new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n ]);\n }\n\n /** Several app filters. */\n severalFilters(): void {\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [\n new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n new ClientFilterDefinition(500, new ContextFullUrlFilter()),\n ]);\n }\n\n /**\n * THE CASE THIS CHANGE EXISTS FOR: a MUTABLE `ClientFilterDefinition[]`, declared empty and\n * pushed to conditionally. Its declared type is not, and cannot be, non-empty; a mutable array\n * is assignable to the `readonly` parameter, so this compiles with no cast.\n */\n conditionallyBuiltList(): void {\n const filters: ClientFilterDefinition[] = [];\n if (perTenant) filters.push(new ClientFilterDefinition(1000, new ContextBaseUrlFilter()));\n if (verbose) filters.push(new ClientFilterDefinition(500, new OutboundLogFilter()));\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), filters);\n }\n}\n\nvoid CreateRpcClientCompileAssertions;\n"]}
|
package/src/NodeProxyClient.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DestinationTrust, RouteMetadata, Secrets } from '@webpieces/core-util';
|
|
1
|
+
import { DestinationTrust, RouteMetadata, Secrets, ClientRole } from '@webpieces/core-util';
|
|
2
2
|
import { RequestContextHeaders } from '@webpieces/core-context';
|
|
3
3
|
import { GcpOidc } from '@webpieces/gcp-identity';
|
|
4
4
|
import { ApiPrototype, ByteReadableStream, ClientRequest, ClientFilterDefinition, ProxyClient } from '@webpieces/http-client-core';
|
|
@@ -35,6 +35,7 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
35
35
|
* Resolved per call, never at construction, so building a client stays synchronous. Any metadata
|
|
36
36
|
* read beneath a deriver is memoized process-wide, so only the first call pays.
|
|
37
37
|
*/
|
|
38
|
+
protected clientRole(): ClientRole;
|
|
38
39
|
protected resolveBaseUrl(): Promise<string>;
|
|
39
40
|
/** Node/undici supports a live ReadableStream body when fetch is given `duplex: 'half'`. */
|
|
40
41
|
protected supportsConcurrentDuplexFetch(): boolean;
|
package/src/NodeProxyClient.js
CHANGED
|
@@ -82,6 +82,9 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
82
82
|
* Resolved per call, never at construction, so building a client stays synchronous. Any metadata
|
|
83
83
|
* read beneath a deriver is memoized process-wide, so only the first call pays.
|
|
84
84
|
*/
|
|
85
|
+
clientRole() {
|
|
86
|
+
return this.config.role;
|
|
87
|
+
}
|
|
85
88
|
resolveBaseUrl() {
|
|
86
89
|
return core_util_1.ClientRegistry.resolve(this.config.svcName);
|
|
87
90
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NodeProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/NodeProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject, optional } from 'inversify';\nimport { once } from 'node:events';\nimport { request as httpRequest, IncomingMessage } from 'node:http';\nimport { request as httpsRequest } from 'node:https';\nimport { Readable } from 'node:stream';\nimport {\n ClientRegistry,\n DestinationTrust,\n ApiImplementationError,\n RecordedEndpoint,\n RecordedError,\n LogApiCallImpl,\n RouteMetadata,\n Secrets,\n SECRETS,\n TestCaseRecorder,\n toError,\n} from '@webpieces/core-util';\nimport {\n RequestContext,\n RequestContextApiCallContext,\n RequestContextHeaders,\n provideFrameworkTransient,\n} from '@webpieces/core-context';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport {\n ApiPrototype,\n ByteReadableStream,\n ClientRequest,\n ClientFilterDefinition,\n ProxyClient,\n} from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { ClientConfig } from './ClientConfig';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nimport { ContextFullUrlFilter } from './ContextFullUrlFilter';\nimport { OutboundAuthFilter } from './OutboundAuthFilter';\nimport { SsrfGuardFilter } from './SsrfGuardFilter';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { WEBHOOK_SIGNER_CALLBACK, WebhookSignerCallback } from './WebhookSignerCallback';\n\n/**\n * The two framework built-ins' priorities, RELATIVE TO EACH OTHER and to nothing else — they are\n * ordered beneath every app filter structurally, not by number (see `ProxyClient.initRoutes`).\n *\n * The guard is OUTSIDE the minter deliberately: a destination that is going to be refused must be\n * refused BEFORE a credential is minted for it, so a hostile URL never causes a token to exist.\n */\nconst SSRF_GUARD_PRIORITY = 900;\nconst OUTBOUND_AUTH_PRIORITY = 800;\n\n/**\n * The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the\n * ambient RequestContext, minting OIDC tokens, holding shared secrets, and recording test cases.\n *\n * TRANSIENT on purpose. Every `createRpcClient(api, config)` needs its own instance, because `init()`\n * binds one instance to exactly one API contract and one target. {@link ProxyClientProvider} hands\n * them out — see its doc.\n */\n@provideFrameworkTransient()\nexport class NodeProxyClient extends ProxyClient {\n protected override selectAuthMethod(route: import('@webpieces/core-util').RouteMetadata): import('@webpieces/core-util').AuthMode {\n const methods = route.authMeta?.methods;\n if (!methods) throw new Error('Endpoint requires @WpAuth or @WpAuthPublic.');\n const selected = methods.find((method: import('@webpieces/core-util').AuthMode) =>\n method.kind === 'oidc' || method.kind === 'public' ||\n (method.kind === 'shared-secret' && !!this.secrets?.get(method.secretKey)) ||\n (method.kind === 'webhook' && !!this.webhookSigner));\n if (selected) return selected;\n if (methods.length === 1) return methods[0];\n throw new Error('No configured Node client credential can satisfy this endpoint; bind a declared service credential.');\n }\n private config!: ClientConfig;\n\n constructor(\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(RequestContextHeaders) private readonly headers: RequestContextHeaders,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(GcpOidc) private readonly gcpOidc: GcpOidc,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(AddressResolver) private readonly addressResolver: AddressResolver,\n // @optional: only sharedSecret(...) endpoints need it; the client sends its bound value.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional() @inject(SECRETS) private readonly secrets?: Secrets,\n // @optional: only webhook(...) endpoints need it, and an unbound one makes them THROW\n // rather than deliver unsigned — see WebhookSignerCallback.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional()\n @inject(WEBHOOK_SIGNER_CALLBACK)\n private readonly webhookSigner?: WebhookSignerCallback,\n ) {\n // This package is node-only and already depends on core-context, so it builds the\n // RequestContext-backed ApiCallContext itself. No startup install, and therefore nothing a\n // non-webpieces host (plain NestJS/Express) can forget.\n super(new LogApiCallImpl(new RequestContextApiCallContext()));\n }\n\n /**\n * Bind this client to one API contract + target, with the app's outbound filters (url\n * rewriting, header editing, logging, per-call re-pointing).\n */\n init(\n apiPrototype: ApiPrototype<object>,\n config: ClientConfig,\n appFilters: ClientFilterDefinition[],\n ): void {\n this.config = config;\n this.initRoutes(apiPrototype, appFilters);\n }\n\n /**\n * The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but\n * with NODE's fallback: THROW. A server has no \"own origin\" to fall back to the way a browser\n * does, so an unresolvable peer is a setup bug and must fail loudly (the error names the fixes).\n *\n * Resolved per call, never at construction, so building a client stays synchronous. Any metadata\n * read beneath a deriver is memoized process-wide, so only the first call pays.\n */\n protected override resolveBaseUrl(): Promise<string> {\n return ClientRegistry.resolve(this.config.svcName);\n }\n\n /** Node/undici supports a live ReadableStream body when fetch is given `duplex: 'half'`. */\n protected override supportsConcurrentDuplexFetch(): boolean {\n return true;\n }\n\n /**\n * Node's native HTTP stream is genuinely concurrent: unlike browser Fetch, an early response\n * does not cancel the still-open upload. The returned Web Response keeps parsing isomorphic.\n */\n protected override sendStreamingTransport(\n request: ClientRequest,\n signal: AbortSignal,\n body: ByteReadableStream,\n ): Promise<Response> {\n return new Promise<Response>(\n (resolve: (response: Response) => void, reject: (error: Error) => void) => {\n const url = new URL(request.url);\n const send = url.protocol === 'https:' ? httpsRequest : httpRequest;\n const outgoing = send(\n url,\n {\n method: 'POST',\n headers: request.headersAsRecord(),\n signal,\n },\n (incoming: IncomingMessage): void => {\n const headers = new Headers();\n for (const [name, value] of Object.entries(incoming.headers)) {\n if (Array.isArray(value)) {\n for (const item of value) headers.append(name, item);\n } else if (value !== undefined) {\n headers.set(name, value);\n }\n }\n const responseBody = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;\n resolve(\n new Response(responseBody, {\n status: incoming.statusCode ?? 500,\n statusText: incoming.statusMessage,\n headers,\n }),\n );\n },\n );\n outgoing.once('error', reject);\n // webpieces-disable no-any-unknown -- a native socket/write rejection is normalized by toError\n void this.pumpStreamingRequest(body, outgoing).catch((err: unknown) => {\n const error = toError(err);\n outgoing.destroy(error);\n });\n },\n );\n }\n\n private async pumpStreamingRequest(\n body: ByteReadableStream,\n outgoing: import('node:http').ClientRequest,\n ): Promise<void> {\n const reader = body.getReader();\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- socket errors reject writer acknowledgements\n try {\n for (;;) {\n const part = await reader.read();\n if (part.done) break;\n if (!outgoing.write(part.value)) await once(outgoing, 'drain');\n }\n outgoing.end();\n } finally {\n reader.releaseLock();\n }\n }\n\n /**\n * The two framework built-ins, installed on EVERY client this package builds and ordered\n * beneath every app filter by {@link ProxyClient.initRoutes}.\n *\n * They are unconditional rather than opt-in because neither costs anything on the path that\n * does not need it: the SSRF guard steps aside when nothing re-pointed the request, and the\n * auth filter does nothing for a `@WpAuthPublic` endpoint. An app therefore cannot forget to install\n * the guard on the one client that takes runtime URLs — the ACT of re-pointing is what arms it.\n */\n protected override clientFilters(): ClientFilterDefinition[] {\n return [\n new ClientFilterDefinition(\n SSRF_GUARD_PRIORITY,\n new SsrfGuardFilter(this.ssrfPolicy(), this.addressResolver),\n ),\n new ClientFilterDefinition(\n OUTBOUND_AUTH_PRIORITY,\n new OutboundAuthFilter(this.gcpOidc, this.secrets, this.webhookSigner),\n ),\n ];\n }\n\n /**\n * WHICH policy the guard applies when something does re-point this client.\n *\n * Read off an installed {@link ContextBaseUrlFilter} or {@link ContextFullUrlFilter}, because\n * that filter is where an app says\n * \"this client may be re-pointed\", and the single legitimate relaxation\n * ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather\n * than in a second place a reader has to correlate. No such filter — or one built with the\n * default — means {@link SsrfPolicy} (the strict one), so the safe answer is what an app gets by saying\n * nothing.\n */\n private ssrfPolicy(): SsrfPolicy {\n for (const definition of this.appFilters) {\n const filter = definition.filter;\n if (filter instanceof ContextBaseUrlFilter || filter instanceof ContextFullUrlFilter) {\n return filter.ssrfPolicy;\n }\n }\n return new SsrfPolicy();\n }\n\n /**\n * Straight from the RequestContext. Throws when there is no active request scope.\n *\n * `destination` rides through unchanged: this is the ONE client that can legitimately propagate a\n * verified identity, and it does so exactly when the callee will authenticate us (oidc(...) /\n * sharedSecret(...)). Calling a peer's @WpAuthPublic or jwt() endpoint now omits `x-user-id` and\n * friends instead of shipping headers that endpoint's AuthFilter is obliged to reject.\n */\n protected override outboundContextHeaders(destination: DestinationTrust): Map<string, string> {\n return this.headers.buildOutboundHeaders(destination);\n }\n\n /**\n * Straight into the ambient RequestContext — the reason a value a callee set climbs the call\n * tree: after this returns, the CALLER's context holds it, and the caller's own response (and its\n * own outbound calls) carry it onward.\n *\n * `destination` is the trust gate, and it is the mirror of the inbound problem rather than a\n * restatement of it: a response is another process's ASSERTION. Untrusted response keys are\n * admitted; a TRUSTED one is admitted only from a destination this client authenticated to, and\n * is otherwise dropped — see `RequestContextHeaders.acceptResponseHeaders`.\n */\n protected override acceptResponseContext(\n headers: Headers,\n destination: DestinationTrust,\n ): void {\n this.headers.acceptResponseHeaders(headers, destination);\n }\n\n /**\n * Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is\n * travelling in the magic context, capture this outbound call + its result so it becomes a mock\n * in the generated test. Absent a recorder this is exactly the base behavior.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected override async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // Preserve the context precondition before logging, which also requires an active scope.\n if (!RequestContext.isActive()) {\n throw new Error(\n 'No active RequestContext. Run the client call inside RequestContext.run(...).',\n );\n }\n const recorder = this.headers.findRecorder();\n if (!recorder) {\n return super.execute(route, requestDto, method);\n }\n return this.recordCall(recorder, route, requestDto, method);\n }\n\n /**\n * Execute the call while recording it (args + masked ctx snapshot + result).\n *\n * The snapshot is a FIXTURE field, not a log line, so it is built here rather than handed down\n * from the call path — a logging backend stamps its own fields and never sees this.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n private async recordCall(\n recorder: TestCaseRecorder,\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n const ctxSnapshot: Record<string, string> = {};\n for (const entry of RequestContext.buildLogFields().entries()) {\n ctxSnapshot[entry[0]] = entry[1];\n }\n const recorded = new RecordedEndpoint(\n this.contractName(),\n route.methodName,\n [requestDto],\n ctxSnapshot,\n );\n recorder.addEndpointInfo(recorded);\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- capture failure into the recording, then rethrow unchanged\n try {\n const response = await super.execute(route, requestDto, method);\n recorded.successResponse = response;\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n recorded.failureResponse = new RecordedError(error.name, error.message);\n throw err;\n }\n }\n\n}\n\n/**\n * DI token for the `Provider<NodeProxyClient>` that hands out RPC clients — one per API contract.\n * `Provider<T>` is erased at runtime, so it cannot be its own token; this Symbol names T.\n *\n * Because NodeProxyClient is bound TRANSIENT, every `get()` constructs a new one. (Were it bound\n * `@provideFrameworkSingleton`, the very same Provider would instead hand back one lazily-created\n * instance — the provider caches nothing, so the target's scope decides.)\n */\n// webpieces-disable no-symbol-di-tokens -- Provider<T> is erased at runtime; the Symbol names T\nexport const NODE_PROXY_CLIENT_PROVIDER = Symbol.for('Provider<NodeProxyClient>');\n"]}
|
|
1
|
+
{"version":3,"file":"NodeProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/NodeProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject, optional } from 'inversify';\nimport { once } from 'node:events';\nimport { request as httpRequest, IncomingMessage } from 'node:http';\nimport { request as httpsRequest } from 'node:https';\nimport { Readable } from 'node:stream';\nimport {\n ClientRegistry,\n DestinationTrust,\n ApiImplementationError,\n RecordedEndpoint,\n RecordedError,\n LogApiCallImpl,\n RouteMetadata,\n Secrets,\n SECRETS,\n TestCaseRecorder,\n toError,\n ClientRole,\n} from '@webpieces/core-util';\nimport {\n RequestContext,\n RequestContextApiCallContext,\n RequestContextHeaders,\n provideFrameworkTransient,\n} from '@webpieces/core-context';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport {\n ApiPrototype,\n ByteReadableStream,\n ClientRequest,\n ClientFilterDefinition,\n ProxyClient,\n} from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { ClientConfig } from './ClientConfig';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nimport { ContextFullUrlFilter } from './ContextFullUrlFilter';\nimport { OutboundAuthFilter } from './OutboundAuthFilter';\nimport { SsrfGuardFilter } from './SsrfGuardFilter';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { WEBHOOK_SIGNER_CALLBACK, WebhookSignerCallback } from './WebhookSignerCallback';\n\n/**\n * The two framework built-ins' priorities, RELATIVE TO EACH OTHER and to nothing else — they are\n * ordered beneath every app filter structurally, not by number (see `ProxyClient.initRoutes`).\n *\n * The guard is OUTSIDE the minter deliberately: a destination that is going to be refused must be\n * refused BEFORE a credential is minted for it, so a hostile URL never causes a token to exist.\n */\nconst SSRF_GUARD_PRIORITY = 900;\nconst OUTBOUND_AUTH_PRIORITY = 800;\n\n/**\n * The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the\n * ambient RequestContext, minting OIDC tokens, holding shared secrets, and recording test cases.\n *\n * TRANSIENT on purpose. Every `createRpcClient(api, config)` needs its own instance, because `init()`\n * binds one instance to exactly one API contract and one target. {@link ProxyClientProvider} hands\n * them out — see its doc.\n */\n@provideFrameworkTransient()\nexport class NodeProxyClient extends ProxyClient {\n protected override selectAuthMethod(route: import('@webpieces/core-util').RouteMetadata): import('@webpieces/core-util').AuthMode {\n const methods = route.authMeta?.methods;\n if (!methods) throw new Error('Endpoint requires @WpAuth or @WpAuthPublic.');\n const selected = methods.find((method: import('@webpieces/core-util').AuthMode) =>\n method.kind === 'oidc' || method.kind === 'public' ||\n (method.kind === 'shared-secret' && !!this.secrets?.get(method.secretKey)) ||\n (method.kind === 'webhook' && !!this.webhookSigner));\n if (selected) return selected;\n if (methods.length === 1) return methods[0];\n throw new Error('No configured Node client credential can satisfy this endpoint; bind a declared service credential.');\n }\n private config!: ClientConfig;\n\n constructor(\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(RequestContextHeaders) private readonly headers: RequestContextHeaders,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(GcpOidc) private readonly gcpOidc: GcpOidc,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(AddressResolver) private readonly addressResolver: AddressResolver,\n // @optional: only sharedSecret(...) endpoints need it; the client sends its bound value.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional() @inject(SECRETS) private readonly secrets?: Secrets,\n // @optional: only webhook(...) endpoints need it, and an unbound one makes them THROW\n // rather than deliver unsigned — see WebhookSignerCallback.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional()\n @inject(WEBHOOK_SIGNER_CALLBACK)\n private readonly webhookSigner?: WebhookSignerCallback,\n ) {\n // This package is node-only and already depends on core-context, so it builds the\n // RequestContext-backed ApiCallContext itself. No startup install, and therefore nothing a\n // non-webpieces host (plain NestJS/Express) can forget.\n super(new LogApiCallImpl(new RequestContextApiCallContext()));\n }\n\n /**\n * Bind this client to one API contract + target, with the app's outbound filters (url\n * rewriting, header editing, logging, per-call re-pointing).\n */\n init(\n apiPrototype: ApiPrototype<object>,\n config: ClientConfig,\n appFilters: ClientFilterDefinition[],\n ): void {\n this.config = config;\n this.initRoutes(apiPrototype, appFilters);\n }\n\n /**\n * The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but\n * with NODE's fallback: THROW. A server has no \"own origin\" to fall back to the way a browser\n * does, so an unresolvable peer is a setup bug and must fail loudly (the error names the fixes).\n *\n * Resolved per call, never at construction, so building a client stays synchronous. Any metadata\n * read beneath a deriver is memoized process-wide, so only the first call pays.\n */\n protected override clientRole(): ClientRole {\n return this.config.role;\n }\n\n protected override resolveBaseUrl(): Promise<string> {\n return ClientRegistry.resolve(this.config.svcName);\n }\n\n /** Node/undici supports a live ReadableStream body when fetch is given `duplex: 'half'`. */\n protected override supportsConcurrentDuplexFetch(): boolean {\n return true;\n }\n\n /**\n * Node's native HTTP stream is genuinely concurrent: unlike browser Fetch, an early response\n * does not cancel the still-open upload. The returned Web Response keeps parsing isomorphic.\n */\n protected override sendStreamingTransport(\n request: ClientRequest,\n signal: AbortSignal,\n body: ByteReadableStream,\n ): Promise<Response> {\n return new Promise<Response>(\n (resolve: (response: Response) => void, reject: (error: Error) => void) => {\n const url = new URL(request.url);\n const send = url.protocol === 'https:' ? httpsRequest : httpRequest;\n const outgoing = send(\n url,\n {\n method: 'POST',\n headers: request.headersAsRecord(),\n signal,\n },\n (incoming: IncomingMessage): void => {\n const headers = new Headers();\n for (const [name, value] of Object.entries(incoming.headers)) {\n if (Array.isArray(value)) {\n for (const item of value) headers.append(name, item);\n } else if (value !== undefined) {\n headers.set(name, value);\n }\n }\n const responseBody = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;\n resolve(\n new Response(responseBody, {\n status: incoming.statusCode ?? 500,\n statusText: incoming.statusMessage,\n headers,\n }),\n );\n },\n );\n outgoing.once('error', reject);\n // webpieces-disable no-any-unknown -- a native socket/write rejection is normalized by toError\n void this.pumpStreamingRequest(body, outgoing).catch((err: unknown) => {\n const error = toError(err);\n outgoing.destroy(error);\n });\n },\n );\n }\n\n private async pumpStreamingRequest(\n body: ByteReadableStream,\n outgoing: import('node:http').ClientRequest,\n ): Promise<void> {\n const reader = body.getReader();\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- socket errors reject writer acknowledgements\n try {\n for (;;) {\n const part = await reader.read();\n if (part.done) break;\n if (!outgoing.write(part.value)) await once(outgoing, 'drain');\n }\n outgoing.end();\n } finally {\n reader.releaseLock();\n }\n }\n\n /**\n * The two framework built-ins, installed on EVERY client this package builds and ordered\n * beneath every app filter by {@link ProxyClient.initRoutes}.\n *\n * They are unconditional rather than opt-in because neither costs anything on the path that\n * does not need it: the SSRF guard steps aside when nothing re-pointed the request, and the\n * auth filter does nothing for a `@WpAuthPublic` endpoint. An app therefore cannot forget to install\n * the guard on the one client that takes runtime URLs — the ACT of re-pointing is what arms it.\n */\n protected override clientFilters(): ClientFilterDefinition[] {\n return [\n new ClientFilterDefinition(\n SSRF_GUARD_PRIORITY,\n new SsrfGuardFilter(this.ssrfPolicy(), this.addressResolver),\n ),\n new ClientFilterDefinition(\n OUTBOUND_AUTH_PRIORITY,\n new OutboundAuthFilter(this.gcpOidc, this.secrets, this.webhookSigner),\n ),\n ];\n }\n\n /**\n * WHICH policy the guard applies when something does re-point this client.\n *\n * Read off an installed {@link ContextBaseUrlFilter} or {@link ContextFullUrlFilter}, because\n * that filter is where an app says\n * \"this client may be re-pointed\", and the single legitimate relaxation\n * ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather\n * than in a second place a reader has to correlate. No such filter — or one built with the\n * default — means {@link SsrfPolicy} (the strict one), so the safe answer is what an app gets by saying\n * nothing.\n */\n private ssrfPolicy(): SsrfPolicy {\n for (const definition of this.appFilters) {\n const filter = definition.filter;\n if (filter instanceof ContextBaseUrlFilter || filter instanceof ContextFullUrlFilter) {\n return filter.ssrfPolicy;\n }\n }\n return new SsrfPolicy();\n }\n\n /**\n * Straight from the RequestContext. Throws when there is no active request scope.\n *\n * `destination` rides through unchanged: this is the ONE client that can legitimately propagate a\n * verified identity, and it does so exactly when the callee will authenticate us (oidc(...) /\n * sharedSecret(...)). Calling a peer's @WpAuthPublic or jwt() endpoint now omits `x-user-id` and\n * friends instead of shipping headers that endpoint's AuthFilter is obliged to reject.\n */\n protected override outboundContextHeaders(destination: DestinationTrust): Map<string, string> {\n return this.headers.buildOutboundHeaders(destination);\n }\n\n /**\n * Straight into the ambient RequestContext — the reason a value a callee set climbs the call\n * tree: after this returns, the CALLER's context holds it, and the caller's own response (and its\n * own outbound calls) carry it onward.\n *\n * `destination` is the trust gate, and it is the mirror of the inbound problem rather than a\n * restatement of it: a response is another process's ASSERTION. Untrusted response keys are\n * admitted; a TRUSTED one is admitted only from a destination this client authenticated to, and\n * is otherwise dropped — see `RequestContextHeaders.acceptResponseHeaders`.\n */\n protected override acceptResponseContext(\n headers: Headers,\n destination: DestinationTrust,\n ): void {\n this.headers.acceptResponseHeaders(headers, destination);\n }\n\n /**\n * Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is\n * travelling in the magic context, capture this outbound call + its result so it becomes a mock\n * in the generated test. Absent a recorder this is exactly the base behavior.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected override async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // Preserve the context precondition before logging, which also requires an active scope.\n if (!RequestContext.isActive()) {\n throw new Error(\n 'No active RequestContext. Run the client call inside RequestContext.run(...).',\n );\n }\n const recorder = this.headers.findRecorder();\n if (!recorder) {\n return super.execute(route, requestDto, method);\n }\n return this.recordCall(recorder, route, requestDto, method);\n }\n\n /**\n * Execute the call while recording it (args + masked ctx snapshot + result).\n *\n * The snapshot is a FIXTURE field, not a log line, so it is built here rather than handed down\n * from the call path — a logging backend stamps its own fields and never sees this.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n private async recordCall(\n recorder: TestCaseRecorder,\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n const ctxSnapshot: Record<string, string> = {};\n for (const entry of RequestContext.buildLogFields().entries()) {\n ctxSnapshot[entry[0]] = entry[1];\n }\n const recorded = new RecordedEndpoint(\n this.contractName(),\n route.methodName,\n [requestDto],\n ctxSnapshot,\n );\n recorder.addEndpointInfo(recorded);\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- capture failure into the recording, then rethrow unchanged\n try {\n const response = await super.execute(route, requestDto, method);\n recorded.successResponse = response;\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n recorded.failureResponse = new RecordedError(error.name, error.message);\n throw err;\n }\n }\n\n}\n\n/**\n * DI token for the `Provider<NodeProxyClient>` that hands out RPC clients — one per API contract.\n * `Provider<T>` is erased at runtime, so it cannot be its own token; this Symbol names T.\n *\n * Because NodeProxyClient is bound TRANSIENT, every `get()` constructs a new one. (Were it bound\n * `@provideFrameworkSingleton`, the very same Provider would instead hand back one lazily-created\n * instance — the provider caches nothing, so the target's scope decides.)\n */\n// webpieces-disable no-symbol-di-tokens -- Provider<T> is erased at runtime; the Symbol names T\nexport const NODE_PROXY_CLIENT_PROVIDER = Symbol.for('Provider<NodeProxyClient>');\n"]}
|
package/src/index.d.ts
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
* import { ClientHttpFactory, ClientConfig } from '@webpieces/http-client-node';
|
|
15
15
|
*
|
|
16
16
|
* // inject the factory, then one client per contract
|
|
17
|
-
* const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));
|
|
17
|
+
* const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', ClientRole.SERVER));
|
|
18
18
|
* const response = await server2.fetchValue(req);
|
|
19
19
|
*
|
|
20
20
|
* // a client whose destination arrives per call: ONE filter, and nothing else changes
|
|
21
|
-
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
21
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
22
22
|
* new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
|
|
23
23
|
* ]);
|
|
24
24
|
* ```
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
export { ClientHttpFactory } from './ClientHttpFactory';
|
|
27
27
|
export { NodeProxyClient, NODE_PROXY_CLIENT_PROVIDER } from './NodeProxyClient';
|
|
28
28
|
export { ClientConfig } from './ClientConfig';
|
|
29
|
+
export { ClientRole } from '@webpieces/core-util';
|
|
29
30
|
export { ContextBaseUrlFilter } from './ContextBaseUrlFilter';
|
|
30
31
|
export { ContextFullUrlFilter } from './ContextFullUrlFilter';
|
|
31
32
|
export { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';
|
package/src/index.js
CHANGED
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
* import { ClientHttpFactory, ClientConfig } from '@webpieces/http-client-node';
|
|
16
16
|
*
|
|
17
17
|
* // inject the factory, then one client per contract
|
|
18
|
-
* const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));
|
|
18
|
+
* const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', ClientRole.SERVER));
|
|
19
19
|
* const response = await server2.fetchValue(req);
|
|
20
20
|
*
|
|
21
21
|
* // a client whose destination arrives per call: ONE filter, and nothing else changes
|
|
22
|
-
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
22
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [
|
|
23
23
|
* new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
|
|
24
24
|
* ]);
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
-
exports.WiringPolicy = exports.StreamingCapabilityError = exports.ClientFilterDefinition = exports.ClientRequest = exports.ClientErrorTranslator = exports.ProxyClient = exports.DnsAddressResolver = exports.AddressResolver = exports.InternalAddressRules = exports.OutboundAuthFilter = exports.SsrfRefusedError = exports.SsrfGuardFilter = exports.SsrfTestingPolicy = exports.SsrfPolicy = exports.WEBHOOK_SIGNER_CALLBACK = exports.SignableRequest = exports.WebhookSignerCallback = exports.MissingWebhookSignerError = exports.MissingSharedSecretError = exports.MissingRuntimeBaseUrlError = exports.ContextFullUrlFilter = exports.ContextBaseUrlFilter = exports.ClientConfig = exports.NODE_PROXY_CLIENT_PROVIDER = exports.NodeProxyClient = exports.ClientHttpFactory = void 0;
|
|
28
|
+
exports.WiringPolicy = exports.StreamingCapabilityError = exports.ClientFilterDefinition = exports.ClientRequest = exports.ClientErrorTranslator = exports.ProxyClient = exports.DnsAddressResolver = exports.AddressResolver = exports.InternalAddressRules = exports.OutboundAuthFilter = exports.SsrfRefusedError = exports.SsrfGuardFilter = exports.SsrfTestingPolicy = exports.SsrfPolicy = exports.WEBHOOK_SIGNER_CALLBACK = exports.SignableRequest = exports.WebhookSignerCallback = exports.MissingWebhookSignerError = exports.MissingSharedSecretError = exports.MissingRuntimeBaseUrlError = exports.ContextFullUrlFilter = exports.ContextBaseUrlFilter = exports.ClientRole = exports.ClientConfig = exports.NODE_PROXY_CLIENT_PROVIDER = exports.NodeProxyClient = exports.ClientHttpFactory = void 0;
|
|
29
29
|
var ClientHttpFactory_1 = require("./ClientHttpFactory");
|
|
30
30
|
Object.defineProperty(exports, "ClientHttpFactory", { enumerable: true, get: function () { return ClientHttpFactory_1.ClientHttpFactory; } });
|
|
31
31
|
var NodeProxyClient_1 = require("./NodeProxyClient");
|
|
@@ -33,6 +33,9 @@ Object.defineProperty(exports, "NodeProxyClient", { enumerable: true, get: funct
|
|
|
33
33
|
Object.defineProperty(exports, "NODE_PROXY_CLIENT_PROVIDER", { enumerable: true, get: function () { return NodeProxyClient_1.NODE_PROXY_CLIENT_PROVIDER; } });
|
|
34
34
|
var ClientConfig_1 = require("./ClientConfig");
|
|
35
35
|
Object.defineProperty(exports, "ClientConfig", { enumerable: true, get: function () { return ClientConfig_1.ClientConfig; } });
|
|
36
|
+
// ClientConfig's required `role` argument, re-exported so a client is set up from one import.
|
|
37
|
+
var core_util_1 = require("@webpieces/core-util");
|
|
38
|
+
Object.defineProperty(exports, "ClientRole", { enumerable: true, get: function () { return core_util_1.ClientRole; } });
|
|
36
39
|
// Install this on the ONE client whose destination is data. Installing it IS the opt-in; a client
|
|
37
40
|
// without it ignores an ambient OVERRIDE_BASE_URL entirely.
|
|
38
41
|
var ContextBaseUrlFilter_1 = require("./ContextBaseUrlFilter");
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;;AAEH,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,qDAAgF;AAAvE,kHAAA,eAAe,OAAA;AAAE,6HAAA,0BAA0B,OAAA;AACpD,+CAA8C;AAArC,4GAAA,YAAY,OAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;;AAEH,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,qDAAgF;AAAvE,kHAAA,eAAe,OAAA;AAAE,6HAAA,0BAA0B,OAAA;AACpD,+CAA8C;AAArC,4GAAA,YAAY,OAAA;AACrB,8FAA8F;AAC9F,kDAAkD;AAAzC,uGAAA,UAAU,OAAA;AAEnB,kGAAkG;AAClG,4DAA4D;AAC5D,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,kGAAkG;AAClG,4EAA4E;AAC5E,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,2EAA0E;AAAjE,wIAAA,0BAA0B,OAAA;AAEnC,mGAAmG;AACnG,kGAAkG;AAClG,6DAA6D;AAC7D,2DAA2F;AAAlF,8HAAA,wBAAwB,OAAA;AAAE,+HAAA,yBAAyB,OAAA;AAE5D,8FAA8F;AAC9F,mFAAmF;AACnF,iEAIiC;AAH7B,8HAAA,qBAAqB,OAAA;AACrB,wHAAA,eAAe,OAAA;AACf,gIAAA,uBAAuB,OAAA;AAG3B,qGAAqG;AACrG,+FAA+F;AAC/F,2CAA6D;AAApD,wGAAA,UAAU,OAAA;AAAE,+GAAA,iBAAiB,OAAA;AACtC,qDAAoD;AAA3C,kHAAA,eAAe,OAAA;AACxB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA;AAC3B,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,qDAAwE;AAA/D,kHAAA,eAAe,OAAA;AAAE,qHAAA,kBAAkB,OAAA;AAE5C,uEAAuE;AACvE,gEAAiF;AAAxE,+GAAA,WAAW,OAAA;AAAE,yHAAA,qBAAqB,OAAA;AAC3C,gEAAoF;AAA3E,iHAAA,aAAa,OAAA;AAAE,0HAAA,sBAAsB,OAAA;AAG9C,gEAAuE;AAA9D,4HAAA,wBAAwB,OAAA;AAEjC,gEAA2D;AAAlD,gHAAA,YAAY,OAAA","sourcesContent":["/**\n * @webpieces/http-client-node\n *\n * The SERVER-side HTTP client. Reads an API contract's decorators and generates type-safe HTTP\n * clients from it — the same contract the callee's controller implements.\n *\n * Node-only, so unlike @webpieces/http-client-browser it is fully inversify-wired and reads the\n * magic context straight out of the AsyncLocalStorage-backed RequestContext. There is no\n * ContextReader indirection, because a server has exactly one right answer, and a call made\n * OUTSIDE `RequestContext.run(...)` throws instead of silently dropping the trace.\n *\n * Usage:\n * ```typescript\n * import { ClientHttpFactory, ClientConfig } from '@webpieces/http-client-node';\n *\n * // inject the factory, then one client per contract\n * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2', ClientRole.SERVER));\n * const response = await server2.fetchValue(req);\n *\n * // a client whose destination arrives per call: ONE filter, and nothing else changes\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks', ClientRole.SERVER), [\n * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),\n * ]);\n * ```\n */\n\nexport { ClientHttpFactory } from './ClientHttpFactory';\nexport { NodeProxyClient, NODE_PROXY_CLIENT_PROVIDER } from './NodeProxyClient';\nexport { ClientConfig } from './ClientConfig';\n// ClientConfig's required `role` argument, re-exported so a client is set up from one import.\nexport { ClientRole } from '@webpieces/core-util';\n\n// Install this on the ONE client whose destination is data. Installing it IS the opt-in; a client\n// without it ignores an ambient OVERRIDE_BASE_URL entirely.\nexport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\n// The same opt-in for a COMPLETE stored url (host + path + query), sent verbatim with no contract\n// path appended — the usual partner-webhook shape. Reads OVERRIDE_FULL_URL.\nexport { ContextFullUrlFilter } from './ContextFullUrlFilter';\nexport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\n\n// The two ways outbound auth refuses to send: a binding a contract's auth mode requires is absent.\n// Typed, so a delivery worker tells 'we are misconfigured' from 'the partner registered something\n// hostile' (SsrfRefusedError) without matching message text.\nexport { MissingSharedSecretError, MissingWebhookSignerError } from './OutboundAuthErrors';\n\n// The OUTBOUND half of webhook(name) — the mirror of http-routing's WebhookAuthCallback. Bind\n// one, or every outbound webhook(...) call throws rather than delivering unsigned.\nexport {\n WebhookSignerCallback,\n SignableRequest,\n WEBHOOK_SIGNER_CALLBACK,\n} from './WebhookSignerCallback';\n\n// The SSRF policy a re-pointed URL is judged under, and the refusal it produces. Automatic, armed by\n// the ACT of re-pointing; the ONLY way to relax it is naming SsrfTestingPolicy at a call site.\nexport { SsrfPolicy, SsrfTestingPolicy } from './SsrfPolicy';\nexport { SsrfGuardFilter } from './SsrfGuardFilter';\nexport { SsrfRefusedError } from './SsrfRefusedError';\nexport { OutboundAuthFilter } from './OutboundAuthFilter';\nexport { InternalAddressRules } from './InternalAddressRules';\nexport { AddressResolver, DnsAddressResolver } from './AddressResolver';\n\n// The isomorphic engine, re-exported so a server app needs one import.\nexport { ProxyClient, ClientErrorTranslator } from '@webpieces/http-client-core';\nexport { ClientRequest, ClientFilterDefinition } from '@webpieces/http-client-core';\nexport type { ClientFilter } from '@webpieces/http-client-core';\nexport type { ApiPrototype } from '@webpieces/http-client-core';\nexport { StreamingCapabilityError } from '@webpieces/http-client-core';\n\nexport { WiringPolicy } from '@webpieces/http-client-core';\n"]}
|