@entifix/service-shell 0.4.2 → 0.6.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 (55) 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/require-organization.d.ts +2 -4
  7. package/dist/auth/require-organization.d.ts.map +1 -1
  8. package/dist/auth/require-organization.js +6 -8
  9. package/dist/auth/require-organization.js.map +1 -1
  10. package/dist/auth/require-principal.d.ts +1 -1
  11. package/dist/auth/require-principal.js.map +1 -1
  12. package/dist/auth/require-service-crossing.d.ts +7 -9
  13. package/dist/auth/require-service-crossing.d.ts.map +1 -1
  14. package/dist/auth/require-service-crossing.js +7 -9
  15. package/dist/auth/require-service-crossing.js.map +1 -1
  16. package/dist/auth/shared-token.d.ts +34 -0
  17. package/dist/auth/shared-token.d.ts.map +1 -0
  18. package/dist/auth/shared-token.js +39 -0
  19. package/dist/auth/shared-token.js.map +1 -0
  20. package/dist/entity-metadata-route.d.ts +1 -2
  21. package/dist/entity-metadata-route.d.ts.map +1 -1
  22. package/dist/entity-metadata-route.js +1 -2
  23. package/dist/entity-metadata-route.js.map +1 -1
  24. package/dist/index.d.ts +0 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +0 -5
  27. package/dist/index.js.map +1 -1
  28. package/dist/load-remote-configuration.d.ts +22 -10
  29. package/dist/load-remote-configuration.d.ts.map +1 -1
  30. package/dist/load-remote-configuration.js +15 -20
  31. package/dist/load-remote-configuration.js.map +1 -1
  32. package/dist/make-service.d.ts +13 -4
  33. package/dist/make-service.d.ts.map +1 -1
  34. package/dist/make-service.js +3 -3
  35. package/dist/make-service.js.map +1 -1
  36. package/dist/runtime.d.ts +12 -0
  37. package/dist/runtime.d.ts.map +1 -0
  38. package/dist/runtime.js +12 -0
  39. package/dist/runtime.js.map +1 -0
  40. package/dist/service-description-route.d.ts +7 -6
  41. package/dist/service-description-route.d.ts.map +1 -1
  42. package/dist/service-description-route.js +5 -5
  43. package/dist/service-description-route.js.map +1 -1
  44. package/dist/sse.d.ts +2 -2
  45. package/dist/sse.js +1 -1
  46. package/dist/sse.js.map +1 -1
  47. package/dist/testing.d.ts +7 -0
  48. package/dist/testing.d.ts.map +1 -0
  49. package/dist/testing.js +7 -0
  50. package/dist/testing.js.map +1 -0
  51. package/package.json +58 -10
  52. package/dist/auth/service-token.d.ts +0 -37
  53. package/dist/auth/service-token.d.ts.map +0 -1
  54. package/dist/auth/service-token.js +0 -62
  55. package/dist/auth/service-token.js.map +0 -1
@@ -26,8 +26,8 @@ export declare const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unkn
26
26
  * listener dying mid-flight, a half-open socket — leaves it waiting forever.
27
27
  * The schedule never sees a failure, never retries, and the service hangs in its
28
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)).
29
+ * 2026-08-14, on two different services, and recovered only by recreating the
30
+ * datastores.
31
31
  *
32
32
  * So an attempt is deadlined, for the same reason a readiness probe is: a check
33
33
  * that hangs is a check that failed. The ordinary race this retry was written
@@ -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).
@@ -27,8 +26,8 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
27
26
  * listener dying mid-flight, a half-open socket — leaves it waiting forever.
28
27
  * The schedule never sees a failure, never retries, and the service hangs in its
29
28
  * boot layer with its port bound and nothing served: measured twice on
30
- * 2026-08-14, on auth-service and then marketplace-service, recovered only by a
31
- * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).
29
+ * 2026-08-14, on two different services, and recovered only by recreating the
30
+ * datastores.
32
31
  *
33
32
  * So an attempt is deadlined, for the same reason a readiness probe is: a check
34
33
  * that hangs is a check that failed. The ordinary race this retry was written
@@ -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 two different services, and recovered only by recreating the\n * datastores.\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
@@ -35,12 +36,20 @@ export interface ServiceDefinition<RRoutes> {
35
36
  * The slices this process hosts, by their `tools/slices/` name.
36
37
  *
37
38
  * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and
38
- * one deployment may host several: ADR 0021 co-deploys `transaction` inside
39
- * marketplace-admin-service. `dev-infra:map` checks each name against the
40
- * register's own `deployments`, so a wrong one fails the diff rather than
39
+ * one deployment may host several, and a host that co-deploys two slices in
40
+ * one process names both. A host that registers its deployments can check
41
+ * each name against that register, so a wrong one fails the diff rather than
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
  /**
@@ -84,7 +93,7 @@ export declare const makeServerLayer: <RRoutes>(def: ServiceDefinition<RRoutes>,
84
93
  * finalizer's, because only layer ordering can guarantee the connections are
85
94
  * still open while it happens.
86
95
  *
87
- * The `preStop` grace period ADR 0030 also names is **not** here and could not
96
+ * A `preStop` grace period is **not** here and could not
88
97
  * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
89
98
  * Deployment for a service. It lands with the first one.
90
99
  */
@@ -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,14 +21,14 @@ 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
28
28
  // registered into are the same instances the readiness and description routes
29
29
  // read.
30
30
  const appLayer = def.appLayer.pipe(Layer.provideMerge(Layer.mergeAll(HealthRegistryLayer, WiringRegistryLayer, Layer.succeed(ShutdownRegistryTag, shutdown))));
31
- // The graceful shutdown, and its **position** is the mechanism (ADR 0030).
31
+ // The graceful shutdown, and its **position** is the mechanism.
32
32
  // Build order is `appLayer` → this → `serve`, so release order is the
33
33
  // reverse: the drain runs after the server has stopped taking requests and
34
34
  // *before* the connections it needs are closed. A signal handler could not
@@ -57,7 +57,7 @@ import { serviceDescriptionRoute } from './service-description-route.js';
57
57
  * finalizer's, because only layer ordering can guarantee the connections are
58
58
  * still open while it happens.
59
59
  *
60
- * The `preStop` grace period ADR 0030 also names is **not** here and could not
60
+ * A `preStop` grace period is **not** here and could not
61
61
  * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
62
62
  * Deployment for a service. It lands with the first one.
63
63
  */ export const makeService = (def)=>{
@@ -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, and a host that co-deploys two slices in\n * one process names both. A host that registers its deployments can check\n * each name against that register, 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.\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 * A `preStop` grace period 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,gEAAgE;IAChE,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
  *
@@ -8,7 +9,7 @@ import { HttpRouter } from 'effect/http';
8
9
  * `find-my-way-ts`, where a static segment beats a parametric one but there is
9
10
  * no backtracking once a parametric branch has matched. A `/api/:something`
10
11
  * route registered anywhere in the same router would otherwise swallow this one
11
- * and answer its own `404` — the failure ADR 0026 hit with `$metadata`.
12
+ * and answer its own `404` — the failure the `$metadata` route hit first.
12
13
  */
13
14
  export declare const SERVICE_DESCRIPTION_PATH = "/api/$service";
14
15
  /** One thing this process is connected to, named logically. */
@@ -24,13 +25,13 @@ export interface DescribedTarget {
24
25
  * Shape, not values — `/api/config` is the sibling that serves the parameters a
25
26
  * service was given, redacted. Keeping them apart is deliberate: one is redacted
26
27
  * because it holds credentials, the other gated because it holds a map, and
27
- * merging them forces the stricter rule onto both (ADR 0031).
28
+ * merging them forces the stricter rule onto both.
28
29
  */
29
30
  export interface ServiceDescription {
30
31
  readonly service: string;
31
32
  /**
32
33
  * The slices this process hosts. More than one means co-deployment
33
- * (ADR 0021) — `marketplace-admin-service` runs the `transaction` slice too.
34
+ * — `marketplace-admin-service` runs the `transaction` slice too.
34
35
  */
35
36
  readonly slices: readonly string[];
36
37
  readonly stores: readonly DescribedTarget[];
@@ -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
  *
@@ -9,7 +9,7 @@ import { requireServiceToken } from './auth/service-token.js';
9
9
  * `find-my-way-ts`, where a static segment beats a parametric one but there is
10
10
  * no backtracking once a parametric branch has matched. A `/api/:something`
11
11
  * route registered anywhere in the same router would otherwise swallow this one
12
- * and answer its own `404` — the failure ADR 0026 hit with `$metadata`.
12
+ * and answer its own `404` — the failure the `$metadata` route hit first.
13
13
  */ export const SERVICE_DESCRIPTION_PATH = '/api/$service';
14
14
  /**
15
15
  * Everything the two registries know, sorted by what it is.
@@ -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 the `$metadata` route hit first.\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.\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * — `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"}
package/dist/sse.d.ts CHANGED
@@ -7,7 +7,7 @@ import { HttpServerResponse } from 'effect/http';
7
7
  * `id:` carries the message's own id — `<transactionId>:<step>` for a
8
8
  * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
9
9
  * names a message that actually exists, rather than a counter invented for the
10
- * wire. The server does not replay it (ADR 0036); the client reconciles by
10
+ * wire. The server does not replay it; the client reconciles by
11
11
  * re-reading the record.
12
12
  *
13
13
  * `data:` is a full `event` envelope, which is what keeps the stream from
@@ -21,7 +21,7 @@ export interface SseResponseOptions {
21
21
  * A held-open response has no next request to fail verification, so the bound
22
22
  * every REST route gets implicitly has to be applied explicitly here. The
23
23
  * browser reconnects with the cookie the app refreshed in the meantime
24
- * (ADR 0036). Omitted, the stream runs until the client goes away.
24
+ *. Omitted, the stream runs until the client goes away.
25
25
  */
26
26
  readonly expiresAt?: number;
27
27
  /** Injectable for tests; defaults to the wall clock. */
package/dist/sse.js CHANGED
@@ -16,7 +16,7 @@ const encoder = new TextEncoder();
16
16
  * `id:` carries the message's own id — `<transactionId>:<step>` for a
17
17
  * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
18
18
  * names a message that actually exists, rather than a counter invented for the
19
- * wire. The server does not replay it (ADR 0036); the client reconciles by
19
+ * wire. The server does not replay it; the client reconciles by
20
20
  * re-reading the record.
21
21
  *
22
22
  * `data:` is a full `event` envelope, which is what keeps the stream from
package/dist/sse.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sse.ts"],"sourcesContent":["import { type DomainEvent, makeEventEnvelope } from '@entifix/core';\nimport { Duration, Effect, Stream } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\n/**\n * How often an idle connection emits a comment frame.\n *\n * An SSE connection that sends nothing for minutes is indistinguishable from a\n * dead one to anything between the browser and the service, and a proxy that\n * decides it is dead closes it with no error either side can report. A comment\n * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.\n */\nconst KEEPALIVE = Duration.seconds(20);\n\nconst encoder = new TextEncoder();\n\n/**\n * One `EventSource` frame.\n *\n * `id:` carries the message's own id — `<transactionId>:<step>` for a\n * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect\n * names a message that actually exists, rather than a counter invented for the\n * wire. The server does not replay it (ADR 0036); the client reconciles by\n * re-reading the record.\n *\n * `data:` is a full `event` envelope, which is what keeps the stream from\n * becoming a fourth framing: the same bytes a bus consumer would read.\n */\nexport const sseFrame = (event: DomainEvent): string =>\n `id: ${event.id}\\ndata: ${JSON.stringify(makeEventEnvelope(event))}\\n\\n`;\n\nexport interface SseResponseOptions {\n /**\n * Unix seconds at which to close the connection — the verified token's `exp`.\n *\n * A held-open response has no next request to fail verification, so the bound\n * every REST route gets implicitly has to be applied explicitly here. The\n * browser reconnects with the cookie the app refreshed in the meantime\n * (ADR 0036). Omitted, the stream runs until the client goes away.\n */\n readonly expiresAt?: number;\n /** Injectable for tests; defaults to the wall clock. */\n readonly now?: () => number;\n}\n\n/**\n * Serves a stream of domain events as `text/event-stream`.\n *\n * `cache-control: no-store` is not politeness — a cached event stream is served\n * to whoever asks next, and this one is scoped to a single principal. The\n * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has\n * a pass-through arm for exactly this content type, because reading the body to\n * completion first holds the request open forever and delivers nothing.\n */\nexport const sseResponse = (\n events: Stream.Stream<DomainEvent>,\n { expiresAt, now = Date.now }: SseResponseOptions = {},\n): HttpServerResponse.HttpServerResponse => {\n const keepalive = Stream.fromEffectRepeat(\n Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\\n\\n'),\n );\n\n const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {\n haltStrategy: 'left',\n });\n\n // An opening comment, emitted before anything has happened.\n //\n // Not cosmetic: a chunked response writes its headers with its first chunk,\n // so a stream that stays silent until something occurs leaves the client in\n // `CONNECTING` — indefinitely, since a stream whose whole purpose is to be\n // idle most of the time may have nothing to say for hours. Measured: without\n // it, `fetch` against this route does not resolve at all.\n const opened = Stream.concat(Stream.make(': open\\n\\n'), frames);\n\n const bounded =\n expiresAt === undefined\n ? opened\n : Stream.interruptWhen(\n opened,\n // Never negative: an already-expired token closes at once rather than\n // scheduling a sleep into the past, which `Effect.sleep` treats as\n // zero anyway but reads as an oversight.\n Effect.sleep(Duration.millis(Math.max(0, expiresAt * 1000 - now()))),\n );\n\n return HttpServerResponse.stream(\n Stream.map(bounded, frame => encoder.encode(frame)),\n {\n contentType: 'text/event-stream',\n headers: {\n 'cache-control': 'no-store',\n connection: 'keep-alive',\n // nginx buffers a proxied response by default, which would hold every\n // frame until the stream ends — that is, forever.\n 'x-accel-buffering': 'no',\n },\n },\n );\n};\n"],"names":["makeEventEnvelope","Duration","Effect","Stream","HttpServerResponse","KEEPALIVE","seconds","encoder","TextEncoder","sseFrame","event","id","JSON","stringify","sseResponse","events","expiresAt","now","Date","keepalive","fromEffectRepeat","as","sleep","frames","merge","map","haltStrategy","opened","concat","make","bounded","undefined","interruptWhen","millis","Math","max","stream","frame","encode","contentType","headers","connection"],"mappings":"AAAA,SAA2BA,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAClD,SAASC,kBAAkB,QAAQ,cAAc;AAEjD;;;;;;;CAOC,GACD,MAAMC,YAAYJ,SAASK,OAAO,CAAC;AAEnC,MAAMC,UAAU,IAAIC;AAEpB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,WAAW,CAACC,QACvB,CAAC,IAAI,EAAEA,MAAMC,EAAE,CAAC,QAAQ,EAAEC,KAAKC,SAAS,CAACb,kBAAkBU,QAAQ,IAAI,CAAC,CAAC;AAgB3E;;;;;;;;CAQC,GACD,OAAO,MAAMI,cAAc,CACzBC,QACA,EAAEC,SAAS,EAAEC,MAAMC,KAAKD,GAAG,EAAsB,GAAG,CAAC,CAAC;IAEtD,MAAME,YAAYhB,OAAOiB,gBAAgB,CACvClB,OAAOmB,EAAE,CAACnB,OAAOoB,KAAK,CAACjB,YAAY;IAGrC,MAAMkB,SAASpB,OAAOqB,KAAK,CAACrB,OAAOsB,GAAG,CAACV,QAAQN,WAAWU,WAAW;QACnEO,cAAc;IAChB;IAEA,4DAA4D;IAC5D,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,0DAA0D;IAC1D,MAAMC,SAASxB,OAAOyB,MAAM,CAACzB,OAAO0B,IAAI,CAAC,eAAeN;IAExD,MAAMO,UACJd,cAAce,YACVJ,SACAxB,OAAO6B,aAAa,CAClBL,QACA,sEAAsE;IACtE,mEAAmE;IACnE,yCAAyC;IACzCzB,OAAOoB,KAAK,CAACrB,SAASgC,MAAM,CAACC,KAAKC,GAAG,CAAC,GAAGnB,YAAY,OAAOC;IAGpE,OAAOb,mBAAmBgC,MAAM,CAC9BjC,OAAOsB,GAAG,CAACK,SAASO,CAAAA,QAAS9B,QAAQ+B,MAAM,CAACD,SAC5C;QACEE,aAAa;QACbC,SAAS;YACP,iBAAiB;YACjBC,YAAY;YACZ,sEAAsE;YACtE,kDAAkD;YAClD,qBAAqB;QACvB;IACF;AAEJ,EAAE"}
1
+ {"version":3,"sources":["../src/sse.ts"],"sourcesContent":["import { type DomainEvent, makeEventEnvelope } from '@entifix/core';\nimport { Duration, Effect, Stream } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\n/**\n * How often an idle connection emits a comment frame.\n *\n * An SSE connection that sends nothing for minutes is indistinguishable from a\n * dead one to anything between the browser and the service, and a proxy that\n * decides it is dead closes it with no error either side can report. A comment\n * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.\n */\nconst KEEPALIVE = Duration.seconds(20);\n\nconst encoder = new TextEncoder();\n\n/**\n * One `EventSource` frame.\n *\n * `id:` carries the message's own id — `<transactionId>:<step>` for a\n * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect\n * names a message that actually exists, rather than a counter invented for the\n * wire. The server does not replay it; the client reconciles by\n * re-reading the record.\n *\n * `data:` is a full `event` envelope, which is what keeps the stream from\n * becoming a fourth framing: the same bytes a bus consumer would read.\n */\nexport const sseFrame = (event: DomainEvent): string =>\n `id: ${event.id}\\ndata: ${JSON.stringify(makeEventEnvelope(event))}\\n\\n`;\n\nexport interface SseResponseOptions {\n /**\n * Unix seconds at which to close the connection — the verified token's `exp`.\n *\n * A held-open response has no next request to fail verification, so the bound\n * every REST route gets implicitly has to be applied explicitly here. The\n * browser reconnects with the cookie the app refreshed in the meantime\n *. Omitted, the stream runs until the client goes away.\n */\n readonly expiresAt?: number;\n /** Injectable for tests; defaults to the wall clock. */\n readonly now?: () => number;\n}\n\n/**\n * Serves a stream of domain events as `text/event-stream`.\n *\n * `cache-control: no-store` is not politeness — a cached event stream is served\n * to whoever asks next, and this one is scoped to a single principal. The\n * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has\n * a pass-through arm for exactly this content type, because reading the body to\n * completion first holds the request open forever and delivers nothing.\n */\nexport const sseResponse = (\n events: Stream.Stream<DomainEvent>,\n { expiresAt, now = Date.now }: SseResponseOptions = {},\n): HttpServerResponse.HttpServerResponse => {\n const keepalive = Stream.fromEffectRepeat(\n Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\\n\\n'),\n );\n\n const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {\n haltStrategy: 'left',\n });\n\n // An opening comment, emitted before anything has happened.\n //\n // Not cosmetic: a chunked response writes its headers with its first chunk,\n // so a stream that stays silent until something occurs leaves the client in\n // `CONNECTING` — indefinitely, since a stream whose whole purpose is to be\n // idle most of the time may have nothing to say for hours. Measured: without\n // it, `fetch` against this route does not resolve at all.\n const opened = Stream.concat(Stream.make(': open\\n\\n'), frames);\n\n const bounded =\n expiresAt === undefined\n ? opened\n : Stream.interruptWhen(\n opened,\n // Never negative: an already-expired token closes at once rather than\n // scheduling a sleep into the past, which `Effect.sleep` treats as\n // zero anyway but reads as an oversight.\n Effect.sleep(Duration.millis(Math.max(0, expiresAt * 1000 - now()))),\n );\n\n return HttpServerResponse.stream(\n Stream.map(bounded, frame => encoder.encode(frame)),\n {\n contentType: 'text/event-stream',\n headers: {\n 'cache-control': 'no-store',\n connection: 'keep-alive',\n // nginx buffers a proxied response by default, which would hold every\n // frame until the stream ends — that is, forever.\n 'x-accel-buffering': 'no',\n },\n },\n );\n};\n"],"names":["makeEventEnvelope","Duration","Effect","Stream","HttpServerResponse","KEEPALIVE","seconds","encoder","TextEncoder","sseFrame","event","id","JSON","stringify","sseResponse","events","expiresAt","now","Date","keepalive","fromEffectRepeat","as","sleep","frames","merge","map","haltStrategy","opened","concat","make","bounded","undefined","interruptWhen","millis","Math","max","stream","frame","encode","contentType","headers","connection"],"mappings":"AAAA,SAA2BA,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAClD,SAASC,kBAAkB,QAAQ,cAAc;AAEjD;;;;;;;CAOC,GACD,MAAMC,YAAYJ,SAASK,OAAO,CAAC;AAEnC,MAAMC,UAAU,IAAIC;AAEpB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,WAAW,CAACC,QACvB,CAAC,IAAI,EAAEA,MAAMC,EAAE,CAAC,QAAQ,EAAEC,KAAKC,SAAS,CAACb,kBAAkBU,QAAQ,IAAI,CAAC,CAAC;AAgB3E;;;;;;;;CAQC,GACD,OAAO,MAAMI,cAAc,CACzBC,QACA,EAAEC,SAAS,EAAEC,MAAMC,KAAKD,GAAG,EAAsB,GAAG,CAAC,CAAC;IAEtD,MAAME,YAAYhB,OAAOiB,gBAAgB,CACvClB,OAAOmB,EAAE,CAACnB,OAAOoB,KAAK,CAACjB,YAAY;IAGrC,MAAMkB,SAASpB,OAAOqB,KAAK,CAACrB,OAAOsB,GAAG,CAACV,QAAQN,WAAWU,WAAW;QACnEO,cAAc;IAChB;IAEA,4DAA4D;IAC5D,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,0DAA0D;IAC1D,MAAMC,SAASxB,OAAOyB,MAAM,CAACzB,OAAO0B,IAAI,CAAC,eAAeN;IAExD,MAAMO,UACJd,cAAce,YACVJ,SACAxB,OAAO6B,aAAa,CAClBL,QACA,sEAAsE;IACtE,mEAAmE;IACnE,yCAAyC;IACzCzB,OAAOoB,KAAK,CAACrB,SAASgC,MAAM,CAACC,KAAKC,GAAG,CAAC,GAAGnB,YAAY,OAAOC;IAGpE,OAAOb,mBAAmBgC,MAAM,CAC9BjC,OAAOsB,GAAG,CAACK,SAASO,CAAAA,QAAS9B,QAAQ+B,MAAM,CAACD,SAC5C;QACEE,aAAa;QACbC,SAAS;YACP,iBAAiB;YACjBC,YAAY;YACZ,sEAAsE;YACtE,kDAAkD;YAClD,qBAAqB;QACvB;IACF;AAEJ,EAAE"}
@@ -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"}