@entifix/service-shell 0.1.0-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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +5 -0
  3. package/dist/auth/index.d.ts.map +1 -0
  4. package/dist/auth/index.js +6 -0
  5. package/dist/auth/index.js.map +1 -0
  6. package/dist/auth/require-organization.d.ts +35 -0
  7. package/dist/auth/require-organization.d.ts.map +1 -0
  8. package/dist/auth/require-organization.js +59 -0
  9. package/dist/auth/require-organization.js.map +1 -0
  10. package/dist/auth/require-principal.d.ts +70 -0
  11. package/dist/auth/require-principal.d.ts.map +1 -0
  12. package/dist/auth/require-principal.js +105 -0
  13. package/dist/auth/require-principal.js.map +1 -0
  14. package/dist/auth/require-service-crossing.d.ts +107 -0
  15. package/dist/auth/require-service-crossing.d.ts.map +1 -0
  16. package/dist/auth/require-service-crossing.js +161 -0
  17. package/dist/auth/require-service-crossing.js.map +1 -0
  18. package/dist/auth/service-token.d.ts +37 -0
  19. package/dist/auth/service-token.d.ts.map +1 -0
  20. package/dist/auth/service-token.js +62 -0
  21. package/dist/auth/service-token.js.map +1 -0
  22. package/dist/entity-metadata-route.d.ts +25 -0
  23. package/dist/entity-metadata-route.d.ts.map +1 -0
  24. package/dist/entity-metadata-route.js +89 -0
  25. package/dist/entity-metadata-route.js.map +1 -0
  26. package/dist/health-routes.d.ts +24 -0
  27. package/dist/health-routes.d.ts.map +1 -0
  28. package/dist/health-routes.js +81 -0
  29. package/dist/health-routes.js.map +1 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/load-remote-configuration.d.ts +77 -0
  35. package/dist/load-remote-configuration.d.ts.map +1 -0
  36. package/dist/load-remote-configuration.js +87 -0
  37. package/dist/load-remote-configuration.js.map +1 -0
  38. package/dist/make-service.d.ts +77 -0
  39. package/dist/make-service.d.ts.map +1 -0
  40. package/dist/make-service.js +70 -0
  41. package/dist/make-service.js.map +1 -0
  42. package/dist/observability.d.ts +118 -0
  43. package/dist/observability.d.ts.map +1 -0
  44. package/dist/observability.js +248 -0
  45. package/dist/observability.js.map +1 -0
  46. package/dist/redact-configuration.d.ts +20 -0
  47. package/dist/redact-configuration.d.ts.map +1 -0
  48. package/dist/redact-configuration.js +34 -0
  49. package/dist/redact-configuration.js.map +1 -0
  50. package/dist/serve-test-service.d.ts +37 -0
  51. package/dist/serve-test-service.d.ts.map +1 -0
  52. package/dist/serve-test-service.js +64 -0
  53. package/dist/serve-test-service.js.map +1 -0
  54. package/dist/service-description-route.d.ts +65 -0
  55. package/dist/service-description-route.d.ts.map +1 -0
  56. package/dist/service-description-route.js +59 -0
  57. package/dist/service-description-route.js.map +1 -0
  58. package/dist/sse.d.ts +40 -0
  59. package/dist/sse.d.ts.map +1 -0
  60. package/dist/sse.js +62 -0
  61. package/dist/sse.js.map +1 -0
  62. package/dist/wait-for-http.d.ts +18 -0
  63. package/dist/wait-for-http.d.ts.map +1 -0
  64. package/dist/wait-for-http.js +26 -0
  65. package/dist/wait-for-http.js.map +1 -0
  66. package/package.json +79 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/require-service-crossing.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { HttpServerRequest, HttpServerResponse } from '@effect/platform';\nimport { type Permission, ServiceCrossingPolicyTag } from '@entifix/authz';\nimport { Context, Effect } from 'effect';\n\n/**\n * The header carrying the crossing secret.\n *\n * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's\n * fleet lookup, and the two secrets are not comparable: one grants a\n * configuration *read*, this one a tenant-data *write* for any organization the\n * caller names. Sharing the header name is how a proxy configured to forward the\n * fleet token ends up presenting it to a route that writes stock, and how a\n * copy-pasted value looks right while being the wrong secret entirely\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).\n */\nexport const CROSSING_TOKEN_HEADER = 'x-crossing-token';\n\n/**\n * The header naming the organization the caller is acting for.\n *\n * It rides beside the token rather than inside the request body because\n * entities are organization-agnostic by rule — no `organizationId` member, no\n * tenant filter — so there is nowhere in an entity envelope for it to live. A\n * header also serves the crossings that carry no body at all (releasing a hold,\n * converting one), which a body field would have needed a second convention for.\n *\n * On its own it grants **nothing**: it is only read after the token has proved\n * the caller is the fleet, so the input the caller controls is never the input\n * that authorizes.\n */\nexport const ORGANIZATION_HEADER = 'x-organization-id';\n\n/**\n * The crossing secret this service expects, provided at the composition root\n * from its own configuration.\n *\n * A `Context.Tag` rather than an environment read, which is the difference from\n * {@link requireServiceToken}: the value is a per-service secret held in\n * config-service as an `is_secret` row, so it arrives through the same boot\n * fetch as `mongo.uri` and can be rotated without redeploying a container.\n */\nexport class ServiceCrossingTokenTag extends Context.Tag(\n 'ServiceCrossingTokenTag',\n)<ServiceCrossingTokenTag, string>() {}\n\n/** Constant-time comparison, so the secret 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 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\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\nconst organizationRequired = HttpServerResponse.json(\n {\n error: 'the crossing must name an organization',\n code: 'invalidRequest',\n detail: `${ORGANIZATION_HEADER} is required`,\n },\n { status: 400 },\n);\n\n/**\n * Guard a **service-to-service** tenant crossing: the second — and only other —\n * way a request resolves the organization it acts for.\n *\n * The first is {@link requireOrganization}, which reads the session's\n * `activeOrganizationId` and is the only *user-facing* resolution. This one\n * exists because a platform-plane caller sometimes acts for an organization the\n * principal is not a member of: checkout reserving a vendor's stock, where the\n * organization comes from the **item** and a buyer's session carries none — and\n * never will, since making a buyer a member of every vendor they buy from would\n * be a far worse breach than the problem it solved.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard simply never reaches a tenant handle\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);\n * ADR 0012's operator crossing is a *different* mechanism — a person choosing an\n * organization, which needs a human's permission, a time box and a `Crossing`\n * record).\n *\n * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is\n * the design.** A route guarded here accepts the crossing token and nothing\n * else: two accepted credentials on one route means the weaker one is the\n * security level. It matters concretely — `super-admin` holds `*:*:*`, which\n * matches every crossing permission, so a route that also took a session would\n * be reachable by an operator's browser tab.\n *\n * Three checks, in this order, so nothing is revealed before fleet membership is\n * proven:\n *\n * 1. the token, compared in constant time → `401`;\n * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token\n * proves the caller is the fleet; it does not say what the caller may do, and\n * fleet membership is not a capability;\n * 3. the organization header, non-blank → `400`.\n *\n * **The recorded residual** (ADR 0023, not argued away here): a shared secret\n * means any process holding it can name any organization. What bounds it is the\n * permission above, the fact that only the slices needing it are configured with\n * the key, and the request log. What would remove it is an RS256 service token\n * carrying a scope claim, which is named work rather than a gap nobody noticed.\n */\nexport const requireServiceCrossing =\n (permission: Permission) =>\n <A, E, R>(use: (organizationId: string) => Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out, for the reason\n // `requireServiceToken` records: `HttpServerResponse.json` contributes an\n // `HttpBodyError` to the failure channel, and spelling the type out while\n // omitting it is what makes the declaration emit fail invisibly.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n const organizationId = request.headers[ORGANIZATION_HEADER]?.trim();\n if (organizationId === undefined || organizationId === '') {\n return yield* organizationRequired;\n }\n\n return yield* use(organizationId);\n });\n\n/**\n * The same fleet check, for a participant that has **no organization to name**.\n *\n * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`\n * above ends with an organization header because it is about to resolve a tenant\n * database handle from it, and a blank one there would read some other vendor's\n * storage. order-service names one database at boot, so there is no handle to\n * choose — and demanding a header nothing consumes would be theatre that a\n * caller learns to satisfy with any value.\n *\n * What is unchanged, and is the part that matters: the token proves the caller\n * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,\n * and **no session is accepted**. A saga step is not a person's act — the buyer\n * behind a checkout holds no grant over the order the coordinator is writing on\n * their behalf, and accepting their session here would make the weaker\n * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).\n *\n * The recorded residual carries over verbatim: a shared secret means any process\n * holding it can act, bounded by the permission and by which slices are\n * configured with the key.\n */\nexport const requireCrossing =\n (permission: Permission) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","HttpServerRequest","HttpServerResponse","ServiceCrossingPolicyTag","Context","Effect","CROSSING_TOKEN_HEADER","ORGANIZATION_HEADER","ServiceCrossingTokenTag","Tag","matches","provided","expected","undefined","left","Buffer","from","right","length","unauthenticated","json","error","code","status","forbidden","permission","organizationRequired","detail","requireServiceCrossing","use","gen","request","headers","crossing","allows","organizationId","trim","requireCrossing","route"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,mBAAmB;AACzE,SAA0BC,wBAAwB,QAAQ,iBAAiB;AAC3E,SAASC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AAEzC;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,wBAAwB,mBAAmB;AAExD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,sBAAsB,oBAAoB;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgCJ,QAAQK,GAAG,CACtD;AACoC;AAEtC,8EAA8E,GAC9E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,yEAAyE;IACzE,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIlB,gBAAgBc,MAAMG;AAC/D;AAEA,MAAME,kBAAkBjB,mBAAmBkB,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBvB,mBAAmBkB,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB,MAAMG,uBAAuBxB,mBAAmBkB,IAAI,CAClD;IACEC,OAAO;IACPC,MAAM;IACNK,QAAQ,GAAGpB,oBAAoB,YAAY,CAAC;AAC9C,GACA;IAAEgB,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GACD,OAAO,MAAMK,yBACX,CAACH,aACD,CAAUI,MACR,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjExB,OAAOyB,GAAG,CAAC;gBAacC;YAZvB,MAAMA,UAAU,OAAO9B,kBAAkBA,iBAAiB;YAC1D,MAAMW,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAO9B;YACxB,IAAI,CAAC8B,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,MAAMU,kBAAiBJ,uCAAAA,QAAQC,OAAO,CAACzB,oBAAoB,qBAApCwB,qCAAsCK,IAAI;YACjE,IAAID,mBAAmBtB,aAAasB,mBAAmB,IAAI;gBACzD,OAAO,OAAOT;YAChB;YAEA,OAAO,OAAOG,IAAIM;QACpB,GAAG;AAEP;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAME,kBACX,CAACZ,aACD,CAAUa,QACRjC,OAAOyB,GAAG,CAAC;YACT,MAAMC,UAAU,OAAO9B,kBAAkBA,iBAAiB;YAC1D,MAAMW,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAO9B;YACxB,IAAI,CAAC8B,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,OAAO,OAAOa;QAChB,GAAG"}
@@ -0,0 +1,37 @@
1
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
2
+ import { Effect } from 'effect';
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/platform/HttpBody").HttpBodyError | E, HttpServerRequest.HttpServerRequest | R>;
37
+ //# sourceMappingURL=service-token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-token.d.ts","sourceRoot":"","sources":["../../src/auth/service-token.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,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,6JAiBtE,CAAC"}
@@ -0,0 +1,62 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
3
+ import { Effect } from 'effect';
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
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/service-token.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { HttpServerRequest, HttpServerResponse } from '@effect/platform';\nimport { Effect } from 'effect';\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","HttpServerRequest","HttpServerResponse","Effect","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,iBAAiB,EAAEC,kBAAkB,QAAQ,mBAAmB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAEhC,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;IAC5EhB,OAAOiB,GAAG,CAAC;QACT,MAAMC,UAAU,OAAOpB,kBAAkBA,iBAAiB;QAE1D,IAAI,CAACS,QAAQW,QAAQC,OAAO,CAAClB,qBAAqB,GAAG;YACnD,OAAO,OAAOF,mBAAmBqB,IAAI,CACnC;gBAAEC,OAAO;gBAAmBC,MAAM;YAAkB,GACpD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,OAAO,OAAOP;IAChB,GAAG"}
@@ -0,0 +1,25 @@
1
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
2
+ import { PolicyDecisionTag } from '@entifix/authz';
3
+ import { type Entity, type EntityConstructor } from '@entifix/core';
4
+ import { Effect } from 'effect';
5
+ /**
6
+ * `GET /api/<entity>/$metadata` — what this caller may do with one entity.
7
+ *
8
+ * Mounted **per entity, at a literal path**, not as a `/api/:entity/$metadata`
9
+ * wildcard. `HttpRouter` resolves through `find-my-way-ts`, where a static
10
+ * segment beats a parametric one and there is no backtracking once the
11
+ * parametric branch has matched: alongside an existing `/api/<entity>/:id`, a
12
+ * wildcard `$metadata` route never runs at all — the by-id handler wins with
13
+ * `id === "$metadata"`, misses, and answers its own `404`. The endpoint would
14
+ * read as "this entity has no metadata" while appearing mounted. A literal wins
15
+ * regardless of registration order, which is why the base exports a route for
16
+ * one entity and each service registers it for each of its own
17
+ * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).
18
+ *
19
+ * Deliberately **not** behind `requireOrganization`, even on a tenant-plane
20
+ * service: the document describes the model, not tenant data, and resolving a
21
+ * database handle here would leave a vendor with no active organization unable
22
+ * to see their own affordances.
23
+ */
24
+ export declare const entityMetadataRoute: <TEntity extends Entity>(entityConstructor: EntityConstructor<TEntity>) => Effect.Effect<HttpServerResponse.HttpServerResponse, import("@effect/platform/HttpBody").HttpBodyError, PolicyDecisionTag | import("@entifix/business").TokenServiceTag | HttpServerRequest.HttpServerRequest>;
25
+ //# sourceMappingURL=entity-metadata-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-metadata-route.d.ts","sourceRoot":"","sources":["../src/entity-metadata-route.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAKL,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EAIvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIhC;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,EACxD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,mNA0D5C,CAAC"}
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
3
+ import { parsePermission, permissionForEntity, permissionForUseCase, PolicyDecisionTag } from '@entifix/authz';
4
+ import { describeEntityUseCases, ENTITY_ACTIONS, extractMetaEntity, makeEntityMetadataEnvelope } from '@entifix/core';
5
+ import { Effect } from 'effect';
6
+ import { requirePrincipal } from './auth/index.js';
7
+ /**
8
+ * `GET /api/<entity>/$metadata` — what this caller may do with one entity.
9
+ *
10
+ * Mounted **per entity, at a literal path**, not as a `/api/:entity/$metadata`
11
+ * wildcard. `HttpRouter` resolves through `find-my-way-ts`, where a static
12
+ * segment beats a parametric one and there is no backtracking once the
13
+ * parametric branch has matched: alongside an existing `/api/<entity>/:id`, a
14
+ * wildcard `$metadata` route never runs at all — the by-id handler wins with
15
+ * `id === "$metadata"`, misses, and answers its own `404`. The endpoint would
16
+ * read as "this entity has no metadata" while appearing mounted. A literal wins
17
+ * regardless of registration order, which is why the base exports a route for
18
+ * one entity and each service registers it for each of its own
19
+ * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).
20
+ *
21
+ * Deliberately **not** behind `requireOrganization`, even on a tenant-plane
22
+ * service: the document describes the model, not tenant data, and resolving a
23
+ * database handle here would leave a vendor with no active organization unable
24
+ * to see their own affordances.
25
+ */ export const entityMetadataRoute = (entityConstructor)=>requirePrincipal((principal)=>Effect.gen(function*() {
26
+ const policy = yield* PolicyDecisionTag;
27
+ const allows = (permission)=>{
28
+ const { resource, action } = parsePermission(permission);
29
+ return policy.decide({
30
+ subject: {
31
+ roles: principal.roles
32
+ },
33
+ resource,
34
+ action
35
+ });
36
+ };
37
+ // SECURITY BOUNDARY. The document names domains, entity keys and verbs —
38
+ // it is a map of the model — so an entity the caller may not read answers
39
+ // exactly as one this service does not host. A `403` here would make the
40
+ // endpoint an oracle: walk it and learn the shape of everything you are
41
+ // not allowed to see. Same category as `redactConfiguration`, not
42
+ // diagnostics polish.
43
+ if (!allows(permissionForEntity(entityConstructor, 'read'))) {
44
+ return yield* notFound(entityConstructor);
45
+ }
46
+ const document = {
47
+ actions: ENTITY_ACTIONS.filter((action)=>allows(permissionForEntity(entityConstructor, action))),
48
+ useCases: describeEntityUseCases(entityConstructor).filter((descriptor)=>allows(permissionForUseCase(entityConstructor, descriptor.key)))
49
+ };
50
+ // The ETag hashes the *computed* document, not the descriptor set the
51
+ // deployment declares. Two principals with different grants get different
52
+ // documents from the same model, so a hash of the model would `304` one
53
+ // caller onto another's affordances. Hashing the permission set instead
54
+ // would read `ROLE_PERMISSIONS` directly and go around `PolicyDecisionTag`,
55
+ // so an injected ABAC policy could change affordances without changing the
56
+ // tag. Computing first costs a metadata read and a handful of pure
57
+ // `decide` calls — no IO.
58
+ const body = JSON.stringify(makeEntityMetadataEnvelope(entityConstructor, document));
59
+ const etag = `"${createHash('sha256').update(body).digest('hex')}"`;
60
+ const request = yield* HttpServerRequest.HttpServerRequest;
61
+ if (request.headers['if-none-match'] === etag) {
62
+ return HttpServerResponse.empty({
63
+ status: 304
64
+ }).pipe(HttpServerResponse.setHeaders(cacheHeaders(etag)));
65
+ }
66
+ return HttpServerResponse.text(body, {
67
+ contentType: 'application/json',
68
+ headers: cacheHeaders(etag)
69
+ });
70
+ }));
71
+ /**
72
+ * The response varies by principal, so it must never land in a shared cache.
73
+ * `Vary` names **both** credential carriers because the shell reads the access
74
+ * token from the `entifix_at` cookie *or* an `Authorization: Bearer` header, and a
75
+ * cache keyed on only one of them would serve across the other.
76
+ */ const cacheHeaders = (etag)=>({
77
+ etag,
78
+ 'cache-control': 'private, no-cache',
79
+ vary: 'Cookie, Authorization'
80
+ });
81
+ /** Indistinguishable from the `404` a service gives for an entity it does not host. */ const notFound = (entityConstructor)=>HttpServerResponse.json({
82
+ message: 'not found',
83
+ code: 'notFound',
84
+ entity: extractMetaEntity(entityConstructor).key
85
+ }, {
86
+ status: 404
87
+ });
88
+
89
+ //# sourceMappingURL=entity-metadata-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/entity-metadata-route.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport { HttpServerRequest, HttpServerResponse } from '@effect/platform';\nimport {\n parsePermission,\n type Permission,\n permissionForEntity,\n permissionForUseCase,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport {\n describeEntityUseCases,\n type Entity,\n ENTITY_ACTIONS,\n type EntityConstructor,\n type EntityMetadataDocument,\n extractMetaEntity,\n makeEntityMetadataEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\n\nimport { requirePrincipal } from './auth/index.js';\n\n/**\n * `GET /api/<entity>/$metadata` — what this caller may do with one entity.\n *\n * Mounted **per entity, at a literal path**, not as a `/api/:entity/$metadata`\n * wildcard. `HttpRouter` resolves through `find-my-way-ts`, where a static\n * segment beats a parametric one and there is no backtracking once the\n * parametric branch has matched: alongside an existing `/api/<entity>/:id`, a\n * wildcard `$metadata` route never runs at all — the by-id handler wins with\n * `id === \"$metadata\"`, misses, and answers its own `404`. The endpoint would\n * read as \"this entity has no metadata\" while appearing mounted. A literal wins\n * regardless of registration order, which is why the base exports a route for\n * one entity and each service registers it for each of its own\n * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).\n *\n * Deliberately **not** behind `requireOrganization`, even on a tenant-plane\n * service: the document describes the model, not tenant data, and resolving a\n * database handle here would leave a vendor with no active organization unable\n * to see their own affordances.\n */\nexport const entityMetadataRoute = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n) =>\n requirePrincipal(principal =>\n Effect.gen(function* () {\n const policy = yield* PolicyDecisionTag;\n const allows = (permission: Permission): boolean => {\n const { resource, action } = parsePermission(permission);\n return policy.decide({\n subject: { roles: principal.roles },\n resource,\n action,\n });\n };\n\n // SECURITY BOUNDARY. The document names domains, entity keys and verbs —\n // it is a map of the model — so an entity the caller may not read answers\n // exactly as one this service does not host. A `403` here would make the\n // endpoint an oracle: walk it and learn the shape of everything you are\n // not allowed to see. Same category as `redactConfiguration`, not\n // diagnostics polish.\n if (!allows(permissionForEntity(entityConstructor, 'read'))) {\n return yield* notFound(entityConstructor);\n }\n\n const document: EntityMetadataDocument = {\n actions: ENTITY_ACTIONS.filter(action =>\n allows(permissionForEntity(entityConstructor, action)),\n ),\n useCases: describeEntityUseCases(entityConstructor).filter(descriptor =>\n allows(permissionForUseCase(entityConstructor, descriptor.key)),\n ),\n };\n\n // The ETag hashes the *computed* document, not the descriptor set the\n // deployment declares. Two principals with different grants get different\n // documents from the same model, so a hash of the model would `304` one\n // caller onto another's affordances. Hashing the permission set instead\n // would read `ROLE_PERMISSIONS` directly and go around `PolicyDecisionTag`,\n // so an injected ABAC policy could change affordances without changing the\n // tag. Computing first costs a metadata read and a handful of pure\n // `decide` calls — no IO.\n const body = JSON.stringify(\n makeEntityMetadataEnvelope(entityConstructor, document),\n );\n const etag = `\"${createHash('sha256').update(body).digest('hex')}\"`;\n\n const request = yield* HttpServerRequest.HttpServerRequest;\n if (request.headers['if-none-match'] === etag) {\n return HttpServerResponse.empty({ status: 304 }).pipe(\n HttpServerResponse.setHeaders(cacheHeaders(etag)),\n );\n }\n\n return HttpServerResponse.text(body, {\n contentType: 'application/json',\n headers: cacheHeaders(etag),\n });\n }),\n );\n\n/**\n * The response varies by principal, so it must never land in a shared cache.\n * `Vary` names **both** credential carriers because the shell reads the access\n * token from the `entifix_at` cookie *or* an `Authorization: Bearer` header, and a\n * cache keyed on only one of them would serve across the other.\n */\nconst cacheHeaders = (etag: string) => ({\n etag,\n 'cache-control': 'private, no-cache',\n vary: 'Cookie, Authorization',\n});\n\n/** Indistinguishable from the `404` a service gives for an entity it does not host. */\nconst notFound = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n) =>\n HttpServerResponse.json(\n {\n message: 'not found',\n code: 'notFound',\n entity: extractMetaEntity(entityConstructor).key,\n },\n { status: 404 },\n );\n"],"names":["createHash","HttpServerRequest","HttpServerResponse","parsePermission","permissionForEntity","permissionForUseCase","PolicyDecisionTag","describeEntityUseCases","ENTITY_ACTIONS","extractMetaEntity","makeEntityMetadataEnvelope","Effect","requirePrincipal","entityMetadataRoute","entityConstructor","principal","gen","policy","allows","permission","resource","action","decide","subject","roles","notFound","document","actions","filter","useCases","descriptor","key","body","JSON","stringify","etag","update","digest","request","headers","empty","status","pipe","setHeaders","cacheHeaders","text","contentType","vary","json","message","code","entity"],"mappings":"AAAA,SAASA,UAAU,QAAQ,cAAc;AAEzC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,mBAAmB;AACzE,SACEC,eAAe,EAEfC,mBAAmB,EACnBC,oBAAoB,EACpBC,iBAAiB,QACZ,iBAAiB;AACxB,SACEC,sBAAsB,EAEtBC,cAAc,EAGdC,iBAAiB,EACjBC,0BAA0B,QACrB,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAEhC,SAASC,gBAAgB,QAAQ,kBAAkB;AAEnD;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,MAAMC,sBAAsB,CACjCC,oBAEAF,iBAAiBG,CAAAA,YACfJ,OAAOK,GAAG,CAAC;YACT,MAAMC,SAAS,OAAOX;YACtB,MAAMY,SAAS,CAACC;gBACd,MAAM,EAAEC,QAAQ,EAAEC,MAAM,EAAE,GAAGlB,gBAAgBgB;gBAC7C,OAAOF,OAAOK,MAAM,CAAC;oBACnBC,SAAS;wBAAEC,OAAOT,UAAUS,KAAK;oBAAC;oBAClCJ;oBACAC;gBACF;YACF;YAEA,yEAAyE;YACzE,0EAA0E;YAC1E,yEAAyE;YACzE,wEAAwE;YACxE,kEAAkE;YAClE,sBAAsB;YACtB,IAAI,CAACH,OAAOd,oBAAoBU,mBAAmB,UAAU;gBAC3D,OAAO,OAAOW,SAASX;YACzB;YAEA,MAAMY,WAAmC;gBACvCC,SAASnB,eAAeoB,MAAM,CAACP,CAAAA,SAC7BH,OAAOd,oBAAoBU,mBAAmBO;gBAEhDQ,UAAUtB,uBAAuBO,mBAAmBc,MAAM,CAACE,CAAAA,aACzDZ,OAAOb,qBAAqBS,mBAAmBgB,WAAWC,GAAG;YAEjE;YAEA,sEAAsE;YACtE,0EAA0E;YAC1E,wEAAwE;YACxE,wEAAwE;YACxE,4EAA4E;YAC5E,2EAA2E;YAC3E,mEAAmE;YACnE,0BAA0B;YAC1B,MAAMC,OAAOC,KAAKC,SAAS,CACzBxB,2BAA2BI,mBAAmBY;YAEhD,MAAMS,OAAO,CAAC,CAAC,EAAEnC,WAAW,UAAUoC,MAAM,CAACJ,MAAMK,MAAM,CAAC,OAAO,CAAC,CAAC;YAEnE,MAAMC,UAAU,OAAOrC,kBAAkBA,iBAAiB;YAC1D,IAAIqC,QAAQC,OAAO,CAAC,gBAAgB,KAAKJ,MAAM;gBAC7C,OAAOjC,mBAAmBsC,KAAK,CAAC;oBAAEC,QAAQ;gBAAI,GAAGC,IAAI,CACnDxC,mBAAmByC,UAAU,CAACC,aAAaT;YAE/C;YAEA,OAAOjC,mBAAmB2C,IAAI,CAACb,MAAM;gBACnCc,aAAa;gBACbP,SAASK,aAAaT;YACxB;QACF,IACA;AAEJ;;;;;CAKC,GACD,MAAMS,eAAe,CAACT,OAAkB,CAAA;QACtCA;QACA,iBAAiB;QACjBY,MAAM;IACR,CAAA;AAEA,qFAAqF,GACrF,MAAMtB,WAAW,CACfX,oBAEAZ,mBAAmB8C,IAAI,CACrB;QACEC,SAAS;QACTC,MAAM;QACNC,QAAQ1C,kBAAkBK,mBAAmBiB,GAAG;IAClD,GACA;QAAEU,QAAQ;IAAI"}
@@ -0,0 +1,24 @@
1
+ import { HttpRouter } from '@effect/platform';
2
+ import { HealthRegistryTag, ShutdownRegistryTag } from '@entifix/business';
3
+ /**
4
+ * Mount the probe endpoints on a router.
5
+ *
6
+ * - `GET /api/health` — the original endpoint, unchanged. Kept because
7
+ * Playwright's `readyPath` and existing tooling point at it.
8
+ * - `GET /api/health/live` — **liveness**. Answers from the process alone: no
9
+ * datastore, no config-service, no session. This is deliberate and load
10
+ * bearing. A liveness probe that checks dependencies turns a Mongo blip into
11
+ * "Kubernetes restarts every replica", which is an outage the probe caused.
12
+ * - `GET /api/health/ready` — **readiness**: `200` when every registered probe
13
+ * passes, `503 {status:'degraded', failing:[…]}` otherwise. Kubernetes pulls
14
+ * a not-ready pod out of the load balancer without killing it, which is what
15
+ * a reconnecting client wants. A process that has begun terminating answers
16
+ * `503 {status:'terminating'}` instead, **before** any probe runs: nothing is
17
+ * failing, and running the probes would ask a closing pool a question whose
18
+ * answer changes nothing.
19
+ *
20
+ * The failing list carries probe **names** only — never a URI, host, or driver
21
+ * message. This endpoint answers anyone who can reach the port.
22
+ */
23
+ export declare const withHealthRoutes: <E, R>(router: HttpRouter.HttpRouter<E, R>, serviceName: string) => HttpRouter.HttpRouter<import("@effect/platform/HttpBody").HttpBodyError | E, HealthRegistryTag | ShutdownRegistryTag | R>;
24
+ //# sourceMappingURL=health-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health-routes.d.ts","sourceRoot":"","sources":["../src/health-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAsB,MAAM,kBAAkB,CAAC;AAElE,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAgB3E;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAAE,CAAC,EACnC,QAAQ,UAAU,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EACnC,aAAa,MAAM,8HAsDpB,CAAC"}
@@ -0,0 +1,81 @@
1
+ import { HttpRouter, HttpServerResponse } from '@effect/platform';
2
+ import { HealthRegistryTag, ShutdownRegistryTag } from '@entifix/business';
3
+ import { Effect, Ref } from 'effect';
4
+ /**
5
+ * How long a readiness result is reused. These endpoints are unauthenticated by
6
+ * necessity (a probe cannot hold a session), so without a cache every request
7
+ * would reach Mongo — a cheap lever for anyone who can reach the port, and
8
+ * noise in the datastore's own metrics.
9
+ */ const READY_CACHE_MS = 1000;
10
+ /**
11
+ * Mount the probe endpoints on a router.
12
+ *
13
+ * - `GET /api/health` — the original endpoint, unchanged. Kept because
14
+ * Playwright's `readyPath` and existing tooling point at it.
15
+ * - `GET /api/health/live` — **liveness**. Answers from the process alone: no
16
+ * datastore, no config-service, no session. This is deliberate and load
17
+ * bearing. A liveness probe that checks dependencies turns a Mongo blip into
18
+ * "Kubernetes restarts every replica", which is an outage the probe caused.
19
+ * - `GET /api/health/ready` — **readiness**: `200` when every registered probe
20
+ * passes, `503 {status:'degraded', failing:[…]}` otherwise. Kubernetes pulls
21
+ * a not-ready pod out of the load balancer without killing it, which is what
22
+ * a reconnecting client wants. A process that has begun terminating answers
23
+ * `503 {status:'terminating'}` instead, **before** any probe runs: nothing is
24
+ * failing, and running the probes would ask a closing pool a question whose
25
+ * answer changes nothing.
26
+ *
27
+ * The failing list carries probe **names** only — never a URI, host, or driver
28
+ * message. This endpoint answers anyone who can reach the port.
29
+ */ // The return type is inferred rather than annotated: `HttpServerResponse.json`
30
+ // widens the router's error channel with `HttpBodyError`, and spelling that out
31
+ // here would only duplicate what the platform types already say.
32
+ export const withHealthRoutes = (router, serviceName)=>{
33
+ // One cache per server instance (the e2e mock profile boots services
34
+ // in-process, so a module-level cache would leak across them).
35
+ const cache = Ref.unsafeMake(null);
36
+ const cachedReport = Effect.gen(function*() {
37
+ const now = Date.now();
38
+ const entry = yield* Ref.get(cache);
39
+ if (entry !== null && now - entry.at < READY_CACHE_MS) return entry.report;
40
+ const registry = yield* HealthRegistryTag;
41
+ const report = yield* registry.report;
42
+ yield* Ref.set(cache, {
43
+ at: now,
44
+ report
45
+ });
46
+ return report;
47
+ });
48
+ return router.pipe(HttpRouter.get('/api/health', HttpServerResponse.json({
49
+ status: 'ok',
50
+ service: serviceName
51
+ })), HttpRouter.get('/api/health/live', HttpServerResponse.json({
52
+ status: 'live',
53
+ service: serviceName
54
+ })), HttpRouter.get('/api/health/ready', Effect.gen(function*() {
55
+ const shutdown = yield* ShutdownRegistryTag;
56
+ // Ahead of the cache, deliberately: a report cached a moment before the
57
+ // signal landed would go on answering `200` for up to READY_CACHE_MS,
58
+ // which is precisely the window a rolling restart lives in.
59
+ if (yield* shutdown.terminating) {
60
+ return yield* HttpServerResponse.json({
61
+ status: 'terminating',
62
+ service: serviceName
63
+ }, {
64
+ status: 503
65
+ });
66
+ }
67
+ const report = yield* cachedReport;
68
+ return yield* HttpServerResponse.json(report.ready ? {
69
+ status: 'ready',
70
+ service: serviceName
71
+ } : {
72
+ status: 'degraded',
73
+ service: serviceName,
74
+ failing: report.failing
75
+ }, {
76
+ status: report.ready ? 200 : 503
77
+ });
78
+ })));
79
+ };
80
+
81
+ //# sourceMappingURL=health-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/health-routes.ts"],"sourcesContent":["import { HttpRouter, HttpServerResponse } from '@effect/platform';\nimport type { HealthReport } from '@entifix/business';\nimport { HealthRegistryTag, ShutdownRegistryTag } from '@entifix/business';\nimport { Effect, Ref } from 'effect';\n\n/**\n * How long a readiness result is reused. These endpoints are unauthenticated by\n * necessity (a probe cannot hold a session), so without a cache every request\n * would reach Mongo — a cheap lever for anyone who can reach the port, and\n * noise in the datastore's own metrics.\n */\nconst READY_CACHE_MS = 1_000;\n\ninterface CacheEntry {\n readonly at: number;\n readonly report: HealthReport;\n}\n\n/**\n * Mount the probe endpoints on a router.\n *\n * - `GET /api/health` — the original endpoint, unchanged. Kept because\n * Playwright's `readyPath` and existing tooling point at it.\n * - `GET /api/health/live` — **liveness**. Answers from the process alone: no\n * datastore, no config-service, no session. This is deliberate and load\n * bearing. A liveness probe that checks dependencies turns a Mongo blip into\n * \"Kubernetes restarts every replica\", which is an outage the probe caused.\n * - `GET /api/health/ready` — **readiness**: `200` when every registered probe\n * passes, `503 {status:'degraded', failing:[…]}` otherwise. Kubernetes pulls\n * a not-ready pod out of the load balancer without killing it, which is what\n * a reconnecting client wants. A process that has begun terminating answers\n * `503 {status:'terminating'}` instead, **before** any probe runs: nothing is\n * failing, and running the probes would ask a closing pool a question whose\n * answer changes nothing.\n *\n * The failing list carries probe **names** only — never a URI, host, or driver\n * message. This endpoint answers anyone who can reach the port.\n */\n// The return type is inferred rather than annotated: `HttpServerResponse.json`\n// widens the router's error channel with `HttpBodyError`, and spelling that out\n// here would only duplicate what the platform types already say.\nexport const withHealthRoutes = <E, R>(\n router: HttpRouter.HttpRouter<E, R>,\n serviceName: string,\n) => {\n // One cache per server instance (the e2e mock profile boots services\n // in-process, so a module-level cache would leak across them).\n const cache = Ref.unsafeMake<CacheEntry | null>(null);\n\n const cachedReport = Effect.gen(function* () {\n const now = Date.now();\n const entry = yield* Ref.get(cache);\n if (entry !== null && now - entry.at < READY_CACHE_MS) return entry.report;\n\n const registry = yield* HealthRegistryTag;\n const report = yield* registry.report;\n yield* Ref.set(cache, { at: now, report });\n return report;\n });\n\n return router.pipe(\n HttpRouter.get(\n '/api/health',\n HttpServerResponse.json({ status: 'ok', service: serviceName }),\n ),\n HttpRouter.get(\n '/api/health/live',\n HttpServerResponse.json({ status: 'live', service: serviceName }),\n ),\n HttpRouter.get(\n '/api/health/ready',\n Effect.gen(function* () {\n const shutdown = yield* ShutdownRegistryTag;\n // Ahead of the cache, deliberately: a report cached a moment before the\n // signal landed would go on answering `200` for up to READY_CACHE_MS,\n // which is precisely the window a rolling restart lives in.\n if (yield* shutdown.terminating) {\n return yield* HttpServerResponse.json(\n { status: 'terminating', service: serviceName },\n { status: 503 },\n );\n }\n\n const report = yield* cachedReport;\n return yield* HttpServerResponse.json(\n report.ready\n ? { status: 'ready', service: serviceName }\n : {\n status: 'degraded',\n service: serviceName,\n failing: report.failing,\n },\n { status: report.ready ? 200 : 503 },\n );\n }),\n ),\n );\n};\n"],"names":["HttpRouter","HttpServerResponse","HealthRegistryTag","ShutdownRegistryTag","Effect","Ref","READY_CACHE_MS","withHealthRoutes","router","serviceName","cache","unsafeMake","cachedReport","gen","now","Date","entry","get","at","report","registry","set","pipe","json","status","service","shutdown","terminating","ready","failing"],"mappings":"AAAA,SAASA,UAAU,EAAEC,kBAAkB,QAAQ,mBAAmB;AAElE,SAASC,iBAAiB,EAAEC,mBAAmB,QAAQ,oBAAoB;AAC3E,SAASC,MAAM,EAAEC,GAAG,QAAQ,SAAS;AAErC;;;;;CAKC,GACD,MAAMC,iBAAiB;AAOvB;;;;;;;;;;;;;;;;;;;CAmBC,GACD,+EAA+E;AAC/E,gFAAgF;AAChF,iEAAiE;AACjE,OAAO,MAAMC,mBAAmB,CAC9BC,QACAC;IAEA,qEAAqE;IACrE,+DAA+D;IAC/D,MAAMC,QAAQL,IAAIM,UAAU,CAAoB;IAEhD,MAAMC,eAAeR,OAAOS,GAAG,CAAC;QAC9B,MAAMC,MAAMC,KAAKD,GAAG;QACpB,MAAME,QAAQ,OAAOX,IAAIY,GAAG,CAACP;QAC7B,IAAIM,UAAU,QAAQF,MAAME,MAAME,EAAE,GAAGZ,gBAAgB,OAAOU,MAAMG,MAAM;QAE1E,MAAMC,WAAW,OAAOlB;QACxB,MAAMiB,SAAS,OAAOC,SAASD,MAAM;QACrC,OAAOd,IAAIgB,GAAG,CAACX,OAAO;YAAEQ,IAAIJ;YAAKK;QAAO;QACxC,OAAOA;IACT;IAEA,OAAOX,OAAOc,IAAI,CAChBtB,WAAWiB,GAAG,CACZ,eACAhB,mBAAmBsB,IAAI,CAAC;QAAEC,QAAQ;QAAMC,SAAShB;IAAY,KAE/DT,WAAWiB,GAAG,CACZ,oBACAhB,mBAAmBsB,IAAI,CAAC;QAAEC,QAAQ;QAAQC,SAAShB;IAAY,KAEjET,WAAWiB,GAAG,CACZ,qBACAb,OAAOS,GAAG,CAAC;QACT,MAAMa,WAAW,OAAOvB;QACxB,wEAAwE;QACxE,sEAAsE;QACtE,4DAA4D;QAC5D,IAAI,OAAOuB,SAASC,WAAW,EAAE;YAC/B,OAAO,OAAO1B,mBAAmBsB,IAAI,CACnC;gBAAEC,QAAQ;gBAAeC,SAAShB;YAAY,GAC9C;gBAAEe,QAAQ;YAAI;QAElB;QAEA,MAAML,SAAS,OAAOP;QACtB,OAAO,OAAOX,mBAAmBsB,IAAI,CACnCJ,OAAOS,KAAK,GACR;YAAEJ,QAAQ;YAASC,SAAShB;QAAY,IACxC;YACEe,QAAQ;YACRC,SAAShB;YACToB,SAASV,OAAOU,OAAO;QACzB,GACJ;YAAEL,QAAQL,OAAOS,KAAK,GAAG,MAAM;QAAI;IAEvC;AAGN,EAAE"}
@@ -0,0 +1,12 @@
1
+ export * from './auth/index.js';
2
+ export * from './entity-metadata-route.js';
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
+ export * from './redact-configuration.js';
8
+ export * from './serve-test-service.js';
9
+ export * from './service-description-route.js';
10
+ export * from './sse.js';
11
+ export * from './wait-for-http.js';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export * from './auth/index.js';
2
+ export * from './entity-metadata-route.js';
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
+ export * from './redact-configuration.js';
8
+ export * from './serve-test-service.js';
9
+ export * from './service-description-route.js';
10
+ export * from './sse.js';
11
+ export * from './wait-for-http.js';
12
+
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"}
@@ -0,0 +1,77 @@
1
+ import { ConfigurationClientInMemory, ConfigurationPlain, EntifixConnError } from '@entifix/core';
2
+ import { Context, Effect, Schedule } from 'effect';
3
+ declare const LoadedConfigurationTag_base: Context.TagClass<LoadedConfigurationTag, "LoadedConfigurationTag", ConfigurationPlain>;
4
+ /**
5
+ * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,
6
+ * so a `GET /api/config` introspection route can expose it (redacted).
7
+ */
8
+ export declare class LoadedConfigurationTag extends LoadedConfigurationTag_base {
9
+ }
10
+ /**
11
+ * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).
12
+ *
13
+ * A dependent service `dev` target `dependsOn config-service:dev`, but Nx
14
+ * considers a *continuous* dependency "ready" the moment its process starts —
15
+ * not when its HTTP server is listening and its table is seeded. So a fast
16
+ * backend can fire its boot fetch before config-service answers and, without a
17
+ * retry, would crash with `fetch failed` (connection refused). Retrying a
18
+ * bounded window lets the fleet come up in any order.
19
+ */
20
+ export declare const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown>;
21
+ /**
22
+ * Deadline for a single boot-fetch attempt.
23
+ *
24
+ * **The retry above only helps if an attempt can fail.** `fetch` has no timeout
25
+ * of its own, so a connection that is accepted but never answered — a previous
26
+ * listener dying mid-flight, a half-open socket — leaves it waiting forever.
27
+ * The schedule never sees a failure, never retries, and the service hangs in its
28
+ * boot layer with its port bound and nothing served: measured twice on
29
+ * 2026-08-14, on auth-service and then marketplace-service, recovered only by a
30
+ * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).
31
+ *
32
+ * So an attempt is deadlined, for the same reason a readiness probe is: a check
33
+ * that hangs is a check that failed. The ordinary race this retry was written
34
+ * for — config-service not listening yet — still fails instantly with
35
+ * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:
36
+ * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits
37
+ * loudly instead of hanging silently.
38
+ */
39
+ export declare const defaultConfigAttemptTimeoutMs = 5000;
40
+ /** Options for {@link loadRemoteConfiguration}. */
41
+ export interface LoadRemoteConfigurationOptions {
42
+ /**
43
+ * Retry policy for the boot fetch. Defaults to
44
+ * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a
45
+ * single attempt, no retry) — used by tests and callers that want the error
46
+ * immediately.
47
+ */
48
+ readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;
49
+ /**
50
+ * Deadline for one attempt, in milliseconds. Defaults to
51
+ * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to
52
+ * disable it — an attempt that cannot time out is the bug this exists for.
53
+ */
54
+ readonly attemptTimeoutMs?: number;
55
+ }
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.
61
+ *
62
+ * Uses Node's global `fetch` (same transport approach as the REST client); any
63
+ * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each
64
+ * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is
65
+ * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a
66
+ * dependent that boots before config-service is HTTP-ready waits it out — and
67
+ * one that reaches a socket nobody is answering gives up rather than hanging.
68
+ */
69
+ export declare const loadRemoteConfiguration: (configApiUrl: string, service: string, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationPlain, EntifixConnError>;
70
+ /**
71
+ * 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)`.
74
+ */
75
+ export declare const loadRemoteConfigurationClient: (configApiUrl: string, service: string, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationClientInMemory, EntifixConnError>;
76
+ export {};
77
+ //# sourceMappingURL=load-remote-configuration.d.ts.map
@@ -0,0 +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,2BAEG;CAAG;AAElD;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CACJ,CAAC;AAExE;;;;;;;;;;;;;;;;;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"}
@@ -0,0 +1,87 @@
1
+ import { ConfigurationClientInMemory, EntifixConnError } from '@entifix/core';
2
+ import { Context, Effect, Schedule } from 'effect';
3
+ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
4
+ /**
5
+ * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,
6
+ * so a `GET /api/config` introspection route can expose it (redacted).
7
+ */ export class LoadedConfigurationTag extends Context.Tag('LoadedConfigurationTag')() {
8
+ }
9
+ /**
10
+ * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).
11
+ *
12
+ * A dependent service `dev` target `dependsOn config-service:dev`, but Nx
13
+ * considers a *continuous* dependency "ready" the moment its process starts —
14
+ * not when its HTTP server is listening and its table is seeded. So a fast
15
+ * backend can fire its boot fetch before config-service answers and, without a
16
+ * retry, would crash with `fetch failed` (connection refused). Retrying a
17
+ * bounded window lets the fleet come up in any order.
18
+ */ export const defaultConfigRetrySchedule = Schedule.intersect(Schedule.spaced('1 seconds'), Schedule.recurs(20));
19
+ /**
20
+ * Deadline for a single boot-fetch attempt.
21
+ *
22
+ * **The retry above only helps if an attempt can fail.** `fetch` has no timeout
23
+ * of its own, so a connection that is accepted but never answered — a previous
24
+ * listener dying mid-flight, a half-open socket — leaves it waiting forever.
25
+ * The schedule never sees a failure, never retries, and the service hangs in its
26
+ * boot layer with its port bound and nothing served: measured twice on
27
+ * 2026-08-14, on auth-service and then marketplace-service, recovered only by a
28
+ * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).
29
+ *
30
+ * So an attempt is deadlined, for the same reason a readiness probe is: a check
31
+ * that hangs is a check that failed. The ordinary race this retry was written
32
+ * for — config-service not listening yet — still fails instantly with
33
+ * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:
34
+ * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits
35
+ * loudly instead of hanging silently.
36
+ */ export const defaultConfigAttemptTimeoutMs = 5000;
37
+ /**
38
+ * Fetches a service's configuration from the central config-service
39
+ * (`GET {configApiUrl}/api/config/{service}`) and returns it as a
40
+ * {@link ConfigurationPlain}. Backends call this at boot to resolve their own
41
+ * parameters (e.g. `mongo.uri`) instead of hardcoding them.
42
+ *
43
+ * Uses Node's global `fetch` (same transport approach as the REST client); any
44
+ * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each
45
+ * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is
46
+ * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a
47
+ * dependent that boots before config-service is HTTP-ready waits it out — and
48
+ * one that reaches a socket nobody is answering gives up rather than hanging.
49
+ */ export const loadRemoteConfiguration = (configApiUrl, service, options = {})=>{
50
+ var _options_attemptTimeoutMs, _options_retrySchedule;
51
+ const url = `${configApiUrl.replace(/\/+$/, '')}/api/config/${service}`;
52
+ const attemptTimeoutMs = (_options_attemptTimeoutMs = options.attemptTimeoutMs) != null ? _options_attemptTimeoutMs : defaultConfigAttemptTimeoutMs;
53
+ const fetchOnce = Effect.tryPromise({
54
+ try: async ()=>{
55
+ const response = await fetch(url, {
56
+ cache: 'no-store',
57
+ headers: {
58
+ [SERVICE_TOKEN_HEADER]: serviceToken()
59
+ },
60
+ signal: AbortSignal.timeout(attemptTimeoutMs)
61
+ }).catch((error)=>{
62
+ // Rewritten because the raw abort says `This operation was aborted`,
63
+ // which reads like a bug in the caller rather than a silent peer.
64
+ if (error instanceof Error && error.name === 'TimeoutError') {
65
+ throw new Error(`config-service did not answer within ${attemptTimeoutMs}ms at ${url}`);
66
+ }
67
+ throw error;
68
+ });
69
+ if (!response.ok) {
70
+ throw new Error(`config-service responded ${response.status} for ${url}`);
71
+ }
72
+ return await response.json();
73
+ },
74
+ catch: (error)=>new EntifixConnError(`Failed to load configuration for "${service}" from config-service`, error, {
75
+ url,
76
+ service
77
+ })
78
+ });
79
+ return Effect.retry(fetchOnce, (_options_retrySchedule = options.retrySchedule) != null ? _options_retrySchedule : defaultConfigRetrySchedule);
80
+ };
81
+ /**
82
+ * Convenience: load the remote configuration and wrap it in a
83
+ * {@link ConfigurationClientInMemory} ready to satisfy `ConfigurationRepositoryTag`
84
+ * and to read the service's own parameters via `.in(group).getString(key)`.
85
+ */ export const loadRemoteConfigurationClient = (configApiUrl, service, options = {})=>loadRemoteConfiguration(configApiUrl, service, options).pipe(Effect.map((plain)=>new ConfigurationClientInMemory(plain)));
86
+
87
+ //# sourceMappingURL=load-remote-configuration.js.map