@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
package/README.md CHANGED
@@ -7,13 +7,25 @@ Routes are guarded with `requirePermission(...)`, which asks the `PolicyDecision
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- pnpm add @entifix/service-shell @opentelemetry/api effect
10
+ pnpm add @entifix/service-shell effect
11
+ # to run the service:
12
+ pnpm add @effect/platform-node
13
+ # for telemetry:
14
+ pnpm add @opentelemetry/api @effect/opentelemetry @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/context-async-hooks
11
15
  ```
12
16
 
13
17
  ## Entry points
14
18
 
15
- | Import | What it holds |
16
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
+ | Import | What it holds |
20
+ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | `@entifix/service-shell` | What a route set is written against: `requirePrincipal`, `requirePermission`, `requireOrganization`, `requireSharedToken`, `entityMetadataRoute`, health and SSE routes |
22
+ | `@entifix/service-shell/runtime` | Running a service: `makeService`, `makeServerLayer`, `loadRemoteConfiguration`. Needs `@effect/platform-node` |
23
+ | `@entifix/service-shell/observability` | `makeObservabilityLayer` and the OpenTelemetry wiring. Needs `@effect/opentelemetry` and the OpenTelemetry SDK packages |
24
+ | `@entifix/service-shell/testing` | `serveTestService`, a service on an ephemeral port for a spec |
25
+
26
+ The runtime and observability halves are optional peers: a package that only declares routes installs neither. The configuration service's URL and credential, and the header and secret that gate `GET /api/$service`, are values the host passes — `loadRemoteConfiguration({ url, headers })` and `makeService({ description: { header, token } })`.
27
+
28
+ ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
17
29
  | `@entifix/service-shell` | `makeService`, `makeServerLayer`, `requirePrincipal`, `requirePermission`, `requireOrganization`, `entityMetadataRoute`, `loadRemoteConfiguration`, `makeObservabilityLayer`, `serveTestService` |
18
30
 
19
31
  ---
@@ -1,5 +1,5 @@
1
1
  export * from './require-organization.js';
2
2
  export * from './require-principal.js';
3
3
  export * from './require-service-crossing.js';
4
- export * from './service-token.js';
4
+ export * from './shared-token.js';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,wBAAwB,CAAC;AACvC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,wBAAwB,CAAC;AACvC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,mBAAmB,CAAC"}
@@ -1,6 +1,6 @@
1
1
  export * from './require-organization.js';
2
2
  export * from './require-principal.js';
3
3
  export * from './require-service-crossing.js';
4
- export * from './service-token.js';
4
+ export * from './shared-token.js';
5
5
 
6
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/index.ts"],"sourcesContent":["export * from './require-organization.js';\nexport * from './require-principal.js';\nexport * from './require-service-crossing.js';\nexport * from './service-token.js';\n"],"names":[],"mappings":"AAAA,cAAc,4BAA4B;AAC1C,cAAc,yBAAyB;AACvC,cAAc,gCAAgC;AAC9C,cAAc,qBAAqB"}
1
+ {"version":3,"sources":["../../src/auth/index.ts"],"sourcesContent":["export * from './require-organization.js';\nexport * from './require-principal.js';\nexport * from './require-service-crossing.js';\nexport * from './shared-token.js';\n"],"names":[],"mappings":"AAAA,cAAc,4BAA4B;AAC1C,cAAc,yBAAyB;AACvC,cAAc,gCAAgC;AAC9C,cAAc,oBAAoB"}
@@ -7,13 +7,11 @@ import { type RequestPrincipal } from './require-principal.js';
7
7
  * require that the session names an organization.
8
8
  *
9
9
  * **This is the first of exactly two ways a request resolves the organization
10
- * it acts for, and the only *user-facing* one**
11
- * ([ADR 0006](../../../../../../docs/adr/0006-multitenancy-planes-and-tenant-storage.md)).
10
+ * it acts for, and the only *user-facing* one**.
12
11
  * The second is {@link requireServiceCrossing}: an explicit `organizationId`
13
12
  * presented with a service token *and* a narrow route permission, for a
14
13
  * platform-plane caller acting for an organization it was handed rather than one
15
- * it picked
16
- * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
14
+ * it picked.
17
15
  *
18
16
  * There is no third path, no fallback and no operator branch. A request
19
17
  * satisfying neither guard never reaches a tenant handle at all, which is what
@@ -1 +1 @@
1
- {"version":3,"file":"require-organization.d.ts","sourceRoot":"","sources":["../../src/auth/require-organization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,wBAAwB,CAAC;AAkBhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,mBAAmB,GAC7B,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EACN,KAAK,CACH,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,gBAAgB,KACxB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,yQAa1B,CAAC"}
1
+ {"version":3,"file":"require-organization.d.ts","sourceRoot":"","sources":["../../src/auth/require-organization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,wBAAwB,CAAC;AAkBhC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,mBAAmB,GAC7B,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EACN,KAAK,CACH,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,gBAAgB,KACxB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,yQAa1B,CAAC"}
@@ -5,12 +5,12 @@ import { requirePermission } from './require-principal.js';
5
5
  * `409` rather than `403`: the caller is authenticated and permitted, but the
6
6
  * session names no organization, so there is no storage to read. An operator
7
7
  * hits this by design — crossing into a tenant is an explicit, audited
8
- * act-as-organization re-mint, never a widening of the default (ADR 0012) —
8
+ * act-as-organization re-mint, never a widening of the default —
9
9
  * and a member of several organizations hits it before choosing one. The `code`
10
10
  * is a key in the shared `errors` catalog, so a client renders "select an
11
- * organization" instead of "forbidden" — a claim `@r10c/i18n-check` now holds
12
- * to, because for a while it was simply false and the code reached the browser
13
- * as its own literal text.
11
+ * organization" instead of "forbidden" — a claim a host has to check its
12
+ * catalog against, because for a while it was simply false and the code
13
+ * reached the browser as its own literal text.
14
14
  */ const noOrganization = HttpServerResponse.json({
15
15
  error: 'no active organization',
16
16
  code: 'noActiveOrganization'
@@ -22,13 +22,11 @@ import { requirePermission } from './require-principal.js';
22
22
  * require that the session names an organization.
23
23
  *
24
24
  * **This is the first of exactly two ways a request resolves the organization
25
- * it acts for, and the only *user-facing* one**
26
- * ([ADR 0006](../../../../../../docs/adr/0006-multitenancy-planes-and-tenant-storage.md)).
25
+ * it acts for, and the only *user-facing* one**.
27
26
  * The second is {@link requireServiceCrossing}: an explicit `organizationId`
28
27
  * presented with a service token *and* a narrow route permission, for a
29
28
  * platform-plane caller acting for an organization it was handed rather than one
30
- * it picked
31
- * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
29
+ * it picked.
32
30
  *
33
31
  * There is no third path, no fallback and no operator branch. A request
34
32
  * satisfying neither guard never reaches a tenant handle at all, which is what
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/require-organization.ts"],"sourcesContent":["import type { Permission } from '@entifix/authz';\nimport { Effect } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\nimport {\n type RequestPrincipal,\n requirePermission,\n} from './require-principal.js';\n\n/**\n * `409` rather than `403`: the caller is authenticated and permitted, but the\n * session names no organization, so there is no storage to read. An operator\n * hits this by design — crossing into a tenant is an explicit, audited\n * act-as-organization re-mint, never a widening of the default (ADR 0012) —\n * and a member of several organizations hits it before choosing one. The `code`\n * is a key in the shared `errors` catalog, so a client renders \"select an\n * organization\" instead of \"forbidden\" — a claim `@r10c/i18n-check` now holds\n * to, because for a while it was simply false and the code reached the browser\n * as its own literal text.\n */\nconst noOrganization = HttpServerResponse.json(\n { error: 'no active organization', code: 'noActiveOrganization' },\n { status: 409 },\n);\n\n/**\n * Guard a **tenant-plane** handler: authenticate, check the permission, and\n * require that the session names an organization.\n *\n * **This is the first of exactly two ways a request resolves the organization\n * it acts for, and the only *user-facing* one**\n * ([ADR 0006](../../../../../../docs/adr/0006-multitenancy-planes-and-tenant-storage.md)).\n * The second is {@link requireServiceCrossing}: an explicit `organizationId`\n * presented with a service token *and* a narrow route permission, for a\n * platform-plane caller acting for an organization it was handed rather than one\n * it picked\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard never reaches a tenant handle at all, which is what\n * keeps the most sensitive capability in the system from being the *absence* of\n * a condition.\n *\n * The organization is handed to the handler so it can resolve its own storage\n * through a `TenantDatabaseResolver`. Resolution happens **inside the request**\n * — the connection pool is a boot-time `Layer`, and a per-request `Layer` would\n * rebuild the pool per request.\n *\n * Here the value comes from the verified token and nowhere else. A route\n * parameter or a body field naming an organization would be caller-controlled,\n * which is the whole failure this guard exists to prevent — and is exactly why\n * the other path honours a caller-named organization only *after* the caller has\n * proved it is the fleet.\n */\nexport const requireOrganization =\n (permission: Permission) =>\n <A, E, R>(\n use: (\n organizationId: string,\n principal: RequestPrincipal,\n ) => Effect.Effect<A, E, R>,\n ) =>\n requirePermission(permission)(principal =>\n // `Effect.gen` rather than a ternary: the two branches have different\n // success types, and a conditional expression would unify them against\n // the first one instead of widening. `requirePrincipal` yields for the\n // same reason.\n Effect.gen(function* () {\n if (principal.organizationId === undefined) {\n return yield* noOrganization;\n }\n return yield* use(principal.organizationId, principal);\n }),\n );\n"],"names":["Effect","HttpServerResponse","requirePermission","noOrganization","json","error","code","status","requireOrganization","permission","use","principal","gen","organizationId","undefined"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAChC,SAASC,kBAAkB,QAAQ,cAAc;AAEjD,SAEEC,iBAAiB,QACZ,yBAAyB;AAEhC;;;;;;;;;;CAUC,GACD,MAAMC,iBAAiBF,mBAAmBG,IAAI,CAC5C;IAAEC,OAAO;IAA0BC,MAAM;AAAuB,GAChE;IAAEC,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,sBACX,CAACC,aACD,CACEC,MAKAR,kBAAkBO,YAAYE,CAAAA,YAC5B,sEAAsE;YACtE,uEAAuE;YACvE,uEAAuE;YACvE,eAAe;YACfX,OAAOY,GAAG,CAAC;gBACT,IAAID,UAAUE,cAAc,KAAKC,WAAW;oBAC1C,OAAO,OAAOX;gBAChB;gBACA,OAAO,OAAOO,IAAIC,UAAUE,cAAc,EAAEF;YAC9C,IACA"}
1
+ {"version":3,"sources":["../../src/auth/require-organization.ts"],"sourcesContent":["import type { Permission } from '@entifix/authz';\nimport { Effect } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\nimport {\n type RequestPrincipal,\n requirePermission,\n} from './require-principal.js';\n\n/**\n * `409` rather than `403`: the caller is authenticated and permitted, but the\n * session names no organization, so there is no storage to read. An operator\n * hits this by design — crossing into a tenant is an explicit, audited\n * act-as-organization re-mint, never a widening of the default —\n * and a member of several organizations hits it before choosing one. The `code`\n * is a key in the shared `errors` catalog, so a client renders \"select an\n * organization\" instead of \"forbidden\" — a claim a host has to check its\n * catalog against, because for a while it was simply false and the code\n * reached the browser as its own literal text.\n */\nconst noOrganization = HttpServerResponse.json(\n { error: 'no active organization', code: 'noActiveOrganization' },\n { status: 409 },\n);\n\n/**\n * Guard a **tenant-plane** handler: authenticate, check the permission, and\n * require that the session names an organization.\n *\n * **This is the first of exactly two ways a request resolves the organization\n * it acts for, and the only *user-facing* one**.\n * The second is {@link requireServiceCrossing}: an explicit `organizationId`\n * presented with a service token *and* a narrow route permission, for a\n * platform-plane caller acting for an organization it was handed rather than one\n * it picked.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard never reaches a tenant handle at all, which is what\n * keeps the most sensitive capability in the system from being the *absence* of\n * a condition.\n *\n * The organization is handed to the handler so it can resolve its own storage\n * through a `TenantDatabaseResolver`. Resolution happens **inside the request**\n * — the connection pool is a boot-time `Layer`, and a per-request `Layer` would\n * rebuild the pool per request.\n *\n * Here the value comes from the verified token and nowhere else. A route\n * parameter or a body field naming an organization would be caller-controlled,\n * which is the whole failure this guard exists to prevent — and is exactly why\n * the other path honours a caller-named organization only *after* the caller has\n * proved it is the fleet.\n */\nexport const requireOrganization =\n (permission: Permission) =>\n <A, E, R>(\n use: (\n organizationId: string,\n principal: RequestPrincipal,\n ) => Effect.Effect<A, E, R>,\n ) =>\n requirePermission(permission)(principal =>\n // `Effect.gen` rather than a ternary: the two branches have different\n // success types, and a conditional expression would unify them against\n // the first one instead of widening. `requirePrincipal` yields for the\n // same reason.\n Effect.gen(function* () {\n if (principal.organizationId === undefined) {\n return yield* noOrganization;\n }\n return yield* use(principal.organizationId, principal);\n }),\n );\n"],"names":["Effect","HttpServerResponse","requirePermission","noOrganization","json","error","code","status","requireOrganization","permission","use","principal","gen","organizationId","undefined"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAChC,SAASC,kBAAkB,QAAQ,cAAc;AAEjD,SAEEC,iBAAiB,QACZ,yBAAyB;AAEhC;;;;;;;;;;CAUC,GACD,MAAMC,iBAAiBF,mBAAmBG,IAAI,CAC5C;IAAEC,OAAO;IAA0BC,MAAM;AAAuB,GAChE;IAAEC,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMC,sBACX,CAACC,aACD,CACEC,MAKAR,kBAAkBO,YAAYE,CAAAA,YAC5B,sEAAsE;YACtE,uEAAuE;YACvE,uEAAuE;YACvE,eAAe;YACfX,OAAOY,GAAG,CAAC;gBACT,IAAID,UAAUE,cAAc,KAAKC,WAAW;oBAC1C,OAAO,OAAOX;gBAChB;gBACA,OAAO,OAAOO,IAAIC,UAAUE,cAAc,EAAEF;YAC9C,IACA"}
@@ -44,7 +44,7 @@ export interface RequestPrincipal {
44
44
  * Every REST route is already bounded by it implicitly — verification is
45
45
  * stateless, so the next call after expiry simply fails. A **held-open**
46
46
  * response has no next call, so `GET /api/transaction/events` reads it and
47
- * closes the connection there (ADR 0036). Absent when the token carries no
47
+ * closes the connection there. Absent when the token carries no
48
48
  * `exp`, which a signed one always does; the guard is for the type, not for a
49
49
  * case the fleet produces.
50
50
  */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/require-principal.ts"],"sourcesContent":["import {\n parsePermission,\n type Permission,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport { type TokenClaims, TokenServiceTag } from '@entifix/business';\nimport { ACCESS_COOKIE } from '@entifix/core';\nimport { Effect, Option } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The authenticated subject as a service sees it. Structurally identical to\n * `business-ts-authn`'s `Principal`, re-declared here so the shared shell does\n * not depend on a single domain package.\n */\nexport interface RequestPrincipal {\n readonly userId: TokenClaims['userId'];\n readonly subject: string;\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this request acts for — the storage routing key, absent\n * for a caller with no tenant scope (a buyer, or an operator). Kept out of\n * `attributes` deliberately: attributes are policy inputs, this decides which\n * database a handler reads.\n */\n readonly organizationId?: string;\n /**\n * Which population the caller belongs to — buyer, vendor staff, or platform\n * staff. Carried so a handler never has to infer it from an absent\n * `organizationId`, which a buyer and an operator share.\n *\n * Context, not a grant: what the caller may *do* still comes from `roles`\n * through {@link PolicyDecisionTag}.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id, absent for an account\n * with no party record.\n *\n * Same rule as the two fields above: it comes from the verified token and\n * never from anything the caller sets on the request. It says *whose* records\n * these are, which is what a route scoping a read to its caller needs; it\n * says nothing about what may be done with them.\n */\n readonly partyId?: string;\n /**\n * When the verified token stops being valid, as a Unix timestamp in seconds.\n *\n * Every REST route is already bounded by it implicitly — verification is\n * stateless, so the next call after expiry simply fails. A **held-open**\n * response has no next call, so `GET /api/transaction/events` reads it and\n * closes the connection there (ADR 0036). Absent when the token carries no\n * `exp`, which a signed one always does; the guard is for the type, not for a\n * case the fleet produces.\n */\n readonly expiresAt?: number;\n readonly attributes: Readonly<Record<string, unknown>>;\n}\n\n/** Extract a bearer token from an `Authorization` header, if present. */\nconst bearerToken = (authorization: string | undefined): string | undefined => {\n if (authorization === undefined) {\n return undefined;\n }\n const [scheme, value] = authorization.split(' ');\n return scheme?.toLowerCase() === 'bearer' ? value : undefined;\n};\n\n/** Rebuild the request principal from verified token claims. */\nconst claimsToPrincipal = (claims: TokenClaims): RequestPrincipal => ({\n userId: claims.userId,\n subject: claims.subject,\n sessionId: claims.sessionId,\n roles: claims.roles,\n // The organization comes from the *verified* token and nowhere else — never\n // from a route parameter, a query string or a body, all of which the caller\n // controls. That is what makes tenant isolation a property of the session\n // rather than of every handler remembering to check.\n organizationId: claims.activeOrganizationId,\n // Same rule, same reason: it comes from the verified token, never from\n // anything the caller can set on the request.\n partyRole: claims.partyRole,\n partyId: claims.partyId,\n // `TokenClaims` carries an index signature, so `exp` arrives as `unknown`\n // even though jose always sets it — hence the guard rather than a cast.\n expiresAt: typeof claims.exp === 'number' ? claims.exp : undefined,\n // Rich/volatile attributes are not in the token; a handler that needs them\n // reads the session store by `sessionId`.\n attributes: {},\n});\n\n// `code` is a key in the shared `errors` catalog; `error` stays English for\n// logs. `permission` is deliberately still in the 403 — it says which grant was\n// missing, which is diagnostic, not a leak.\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\n/**\n * Resolve the caller's principal from the request, or `None`. Reads the token\n * from the `entifix_at` cookie (the Next app forwards it) or an\n * `Authorization: Bearer` header and verifies it statelessly via\n * {@link TokenServiceTag} — no store round trip on the hot path.\n */\nconst resolvePrincipal = Effect.gen(function* () {\n const req = yield* HttpServerRequest.HttpServerRequest;\n const token =\n req.cookies[ACCESS_COOKIE] ?? bearerToken(req.headers['authorization']);\n if (token === undefined) {\n return Option.none<RequestPrincipal>();\n }\n\n const tokens = yield* TokenServiceTag;\n const claims = yield* tokens.verify(token).pipe(Effect.option);\n return Option.map(claims, claimsToPrincipal);\n});\n\n/**\n * Guard a handler behind a valid access token — authentication only. Any\n * missing or invalid token is a `401`; the wrapped handler's own errors are\n * left intact.\n */\nexport const requirePrincipal = <A, E, R>(\n use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>,\n) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n return yield* use(principal.value);\n });\n\n/**\n * Guard a handler behind a permission. Authenticates as above, then asks\n * {@link PolicyDecisionTag} whether this subject may perform the action — so\n * the two failure modes stay distinguishable: `401` means \"sign in\", `403`\n * means \"signed in, not allowed\".\n *\n * **This is the authorization boundary.** Filtering a nav menu or hiding a page\n * is presentation; a route that skips this guard is open regardless.\n */\nexport const requirePermission =\n (permission: Permission) =>\n <A, E, R>(use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n\n const { resource, action } = parsePermission(permission);\n const policy = yield* PolicyDecisionTag;\n const allowed = policy.decide({\n subject: { roles: principal.value.roles },\n resource,\n action,\n });\n if (!allowed) {\n return yield* forbidden(permission);\n }\n\n return yield* use(principal.value);\n });\n"],"names":["parsePermission","PolicyDecisionTag","TokenServiceTag","ACCESS_COOKIE","Effect","Option","HttpServerRequest","HttpServerResponse","bearerToken","authorization","undefined","scheme","value","split","toLowerCase","claimsToPrincipal","claims","userId","subject","sessionId","roles","organizationId","activeOrganizationId","partyRole","partyId","expiresAt","exp","attributes","unauthenticated","json","error","code","status","forbidden","permission","resolvePrincipal","gen","req","token","cookies","headers","none","tokens","verify","pipe","option","map","requirePrincipal","use","principal","isNone","requirePermission","resource","action","policy","allowed","decide"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/auth/require-principal.ts"],"sourcesContent":["import {\n parsePermission,\n type Permission,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport { type TokenClaims, TokenServiceTag } from '@entifix/business';\nimport { ACCESS_COOKIE } from '@entifix/core';\nimport { Effect, Option } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The authenticated subject as a service sees it. Structurally identical to\n * `business-ts-authn`'s `Principal`, re-declared here so the shared shell does\n * not depend on a single domain package.\n */\nexport interface RequestPrincipal {\n readonly userId: TokenClaims['userId'];\n readonly subject: string;\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this request acts for — the storage routing key, absent\n * for a caller with no tenant scope (a buyer, or an operator). Kept out of\n * `attributes` deliberately: attributes are policy inputs, this decides which\n * database a handler reads.\n */\n readonly organizationId?: string;\n /**\n * Which population the caller belongs to — buyer, vendor staff, or platform\n * staff. Carried so a handler never has to infer it from an absent\n * `organizationId`, which a buyer and an operator share.\n *\n * Context, not a grant: what the caller may *do* still comes from `roles`\n * through {@link PolicyDecisionTag}.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id, absent for an account\n * with no party record.\n *\n * Same rule as the two fields above: it comes from the verified token and\n * never from anything the caller sets on the request. It says *whose* records\n * these are, which is what a route scoping a read to its caller needs; it\n * says nothing about what may be done with them.\n */\n readonly partyId?: string;\n /**\n * When the verified token stops being valid, as a Unix timestamp in seconds.\n *\n * Every REST route is already bounded by it implicitly — verification is\n * stateless, so the next call after expiry simply fails. A **held-open**\n * response has no next call, so `GET /api/transaction/events` reads it and\n * closes the connection there. Absent when the token carries no\n * `exp`, which a signed one always does; the guard is for the type, not for a\n * case the fleet produces.\n */\n readonly expiresAt?: number;\n readonly attributes: Readonly<Record<string, unknown>>;\n}\n\n/** Extract a bearer token from an `Authorization` header, if present. */\nconst bearerToken = (authorization: string | undefined): string | undefined => {\n if (authorization === undefined) {\n return undefined;\n }\n const [scheme, value] = authorization.split(' ');\n return scheme?.toLowerCase() === 'bearer' ? value : undefined;\n};\n\n/** Rebuild the request principal from verified token claims. */\nconst claimsToPrincipal = (claims: TokenClaims): RequestPrincipal => ({\n userId: claims.userId,\n subject: claims.subject,\n sessionId: claims.sessionId,\n roles: claims.roles,\n // The organization comes from the *verified* token and nowhere else — never\n // from a route parameter, a query string or a body, all of which the caller\n // controls. That is what makes tenant isolation a property of the session\n // rather than of every handler remembering to check.\n organizationId: claims.activeOrganizationId,\n // Same rule, same reason: it comes from the verified token, never from\n // anything the caller can set on the request.\n partyRole: claims.partyRole,\n partyId: claims.partyId,\n // `TokenClaims` carries an index signature, so `exp` arrives as `unknown`\n // even though jose always sets it — hence the guard rather than a cast.\n expiresAt: typeof claims.exp === 'number' ? claims.exp : undefined,\n // Rich/volatile attributes are not in the token; a handler that needs them\n // reads the session store by `sessionId`.\n attributes: {},\n});\n\n// `code` is a key in the shared `errors` catalog; `error` stays English for\n// logs. `permission` is deliberately still in the 403 — it says which grant was\n// missing, which is diagnostic, not a leak.\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\n/**\n * Resolve the caller's principal from the request, or `None`. Reads the token\n * from the `entifix_at` cookie (the Next app forwards it) or an\n * `Authorization: Bearer` header and verifies it statelessly via\n * {@link TokenServiceTag} — no store round trip on the hot path.\n */\nconst resolvePrincipal = Effect.gen(function* () {\n const req = yield* HttpServerRequest.HttpServerRequest;\n const token =\n req.cookies[ACCESS_COOKIE] ?? bearerToken(req.headers['authorization']);\n if (token === undefined) {\n return Option.none<RequestPrincipal>();\n }\n\n const tokens = yield* TokenServiceTag;\n const claims = yield* tokens.verify(token).pipe(Effect.option);\n return Option.map(claims, claimsToPrincipal);\n});\n\n/**\n * Guard a handler behind a valid access token — authentication only. Any\n * missing or invalid token is a `401`; the wrapped handler's own errors are\n * left intact.\n */\nexport const requirePrincipal = <A, E, R>(\n use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>,\n) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n return yield* use(principal.value);\n });\n\n/**\n * Guard a handler behind a permission. Authenticates as above, then asks\n * {@link PolicyDecisionTag} whether this subject may perform the action — so\n * the two failure modes stay distinguishable: `401` means \"sign in\", `403`\n * means \"signed in, not allowed\".\n *\n * **This is the authorization boundary.** Filtering a nav menu or hiding a page\n * is presentation; a route that skips this guard is open regardless.\n */\nexport const requirePermission =\n (permission: Permission) =>\n <A, E, R>(use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n\n const { resource, action } = parsePermission(permission);\n const policy = yield* PolicyDecisionTag;\n const allowed = policy.decide({\n subject: { roles: principal.value.roles },\n resource,\n action,\n });\n if (!allowed) {\n return yield* forbidden(permission);\n }\n\n return yield* use(principal.value);\n });\n"],"names":["parsePermission","PolicyDecisionTag","TokenServiceTag","ACCESS_COOKIE","Effect","Option","HttpServerRequest","HttpServerResponse","bearerToken","authorization","undefined","scheme","value","split","toLowerCase","claimsToPrincipal","claims","userId","subject","sessionId","roles","organizationId","activeOrganizationId","partyRole","partyId","expiresAt","exp","attributes","unauthenticated","json","error","code","status","forbidden","permission","resolvePrincipal","gen","req","token","cookies","headers","none","tokens","verify","pipe","option","map","requirePrincipal","use","principal","isNone","requirePermission","resource","action","policy","allowed","decide"],"mappings":"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"}
@@ -9,8 +9,7 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
9
9
  * configuration *read*, this one a tenant-data *write* for any organization the
10
10
  * caller names. Sharing the header name is how a proxy configured to forward the
11
11
  * fleet token ends up presenting it to a route that writes stock, and how a
12
- * copy-pasted value looks right while being the wrong secret entirely
13
- * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
12
+ * copy-pasted value looks right while being the wrong secret entirely.
14
13
  */
15
14
  export declare const CROSSING_TOKEN_HEADER = "x-crossing-token";
16
15
  /**
@@ -52,11 +51,10 @@ export declare class ServiceCrossingTokenTag extends ServiceCrossingTokenTag_bas
52
51
  * be a far worse breach than the problem it solved.
53
52
  *
54
53
  * There is no third path, no fallback and no operator branch. A request
55
- * satisfying neither guard simply never reaches a tenant handle
56
- * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);
57
- * ADR 0012's operator crossing is a *different* mechanism — a person choosing an
58
- * organization, which needs a human's permission, a time box and a `Crossing`
59
- * record).
54
+ * satisfying neither guard simply never reaches a tenant handle. An operator
55
+ * reaching into a tenant is a *different* mechanism — a person choosing an
56
+ * organization, which needs a human's permission, a time box and a record of
57
+ * the crossing — and does not belong in this guard.
60
58
  *
61
59
  * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is
62
60
  * the design.** A route guarded here accepts the crossing token and nothing
@@ -74,7 +72,7 @@ export declare class ServiceCrossingTokenTag extends ServiceCrossingTokenTag_bas
74
72
  * fleet membership is not a capability;
75
73
  * 3. the organization header, non-blank → `400`.
76
74
  *
77
- * **The recorded residual** (ADR 0023, not argued away here): a shared secret
75
+ * **The residual, stated rather than argued away**: a shared secret
78
76
  * means any process holding it can name any organization. What bounds it is the
79
77
  * permission above, the fact that only the slices needing it are configured with
80
78
  * the key, and the request log. What would remove it is an RS256 service token
@@ -96,7 +94,7 @@ export declare const requireServiceCrossing: (permission: Permission) => <A, E,
96
94
  * and **no session is accepted**. A saga step is not a person's act — the buyer
97
95
  * behind a checkout holds no grant over the order the coordinator is writing on
98
96
  * their behalf, and accepting their session here would make the weaker
99
- * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).
97
+ * credential the security level.
100
98
  *
101
99
  * The recorded residual carries over verbatim: a shared secret means any process
102
100
  * holding it can act, bounded by the permission and by which slices are
@@ -1 +1 @@
1
- {"version":3,"file":"require-service-crossing.d.ts","sourceRoot":"","sources":["../../src/auth/require-service-crossing.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;;AAEvD;;;;;;;;GAQG;AACH,qBAAa,uBAAwB,SAAQ,4BAGf;CAAG;AAgCjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,sBAAsB,GAChC,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,cAAc,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6MAwB7D,CAAC;AAEP;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,eAAe,GACzB,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6MAenC,CAAC"}
1
+ {"version":3,"file":"require-service-crossing.d.ts","sourceRoot":"","sources":["../../src/auth/require-service-crossing.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;;AAEvD;;;;;;;;GAQG;AACH,qBAAa,uBAAwB,SAAQ,4BAGf;CAAG;AAgCjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,sBAAsB,GAChC,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,cAAc,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6MAwB7D,CAAC;AAEP;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,eAAe,GACzB,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6MAenC,CAAC"}
@@ -10,8 +10,7 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
10
10
  * configuration *read*, this one a tenant-data *write* for any organization the
11
11
  * caller names. Sharing the header name is how a proxy configured to forward the
12
12
  * fleet token ends up presenting it to a route that writes stock, and how a
13
- * copy-pasted value looks right while being the wrong secret entirely
14
- * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
13
+ * copy-pasted value looks right while being the wrong secret entirely.
15
14
  */ export const CROSSING_TOKEN_HEADER = 'x-crossing-token';
16
15
  /**
17
16
  * The header naming the organization the caller is acting for.
@@ -77,11 +76,10 @@ const organizationRequired = HttpServerResponse.json({
77
76
  * be a far worse breach than the problem it solved.
78
77
  *
79
78
  * There is no third path, no fallback and no operator branch. A request
80
- * satisfying neither guard simply never reaches a tenant handle
81
- * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);
82
- * ADR 0012's operator crossing is a *different* mechanism — a person choosing an
83
- * organization, which needs a human's permission, a time box and a `Crossing`
84
- * record).
79
+ * satisfying neither guard simply never reaches a tenant handle. An operator
80
+ * reaching into a tenant is a *different* mechanism — a person choosing an
81
+ * organization, which needs a human's permission, a time box and a record of
82
+ * the crossing — and does not belong in this guard.
85
83
  *
86
84
  * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is
87
85
  * the design.** A route guarded here accepts the crossing token and nothing
@@ -99,7 +97,7 @@ const organizationRequired = HttpServerResponse.json({
99
97
  * fleet membership is not a capability;
100
98
  * 3. the organization header, non-blank → `400`.
101
99
  *
102
- * **The recorded residual** (ADR 0023, not argued away here): a shared secret
100
+ * **The residual, stated rather than argued away**: a shared secret
103
101
  * means any process holding it can name any organization. What bounds it is the
104
102
  * permission above, the fact that only the slices needing it are configured with
105
103
  * the key, and the request log. What would remove it is an RS256 service token
@@ -140,7 +138,7 @@ const organizationRequired = HttpServerResponse.json({
140
138
  * and **no session is accepted**. A saga step is not a person's act — the buyer
141
139
  * behind a checkout holds no grant over the order the coordinator is writing on
142
140
  * their behalf, and accepting their session here would make the weaker
143
- * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).
141
+ * credential the security level.
144
142
  *
145
143
  * The recorded residual carries over verbatim: a shared secret means any process
146
144
  * holding it can act, bounded by the permission and by which slices are
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/require-service-crossing.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { type Permission, ServiceCrossingPolicyTag } from '@entifix/authz';\nimport { Context, Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The header carrying the crossing secret.\n *\n * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's\n * fleet lookup, and the two secrets are not comparable: one grants a\n * configuration *read*, this one a tenant-data *write* for any organization the\n * caller names. Sharing the header name is how a proxy configured to forward the\n * fleet token ends up presenting it to a route that writes stock, and how a\n * copy-pasted value looks right while being the wrong secret entirely\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).\n */\nexport const CROSSING_TOKEN_HEADER = 'x-crossing-token';\n\n/**\n * The header naming the organization the caller is acting for.\n *\n * It rides beside the token rather than inside the request body because\n * entities are organization-agnostic by rule — no `organizationId` member, no\n * tenant filter — so there is nowhere in an entity envelope for it to live. A\n * header also serves the crossings that carry no body at all (releasing a hold,\n * converting one), which a body field would have needed a second convention for.\n *\n * On its own it grants **nothing**: it is only read after the token has proved\n * the caller is the fleet, so the input the caller controls is never the input\n * that authorizes.\n */\nexport const ORGANIZATION_HEADER = 'x-organization-id';\n\n/**\n * The crossing secret this service expects, provided at the composition root\n * from its own configuration.\n *\n * A `Context.Tag` rather than an environment read, which is the difference from\n * {@link requireServiceToken}: the value is a per-service secret held in\n * config-service as an `is_secret` row, so it arrives through the same boot\n * fetch as `mongo.uri` and can be rotated without redeploying a container.\n */\nexport class ServiceCrossingTokenTag extends Context.Service<\n ServiceCrossingTokenTag,\n string\n>()('ServiceCrossingTokenTag') {}\n\n/** Constant-time comparison, so the secret cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined, expected: string): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(expected);\n // `timingSafeEqual` throws on a length mismatch, so lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\nconst organizationRequired = HttpServerResponse.json(\n {\n error: 'the crossing must name an organization',\n code: 'invalidRequest',\n detail: `${ORGANIZATION_HEADER} is required`,\n },\n { status: 400 },\n);\n\n/**\n * Guard a **service-to-service** tenant crossing: the second — and only other —\n * way a request resolves the organization it acts for.\n *\n * The first is {@link requireOrganization}, which reads the session's\n * `activeOrganizationId` and is the only *user-facing* resolution. This one\n * exists because a platform-plane caller sometimes acts for an organization the\n * principal is not a member of: checkout reserving a vendor's stock, where the\n * organization comes from the **item** and a buyer's session carries none — and\n * never will, since making a buyer a member of every vendor they buy from would\n * be a far worse breach than the problem it solved.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard simply never reaches a tenant handle\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);\n * ADR 0012's operator crossing is a *different* mechanism — a person choosing an\n * organization, which needs a human's permission, a time box and a `Crossing`\n * record).\n *\n * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is\n * the design.** A route guarded here accepts the crossing token and nothing\n * else: two accepted credentials on one route means the weaker one is the\n * security level. It matters concretely — `super-admin` holds `*:*:*`, which\n * matches every crossing permission, so a route that also took a session would\n * be reachable by an operator's browser tab.\n *\n * Three checks, in this order, so nothing is revealed before fleet membership is\n * proven:\n *\n * 1. the token, compared in constant time → `401`;\n * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token\n * proves the caller is the fleet; it does not say what the caller may do, and\n * fleet membership is not a capability;\n * 3. the organization header, non-blank → `400`.\n *\n * **The recorded residual** (ADR 0023, not argued away here): a shared secret\n * means any process holding it can name any organization. What bounds it is the\n * permission above, the fact that only the slices needing it are configured with\n * the key, and the request log. What would remove it is an RS256 service token\n * carrying a scope claim, which is named work rather than a gap nobody noticed.\n */\nexport const requireServiceCrossing =\n (permission: Permission) =>\n <A, E, R>(use: (organizationId: string) => Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out, for the reason\n // `requireServiceToken` records: `HttpServerResponse.json` contributes an\n // `HttpBodyError` to the failure channel, and spelling the type out while\n // omitting it is what makes the declaration emit fail invisibly.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n const organizationId = request.headers[ORGANIZATION_HEADER]?.trim();\n if (organizationId === undefined || organizationId === '') {\n return yield* organizationRequired;\n }\n\n return yield* use(organizationId);\n });\n\n/**\n * The same fleet check, for a participant that has **no organization to name**.\n *\n * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`\n * above ends with an organization header because it is about to resolve a tenant\n * database handle from it, and a blank one there would read some other vendor's\n * storage. order-service names one database at boot, so there is no handle to\n * choose — and demanding a header nothing consumes would be theatre that a\n * caller learns to satisfy with any value.\n *\n * What is unchanged, and is the part that matters: the token proves the caller\n * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,\n * and **no session is accepted**. A saga step is not a person's act — the buyer\n * behind a checkout holds no grant over the order the coordinator is writing on\n * their behalf, and accepting their session here would make the weaker\n * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).\n *\n * The recorded residual carries over verbatim: a shared secret means any process\n * holding it can act, bounded by the permission and by which slices are\n * configured with the key.\n */\nexport const requireCrossing =\n (permission: Permission) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","ServiceCrossingPolicyTag","Context","Effect","HttpServerRequest","HttpServerResponse","CROSSING_TOKEN_HEADER","ORGANIZATION_HEADER","ServiceCrossingTokenTag","Service","matches","provided","expected","undefined","left","Buffer","from","right","length","unauthenticated","json","error","code","status","forbidden","permission","organizationRequired","detail","requireServiceCrossing","use","gen","request","headers","crossing","allows","organizationId","trim","requireCrossing","route"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAA0BC,wBAAwB,QAAQ,iBAAiB;AAC3E,SAASC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AACzC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,wBAAwB,mBAAmB;AAExD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,sBAAsB,oBAAoB;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgCN,QAAQO,OAAO,GAGxD;AAA4B;AAEhC,8EAA8E,GAC9E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,yEAAyE;IACzE,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIlB,gBAAgBc,MAAMG;AAC/D;AAEA,MAAME,kBAAkBd,mBAAmBe,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBpB,mBAAmBe,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB,MAAMG,uBAAuBrB,mBAAmBe,IAAI,CAClD;IACEC,OAAO;IACPC,MAAM;IACNK,QAAQ,GAAGpB,oBAAoB,YAAY,CAAC;AAC9C,GACA;IAAEgB,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GACD,OAAO,MAAMK,yBACX,CAACH,aACD,CAAUI,MACR,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjE1B,OAAO2B,GAAG,CAAC;gBAacC;YAZvB,MAAMA,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,MAAMU,kBAAiBJ,uCAAAA,QAAQC,OAAO,CAACzB,oBAAoB,qBAApCwB,qCAAsCK,IAAI;YACjE,IAAID,mBAAmBtB,aAAasB,mBAAmB,IAAI;gBACzD,OAAO,OAAOT;YAChB;YAEA,OAAO,OAAOG,IAAIM;QACpB,GAAG;AAEP;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAME,kBACX,CAACZ,aACD,CAAUa,QACRnC,OAAO2B,GAAG,CAAC;YACT,MAAMC,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,OAAO,OAAOa;QAChB,GAAG"}
1
+ {"version":3,"sources":["../../src/auth/require-service-crossing.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { type Permission, ServiceCrossingPolicyTag } from '@entifix/authz';\nimport { Context, Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The header carrying the crossing secret.\n *\n * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's\n * fleet lookup, and the two secrets are not comparable: one grants a\n * configuration *read*, this one a tenant-data *write* for any organization the\n * caller names. Sharing the header name is how a proxy configured to forward the\n * fleet token ends up presenting it to a route that writes stock, and how a\n * copy-pasted value looks right while being the wrong secret entirely.\n */\nexport const CROSSING_TOKEN_HEADER = 'x-crossing-token';\n\n/**\n * The header naming the organization the caller is acting for.\n *\n * It rides beside the token rather than inside the request body because\n * entities are organization-agnostic by rule — no `organizationId` member, no\n * tenant filter — so there is nowhere in an entity envelope for it to live. A\n * header also serves the crossings that carry no body at all (releasing a hold,\n * converting one), which a body field would have needed a second convention for.\n *\n * On its own it grants **nothing**: it is only read after the token has proved\n * the caller is the fleet, so the input the caller controls is never the input\n * that authorizes.\n */\nexport const ORGANIZATION_HEADER = 'x-organization-id';\n\n/**\n * The crossing secret this service expects, provided at the composition root\n * from its own configuration.\n *\n * A `Context.Tag` rather than an environment read, which is the difference from\n * {@link requireServiceToken}: the value is a per-service secret held in\n * config-service as an `is_secret` row, so it arrives through the same boot\n * fetch as `mongo.uri` and can be rotated without redeploying a container.\n */\nexport class ServiceCrossingTokenTag extends Context.Service<\n ServiceCrossingTokenTag,\n string\n>()('ServiceCrossingTokenTag') {}\n\n/** Constant-time comparison, so the secret cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined, expected: string): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(expected);\n // `timingSafeEqual` throws on a length mismatch, so lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\nconst organizationRequired = HttpServerResponse.json(\n {\n error: 'the crossing must name an organization',\n code: 'invalidRequest',\n detail: `${ORGANIZATION_HEADER} is required`,\n },\n { status: 400 },\n);\n\n/**\n * Guard a **service-to-service** tenant crossing: the second — and only other —\n * way a request resolves the organization it acts for.\n *\n * The first is {@link requireOrganization}, which reads the session's\n * `activeOrganizationId` and is the only *user-facing* resolution. This one\n * exists because a platform-plane caller sometimes acts for an organization the\n * principal is not a member of: checkout reserving a vendor's stock, where the\n * organization comes from the **item** and a buyer's session carries none — and\n * never will, since making a buyer a member of every vendor they buy from would\n * be a far worse breach than the problem it solved.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard simply never reaches a tenant handle. An operator\n * reaching into a tenant is a *different* mechanism — a person choosing an\n * organization, which needs a human's permission, a time box and a record of\n * the crossing — and does not belong in this guard.\n *\n * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is\n * the design.** A route guarded here accepts the crossing token and nothing\n * else: two accepted credentials on one route means the weaker one is the\n * security level. It matters concretely — `super-admin` holds `*:*:*`, which\n * matches every crossing permission, so a route that also took a session would\n * be reachable by an operator's browser tab.\n *\n * Three checks, in this order, so nothing is revealed before fleet membership is\n * proven:\n *\n * 1. the token, compared in constant time → `401`;\n * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token\n * proves the caller is the fleet; it does not say what the caller may do, and\n * fleet membership is not a capability;\n * 3. the organization header, non-blank → `400`.\n *\n * **The residual, stated rather than argued away**: a shared secret\n * means any process holding it can name any organization. What bounds it is the\n * permission above, the fact that only the slices needing it are configured with\n * the key, and the request log. What would remove it is an RS256 service token\n * carrying a scope claim, which is named work rather than a gap nobody noticed.\n */\nexport const requireServiceCrossing =\n (permission: Permission) =>\n <A, E, R>(use: (organizationId: string) => Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out, for the reason\n // `requireServiceToken` records: `HttpServerResponse.json` contributes an\n // `HttpBodyError` to the failure channel, and spelling the type out while\n // omitting it is what makes the declaration emit fail invisibly.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n const organizationId = request.headers[ORGANIZATION_HEADER]?.trim();\n if (organizationId === undefined || organizationId === '') {\n return yield* organizationRequired;\n }\n\n return yield* use(organizationId);\n });\n\n/**\n * The same fleet check, for a participant that has **no organization to name**.\n *\n * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`\n * above ends with an organization header because it is about to resolve a tenant\n * database handle from it, and a blank one there would read some other vendor's\n * storage. order-service names one database at boot, so there is no handle to\n * choose — and demanding a header nothing consumes would be theatre that a\n * caller learns to satisfy with any value.\n *\n * What is unchanged, and is the part that matters: the token proves the caller\n * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,\n * and **no session is accepted**. A saga step is not a person's act — the buyer\n * behind a checkout holds no grant over the order the coordinator is writing on\n * their behalf, and accepting their session here would make the weaker\n * credential the security level.\n *\n * The recorded residual carries over verbatim: a shared secret means any process\n * holding it can act, bounded by the permission and by which slices are\n * configured with the key.\n */\nexport const requireCrossing =\n (permission: Permission) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","ServiceCrossingPolicyTag","Context","Effect","HttpServerRequest","HttpServerResponse","CROSSING_TOKEN_HEADER","ORGANIZATION_HEADER","ServiceCrossingTokenTag","Service","matches","provided","expected","undefined","left","Buffer","from","right","length","unauthenticated","json","error","code","status","forbidden","permission","organizationRequired","detail","requireServiceCrossing","use","gen","request","headers","crossing","allows","organizationId","trim","requireCrossing","route"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAA0BC,wBAAwB,QAAQ,iBAAiB;AAC3E,SAASC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AACzC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE;;;;;;;;;CASC,GACD,OAAO,MAAMC,wBAAwB,mBAAmB;AAExD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,sBAAsB,oBAAoB;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgCN,QAAQO,OAAO,GAGxD;AAA4B;AAEhC,8EAA8E,GAC9E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,yEAAyE;IACzE,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIlB,gBAAgBc,MAAMG;AAC/D;AAEA,MAAME,kBAAkBd,mBAAmBe,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBpB,mBAAmBe,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB,MAAMG,uBAAuBrB,mBAAmBe,IAAI,CAClD;IACEC,OAAO;IACPC,MAAM;IACNK,QAAQ,GAAGpB,oBAAoB,YAAY,CAAC;AAC9C,GACA;IAAEgB,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,OAAO,MAAMK,yBACX,CAACH,aACD,CAAUI,MACR,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjE1B,OAAO2B,GAAG,CAAC;gBAacC;YAZvB,MAAMA,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,MAAMU,kBAAiBJ,uCAAAA,QAAQC,OAAO,CAACzB,oBAAoB,qBAApCwB,qCAAsCK,IAAI;YACjE,IAAID,mBAAmBtB,aAAasB,mBAAmB,IAAI;gBACzD,OAAO,OAAOT;YAChB;YAEA,OAAO,OAAOG,IAAIM;QACpB,GAAG;AAEP;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAME,kBACX,CAACZ,aACD,CAAUa,QACRnC,OAAO2B,GAAG,CAAC;YACT,MAAMC,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,OAAO,OAAOa;QAChB,GAAG"}
@@ -0,0 +1,34 @@
1
+ import { Effect } from 'effect';
2
+ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
3
+ /**
4
+ * A secret every caller of a route presents in one header.
5
+ *
6
+ * Both halves are the host's. Which header carries it and where the expected
7
+ * value comes from are a deployment's conventions — a fleet-wide environment
8
+ * variable, a mounted secret, a fixed value in a test — so the framework takes
9
+ * them as values rather than naming either.
10
+ */
11
+ export interface SharedToken {
12
+ /** The request header the token travels in, e.g. `x-service-token`. */
13
+ readonly header: string;
14
+ /**
15
+ * The expected token, asked for on each request rather than captured once —
16
+ * so a rotated secret, or a test that sets one, takes effect without a
17
+ * restart.
18
+ */
19
+ readonly token: () => string;
20
+ }
21
+ /**
22
+ * Serves `route` only to a caller presenting the shared token.
23
+ *
24
+ * ⚠️ This is **not** identity: every caller presents the same value, so it
25
+ * proves membership of whatever group holds the secret and nothing more. It is
26
+ * the right shape for a route whose readers are machines — a fleet fetching its
27
+ * configuration at boot, tooling reading a service's wiring — and the wrong one
28
+ * for anything a person or a browser reaches.
29
+ *
30
+ * Health endpoints should stay unguarded: a probe cannot hold a credential, and
31
+ * a readiness check that needs one becomes an authentication problem.
32
+ */
33
+ export declare const requireSharedToken: (shared: SharedToken) => <A, E, R>(route: Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("effect/http/HttpBody").HttpBodyError | E, HttpServerRequest.HttpServerRequest | R>;
34
+ //# sourceMappingURL=shared-token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared-token.d.ts","sourceRoot":"","sources":["../../src/auth/shared-token.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,MAAM,CAAC;CAC9B;AAYD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC5B,QAAQ,WAAW,MACnB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,wJAenC,CAAC"}
@@ -0,0 +1,39 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { Effect } from 'effect';
3
+ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
4
+ /** Constant-time comparison, so the token cannot be recovered byte by byte. */ const matches = (provided, expected)=>{
5
+ if (provided === undefined) return false;
6
+ const left = Buffer.from(provided);
7
+ const right = Buffer.from(expected);
8
+ // `timingSafeEqual` throws on a length mismatch, so the lengths are compared
9
+ // first — the length of a shared secret is not what protects it.
10
+ return left.length === right.length && timingSafeEqual(left, right);
11
+ };
12
+ /**
13
+ * Serves `route` only to a caller presenting the shared token.
14
+ *
15
+ * ⚠️ This is **not** identity: every caller presents the same value, so it
16
+ * proves membership of whatever group holds the secret and nothing more. It is
17
+ * the right shape for a route whose readers are machines — a fleet fetching its
18
+ * configuration at boot, tooling reading a service's wiring — and the wrong one
19
+ * for anything a person or a browser reaches.
20
+ *
21
+ * Health endpoints should stay unguarded: a probe cannot hold a credential, and
22
+ * a readiness check that needs one becomes an authentication problem.
23
+ */ export const requireSharedToken = (shared)=>(route)=>// The return type is inferred rather than written out: `HttpServerResponse.json`
24
+ // contributes an `HttpBodyError` to the failure channel, and spelling the type
25
+ // out while omitting it is what makes `tsc` refuse the declaration emit.
26
+ Effect.gen(function*() {
27
+ const request = yield* HttpServerRequest.HttpServerRequest;
28
+ if (!matches(request.headers[shared.header], shared.token())) {
29
+ return yield* HttpServerResponse.json({
30
+ error: 'unauthenticated',
31
+ code: 'unauthenticated'
32
+ }, {
33
+ status: 401
34
+ });
35
+ }
36
+ return yield* route;
37
+ });
38
+
39
+ //# sourceMappingURL=shared-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/shared-token.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * A secret every caller of a route presents in one header.\n *\n * Both halves are the host's. Which header carries it and where the expected\n * value comes from are a deployment's conventions — a fleet-wide environment\n * variable, a mounted secret, a fixed value in a test — so the framework takes\n * them as values rather than naming either.\n */\nexport interface SharedToken {\n /** The request header the token travels in, e.g. `x-service-token`. */\n readonly header: string;\n /**\n * The expected token, asked for on each request rather than captured once —\n * so a rotated secret, or a test that sets one, takes effect without a\n * restart.\n */\n readonly token: () => string;\n}\n\n/** Constant-time comparison, so the token cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined, expected: string): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(expected);\n // `timingSafeEqual` throws on a length mismatch, so the lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\n/**\n * Serves `route` only to a caller presenting the shared token.\n *\n * ⚠️ This is **not** identity: every caller presents the same value, so it\n * proves membership of whatever group holds the secret and nothing more. It is\n * the right shape for a route whose readers are machines — a fleet fetching its\n * configuration at boot, tooling reading a service's wiring — and the wrong one\n * for anything a person or a browser reaches.\n *\n * Health endpoints should stay unguarded: a probe cannot hold a credential, and\n * a readiness check that needs one becomes an authentication problem.\n */\nexport const requireSharedToken =\n (shared: SharedToken) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out: `HttpServerResponse.json`\n // contributes an `HttpBodyError` to the failure channel, and spelling the type\n // out while omitting it is what makes `tsc` refuse the declaration emit.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n\n if (!matches(request.headers[shared.header], shared.token())) {\n return yield* HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n );\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","Effect","HttpServerRequest","HttpServerResponse","matches","provided","expected","undefined","left","Buffer","from","right","length","requireSharedToken","shared","route","gen","request","headers","header","token","json","error","code","status"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAqBpE,6EAA6E,GAC7E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,6EAA6E;IAC7E,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIZ,gBAAgBQ,MAAMG;AAC/D;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,MAAME,qBACX,CAACC,SACD,CAAUC,QACR,iFAAiF;QACjF,+EAA+E;QAC/E,yEAAyE;QACzEd,OAAOe,GAAG,CAAC;YACT,MAAMC,UAAU,OAAOf,kBAAkBA,iBAAiB;YAE1D,IAAI,CAACE,QAAQa,QAAQC,OAAO,CAACJ,OAAOK,MAAM,CAAC,EAAEL,OAAOM,KAAK,KAAK;gBAC5D,OAAO,OAAOjB,mBAAmBkB,IAAI,CACnC;oBAAEC,OAAO;oBAAmBC,MAAM;gBAAkB,GACpD;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,OAAO,OAAOT;QAChB,GAAG"}
@@ -13,8 +13,7 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
13
13
  * `id === "$metadata"`, misses, and answers its own `404`. The endpoint would
14
14
  * read as "this entity has no metadata" while appearing mounted. A literal wins
15
15
  * regardless of registration order, which is why the base exports a route for
16
- * one entity and each service registers it for each of its own
17
- * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).
16
+ * one entity and each service registers it for each of its own.
18
17
  *
19
18
  * Deliberately **not** behind `requireOrganization`, even on a tenant-plane
20
19
  * service: the document describes the model, not tenant data, and resolving a
@@ -1 +1 @@
1
- {"version":3,"file":"entity-metadata-route.d.ts","sourceRoot":"","sources":["../src/entity-metadata-route.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EAIvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIpE;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,EACxD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,8MA0D5C,CAAC"}
1
+ {"version":3,"file":"entity-metadata-route.d.ts","sourceRoot":"","sources":["../src/entity-metadata-route.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EAIvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,EACxD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,8MA0D5C,CAAC"}
@@ -15,8 +15,7 @@ import { requirePrincipal } from './auth/index.js';
15
15
  * `id === "$metadata"`, misses, and answers its own `404`. The endpoint would
16
16
  * read as "this entity has no metadata" while appearing mounted. A literal wins
17
17
  * regardless of registration order, which is why the base exports a route for
18
- * one entity and each service registers it for each of its own
19
- * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).
18
+ * one entity and each service registers it for each of its own.
20
19
  *
21
20
  * Deliberately **not** behind `requireOrganization`, even on a tenant-plane
22
21
  * service: the document describes the model, not tenant data, and resolving a
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/entity-metadata-route.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport {\n parsePermission,\n type Permission,\n permissionForEntity,\n permissionForUseCase,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport {\n describeEntityUseCases,\n type Entity,\n ENTITY_ACTIONS,\n type EntityConstructor,\n type EntityMetadataDocument,\n extractMetaEntity,\n makeEntityMetadataEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport { requirePrincipal } from './auth/index.js';\n\n/**\n * `GET /api/<entity>/$metadata` — what this caller may do with one entity.\n *\n * Mounted **per entity, at a literal path**, not as a `/api/:entity/$metadata`\n * wildcard. `HttpRouter` resolves through `find-my-way-ts`, where a static\n * segment beats a parametric one and there is no backtracking once the\n * parametric branch has matched: alongside an existing `/api/<entity>/:id`, a\n * wildcard `$metadata` route never runs at all — the by-id handler wins with\n * `id === \"$metadata\"`, misses, and answers its own `404`. The endpoint would\n * read as \"this entity has no metadata\" while appearing mounted. A literal wins\n * regardless of registration order, which is why the base exports a route for\n * one entity and each service registers it for each of its own\n * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).\n *\n * Deliberately **not** behind `requireOrganization`, even on a tenant-plane\n * service: the document describes the model, not tenant data, and resolving a\n * database handle here would leave a vendor with no active organization unable\n * to see their own affordances.\n */\nexport const entityMetadataRoute = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n) =>\n requirePrincipal(principal =>\n Effect.gen(function* () {\n const policy = yield* PolicyDecisionTag;\n const allows = (permission: Permission): boolean => {\n const { resource, action } = parsePermission(permission);\n return policy.decide({\n subject: { roles: principal.roles },\n resource,\n action,\n });\n };\n\n // SECURITY BOUNDARY. The document names domains, entity keys and verbs —\n // it is a map of the model — so an entity the caller may not read answers\n // exactly as one this service does not host. A `403` here would make the\n // endpoint an oracle: walk it and learn the shape of everything you are\n // not allowed to see. Same category as `redactConfiguration`, not\n // diagnostics polish.\n if (!allows(permissionForEntity(entityConstructor, 'read'))) {\n return yield* notFound(entityConstructor);\n }\n\n const document: EntityMetadataDocument = {\n actions: ENTITY_ACTIONS.filter(action =>\n allows(permissionForEntity(entityConstructor, action)),\n ),\n useCases: describeEntityUseCases(entityConstructor).filter(descriptor =>\n allows(permissionForUseCase(entityConstructor, descriptor.key)),\n ),\n };\n\n // The ETag hashes the *computed* document, not the descriptor set the\n // deployment declares. Two principals with different grants get different\n // documents from the same model, so a hash of the model would `304` one\n // caller onto another's affordances. Hashing the permission set instead\n // would read `ROLE_PERMISSIONS` directly and go around `PolicyDecisionTag`,\n // so an injected ABAC policy could change affordances without changing the\n // tag. Computing first costs a metadata read and a handful of pure\n // `decide` calls — no IO.\n const body = JSON.stringify(\n makeEntityMetadataEnvelope(entityConstructor, document),\n );\n const etag = `\"${createHash('sha256').update(body).digest('hex')}\"`;\n\n const request = yield* HttpServerRequest.HttpServerRequest;\n if (request.headers['if-none-match'] === etag) {\n return HttpServerResponse.empty({ status: 304 }).pipe(\n HttpServerResponse.setHeaders(cacheHeaders(etag)),\n );\n }\n\n return HttpServerResponse.text(body, {\n contentType: 'application/json',\n headers: cacheHeaders(etag),\n });\n }),\n );\n\n/**\n * The response varies by principal, so it must never land in a shared cache.\n * `Vary` names **both** credential carriers because the shell reads the access\n * token from the `entifix_at` cookie *or* an `Authorization: Bearer` header, and a\n * cache keyed on only one of them would serve across the other.\n */\nconst cacheHeaders = (etag: string) => ({\n etag,\n 'cache-control': 'private, no-cache',\n vary: 'Cookie, Authorization',\n});\n\n/** Indistinguishable from the `404` a service gives for an entity it does not host. */\nconst notFound = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n) =>\n HttpServerResponse.json(\n {\n message: 'not found',\n code: 'notFound',\n entity: extractMetaEntity(entityConstructor).key,\n },\n { status: 404 },\n );\n"],"names":["createHash","parsePermission","permissionForEntity","permissionForUseCase","PolicyDecisionTag","describeEntityUseCases","ENTITY_ACTIONS","extractMetaEntity","makeEntityMetadataEnvelope","Effect","HttpServerRequest","HttpServerResponse","requirePrincipal","entityMetadataRoute","entityConstructor","principal","gen","policy","allows","permission","resource","action","decide","subject","roles","notFound","document","actions","filter","useCases","descriptor","key","body","JSON","stringify","etag","update","digest","request","headers","empty","status","pipe","setHeaders","cacheHeaders","text","contentType","vary","json","message","code","entity"],"mappings":"AAAA,SAASA,UAAU,QAAQ,cAAc;AAEzC,SACEC,eAAe,EAEfC,mBAAmB,EACnBC,oBAAoB,EACpBC,iBAAiB,QACZ,iBAAiB;AACxB,SACEC,sBAAsB,EAEtBC,cAAc,EAGdC,iBAAiB,EACjBC,0BAA0B,QACrB,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE,SAASC,gBAAgB,QAAQ,kBAAkB;AAEnD;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,MAAMC,sBAAsB,CACjCC,oBAEAF,iBAAiBG,CAAAA,YACfN,OAAOO,GAAG,CAAC;YACT,MAAMC,SAAS,OAAOb;YACtB,MAAMc,SAAS,CAACC;gBACd,MAAM,EAAEC,QAAQ,EAAEC,MAAM,EAAE,GAAGpB,gBAAgBkB;gBAC7C,OAAOF,OAAOK,MAAM,CAAC;oBACnBC,SAAS;wBAAEC,OAAOT,UAAUS,KAAK;oBAAC;oBAClCJ;oBACAC;gBACF;YACF;YAEA,yEAAyE;YACzE,0EAA0E;YAC1E,yEAAyE;YACzE,wEAAwE;YACxE,kEAAkE;YAClE,sBAAsB;YACtB,IAAI,CAACH,OAAOhB,oBAAoBY,mBAAmB,UAAU;gBAC3D,OAAO,OAAOW,SAASX;YACzB;YAEA,MAAMY,WAAmC;gBACvCC,SAASrB,eAAesB,MAAM,CAACP,CAAAA,SAC7BH,OAAOhB,oBAAoBY,mBAAmBO;gBAEhDQ,UAAUxB,uBAAuBS,mBAAmBc,MAAM,CAACE,CAAAA,aACzDZ,OAAOf,qBAAqBW,mBAAmBgB,WAAWC,GAAG;YAEjE;YAEA,sEAAsE;YACtE,0EAA0E;YAC1E,wEAAwE;YACxE,wEAAwE;YACxE,4EAA4E;YAC5E,2EAA2E;YAC3E,mEAAmE;YACnE,0BAA0B;YAC1B,MAAMC,OAAOC,KAAKC,SAAS,CACzB1B,2BAA2BM,mBAAmBY;YAEhD,MAAMS,OAAO,CAAC,CAAC,EAAEnC,WAAW,UAAUoC,MAAM,CAACJ,MAAMK,MAAM,CAAC,OAAO,CAAC,CAAC;YAEnE,MAAMC,UAAU,OAAO5B,kBAAkBA,iBAAiB;YAC1D,IAAI4B,QAAQC,OAAO,CAAC,gBAAgB,KAAKJ,MAAM;gBAC7C,OAAOxB,mBAAmB6B,KAAK,CAAC;oBAAEC,QAAQ;gBAAI,GAAGC,IAAI,CACnD/B,mBAAmBgC,UAAU,CAACC,aAAaT;YAE/C;YAEA,OAAOxB,mBAAmBkC,IAAI,CAACb,MAAM;gBACnCc,aAAa;gBACbP,SAASK,aAAaT;YACxB;QACF,IACA;AAEJ;;;;;CAKC,GACD,MAAMS,eAAe,CAACT,OAAkB,CAAA;QACtCA;QACA,iBAAiB;QACjBY,MAAM;IACR,CAAA;AAEA,qFAAqF,GACrF,MAAMtB,WAAW,CACfX,oBAEAH,mBAAmBqC,IAAI,CACrB;QACEC,SAAS;QACTC,MAAM;QACNC,QAAQ5C,kBAAkBO,mBAAmBiB,GAAG;IAClD,GACA;QAAEU,QAAQ;IAAI"}
1
+ {"version":3,"sources":["../src/entity-metadata-route.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport {\n parsePermission,\n type Permission,\n permissionForEntity,\n permissionForUseCase,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport {\n describeEntityUseCases,\n type Entity,\n ENTITY_ACTIONS,\n type EntityConstructor,\n type EntityMetadataDocument,\n extractMetaEntity,\n makeEntityMetadataEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport { requirePrincipal } from './auth/index.js';\n\n/**\n * `GET /api/<entity>/$metadata` — what this caller may do with one entity.\n *\n * Mounted **per entity, at a literal path**, not as a `/api/:entity/$metadata`\n * wildcard. `HttpRouter` resolves through `find-my-way-ts`, where a static\n * segment beats a parametric one and there is no backtracking once the\n * parametric branch has matched: alongside an existing `/api/<entity>/:id`, a\n * wildcard `$metadata` route never runs at all — the by-id handler wins with\n * `id === \"$metadata\"`, misses, and answers its own `404`. The endpoint would\n * read as \"this entity has no metadata\" while appearing mounted. A literal wins\n * regardless of registration order, which is why the base exports a route for\n * one entity and each service registers it for each of its own.\n *\n * Deliberately **not** behind `requireOrganization`, even on a tenant-plane\n * service: the document describes the model, not tenant data, and resolving a\n * database handle here would leave a vendor with no active organization unable\n * to see their own affordances.\n */\nexport const entityMetadataRoute = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n) =>\n requirePrincipal(principal =>\n Effect.gen(function* () {\n const policy = yield* PolicyDecisionTag;\n const allows = (permission: Permission): boolean => {\n const { resource, action } = parsePermission(permission);\n return policy.decide({\n subject: { roles: principal.roles },\n resource,\n action,\n });\n };\n\n // SECURITY BOUNDARY. The document names domains, entity keys and verbs —\n // it is a map of the model — so an entity the caller may not read answers\n // exactly as one this service does not host. A `403` here would make the\n // endpoint an oracle: walk it and learn the shape of everything you are\n // not allowed to see. Same category as `redactConfiguration`, not\n // diagnostics polish.\n if (!allows(permissionForEntity(entityConstructor, 'read'))) {\n return yield* notFound(entityConstructor);\n }\n\n const document: EntityMetadataDocument = {\n actions: ENTITY_ACTIONS.filter(action =>\n allows(permissionForEntity(entityConstructor, action)),\n ),\n useCases: describeEntityUseCases(entityConstructor).filter(descriptor =>\n allows(permissionForUseCase(entityConstructor, descriptor.key)),\n ),\n };\n\n // The ETag hashes the *computed* document, not the descriptor set the\n // deployment declares. Two principals with different grants get different\n // documents from the same model, so a hash of the model would `304` one\n // caller onto another's affordances. Hashing the permission set instead\n // would read `ROLE_PERMISSIONS` directly and go around `PolicyDecisionTag`,\n // so an injected ABAC policy could change affordances without changing the\n // tag. Computing first costs a metadata read and a handful of pure\n // `decide` calls — no IO.\n const body = JSON.stringify(\n makeEntityMetadataEnvelope(entityConstructor, document),\n );\n const etag = `\"${createHash('sha256').update(body).digest('hex')}\"`;\n\n const request = yield* HttpServerRequest.HttpServerRequest;\n if (request.headers['if-none-match'] === etag) {\n return HttpServerResponse.empty({ status: 304 }).pipe(\n HttpServerResponse.setHeaders(cacheHeaders(etag)),\n );\n }\n\n return HttpServerResponse.text(body, {\n contentType: 'application/json',\n headers: cacheHeaders(etag),\n });\n }),\n );\n\n/**\n * The response varies by principal, so it must never land in a shared cache.\n * `Vary` names **both** credential carriers because the shell reads the access\n * token from the `entifix_at` cookie *or* an `Authorization: Bearer` header, and a\n * cache keyed on only one of them would serve across the other.\n */\nconst cacheHeaders = (etag: string) => ({\n etag,\n 'cache-control': 'private, no-cache',\n vary: 'Cookie, Authorization',\n});\n\n/** Indistinguishable from the `404` a service gives for an entity it does not host. */\nconst notFound = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n) =>\n HttpServerResponse.json(\n {\n message: 'not found',\n code: 'notFound',\n entity: extractMetaEntity(entityConstructor).key,\n },\n { status: 404 },\n );\n"],"names":["createHash","parsePermission","permissionForEntity","permissionForUseCase","PolicyDecisionTag","describeEntityUseCases","ENTITY_ACTIONS","extractMetaEntity","makeEntityMetadataEnvelope","Effect","HttpServerRequest","HttpServerResponse","requirePrincipal","entityMetadataRoute","entityConstructor","principal","gen","policy","allows","permission","resource","action","decide","subject","roles","notFound","document","actions","filter","useCases","descriptor","key","body","JSON","stringify","etag","update","digest","request","headers","empty","status","pipe","setHeaders","cacheHeaders","text","contentType","vary","json","message","code","entity"],"mappings":"AAAA,SAASA,UAAU,QAAQ,cAAc;AAEzC,SACEC,eAAe,EAEfC,mBAAmB,EACnBC,oBAAoB,EACpBC,iBAAiB,QACZ,iBAAiB;AACxB,SACEC,sBAAsB,EAEtBC,cAAc,EAGdC,iBAAiB,EACjBC,0BAA0B,QACrB,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE,SAASC,gBAAgB,QAAQ,kBAAkB;AAEnD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,sBAAsB,CACjCC,oBAEAF,iBAAiBG,CAAAA,YACfN,OAAOO,GAAG,CAAC;YACT,MAAMC,SAAS,OAAOb;YACtB,MAAMc,SAAS,CAACC;gBACd,MAAM,EAAEC,QAAQ,EAAEC,MAAM,EAAE,GAAGpB,gBAAgBkB;gBAC7C,OAAOF,OAAOK,MAAM,CAAC;oBACnBC,SAAS;wBAAEC,OAAOT,UAAUS,KAAK;oBAAC;oBAClCJ;oBACAC;gBACF;YACF;YAEA,yEAAyE;YACzE,0EAA0E;YAC1E,yEAAyE;YACzE,wEAAwE;YACxE,kEAAkE;YAClE,sBAAsB;YACtB,IAAI,CAACH,OAAOhB,oBAAoBY,mBAAmB,UAAU;gBAC3D,OAAO,OAAOW,SAASX;YACzB;YAEA,MAAMY,WAAmC;gBACvCC,SAASrB,eAAesB,MAAM,CAACP,CAAAA,SAC7BH,OAAOhB,oBAAoBY,mBAAmBO;gBAEhDQ,UAAUxB,uBAAuBS,mBAAmBc,MAAM,CAACE,CAAAA,aACzDZ,OAAOf,qBAAqBW,mBAAmBgB,WAAWC,GAAG;YAEjE;YAEA,sEAAsE;YACtE,0EAA0E;YAC1E,wEAAwE;YACxE,wEAAwE;YACxE,4EAA4E;YAC5E,2EAA2E;YAC3E,mEAAmE;YACnE,0BAA0B;YAC1B,MAAMC,OAAOC,KAAKC,SAAS,CACzB1B,2BAA2BM,mBAAmBY;YAEhD,MAAMS,OAAO,CAAC,CAAC,EAAEnC,WAAW,UAAUoC,MAAM,CAACJ,MAAMK,MAAM,CAAC,OAAO,CAAC,CAAC;YAEnE,MAAMC,UAAU,OAAO5B,kBAAkBA,iBAAiB;YAC1D,IAAI4B,QAAQC,OAAO,CAAC,gBAAgB,KAAKJ,MAAM;gBAC7C,OAAOxB,mBAAmB6B,KAAK,CAAC;oBAAEC,QAAQ;gBAAI,GAAGC,IAAI,CACnD/B,mBAAmBgC,UAAU,CAACC,aAAaT;YAE/C;YAEA,OAAOxB,mBAAmBkC,IAAI,CAACb,MAAM;gBACnCc,aAAa;gBACbP,SAASK,aAAaT;YACxB;QACF,IACA;AAEJ;;;;;CAKC,GACD,MAAMS,eAAe,CAACT,OAAkB,CAAA;QACtCA;QACA,iBAAiB;QACjBY,MAAM;IACR,CAAA;AAEA,qFAAqF,GACrF,MAAMtB,WAAW,CACfX,oBAEAH,mBAAmBqC,IAAI,CACrB;QACEC,SAAS;QACTC,MAAM;QACNC,QAAQ5C,kBAAkBO,mBAAmBiB,GAAG;IAClD,GACA;QAAEU,QAAQ;IAAI"}
package/dist/index.d.ts CHANGED
@@ -1,12 +1,7 @@
1
1
  export * from './auth/index.js';
2
2
  export * from './entity-metadata-route.js';
3
3
  export * from './health-routes.js';
4
- export * from './load-remote-configuration.js';
5
- export * from './make-service.js';
6
- export * from './observability.js';
7
4
  export * from './redact-configuration.js';
8
- export * from './serve-test-service.js';
9
5
  export * from './service-description-route.js';
10
6
  export * from './sse.js';
11
- export * from './wait-for-http.js';
12
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,yBAAyB,CAAC;AACxC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC;AACzB,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -1,13 +1,8 @@
1
1
  export * from './auth/index.js';
2
2
  export * from './entity-metadata-route.js';
3
3
  export * from './health-routes.js';
4
- export * from './load-remote-configuration.js';
5
- export * from './make-service.js';
6
- export * from './observability.js';
7
4
  export * from './redact-configuration.js';
8
- export * from './serve-test-service.js';
9
5
  export * from './service-description-route.js';
10
6
  export * from './sse.js';
11
- export * from './wait-for-http.js';
12
7
 
13
8
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-metadata-route.js';\nexport * from './health-routes.js';\nexport * from './load-remote-configuration.js';\nexport * from './make-service.js';\nexport * from './observability.js';\nexport * from './redact-configuration.js';\nexport * from './serve-test-service.js';\nexport * from './service-description-route.js';\nexport * from './sse.js';\nexport * from './wait-for-http.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB;AAChC,cAAc,6BAA6B;AAC3C,cAAc,qBAAqB;AACnC,cAAc,iCAAiC;AAC/C,cAAc,oBAAoB;AAClC,cAAc,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,0BAA0B;AACxC,cAAc,iCAAiC;AAC/C,cAAc,WAAW;AACzB,cAAc,qBAAqB"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-metadata-route.js';\nexport * from './health-routes.js';\nexport * from './redact-configuration.js';\nexport * from './service-description-route.js';\nexport * from './sse.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB;AAChC,cAAc,6BAA6B;AAC3C,cAAc,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,iCAAiC;AAC/C,cAAc,WAAW"}