@entifix/service-shell 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +15 -3
  2. package/dist/auth/index.d.ts +1 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +1 -1
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/auth/shared-token.d.ts +34 -0
  7. package/dist/auth/shared-token.d.ts.map +1 -0
  8. package/dist/auth/shared-token.js +39 -0
  9. package/dist/auth/shared-token.js.map +1 -0
  10. package/dist/index.d.ts +0 -5
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +0 -5
  13. package/dist/index.js.map +1 -1
  14. package/dist/load-remote-configuration.d.ts +20 -8
  15. package/dist/load-remote-configuration.d.ts.map +1 -1
  16. package/dist/load-remote-configuration.js +13 -18
  17. package/dist/load-remote-configuration.js.map +1 -1
  18. package/dist/make-service.d.ts +9 -0
  19. package/dist/make-service.d.ts.map +1 -1
  20. package/dist/make-service.js +1 -1
  21. package/dist/make-service.js.map +1 -1
  22. package/dist/runtime.d.ts +12 -0
  23. package/dist/runtime.d.ts.map +1 -0
  24. package/dist/runtime.js +12 -0
  25. package/dist/runtime.js.map +1 -0
  26. package/dist/service-description-route.d.ts +4 -3
  27. package/dist/service-description-route.d.ts.map +1 -1
  28. package/dist/service-description-route.js +4 -4
  29. package/dist/service-description-route.js.map +1 -1
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.d.ts.map +1 -0
  32. package/dist/testing.js +7 -0
  33. package/dist/testing.js.map +1 -0
  34. package/package.json +58 -10
  35. package/dist/auth/service-token.d.ts +0 -37
  36. package/dist/auth/service-token.d.ts.map +0 -1
  37. package/dist/auth/service-token.js +0 -62
  38. package/dist/auth/service-token.js.map +0 -1
package/README.md CHANGED
@@ -7,13 +7,25 @@ Routes are guarded with `requirePermission(...)`, which asks the `PolicyDecision
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- pnpm add @entifix/service-shell @opentelemetry/api effect
10
+ pnpm add @entifix/service-shell effect
11
+ # to run the service:
12
+ pnpm add @effect/platform-node
13
+ # for telemetry:
14
+ pnpm add @opentelemetry/api @effect/opentelemetry @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/context-async-hooks
11
15
  ```
12
16
 
13
17
  ## Entry points
14
18
 
15
- | Import | What it holds |
16
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
+ | Import | What it holds |
20
+ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | `@entifix/service-shell` | What a route set is written against: `requirePrincipal`, `requirePermission`, `requireOrganization`, `requireSharedToken`, `entityMetadataRoute`, health and SSE routes |
22
+ | `@entifix/service-shell/runtime` | Running a service: `makeService`, `makeServerLayer`, `loadRemoteConfiguration`. Needs `@effect/platform-node` |
23
+ | `@entifix/service-shell/observability` | `makeObservabilityLayer` and the OpenTelemetry wiring. Needs `@effect/opentelemetry` and the OpenTelemetry SDK packages |
24
+ | `@entifix/service-shell/testing` | `serveTestService`, a service on an ephemeral port for a spec |
25
+
26
+ The runtime and observability halves are optional peers: a package that only declares routes installs neither. The configuration service's URL and credential, and the header and secret that gate `GET /api/$service`, are values the host passes — `loadRemoteConfiguration({ url, headers })` and `makeService({ description: { header, token } })`.
27
+
28
+ ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
17
29
  | `@entifix/service-shell` | `makeService`, `makeServerLayer`, `requirePrincipal`, `requirePermission`, `requireOrganization`, `entityMetadataRoute`, `loadRemoteConfiguration`, `makeObservabilityLayer`, `serveTestService` |
18
30
 
19
31
  ---
@@ -1,5 +1,5 @@
1
1
  export * from './require-organization.js';
2
2
  export * from './require-principal.js';
3
3
  export * from './require-service-crossing.js';
4
- export * from './service-token.js';
4
+ export * from './shared-token.js';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,wBAAwB,CAAC;AACvC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,wBAAwB,CAAC;AACvC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,mBAAmB,CAAC"}
@@ -1,6 +1,6 @@
1
1
  export * from './require-organization.js';
2
2
  export * from './require-principal.js';
3
3
  export * from './require-service-crossing.js';
4
- export * from './service-token.js';
4
+ export * from './shared-token.js';
5
5
 
6
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/index.ts"],"sourcesContent":["export * from './require-organization.js';\nexport * from './require-principal.js';\nexport * from './require-service-crossing.js';\nexport * from './service-token.js';\n"],"names":[],"mappings":"AAAA,cAAc,4BAA4B;AAC1C,cAAc,yBAAyB;AACvC,cAAc,gCAAgC;AAC9C,cAAc,qBAAqB"}
1
+ {"version":3,"sources":["../../src/auth/index.ts"],"sourcesContent":["export * from './require-organization.js';\nexport * from './require-principal.js';\nexport * from './require-service-crossing.js';\nexport * from './shared-token.js';\n"],"names":[],"mappings":"AAAA,cAAc,4BAA4B;AAC1C,cAAc,yBAAyB;AACvC,cAAc,gCAAgC;AAC9C,cAAc,oBAAoB"}
@@ -0,0 +1,34 @@
1
+ import { Effect } from 'effect';
2
+ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
3
+ /**
4
+ * A secret every caller of a route presents in one header.
5
+ *
6
+ * Both halves are the host's. Which header carries it and where the expected
7
+ * value comes from are a deployment's conventions — a fleet-wide environment
8
+ * variable, a mounted secret, a fixed value in a test — so the framework takes
9
+ * them as values rather than naming either.
10
+ */
11
+ export interface SharedToken {
12
+ /** The request header the token travels in, e.g. `x-service-token`. */
13
+ readonly header: string;
14
+ /**
15
+ * The expected token, asked for on each request rather than captured once —
16
+ * so a rotated secret, or a test that sets one, takes effect without a
17
+ * restart.
18
+ */
19
+ readonly token: () => string;
20
+ }
21
+ /**
22
+ * Serves `route` only to a caller presenting the shared token.
23
+ *
24
+ * ⚠️ This is **not** identity: every caller presents the same value, so it
25
+ * proves membership of whatever group holds the secret and nothing more. It is
26
+ * the right shape for a route whose readers are machines — a fleet fetching its
27
+ * configuration at boot, tooling reading a service's wiring — and the wrong one
28
+ * for anything a person or a browser reaches.
29
+ *
30
+ * Health endpoints should stay unguarded: a probe cannot hold a credential, and
31
+ * a readiness check that needs one becomes an authentication problem.
32
+ */
33
+ export declare const requireSharedToken: (shared: SharedToken) => <A, E, R>(route: Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("effect/http/HttpBody").HttpBodyError | E, HttpServerRequest.HttpServerRequest | R>;
34
+ //# sourceMappingURL=shared-token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared-token.d.ts","sourceRoot":"","sources":["../../src/auth/shared-token.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,MAAM,CAAC;CAC9B;AAYD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC5B,QAAQ,WAAW,MACnB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,wJAenC,CAAC"}
@@ -0,0 +1,39 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { Effect } from 'effect';
3
+ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
4
+ /** Constant-time comparison, so the token cannot be recovered byte by byte. */ const matches = (provided, expected)=>{
5
+ if (provided === undefined) return false;
6
+ const left = Buffer.from(provided);
7
+ const right = Buffer.from(expected);
8
+ // `timingSafeEqual` throws on a length mismatch, so the lengths are compared
9
+ // first — the length of a shared secret is not what protects it.
10
+ return left.length === right.length && timingSafeEqual(left, right);
11
+ };
12
+ /**
13
+ * Serves `route` only to a caller presenting the shared token.
14
+ *
15
+ * ⚠️ This is **not** identity: every caller presents the same value, so it
16
+ * proves membership of whatever group holds the secret and nothing more. It is
17
+ * the right shape for a route whose readers are machines — a fleet fetching its
18
+ * configuration at boot, tooling reading a service's wiring — and the wrong one
19
+ * for anything a person or a browser reaches.
20
+ *
21
+ * Health endpoints should stay unguarded: a probe cannot hold a credential, and
22
+ * a readiness check that needs one becomes an authentication problem.
23
+ */ export const requireSharedToken = (shared)=>(route)=>// The return type is inferred rather than written out: `HttpServerResponse.json`
24
+ // contributes an `HttpBodyError` to the failure channel, and spelling the type
25
+ // out while omitting it is what makes `tsc` refuse the declaration emit.
26
+ Effect.gen(function*() {
27
+ const request = yield* HttpServerRequest.HttpServerRequest;
28
+ if (!matches(request.headers[shared.header], shared.token())) {
29
+ return yield* HttpServerResponse.json({
30
+ error: 'unauthenticated',
31
+ code: 'unauthenticated'
32
+ }, {
33
+ status: 401
34
+ });
35
+ }
36
+ return yield* route;
37
+ });
38
+
39
+ //# sourceMappingURL=shared-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/shared-token.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * A secret every caller of a route presents in one header.\n *\n * Both halves are the host's. Which header carries it and where the expected\n * value comes from are a deployment's conventions — a fleet-wide environment\n * variable, a mounted secret, a fixed value in a test — so the framework takes\n * them as values rather than naming either.\n */\nexport interface SharedToken {\n /** The request header the token travels in, e.g. `x-service-token`. */\n readonly header: string;\n /**\n * The expected token, asked for on each request rather than captured once —\n * so a rotated secret, or a test that sets one, takes effect without a\n * restart.\n */\n readonly token: () => string;\n}\n\n/** Constant-time comparison, so the token cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined, expected: string): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(expected);\n // `timingSafeEqual` throws on a length mismatch, so the lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\n/**\n * Serves `route` only to a caller presenting the shared token.\n *\n * ⚠️ This is **not** identity: every caller presents the same value, so it\n * proves membership of whatever group holds the secret and nothing more. It is\n * the right shape for a route whose readers are machines — a fleet fetching its\n * configuration at boot, tooling reading a service's wiring — and the wrong one\n * for anything a person or a browser reaches.\n *\n * Health endpoints should stay unguarded: a probe cannot hold a credential, and\n * a readiness check that needs one becomes an authentication problem.\n */\nexport const requireSharedToken =\n (shared: SharedToken) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out: `HttpServerResponse.json`\n // contributes an `HttpBodyError` to the failure channel, and spelling the type\n // out while omitting it is what makes `tsc` refuse the declaration emit.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n\n if (!matches(request.headers[shared.header], shared.token())) {\n return yield* HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n );\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","Effect","HttpServerRequest","HttpServerResponse","matches","provided","expected","undefined","left","Buffer","from","right","length","requireSharedToken","shared","route","gen","request","headers","header","token","json","error","code","status"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAqBpE,6EAA6E,GAC7E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,6EAA6E;IAC7E,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIZ,gBAAgBQ,MAAMG;AAC/D;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,MAAME,qBACX,CAACC,SACD,CAAUC,QACR,iFAAiF;QACjF,+EAA+E;QAC/E,yEAAyE;QACzEd,OAAOe,GAAG,CAAC;YACT,MAAMC,UAAU,OAAOf,kBAAkBA,iBAAiB;YAE1D,IAAI,CAACE,QAAQa,QAAQC,OAAO,CAACJ,OAAOK,MAAM,CAAC,EAAEL,OAAOM,KAAK,KAAK;gBAC5D,OAAO,OAAOjB,mBAAmBkB,IAAI,CACnC;oBAAEC,OAAO;oBAAmBC,MAAM;gBAAkB,GACpD;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,OAAO,OAAOT;QAChB,GAAG"}
package/dist/index.d.ts CHANGED
@@ -1,12 +1,7 @@
1
1
  export * from './auth/index.js';
2
2
  export * from './entity-metadata-route.js';
3
3
  export * from './health-routes.js';
4
- export * from './load-remote-configuration.js';
5
- export * from './make-service.js';
6
- export * from './observability.js';
7
4
  export * from './redact-configuration.js';
8
- export * from './serve-test-service.js';
9
5
  export * from './service-description-route.js';
10
6
  export * from './sse.js';
11
- export * from './wait-for-http.js';
12
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,yBAAyB,CAAC;AACxC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC;AACzB,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -1,13 +1,8 @@
1
1
  export * from './auth/index.js';
2
2
  export * from './entity-metadata-route.js';
3
3
  export * from './health-routes.js';
4
- export * from './load-remote-configuration.js';
5
- export * from './make-service.js';
6
- export * from './observability.js';
7
4
  export * from './redact-configuration.js';
8
- export * from './serve-test-service.js';
9
5
  export * from './service-description-route.js';
10
6
  export * from './sse.js';
11
- export * from './wait-for-http.js';
12
7
 
13
8
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-metadata-route.js';\nexport * from './health-routes.js';\nexport * from './load-remote-configuration.js';\nexport * from './make-service.js';\nexport * from './observability.js';\nexport * from './redact-configuration.js';\nexport * from './serve-test-service.js';\nexport * from './service-description-route.js';\nexport * from './sse.js';\nexport * from './wait-for-http.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB;AAChC,cAAc,6BAA6B;AAC3C,cAAc,qBAAqB;AACnC,cAAc,iCAAiC;AAC/C,cAAc,oBAAoB;AAClC,cAAc,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,0BAA0B;AACxC,cAAc,iCAAiC;AAC/C,cAAc,WAAW;AACzB,cAAc,qBAAqB"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-metadata-route.js';\nexport * from './health-routes.js';\nexport * from './redact-configuration.js';\nexport * from './service-description-route.js';\nexport * from './sse.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB;AAChC,cAAc,6BAA6B;AAC3C,cAAc,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,iCAAiC;AAC/C,cAAc,WAAW"}
@@ -54,10 +54,22 @@ export interface LoadRemoteConfigurationOptions {
54
54
  readonly attemptTimeoutMs?: number;
55
55
  }
56
56
  /**
57
- * Fetches a service's configuration from the central config-service
58
- * (`GET {configApiUrl}/api/config/{service}`) and returns it as a
59
- * {@link ConfigurationPlain}. Backends call this at boot to resolve their own
60
- * parameters (e.g. `mongo.uri`) instead of hardcoding them.
57
+ * Where a service's configuration is fetched from, and what the request
58
+ * carries.
59
+ *
60
+ * Both are the host's: the path a configuration service answers on and the
61
+ * credential it asks for are that deployment's protocol, so the framework takes
62
+ * the finished URL and headers rather than composing either.
63
+ */
64
+ export interface RemoteConfigurationSource {
65
+ /** The full URL, e.g. `http://config:3190/api/config/order-service`. */
66
+ readonly url: string;
67
+ readonly headers?: Readonly<Record<string, string>>;
68
+ }
69
+ /**
70
+ * Fetches a service's configuration from a configuration service and returns
71
+ * it as a {@link ConfigurationPlain}. Backends call this at boot to resolve
72
+ * their own parameters (e.g. `mongo.uri`) instead of hardcoding them.
61
73
  *
62
74
  * Uses Node's global `fetch` (same transport approach as the REST client); any
63
75
  * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each
@@ -66,12 +78,12 @@ export interface LoadRemoteConfigurationOptions {
66
78
  * dependent that boots before config-service is HTTP-ready waits it out — and
67
79
  * one that reaches a socket nobody is answering gives up rather than hanging.
68
80
  */
69
- export declare const loadRemoteConfiguration: (configApiUrl: string, service: string, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationPlain, EntifixConnError>;
81
+ export declare const loadRemoteConfiguration: (source: RemoteConfigurationSource, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationPlain, EntifixConnError>;
70
82
  /**
71
83
  * Convenience: load the remote configuration and wrap it in a
72
- * {@link ConfigurationClientInMemory} ready to satisfy `ConfigurationRepositoryTag`
73
- * and to read the service's own parameters via `.in(group).getString(key)`.
84
+ * {@link ConfigurationClientInMemory}, to read the service's own parameters via
85
+ * `.in(group).getString(key)` or to hand to an adapter that is built with one.
74
86
  */
75
- export declare const loadRemoteConfigurationClient: (configApiUrl: string, service: string, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationClientInMemory, EntifixConnError>;
87
+ export declare const loadRemoteConfigurationClient: (source: RemoteConfigurationSource, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationClientInMemory, EntifixConnError>;
76
88
  export {};
77
89
  //# sourceMappingURL=load-remote-configuration.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"load-remote-configuration.d.ts","sourceRoot":"","sources":["../src/load-remote-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;;AAInD;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,2BAGf;CAAG;AAEhC;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CACR,CAAC;AAEpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AAEnD,mDAAmD;AACnD,MAAM,WAAW,8BAA8B;IAC7C;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,GAClC,cAAc,MAAM,EACpB,SAAS,MAAM,EACf,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,kBAAkB,EAAE,gBAAgB,CAwCpD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GACxC,cAAc,MAAM,EACpB,SAAS,MAAM,EACf,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,gBAAgB,CAG3D,CAAC"}
1
+ {"version":3,"file":"load-remote-configuration.d.ts","sourceRoot":"","sources":["../src/load-remote-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;;AAEnD;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,2BAGf;CAAG;AAEhC;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CACR,CAAC;AAEpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AAEnD,mDAAmD;AACnD,MAAM,WAAW,8BAA8B;IAC7C;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,yBAAyB;IACxC,wEAAwE;IACxE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,GAClC,QAAQ,yBAAyB,EACjC,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,kBAAkB,EAAE,gBAAgB,CAsCpD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GACxC,QAAQ,yBAAyB,EACjC,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,gBAAgB,CAG3D,CAAC"}
@@ -1,6 +1,5 @@
1
1
  import { ConfigurationClientInMemory, EntifixConnError } from '@entifix/core';
2
2
  import { Context, Effect, Schedule } from 'effect';
3
- import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
4
3
  /**
5
4
  * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,
6
5
  * so a `GET /api/config` introspection route can expose it (redacted).
@@ -38,10 +37,9 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
38
37
  * loudly instead of hanging silently.
39
38
  */ export const defaultConfigAttemptTimeoutMs = 5000;
40
39
  /**
41
- * Fetches a service's configuration from the central config-service
42
- * (`GET {configApiUrl}/api/config/{service}`) and returns it as a
43
- * {@link ConfigurationPlain}. Backends call this at boot to resolve their own
44
- * parameters (e.g. `mongo.uri`) instead of hardcoding them.
40
+ * Fetches a service's configuration from a configuration service and returns
41
+ * it as a {@link ConfigurationPlain}. Backends call this at boot to resolve
42
+ * their own parameters (e.g. `mongo.uri`) instead of hardcoding them.
45
43
  *
46
44
  * Uses Node's global `fetch` (same transport approach as the REST client); any
47
45
  * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each
@@ -49,42 +47,39 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
49
47
  * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a
50
48
  * dependent that boots before config-service is HTTP-ready waits it out — and
51
49
  * one that reaches a socket nobody is answering gives up rather than hanging.
52
- */ export const loadRemoteConfiguration = (configApiUrl, service, options = {})=>{
50
+ */ export const loadRemoteConfiguration = (source, options = {})=>{
53
51
  var _options_attemptTimeoutMs, _options_retrySchedule;
54
- const url = `${configApiUrl.replace(/\/+$/, '')}/api/config/${service}`;
52
+ const { url } = source;
55
53
  const attemptTimeoutMs = (_options_attemptTimeoutMs = options.attemptTimeoutMs) != null ? _options_attemptTimeoutMs : defaultConfigAttemptTimeoutMs;
56
54
  const fetchOnce = Effect.tryPromise({
57
55
  try: async ()=>{
58
56
  const response = await fetch(url, {
59
57
  cache: 'no-store',
60
- headers: {
61
- [SERVICE_TOKEN_HEADER]: serviceToken()
62
- },
58
+ headers: source.headers,
63
59
  signal: AbortSignal.timeout(attemptTimeoutMs)
64
60
  }).catch((error)=>{
65
61
  // Rewritten because the raw abort says `This operation was aborted`,
66
62
  // which reads like a bug in the caller rather than a silent peer.
67
63
  if (error instanceof Error && error.name === 'TimeoutError') {
68
- throw new Error(`config-service did not answer within ${attemptTimeoutMs}ms at ${url}`);
64
+ throw new Error(`the configuration source did not answer within ${attemptTimeoutMs}ms at ${url}`);
69
65
  }
70
66
  throw error;
71
67
  });
72
68
  if (!response.ok) {
73
- throw new Error(`config-service responded ${response.status} for ${url}`);
69
+ throw new Error(`the configuration source responded ${response.status} for ${url}`);
74
70
  }
75
71
  return await response.json();
76
72
  },
77
- catch: (error)=>new EntifixConnError(`Failed to load configuration for "${service}" from config-service`, error, {
78
- url,
79
- service
73
+ catch: (error)=>new EntifixConnError(`Failed to load configuration from ${url}`, error, {
74
+ url
80
75
  })
81
76
  });
82
77
  return Effect.retry(fetchOnce, (_options_retrySchedule = options.retrySchedule) != null ? _options_retrySchedule : defaultConfigRetrySchedule);
83
78
  };
84
79
  /**
85
80
  * Convenience: load the remote configuration and wrap it in a
86
- * {@link ConfigurationClientInMemory} ready to satisfy `ConfigurationRepositoryTag`
87
- * and to read the service's own parameters via `.in(group).getString(key)`.
88
- */ export const loadRemoteConfigurationClient = (configApiUrl, service, options = {})=>loadRemoteConfiguration(configApiUrl, service, options).pipe(Effect.map((plain)=>new ConfigurationClientInMemory(plain)));
81
+ * {@link ConfigurationClientInMemory}, to read the service's own parameters via
82
+ * `.in(group).getString(key)` or to hand to an adapter that is built with one.
83
+ */ export const loadRemoteConfigurationClient = (source, options = {})=>loadRemoteConfiguration(source, options).pipe(Effect.map((plain)=>new ConfigurationClientInMemory(plain)));
89
84
 
90
85
  //# sourceMappingURL=load-remote-configuration.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\nimport { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Service<\n LoadedConfigurationTag,\n ConfigurationPlain\n>()('LoadedConfigurationTag') {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.max([Schedule.spaced('1 seconds'), Schedule.recurs(20)]);\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on auth-service and then marketplace-service, recovered only by a\n * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).\n *\n * So an attempt is deadlined, for the same reason a readiness probe is: a check\n * that hangs is a check that failed. The ordinary race this retry was written\n * for — config-service not listening yet — still fails instantly with\n * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:\n * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits\n * loudly instead of hanging silently.\n */\nexport const defaultConfigAttemptTimeoutMs = 5_000;\n\n/** Options for {@link loadRemoteConfiguration}. */\nexport interface LoadRemoteConfigurationOptions {\n /**\n * Retry policy for the boot fetch. Defaults to\n * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a\n * single attempt, no retry) — used by tests and callers that want the error\n * immediately.\n */\n readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;\n /**\n * Deadline for one attempt, in milliseconds. Defaults to\n * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to\n * disable it — an attempt that cannot time out is the bug this exists for.\n */\n readonly attemptTimeoutMs?: number;\n}\n\n/**\n * Fetches a service's configuration from the central config-service\n * (`GET {configApiUrl}/api/config/{service}`) and returns it as a\n * {@link ConfigurationPlain}. Backends call this at boot to resolve their own\n * parameters (e.g. `mongo.uri`) instead of hardcoding them.\n *\n * Uses Node's global `fetch` (same transport approach as the REST client); any\n * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each\n * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is\n * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a\n * dependent that boots before config-service is HTTP-ready waits it out — and\n * one that reaches a socket nobody is answering gives up rather than hanging.\n */\nexport const loadRemoteConfiguration = (\n configApiUrl: string,\n service: string,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationPlain, EntifixConnError> => {\n const url = `${configApiUrl.replace(/\\/+$/, '')}/api/config/${service}`;\n const attemptTimeoutMs =\n options.attemptTimeoutMs ?? defaultConfigAttemptTimeoutMs;\n\n const fetchOnce = Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n cache: 'no-store',\n headers: { [SERVICE_TOKEN_HEADER]: serviceToken() },\n signal: AbortSignal.timeout(attemptTimeoutMs),\n }).catch((error: unknown) => {\n // Rewritten because the raw abort says `This operation was aborted`,\n // which reads like a bug in the caller rather than a silent peer.\n if (error instanceof Error && error.name === 'TimeoutError') {\n throw new Error(\n `config-service did not answer within ${attemptTimeoutMs}ms at ${url}`,\n );\n }\n throw error;\n });\n if (!response.ok) {\n throw new Error(\n `config-service responded ${response.status} for ${url}`,\n );\n }\n return (await response.json()) as ConfigurationPlain;\n },\n catch: error =>\n new EntifixConnError(\n `Failed to load configuration for \"${service}\" from config-service`,\n error,\n { url, service },\n ),\n });\n\n return Effect.retry(\n fetchOnce,\n options.retrySchedule ?? defaultConfigRetrySchedule,\n );\n};\n\n/**\n * Convenience: load the remote configuration and wrap it in a\n * {@link ConfigurationClientInMemory} ready to satisfy `ConfigurationRepositoryTag`\n * and to read the service's own parameters via `.in(group).getString(key)`.\n */\nexport const loadRemoteConfigurationClient = (\n configApiUrl: string,\n service: string,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationClientInMemory, EntifixConnError> =>\n loadRemoteConfiguration(configApiUrl, service, options).pipe(\n Effect.map(plain => new ConfigurationClientInMemory(plain)),\n );\n"],"names":["ConfigurationClientInMemory","EntifixConnError","Context","Effect","Schedule","SERVICE_TOKEN_HEADER","serviceToken","LoadedConfigurationTag","Service","defaultConfigRetrySchedule","max","spaced","recurs","defaultConfigAttemptTimeoutMs","loadRemoteConfiguration","configApiUrl","service","options","url","replace","attemptTimeoutMs","fetchOnce","tryPromise","try","response","fetch","cache","headers","signal","AbortSignal","timeout","catch","error","Error","name","ok","status","json","retry","retrySchedule","loadRemoteConfigurationClient","pipe","map","plain"],"mappings":"AAAA,SACEA,2BAA2B,EAE3BC,gBAAgB,QACX,gBAAgB;AACvB,SAASC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAEnD,SAASC,oBAAoB,EAAEC,YAAY,QAAQ,0BAA0B;AAE7E;;;CAGC,GACD,OAAO,MAAMC,+BAA+BL,QAAQM,OAAO,GAGvD;AAA2B;AAE/B;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BACXL,SAASM,GAAG,CAAC;IAACN,SAASO,MAAM,CAAC;IAAcP,SAASQ,MAAM,CAAC;CAAI,EAAE;AAEpE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,gCAAgC,KAAM;AAmBnD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,cACAC,SACAC,UAA0C,CAAC,CAAC;QAI1CA,2BAmCAA;IArCF,MAAMC,MAAM,GAAGH,aAAaI,OAAO,CAAC,QAAQ,IAAI,YAAY,EAAEH,SAAS;IACvE,MAAMI,oBACJH,4BAAAA,QAAQG,gBAAgB,YAAxBH,4BAA4BJ;IAE9B,MAAMQ,YAAYlB,OAAOmB,UAAU,CAAC;QAClCC,KAAK;YACH,MAAMC,WAAW,MAAMC,MAAMP,KAAK;gBAChCQ,OAAO;gBACPC,SAAS;oBAAE,CAACtB,qBAAqB,EAAEC;gBAAe;gBAClDsB,QAAQC,YAAYC,OAAO,CAACV;YAC9B,GAAGW,KAAK,CAAC,CAACC;gBACR,qEAAqE;gBACrE,kEAAkE;gBAClE,IAAIA,iBAAiBC,SAASD,MAAME,IAAI,KAAK,gBAAgB;oBAC3D,MAAM,IAAID,MACR,CAAC,qCAAqC,EAAEb,iBAAiB,MAAM,EAAEF,KAAK;gBAE1E;gBACA,MAAMc;YACR;YACA,IAAI,CAACR,SAASW,EAAE,EAAE;gBAChB,MAAM,IAAIF,MACR,CAAC,yBAAyB,EAAET,SAASY,MAAM,CAAC,KAAK,EAAElB,KAAK;YAE5D;YACA,OAAQ,MAAMM,SAASa,IAAI;QAC7B;QACAN,OAAOC,CAAAA,QACL,IAAI/B,iBACF,CAAC,kCAAkC,EAAEe,QAAQ,qBAAqB,CAAC,EACnEgB,OACA;gBAAEd;gBAAKF;YAAQ;IAErB;IAEA,OAAOb,OAAOmC,KAAK,CACjBjB,YACAJ,yBAAAA,QAAQsB,aAAa,YAArBtB,yBAAyBR;AAE7B,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAM+B,gCAAgC,CAC3CzB,cACAC,SACAC,UAA0C,CAAC,CAAC,GAE5CH,wBAAwBC,cAAcC,SAASC,SAASwB,IAAI,CAC1DtC,OAAOuC,GAAG,CAACC,CAAAA,QAAS,IAAI3C,4BAA4B2C,SACpD"}
1
+ {"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Service<\n LoadedConfigurationTag,\n ConfigurationPlain\n>()('LoadedConfigurationTag') {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.max([Schedule.spaced('1 seconds'), Schedule.recurs(20)]);\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on auth-service and then marketplace-service, recovered only by a\n * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).\n *\n * So an attempt is deadlined, for the same reason a readiness probe is: a check\n * that hangs is a check that failed. The ordinary race this retry was written\n * for — config-service not listening yet — still fails instantly with\n * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:\n * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits\n * loudly instead of hanging silently.\n */\nexport const defaultConfigAttemptTimeoutMs = 5_000;\n\n/** Options for {@link loadRemoteConfiguration}. */\nexport interface LoadRemoteConfigurationOptions {\n /**\n * Retry policy for the boot fetch. Defaults to\n * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a\n * single attempt, no retry) — used by tests and callers that want the error\n * immediately.\n */\n readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;\n /**\n * Deadline for one attempt, in milliseconds. Defaults to\n * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to\n * disable it — an attempt that cannot time out is the bug this exists for.\n */\n readonly attemptTimeoutMs?: number;\n}\n\n/**\n * Where a service's configuration is fetched from, and what the request\n * carries.\n *\n * Both are the host's: the path a configuration service answers on and the\n * credential it asks for are that deployment's protocol, so the framework takes\n * the finished URL and headers rather than composing either.\n */\nexport interface RemoteConfigurationSource {\n /** The full URL, e.g. `http://config:3190/api/config/order-service`. */\n readonly url: string;\n readonly headers?: Readonly<Record<string, string>>;\n}\n\n/**\n * Fetches a service's configuration from a configuration service and returns\n * it as a {@link ConfigurationPlain}. Backends call this at boot to resolve\n * their own parameters (e.g. `mongo.uri`) instead of hardcoding them.\n *\n * Uses Node's global `fetch` (same transport approach as the REST client); any\n * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each\n * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is\n * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a\n * dependent that boots before config-service is HTTP-ready waits it out — and\n * one that reaches a socket nobody is answering gives up rather than hanging.\n */\nexport const loadRemoteConfiguration = (\n source: RemoteConfigurationSource,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationPlain, EntifixConnError> => {\n const { url } = source;\n const attemptTimeoutMs =\n options.attemptTimeoutMs ?? defaultConfigAttemptTimeoutMs;\n\n const fetchOnce = Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n cache: 'no-store',\n headers: source.headers,\n signal: AbortSignal.timeout(attemptTimeoutMs),\n }).catch((error: unknown) => {\n // Rewritten because the raw abort says `This operation was aborted`,\n // which reads like a bug in the caller rather than a silent peer.\n if (error instanceof Error && error.name === 'TimeoutError') {\n throw new Error(\n `the configuration source did not answer within ${attemptTimeoutMs}ms at ${url}`,\n );\n }\n throw error;\n });\n if (!response.ok) {\n throw new Error(\n `the configuration source responded ${response.status} for ${url}`,\n );\n }\n return (await response.json()) as ConfigurationPlain;\n },\n catch: error =>\n new EntifixConnError(`Failed to load configuration from ${url}`, error, {\n url,\n }),\n });\n\n return Effect.retry(\n fetchOnce,\n options.retrySchedule ?? defaultConfigRetrySchedule,\n );\n};\n\n/**\n * Convenience: load the remote configuration and wrap it in a\n * {@link ConfigurationClientInMemory}, to read the service's own parameters via\n * `.in(group).getString(key)` or to hand to an adapter that is built with one.\n */\nexport const loadRemoteConfigurationClient = (\n source: RemoteConfigurationSource,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationClientInMemory, EntifixConnError> =>\n loadRemoteConfiguration(source, options).pipe(\n Effect.map(plain => new ConfigurationClientInMemory(plain)),\n );\n"],"names":["ConfigurationClientInMemory","EntifixConnError","Context","Effect","Schedule","LoadedConfigurationTag","Service","defaultConfigRetrySchedule","max","spaced","recurs","defaultConfigAttemptTimeoutMs","loadRemoteConfiguration","source","options","url","attemptTimeoutMs","fetchOnce","tryPromise","try","response","fetch","cache","headers","signal","AbortSignal","timeout","catch","error","Error","name","ok","status","json","retry","retrySchedule","loadRemoteConfigurationClient","pipe","map","plain"],"mappings":"AAAA,SACEA,2BAA2B,EAE3BC,gBAAgB,QACX,gBAAgB;AACvB,SAASC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAEnD;;;CAGC,GACD,OAAO,MAAMC,+BAA+BH,QAAQI,OAAO,GAGvD;AAA2B;AAE/B;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BACXH,SAASI,GAAG,CAAC;IAACJ,SAASK,MAAM,CAAC;IAAcL,SAASM,MAAM,CAAC;CAAI,EAAE;AAEpE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,gCAAgC,KAAM;AAiCnD;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,QACAC,UAA0C,CAAC,CAAC;QAI1CA,2BAiCAA;IAnCF,MAAM,EAAEC,GAAG,EAAE,GAAGF;IAChB,MAAMG,oBACJF,4BAAAA,QAAQE,gBAAgB,YAAxBF,4BAA4BH;IAE9B,MAAMM,YAAYd,OAAOe,UAAU,CAAC;QAClCC,KAAK;YACH,MAAMC,WAAW,MAAMC,MAAMN,KAAK;gBAChCO,OAAO;gBACPC,SAASV,OAAOU,OAAO;gBACvBC,QAAQC,YAAYC,OAAO,CAACV;YAC9B,GAAGW,KAAK,CAAC,CAACC;gBACR,qEAAqE;gBACrE,kEAAkE;gBAClE,IAAIA,iBAAiBC,SAASD,MAAME,IAAI,KAAK,gBAAgB;oBAC3D,MAAM,IAAID,MACR,CAAC,+CAA+C,EAAEb,iBAAiB,MAAM,EAAED,KAAK;gBAEpF;gBACA,MAAMa;YACR;YACA,IAAI,CAACR,SAASW,EAAE,EAAE;gBAChB,MAAM,IAAIF,MACR,CAAC,mCAAmC,EAAET,SAASY,MAAM,CAAC,KAAK,EAAEjB,KAAK;YAEtE;YACA,OAAQ,MAAMK,SAASa,IAAI;QAC7B;QACAN,OAAOC,CAAAA,QACL,IAAI3B,iBAAiB,CAAC,kCAAkC,EAAEc,KAAK,EAAEa,OAAO;gBACtEb;YACF;IACJ;IAEA,OAAOZ,OAAO+B,KAAK,CACjBjB,YACAH,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyBP;AAE7B,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAM6B,gCAAgC,CAC3CvB,QACAC,UAA0C,CAAC,CAAC,GAE5CF,wBAAwBC,QAAQC,SAASuB,IAAI,CAC3ClC,OAAOmC,GAAG,CAACC,CAAAA,QAAS,IAAIvC,4BAA4BuC,SACpD"}
@@ -1,6 +1,7 @@
1
1
  import { HealthRegistryTag, type ShutdownRegistry, ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
2
2
  import { Layer } from 'effect';
3
3
  import { HttpRouter } from 'effect/http';
4
+ import type { SharedToken } from './auth/shared-token.js';
4
5
  /**
5
6
  * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged
6
7
  * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request
@@ -41,6 +42,14 @@ export interface ServiceDefinition<RRoutes> {
41
42
  * quietly mislabelling the fleet.
42
43
  */
43
44
  readonly slices: readonly string[];
45
+ /**
46
+ * The token that gates `GET /api/$service`, the service's wiring document.
47
+ *
48
+ * Optional, and **absent means the route is not mounted** rather than served
49
+ * open: the document names every store, exchange and upstream the service
50
+ * has, and an unguarded reconnaissance map is worse than none.
51
+ */
52
+ readonly description?: SharedToken;
44
53
  /** The service's routes; their requirements are satisfied by `appLayer`. */
45
54
  readonly router: ServiceRoutes<RRoutes>;
46
55
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAKzC;;;;GAIG;AAEH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAElE;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,CAAC,CAAC,IAC5B,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,GACtC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;AAElE,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAC3B,iBAAiB,GAAG,mBAAmB,GAAG,iBAAiB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO;IACxC,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC5B,kBAAkB,CAAC,OAAO,CAAC,EAC3B,KAAK,EACL,iBAAiB,CAClB,CAAC;CACH;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,EACrC,KAAK,iBAAiB,CAAC,OAAO,CAAC,EAC/B,OAAM,MAAiB,EACvB,WAAU,gBAAkD,KA8C3C,KAAK,CAAC,KAAK,CAC1B,KAAK,CAAC,OAAO,4iFAAe,EAC5B,KAAK,CAAC,KAAK,4iFAAe,CAE7B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,EAAE,KAAK,iBAAiB,CAAC,OAAO,CAAC,KAAG,IAQtE,CAAC"}
1
+ {"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAI1D;;;;GAIG;AAEH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAElE;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,CAAC,CAAC,IAC5B,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,GACtC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;AAElE,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAC3B,iBAAiB,GAAG,mBAAmB,GAAG,iBAAiB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO;IACxC,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC5B,kBAAkB,CAAC,OAAO,CAAC,EAC3B,KAAK,EACL,iBAAiB,CAClB,CAAC;CACH;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,EACrC,KAAK,iBAAiB,CAAC,OAAO,CAAC,EAC/B,OAAM,MAAiB,EACvB,WAAU,gBAAkD,KAgD3C,KAAK,CAAC,KAAK,CAC1B,KAAK,CAAC,OAAO,4iFAAe,EAC5B,KAAK,CAAC,KAAK,4iFAAe,CAE7B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,EAAE,KAAK,iBAAiB,CAAC,OAAO,CAAC,KAAG,IAQtE,CAAC"}
@@ -21,7 +21,7 @@ import { serviceDescriptionRoute } from './service-description-route.js';
21
21
  * only a real process boot has a SIGTERM to latch, and an in-process e2e boot
22
22
  * that installed one would leave a process listener per service it started.
23
23
  */ export const makeServerLayer = (def, port = def.port, shutdown = makeShutdownRegistry().registry)=>{
24
- const routes = Layer.mergeAll(def.router, healthRoutes(def.name), serviceDescriptionRoute(def.name, def.slices), // Permissive CORS (frontends call these services cross-origin from their
24
+ const routes = Layer.mergeAll(def.router, healthRoutes(def.name), def.description === undefined ? Layer.empty : serviceDescriptionRoute(def.name, def.slices, def.description), // Permissive CORS (frontends call these services cross-origin from their
25
25
  // dev ports). Tighten per-environment later.
26
26
  HttpRouter.cors());
27
27
  // `provideMerge` (not `provide`) so the registries the client layers
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several: ADR 0021 co-deploys `transaction` inside\n * marketplace-admin-service. `dev-infra:map` checks each name against the\n * register's own `deployments`, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n serviceDescriptionRoute(def.name, def.slices),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism (ADR 0030).\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * The `preStop` grace period ADR 0030 also names is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAEzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AAoEzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBT,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIQ,MAAM,GAC5C,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWa,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWV,IAAIU,QAAQ,CAACC,IAAI,CAChChB,MAAMiB,YAAY,CAChBjB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMkB,OAAO,CAACrB,qBAAqBU;IAKzC,2EAA2E;IAC3E,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMY,UAAUnB,MAAMiB,YAAY,CAChCjB,MAAMoB,aAAa,CACjBrB,OAAOsB,cAAc,CAACtB,OAAOuB,IAAI,EAAE,IAAMf,SAASgB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAASvB,WAAWwB,KAAK,CAAChB,QAAQO,IAAI,CAC1ChB,MAAM0B,OAAO,CAACP,UACdnB,MAAM0B,OAAO,CAACjC,eAAekC,KAAK,CAAC,IAAMnC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOkB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAUvB;IACnC,MAAME,WAAWX;IACjBiC,QAAQC,IAAI,CAAC,WAAWvB,SAASwB,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAUvB,SAASwB,KAAK;IAErCrC,YAAYsC,OAAO,CACjBhC,MAAMiC,MAAM,CAAC7B,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
1
+ {"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport type { SharedToken } from './auth/shared-token.js';\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several: ADR 0021 co-deploys `transaction` inside\n * marketplace-admin-service. `dev-infra:map` checks each name against the\n * register's own `deployments`, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /**\n * The token that gates `GET /api/$service`, the service's wiring document.\n *\n * Optional, and **absent means the route is not mounted** rather than served\n * open: the document names every store, exchange and upstream the service\n * has, and an unguarded reconnaissance map is worse than none.\n */\n readonly description?: SharedToken;\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n def.description === undefined\n ? Layer.empty\n : serviceDescriptionRoute(def.name, def.slices, def.description),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism (ADR 0030).\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * The `preStop` grace period ADR 0030 also names is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","description","undefined","empty","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAGzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AA4EzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBP,IAAIQ,WAAW,KAAKC,YAChBd,MAAMe,KAAK,GACXZ,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIW,MAAM,EAAEX,IAAIQ,WAAW,GACjE,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWgB,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWb,IAAIa,QAAQ,CAACC,IAAI,CAChCnB,MAAMoB,YAAY,CAChBpB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMqB,OAAO,CAACxB,qBAAqBU;IAKzC,2EAA2E;IAC3E,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMe,UAAUtB,MAAMoB,YAAY,CAChCpB,MAAMuB,aAAa,CACjBxB,OAAOyB,cAAc,CAACzB,OAAO0B,IAAI,EAAE,IAAMlB,SAASmB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAAS1B,WAAW2B,KAAK,CAACnB,QAAQU,IAAI,CAC1CnB,MAAM6B,OAAO,CAACP,UACdtB,MAAM6B,OAAO,CAACpC,eAAeqC,KAAK,CAAC,IAAMtC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOqB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAU1B;IACnC,MAAME,WAAWX;IACjBoC,QAAQC,IAAI,CAAC,WAAW1B,SAAS2B,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAU1B,SAAS2B,KAAK;IAErCxC,YAAYyC,OAAO,CACjBnC,MAAMoC,MAAM,CAAChC,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Running a service: the HTTP server and its lifecycle, and loading its
3
+ * configuration at boot.
4
+ *
5
+ * Apart from `.` because `.` is what a route set is written against — guards,
6
+ * generic routes — and a cell that holds one must be able to ban the process it
7
+ * runs in by specifier (ADR 0006). It is also the half that needs
8
+ * `@effect/platform-node`, an optional peer for that reason.
9
+ */
10
+ export * from './load-remote-configuration.js';
11
+ export * from './make-service.js';
12
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mBAAmB,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Running a service: the HTTP server and its lifecycle, and loading its
3
+ * configuration at boot.
4
+ *
5
+ * Apart from `.` because `.` is what a route set is written against — guards,
6
+ * generic routes — and a cell that holds one must be able to ban the process it
7
+ * runs in by specifier (ADR 0006). It is also the half that needs
8
+ * `@effect/platform-node`, an optional peer for that reason.
9
+ */ export * from './load-remote-configuration.js';
10
+ export * from './make-service.js';
11
+
12
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Running a service: the HTTP server and its lifecycle, and loading its\n * configuration at boot.\n *\n * Apart from `.` because `.` is what a route set is written against — guards,\n * generic routes — and a cell that holds one must be able to ban the process it\n * runs in by specifier (ADR 0006). It is also the half that needs\n * `@effect/platform-node`, an optional peer for that reason.\n */\nexport * from './load-remote-configuration.js';\nexport * from './make-service.js';\n"],"names":[],"mappings":"AAAA;;;;;;;;CAQC,GACD,cAAc,iCAAiC;AAC/C,cAAc,oBAAoB"}
@@ -1,6 +1,7 @@
1
1
  import type { BoundSubscription } from '@entifix/business';
2
2
  import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
3
3
  import { HttpRouter } from 'effect/http';
4
+ import { type SharedToken } from './auth/shared-token.js';
4
5
  /**
5
6
  * The path the wiring document is served at.
6
7
  *
@@ -44,7 +45,7 @@ export interface ServiceDescription {
44
45
  /**
45
46
  * `GET /api/$service`, as a route layer to merge beside a service's own.
46
47
  *
47
- * **Gated by `X-Service-Token`.** This names every store, exchange and upstream
48
+ * **Gated by a shared token**, which the host names. This names every store, exchange and upstream
48
49
  * a service has, which is a reconnaissance map rather than diagnostics. The
49
50
  * repository's precedent on maps of the model is uniform: `$metadata` answers
50
51
  * `404` rather than `403` so it cannot be walked as an oracle, readiness serves
@@ -53,7 +54,7 @@ export interface ServiceDescription {
53
54
  * a probe cannot hold a credential.
54
55
  *
55
56
  * The token is fleet membership, which is the right shape here: the readers are
56
- * `dev-infra:map` and CI, not a person and not a browser.
57
+ * tooling and CI, not a person and not a browser.
57
58
  *
58
59
  * **Logical names only.** Nothing in the response is a URI, and emphatically
59
60
  * nothing is `tenant_<organizationId>` — the tenant databases are named after
@@ -61,5 +62,5 @@ export interface ServiceDescription {
61
62
  * The names come from a probe's `targets`, which are Store register names by
62
63
  * construction.
63
64
  */
64
- export declare const serviceDescriptionRoute: (serviceName: string, slices: readonly string[]) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", HealthRegistryTag> | HttpRouter.Request<"Error", import("effect/http/HttpBody").HttpBodyError> | HttpRouter.Request<"Requires", WiringRegistryTag>>;
65
+ export declare const serviceDescriptionRoute: (serviceName: string, slices: readonly string[], guard: SharedToken) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", HealthRegistryTag> | HttpRouter.Request<"Error", import("effect/http/HttpBody").HttpBodyError> | HttpRouter.Request<"Requires", WiringRegistryTag>>;
65
66
  //# sourceMappingURL=service-description-route.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service-description-route.d.ts","sourceRoot":"","sources":["../src/service-description-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAI7D;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAExD,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,SAAS,eAAe,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IAC/C,gEAAgE;IAChE,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,mDAAmD;IACnD,QAAQ,CAAC,aAAa,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACtD;AAwCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,uBAAuB,GAClC,aAAa,MAAM,EACnB,QAAQ,SAAS,MAAM,EAAE,0PAUxB,CAAC"}
1
+ {"version":3,"file":"service-description-route.d.ts","sourceRoot":"","sources":["../src/service-description-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAE7D,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAExD,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,SAAS,eAAe,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IAC/C,gEAAgE;IAChE,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,mDAAmD;IACnD,QAAQ,CAAC,aAAa,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACtD;AAwCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,uBAAuB,GAClC,aAAa,MAAM,EACnB,QAAQ,SAAS,MAAM,EAAE,EACzB,OAAO,WAAW,0PAUjB,CAAC"}
@@ -1,7 +1,7 @@
1
1
  import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
2
2
  import { Effect } from 'effect';
3
3
  import { HttpRouter, HttpServerResponse } from 'effect/http';
4
- import { requireServiceToken } from './auth/service-token.js';
4
+ import { requireSharedToken } from './auth/shared-token.js';
5
5
  /**
6
6
  * The path the wiring document is served at.
7
7
  *
@@ -38,7 +38,7 @@ import { requireServiceToken } from './auth/service-token.js';
38
38
  /**
39
39
  * `GET /api/$service`, as a route layer to merge beside a service's own.
40
40
  *
41
- * **Gated by `X-Service-Token`.** This names every store, exchange and upstream
41
+ * **Gated by a shared token**, which the host names. This names every store, exchange and upstream
42
42
  * a service has, which is a reconnaissance map rather than diagnostics. The
43
43
  * repository's precedent on maps of the model is uniform: `$metadata` answers
44
44
  * `404` rather than `403` so it cannot be walked as an oracle, readiness serves
@@ -47,13 +47,13 @@ import { requireServiceToken } from './auth/service-token.js';
47
47
  * a probe cannot hold a credential.
48
48
  *
49
49
  * The token is fleet membership, which is the right shape here: the readers are
50
- * `dev-infra:map` and CI, not a person and not a browser.
50
+ * tooling and CI, not a person and not a browser.
51
51
  *
52
52
  * **Logical names only.** Nothing in the response is a URI, and emphatically
53
53
  * nothing is `tenant_<organizationId>` — the tenant databases are named after
54
54
  * organizations, so leaking one turns a wiring document into a customer list.
55
55
  * The names come from a probe's `targets`, which are Store register names by
56
56
  * construction.
57
- */ export const serviceDescriptionRoute = (serviceName, slices)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH, requireServiceToken(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description))));
57
+ */ export const serviceDescriptionRoute = (serviceName, slices, guard)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH, requireSharedToken(guard)(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description))));
58
58
 
59
59
  //# sourceMappingURL=service-description-route.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireServiceToken } from './auth/service-token.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure ADR 0026 hit with `$metadata`.\n */\nexport const SERVICE_DESCRIPTION_PATH = '/api/$service';\n\n/** One thing this process is connected to, named logically. */\nexport interface DescribedTarget {\n /** The Store's register name, an exchange, or a service. Never a URI. */\n readonly name: string;\n /** The readiness probe that covers it, so the two documents line up. */\n readonly probe: string;\n}\n\n/**\n * What a service says about its own wiring.\n *\n * Shape, not values — `/api/config` is the sibling that serves the parameters a\n * service was given, redacted. Keeping them apart is deliberate: one is redacted\n * because it holds credentials, the other gated because it holds a map, and\n * merging them forces the stricter rule onto both (ADR 0031).\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * (ADR 0021) — `marketplace-admin-service` runs the `transaction` slice too.\n */\n readonly slices: readonly string[];\n readonly stores: readonly DescribedTarget[];\n readonly brokers: readonly DescribedTarget[];\n readonly upstreams: readonly DescribedTarget[];\n /** Distinct event names this process has emitted since boot. */\n readonly published: readonly string[];\n /** The bus bindings this process actually made. */\n readonly subscriptions: readonly BoundSubscription[];\n}\n\n/**\n * Everything the two registries know, sorted by what it is.\n *\n * Generated from the **health probe registrations**, which is the one source\n * that cannot drift: a service that gains a datastore gains its probe by merging\n * the client's probe layer, so it gains a line here with no edit of its own.\n */\nconst describe = (\n service: string,\n slices: readonly string[],\n): Effect.Effect<\n ServiceDescription,\n never,\n HealthRegistryTag | WiringRegistryTag\n> =>\n Effect.gen(function* () {\n const health = yield* HealthRegistryTag;\n const wiring = yield* WiringRegistryTag;\n const probes = yield* health.probes;\n\n const ofKind = (kind: ProbeKind): readonly DescribedTarget[] =>\n probes\n .filter(probe => probe.kind === kind)\n .flatMap(probe =>\n probe.targets.map(name => ({ name, probe: probe.name })),\n );\n\n return {\n service,\n slices,\n stores: ofKind('datastore'),\n brokers: ofKind('broker'),\n upstreams: ofKind('upstream'),\n published: yield* wiring.published,\n subscriptions: yield* wiring.subscriptions,\n };\n });\n\n/**\n * `GET /api/$service`, as a route layer to merge beside a service's own.\n *\n * **Gated by `X-Service-Token`.** This names every store, exchange and upstream\n * a service has, which is a reconnaissance map rather than diagnostics. The\n * repository's precedent on maps of the model is uniform: `$metadata` answers\n * `404` rather than `403` so it cannot be walked as an oracle, readiness serves\n * probe names only, and `redactConfiguration` blanks anything flagged secret\n * because `/api/config` is unauthenticated. The health endpoints stay open —\n * a probe cannot hold a credential.\n *\n * The token is fleet membership, which is the right shape here: the readers are\n * `dev-infra:map` and CI, not a person and not a browser.\n *\n * **Logical names only.** Nothing in the response is a URI, and emphatically\n * nothing is `tenant_<organizationId>` — the tenant databases are named after\n * organizations, so leaking one turns a wiring document into a customer list.\n * The names come from a probe's `targets`, which are Store register names by\n * construction.\n */\nexport const serviceDescriptionRoute = (\n serviceName: string,\n slices: readonly string[],\n) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n requireServiceToken(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n );\n"],"names":["HealthRegistryTag","WiringRegistryTag","Effect","HttpRouter","HttpServerResponse","requireServiceToken","SERVICE_DESCRIPTION_PATH","describe","service","slices","gen","health","wiring","probes","ofKind","kind","filter","probe","flatMap","targets","map","name","stores","brokers","upstreams","published","subscriptions","serviceDescriptionRoute","serviceName","add","description","json"],"mappings":"AACA,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,oBAAoB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,UAAU,EAAEC,kBAAkB,QAAQ,cAAc;AAE7D,SAASC,mBAAmB,QAAQ,0BAA0B;AAE9D;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAkCxD;;;;;;CAMC,GACD,MAAMC,WAAW,CACfC,SACAC,SAMAP,OAAOQ,GAAG,CAAC;QACT,MAAMC,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOF,OAAOE,MAAM;QAEnC,MAAMC,SAAS,CAACC,OACdF,OACGG,MAAM,CAACC,CAAAA,QAASA,MAAMF,IAAI,KAAKA,MAC/BG,OAAO,CAACD,CAAAA,QACPA,MAAME,OAAO,CAACC,GAAG,CAACC,CAAAA,OAAS,CAAA;wBAAEA;wBAAMJ,OAAOA,MAAMI,IAAI;oBAAC,CAAA;QAG3D,OAAO;YACLb;YACAC;YACAa,QAAQR,OAAO;YACfS,SAAST,OAAO;YAChBU,WAAWV,OAAO;YAClBW,WAAW,OAAOb,OAAOa,SAAS;YAClCC,eAAe,OAAOd,OAAOc,aAAa;QAC5C;IACF;AAEF;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,aACAnB,SAEAN,WAAW0B,GAAG,CACZ,OACAvB,0BACAD,oBACEH,OAAOgB,OAAO,CAACX,SAASqB,aAAanB,SAASqB,CAAAA,cAC5C1B,mBAAmB2B,IAAI,CAACD,gBAG5B"}
1
+ {"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireSharedToken, type SharedToken } from './auth/shared-token.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure ADR 0026 hit with `$metadata`.\n */\nexport const SERVICE_DESCRIPTION_PATH = '/api/$service';\n\n/** One thing this process is connected to, named logically. */\nexport interface DescribedTarget {\n /** The Store's register name, an exchange, or a service. Never a URI. */\n readonly name: string;\n /** The readiness probe that covers it, so the two documents line up. */\n readonly probe: string;\n}\n\n/**\n * What a service says about its own wiring.\n *\n * Shape, not values — `/api/config` is the sibling that serves the parameters a\n * service was given, redacted. Keeping them apart is deliberate: one is redacted\n * because it holds credentials, the other gated because it holds a map, and\n * merging them forces the stricter rule onto both (ADR 0031).\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * (ADR 0021) — `marketplace-admin-service` runs the `transaction` slice too.\n */\n readonly slices: readonly string[];\n readonly stores: readonly DescribedTarget[];\n readonly brokers: readonly DescribedTarget[];\n readonly upstreams: readonly DescribedTarget[];\n /** Distinct event names this process has emitted since boot. */\n readonly published: readonly string[];\n /** The bus bindings this process actually made. */\n readonly subscriptions: readonly BoundSubscription[];\n}\n\n/**\n * Everything the two registries know, sorted by what it is.\n *\n * Generated from the **health probe registrations**, which is the one source\n * that cannot drift: a service that gains a datastore gains its probe by merging\n * the client's probe layer, so it gains a line here with no edit of its own.\n */\nconst describe = (\n service: string,\n slices: readonly string[],\n): Effect.Effect<\n ServiceDescription,\n never,\n HealthRegistryTag | WiringRegistryTag\n> =>\n Effect.gen(function* () {\n const health = yield* HealthRegistryTag;\n const wiring = yield* WiringRegistryTag;\n const probes = yield* health.probes;\n\n const ofKind = (kind: ProbeKind): readonly DescribedTarget[] =>\n probes\n .filter(probe => probe.kind === kind)\n .flatMap(probe =>\n probe.targets.map(name => ({ name, probe: probe.name })),\n );\n\n return {\n service,\n slices,\n stores: ofKind('datastore'),\n brokers: ofKind('broker'),\n upstreams: ofKind('upstream'),\n published: yield* wiring.published,\n subscriptions: yield* wiring.subscriptions,\n };\n });\n\n/**\n * `GET /api/$service`, as a route layer to merge beside a service's own.\n *\n * **Gated by a shared token**, which the host names. This names every store, exchange and upstream\n * a service has, which is a reconnaissance map rather than diagnostics. The\n * repository's precedent on maps of the model is uniform: `$metadata` answers\n * `404` rather than `403` so it cannot be walked as an oracle, readiness serves\n * probe names only, and `redactConfiguration` blanks anything flagged secret\n * because `/api/config` is unauthenticated. The health endpoints stay open —\n * a probe cannot hold a credential.\n *\n * The token is fleet membership, which is the right shape here: the readers are\n * tooling and CI, not a person and not a browser.\n *\n * **Logical names only.** Nothing in the response is a URI, and emphatically\n * nothing is `tenant_<organizationId>` — the tenant databases are named after\n * organizations, so leaking one turns a wiring document into a customer list.\n * The names come from a probe's `targets`, which are Store register names by\n * construction.\n */\nexport const serviceDescriptionRoute = (\n serviceName: string,\n slices: readonly string[],\n guard: SharedToken,\n) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n requireSharedToken(guard)(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n );\n"],"names":["HealthRegistryTag","WiringRegistryTag","Effect","HttpRouter","HttpServerResponse","requireSharedToken","SERVICE_DESCRIPTION_PATH","describe","service","slices","gen","health","wiring","probes","ofKind","kind","filter","probe","flatMap","targets","map","name","stores","brokers","upstreams","published","subscriptions","serviceDescriptionRoute","serviceName","guard","add","description","json"],"mappings":"AACA,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,oBAAoB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,UAAU,EAAEC,kBAAkB,QAAQ,cAAc;AAE7D,SAASC,kBAAkB,QAA0B,yBAAyB;AAE9E;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAkCxD;;;;;;CAMC,GACD,MAAMC,WAAW,CACfC,SACAC,SAMAP,OAAOQ,GAAG,CAAC;QACT,MAAMC,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOF,OAAOE,MAAM;QAEnC,MAAMC,SAAS,CAACC,OACdF,OACGG,MAAM,CAACC,CAAAA,QAASA,MAAMF,IAAI,KAAKA,MAC/BG,OAAO,CAACD,CAAAA,QACPA,MAAME,OAAO,CAACC,GAAG,CAACC,CAAAA,OAAS,CAAA;wBAAEA;wBAAMJ,OAAOA,MAAMI,IAAI;oBAAC,CAAA;QAG3D,OAAO;YACLb;YACAC;YACAa,QAAQR,OAAO;YACfS,SAAST,OAAO;YAChBU,WAAWV,OAAO;YAClBW,WAAW,OAAOb,OAAOa,SAAS;YAClCC,eAAe,OAAOd,OAAOc,aAAa;QAC5C;IACF;AAEF;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,aACAnB,QACAoB,QAEA1B,WAAW2B,GAAG,CACZ,OACAxB,0BACAD,mBAAmBwB,OACjB3B,OAAOgB,OAAO,CAACX,SAASqB,aAAanB,SAASsB,CAAAA,cAC5C3B,mBAAmB4B,IAAI,CAACD,gBAG5B"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A service on an ephemeral port for a spec, and the wait that tells it is up.
3
+ * Test-only, so it never reaches `.`.
4
+ */
5
+ export * from './serve-test-service.js';
6
+ export * from './wait-for-http.js';
7
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A service on an ephemeral port for a spec, and the wait that tells it is up.
3
+ * Test-only, so it never reaches `.`.
4
+ */ export * from './serve-test-service.js';
5
+ export * from './wait-for-http.js';
6
+
7
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/testing.ts"],"sourcesContent":["/**\n * A service on an ephemeral port for a spec, and the wait that tells it is up.\n * Test-only, so it never reaches `.`.\n */\nexport * from './serve-test-service.js';\nexport * from './wait-for-http.js';\n"],"names":[],"mappings":"AAAA;;;CAGC,GACD,cAAc,0BAA0B;AACxC,cAAc,qBAAqB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/service-shell",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Effect HTTP service skeleton: server layer, health and metadata routes, permission guards, remote configuration and OpenTelemetry.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,21 @@
26
26
  "types": "./dist/index.d.ts",
27
27
  "import": "./dist/index.js",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./runtime": {
31
+ "types": "./dist/runtime.d.ts",
32
+ "import": "./dist/runtime.js",
33
+ "default": "./dist/runtime.js"
34
+ },
35
+ "./observability": {
36
+ "types": "./dist/observability.d.ts",
37
+ "import": "./dist/observability.js",
38
+ "default": "./dist/observability.js"
39
+ },
40
+ "./testing": {
41
+ "types": "./dist/testing.d.ts",
42
+ "import": "./dist/testing.js",
43
+ "default": "./dist/testing.js"
29
44
  }
30
45
  },
31
46
  "nx": {
@@ -50,26 +65,59 @@
50
65
  }
51
66
  },
52
67
  "dependencies": {
68
+ "@swc/helpers": "0.5.23",
69
+ "@entifix/authz": "0.5.0",
70
+ "@entifix/core": "0.5.0",
71
+ "@entifix/business": "0.5.0",
72
+ "@entifix/tooling": "0.5.0"
73
+ },
74
+ "devDependencies": {
53
75
  "@effect/opentelemetry": "4.0.5",
54
76
  "@effect/platform-node": "4.0.5",
77
+ "@opentelemetry/api": "1.9.1",
55
78
  "@opentelemetry/context-async-hooks": "2.12.0",
56
79
  "@opentelemetry/exporter-metrics-otlp-http": "0.223.0",
57
80
  "@opentelemetry/exporter-trace-otlp-http": "0.223.0",
58
81
  "@opentelemetry/sdk-metrics": "2.12.0",
59
82
  "@opentelemetry/sdk-trace-base": "2.12.0",
60
- "@swc/helpers": "0.5.23",
61
- "@entifix/authz": "0.4.1",
62
- "@entifix/business": "0.4.1",
63
- "@entifix/core": "0.4.1",
64
- "@entifix/tooling": "0.4.1"
65
- },
66
- "devDependencies": {
67
- "@opentelemetry/api": "1.9.1",
68
83
  "effect": "4.0.5",
69
- "@entifix/testing-unit": "0.4.1"
84
+ "@entifix/testing-unit": "0.5.0"
70
85
  },
71
86
  "peerDependencies": {
87
+ "@effect/opentelemetry": "^4.0.5",
88
+ "@effect/platform-node": "^4.0.5",
72
89
  "@opentelemetry/api": ">=1.9 <2",
90
+ "@opentelemetry/context-async-hooks": ">=2.12 <3",
91
+ "@opentelemetry/exporter-metrics-otlp-http": ">=0.223 <0.224",
92
+ "@opentelemetry/exporter-trace-otlp-http": ">=0.223 <0.224",
93
+ "@opentelemetry/sdk-metrics": ">=2.12 <3",
94
+ "@opentelemetry/sdk-trace-base": ">=2.12 <3",
73
95
  "effect": "^4.0.5"
96
+ },
97
+ "peerDependenciesMeta": {
98
+ "@effect/opentelemetry": {
99
+ "optional": true
100
+ },
101
+ "@effect/platform-node": {
102
+ "optional": true
103
+ },
104
+ "@opentelemetry/api": {
105
+ "optional": true
106
+ },
107
+ "@opentelemetry/context-async-hooks": {
108
+ "optional": true
109
+ },
110
+ "@opentelemetry/exporter-metrics-otlp-http": {
111
+ "optional": true
112
+ },
113
+ "@opentelemetry/exporter-trace-otlp-http": {
114
+ "optional": true
115
+ },
116
+ "@opentelemetry/sdk-metrics": {
117
+ "optional": true
118
+ },
119
+ "@opentelemetry/sdk-trace-base": {
120
+ "optional": true
121
+ }
74
122
  }
75
123
  }
@@ -1,37 +0,0 @@
1
- import { Effect } from 'effect';
2
- import { HttpServerRequest, HttpServerResponse } from 'effect/http';
3
- /** The header a caller of config-service's fleet lookup presents. */
4
- export declare const SERVICE_TOKEN_HEADER = "x-service-token";
5
- /**
6
- * Local-dev default, so `pnpm run back-office:dev` stays zero-config. Same
7
- * convention as the seeded `jwt.secret`: obviously a placeholder, replaced per
8
- * environment through `CONFIG_SERVICE_TOKEN`.
9
- */
10
- export declare const DEV_SERVICE_TOKEN = "dev-config-service-token-change-me";
11
- /**
12
- * The expected token, read from the environment on each call rather than captured
13
- * at module load — so a test can set it without re-importing the module.
14
- */
15
- export declare const serviceToken: () => string;
16
- /**
17
- * Guards config-service's fleet configuration lookup.
18
- *
19
- * `GET /api/config/:service` hands out `jwt.secret`, `mongo.uri`, `redis.uri` and
20
- * `rabbitmq.uri` in plaintext, because that is exactly what a booting service
21
- * needs — the values cannot be redacted here without breaking the boot the route
22
- * exists to serve. So the route is gated on a shared token instead, which closes
23
- * the drive-by read that being merely "cluster-internal" did not.
24
- *
25
- * This is deliberately **not** service identity: every caller presents the same
26
- * token, so it proves fleet membership and nothing more. Per-service credentials,
27
- * with the response scoped to the service that asked, are separate work; this is
28
- * the cheap step that stops the port from being self-serve.
29
- *
30
- * It lives here beside {@link loadRemoteConfiguration}, the client half of the
31
- * same protocol, so the header and the token cannot drift apart.
32
- *
33
- * Health endpoints are **not** guarded — a probe must answer without credentials,
34
- * or a readiness check becomes an authentication problem.
35
- */
36
- export declare const requireServiceToken: <A, E, R>(route: Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("effect/http/HttpBody").HttpBodyError | E, HttpServerRequest.HttpServerRequest | R>;
37
- //# sourceMappingURL=service-token.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"service-token.d.ts","sourceRoot":"","sources":["../../src/auth/service-token.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,oBAAoB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,uCAAuC,CAAC;AAEtE;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAO,MACuB,CAAC;AAYxD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,wJAiBtE,CAAC"}
@@ -1,62 +0,0 @@
1
- import { timingSafeEqual } from 'node:crypto';
2
- import { Effect } from 'effect';
3
- import { HttpServerRequest, HttpServerResponse } from 'effect/http';
4
- /** The header a caller of config-service's fleet lookup presents. */ export const SERVICE_TOKEN_HEADER = 'x-service-token';
5
- /**
6
- * Local-dev default, so `pnpm run back-office:dev` stays zero-config. Same
7
- * convention as the seeded `jwt.secret`: obviously a placeholder, replaced per
8
- * environment through `CONFIG_SERVICE_TOKEN`.
9
- */ export const DEV_SERVICE_TOKEN = 'dev-config-service-token-change-me';
10
- /**
11
- * The expected token, read from the environment on each call rather than captured
12
- * at module load — so a test can set it without re-importing the module.
13
- */ export const serviceToken = ()=>{
14
- var _process_env_CONFIG_SERVICE_TOKEN;
15
- return (_process_env_CONFIG_SERVICE_TOKEN = process.env.CONFIG_SERVICE_TOKEN) != null ? _process_env_CONFIG_SERVICE_TOKEN : DEV_SERVICE_TOKEN;
16
- };
17
- /** Constant-time comparison, so the token cannot be recovered byte by byte. */ const matches = (provided)=>{
18
- if (provided === undefined) return false;
19
- const left = Buffer.from(provided);
20
- const right = Buffer.from(serviceToken());
21
- // `timingSafeEqual` throws on a length mismatch, so the lengths are compared
22
- // first — the length of a shared secret is not what protects it.
23
- return left.length === right.length && timingSafeEqual(left, right);
24
- };
25
- /**
26
- * Guards config-service's fleet configuration lookup.
27
- *
28
- * `GET /api/config/:service` hands out `jwt.secret`, `mongo.uri`, `redis.uri` and
29
- * `rabbitmq.uri` in plaintext, because that is exactly what a booting service
30
- * needs — the values cannot be redacted here without breaking the boot the route
31
- * exists to serve. So the route is gated on a shared token instead, which closes
32
- * the drive-by read that being merely "cluster-internal" did not.
33
- *
34
- * This is deliberately **not** service identity: every caller presents the same
35
- * token, so it proves fleet membership and nothing more. Per-service credentials,
36
- * with the response scoped to the service that asked, are separate work; this is
37
- * the cheap step that stops the port from being self-serve.
38
- *
39
- * It lives here beside {@link loadRemoteConfiguration}, the client half of the
40
- * same protocol, so the header and the token cannot drift apart.
41
- *
42
- * Health endpoints are **not** guarded — a probe must answer without credentials,
43
- * or a readiness check becomes an authentication problem.
44
- */ export const requireServiceToken = (route)=>// The return type is inferred rather than written out: `HttpServerResponse.json`
45
- // contributes an `HttpBodyError` to the failure channel, and spelling the type
46
- // out while omitting it is what makes `tsc` refuse the declaration emit — which
47
- // `@nx/js:swc` hides, leaving a green build with no `.d.ts` and a TS6305
48
- // cascade in every consumer. `requirePrincipal` infers for the same reason.
49
- Effect.gen(function*() {
50
- const request = yield* HttpServerRequest.HttpServerRequest;
51
- if (!matches(request.headers[SERVICE_TOKEN_HEADER])) {
52
- return yield* HttpServerResponse.json({
53
- error: 'unauthenticated',
54
- code: 'unauthenticated'
55
- }, {
56
- status: 401
57
- });
58
- }
59
- return yield* route;
60
- });
61
-
62
- //# sourceMappingURL=service-token.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/auth/service-token.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/** The header a caller of config-service's fleet lookup presents. */\nexport const SERVICE_TOKEN_HEADER = 'x-service-token';\n\n/**\n * Local-dev default, so `pnpm run back-office:dev` stays zero-config. Same\n * convention as the seeded `jwt.secret`: obviously a placeholder, replaced per\n * environment through `CONFIG_SERVICE_TOKEN`.\n */\nexport const DEV_SERVICE_TOKEN = 'dev-config-service-token-change-me';\n\n/**\n * The expected token, read from the environment on each call rather than captured\n * at module load — so a test can set it without re-importing the module.\n */\nexport const serviceToken = (): string =>\n process.env.CONFIG_SERVICE_TOKEN ?? DEV_SERVICE_TOKEN;\n\n/** Constant-time comparison, so the token cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(serviceToken());\n // `timingSafeEqual` throws on a length mismatch, so the lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\n/**\n * Guards config-service's fleet configuration lookup.\n *\n * `GET /api/config/:service` hands out `jwt.secret`, `mongo.uri`, `redis.uri` and\n * `rabbitmq.uri` in plaintext, because that is exactly what a booting service\n * needs — the values cannot be redacted here without breaking the boot the route\n * exists to serve. So the route is gated on a shared token instead, which closes\n * the drive-by read that being merely \"cluster-internal\" did not.\n *\n * This is deliberately **not** service identity: every caller presents the same\n * token, so it proves fleet membership and nothing more. Per-service credentials,\n * with the response scoped to the service that asked, are separate work; this is\n * the cheap step that stops the port from being self-serve.\n *\n * It lives here beside {@link loadRemoteConfiguration}, the client half of the\n * same protocol, so the header and the token cannot drift apart.\n *\n * Health endpoints are **not** guarded — a probe must answer without credentials,\n * or a readiness check becomes an authentication problem.\n */\nexport const requireServiceToken = <A, E, R>(route: Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out: `HttpServerResponse.json`\n // contributes an `HttpBodyError` to the failure channel, and spelling the type\n // out while omitting it is what makes `tsc` refuse the declaration emit — which\n // `@nx/js:swc` hides, leaving a green build with no `.d.ts` and a TS6305\n // cascade in every consumer. `requirePrincipal` infers for the same reason.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n\n if (!matches(request.headers[SERVICE_TOKEN_HEADER])) {\n return yield* HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n );\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","Effect","HttpServerRequest","HttpServerResponse","SERVICE_TOKEN_HEADER","DEV_SERVICE_TOKEN","serviceToken","process","env","CONFIG_SERVICE_TOKEN","matches","provided","undefined","left","Buffer","from","right","length","requireServiceToken","route","gen","request","headers","json","error","code","status"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE,mEAAmE,GACnE,OAAO,MAAMC,uBAAuB,kBAAkB;AAEtD;;;;CAIC,GACD,OAAO,MAAMC,oBAAoB,qCAAqC;AAEtE;;;CAGC,GACD,OAAO,MAAMC,eAAe;QAC1BC;YAAAA,oCAAAA,QAAQC,GAAG,CAACC,oBAAoB,YAAhCF,oCAAoCF;EAAkB;AAExD,6EAA6E,GAC7E,MAAMK,UAAU,CAACC;IACf,IAAIA,aAAaC,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACJ;IACzB,MAAMK,QAAQF,OAAOC,IAAI,CAACT;IAC1B,6EAA6E;IAC7E,iEAAiE;IACjE,OAAOO,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIjB,gBAAgBa,MAAMG;AAC/D;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,MAAME,sBAAsB,CAAUC,QAC3C,iFAAiF;IACjF,+EAA+E;IAC/E,gFAAgF;IAChF,yEAAyE;IACzE,4EAA4E;IAC5ElB,OAAOmB,GAAG,CAAC;QACT,MAAMC,UAAU,OAAOnB,kBAAkBA,iBAAiB;QAE1D,IAAI,CAACQ,QAAQW,QAAQC,OAAO,CAAClB,qBAAqB,GAAG;YACnD,OAAO,OAAOD,mBAAmBoB,IAAI,CACnC;gBAAEC,OAAO;gBAAmBC,MAAM;YAAkB,GACpD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,OAAO,OAAOP;IAChB,GAAG"}