@entifix/service-shell 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,17 +16,17 @@ pnpm add @opentelemetry/api @effect/opentelemetry @opentelemetry/sdk-trace-base
16
16
 
17
17
  ## Entry points
18
18
 
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 |
19
+ | Import | What it holds |
20
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | `@entifix/service-shell` | What a route set is written against: `makeEntityRoutes`, `requirePrincipal`, `requirePermission`, `requireOrganization`, `requireServiceCrossing`, `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
25
 
26
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
27
 
28
28
  ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
29
- | `@entifix/service-shell` | `makeService`, `makeServerLayer`, `requirePrincipal`, `requirePermission`, `requireOrganization`, `entityMetadataRoute`, `loadRemoteConfiguration`, `makeObservabilityLayer`, `serveTestService` |
29
+ | `@entifix/service-shell` | `makeService`, `makeServerLayer`, `makeEntityRoutes`, `requirePrincipal`, `requirePermission`, `requireOrganization`, `entityMetadataRoute`, `loadRemoteConfiguration`, `makeObservabilityLayer`, `serveTestService` |
30
30
 
31
31
  ---
32
32
 
@@ -26,6 +26,11 @@ export declare const CROSSING_TOKEN_HEADER = "x-crossing-token";
26
26
  * that authorizes.
27
27
  */
28
28
  export declare const ORGANIZATION_HEADER = "x-organization-id";
29
+ /**
30
+ * The header a saga dispatch carries its command id in — what a participant
31
+ * dedupes a redelivered command on, and what its unit of work claims.
32
+ */
33
+ export declare const COMMAND_ID_HEADER = "x-command-id";
29
34
  declare const ServiceCrossingTokenTag_base: Context.ServiceClass<ServiceCrossingTokenTag, "ServiceCrossingTokenTag", string>;
30
35
  /**
31
36
  * The crossing secret this service expects, provided at the composition root
@@ -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;;;;;;;;;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"}
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;;;GAGG;AACH,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;;AAEhD;;;;;;;;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"}
@@ -25,6 +25,10 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
25
25
  * the caller is the fleet, so the input the caller controls is never the input
26
26
  * that authorizes.
27
27
  */ export const ORGANIZATION_HEADER = 'x-organization-id';
28
+ /**
29
+ * The header a saga dispatch carries its command id in — what a participant
30
+ * dedupes a redelivered command on, and what its unit of work claims.
31
+ */ export const COMMAND_ID_HEADER = 'x-command-id';
28
32
  /**
29
33
  * The crossing secret this service expects, provided at the composition root
30
34
  * from its own configuration.
@@ -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 */\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"}
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 header a saga dispatch carries its command id in — what a participant\n * dedupes a redelivered command on, and what its unit of work claims.\n */\nexport const COMMAND_ID_HEADER = 'x-command-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","COMMAND_ID_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;;;CAGC,GACD,OAAO,MAAMC,oBAAoB,eAAe;AAEhD;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgCP,QAAQQ,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,IAAInB,gBAAgBe,MAAMG;AAC/D;AAEA,MAAME,kBAAkBf,mBAAmBgB,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBrB,mBAAmBgB,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB,MAAMG,uBAAuBtB,mBAAmBgB,IAAI,CAClD;IACEC,OAAO;IACPC,MAAM;IACNK,QAAQ,GAAGrB,oBAAoB,YAAY,CAAC;AAC9C,GACA;IAAEiB,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,OAAO,MAAMK,yBACX,CAACH,aACD,CAAUI,MACR,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjE3B,OAAO4B,GAAG,CAAC;gBAacC;YAZvB,MAAMA,UAAU,OAAO5B,kBAAkBA,iBAAiB;YAC1D,MAAMS,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC3B,sBAAsB,EAAEO,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOjC;YACxB,IAAI,CAACiC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,MAAMU,kBAAiBJ,uCAAAA,QAAQC,OAAO,CAAC1B,oBAAoB,qBAApCyB,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,QACRpC,OAAO4B,GAAG,CAAC;YACT,MAAMC,UAAU,OAAO5B,kBAAkBA,iBAAiB;YAC1D,MAAMS,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC3B,sBAAsB,EAAEO,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOjC;YACxB,IAAI,CAACiC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,OAAO,OAAOa;QAChB,GAAG"}
@@ -0,0 +1,112 @@
1
+ import { type PolicyDecisionTag, type ServiceCrossingPolicyTag } from '@entifix/authz';
2
+ import { EntityRepositoryFactoryTag, type RepositoryScope, type TokenServiceTag } from '@entifix/business';
3
+ import { EntifixBuildError, type EntifixEnvelopeLink, type Entity, type EntityConstructor, type EntityId, type FilterGroup } from '@entifix/core';
4
+ import { Effect } from 'effect';
5
+ import { HttpRouter, HttpServerResponse } from 'effect/http';
6
+ import { type RequestPrincipal, type ServiceCrossingTokenTag } from './auth/index.js';
7
+ /** The five generic verbs a route set can mount. */
8
+ export type EntityVerb = 'list' | 'get' | 'create' | 'update' | 'delete';
9
+ /**
10
+ * Who may reach a route, and where the organization a per-organization store
11
+ * needs comes from.
12
+ *
13
+ * - `public` — anybody; nothing is checked and no organization is known.
14
+ * - `permission` — a verified session holding the permission the entity
15
+ * derives for the verb.
16
+ * - `organization` — the same, plus the session's active organization, which
17
+ * is what a per-organization store opens.
18
+ * - `crossing` — the fleet's crossing token and no session at all. With
19
+ * `organization: true` the caller names the organization in a header, which
20
+ * only a caller that has proved it is the fleet may do.
21
+ */
22
+ export type EntityRouteGuard = {
23
+ readonly kind: 'public';
24
+ } | {
25
+ readonly kind: 'permission';
26
+ } | {
27
+ readonly kind: 'organization';
28
+ } | {
29
+ readonly kind: 'crossing';
30
+ readonly organization: boolean;
31
+ };
32
+ /** What a route learned about its caller from its guard. */
33
+ export interface EntityRouteContext extends RepositoryScope {
34
+ readonly principal?: RequestPrincipal;
35
+ }
36
+ /**
37
+ * Which records of an entity a caller may see.
38
+ *
39
+ * `filter` narrows a list and `includes` decides a by-id read — both are
40
+ * needed, because `id` is the one member no query can name. `'none'` is a
41
+ * caller who holds the read grant and owns nothing: an empty page and a `404`,
42
+ * never a `403`, because owning no records is not an error.
43
+ */
44
+ export type EntityReadScope<T extends Entity> = 'all' | 'none' | {
45
+ readonly filter: FilterGroup<T>;
46
+ readonly includes: (entity: T) => boolean;
47
+ };
48
+ /**
49
+ * A save refused by a rule the entity's own deserializer does not make — a
50
+ * closed set, a rule over the other records — answered as `status` with
51
+ * `code`, which is what a client translates.
52
+ */
53
+ export declare class EntityRouteRefusal {
54
+ readonly status: 400 | 409 | 422;
55
+ readonly code: string;
56
+ readonly detail?: string | undefined;
57
+ readonly _tag = "EntityRouteRefusal";
58
+ constructor(status: 400 | 409 | 422, code: string, detail?: string | undefined);
59
+ }
60
+ /** A hand-written handler for one verb, mounted under the derived guard. */
61
+ export type EntityRouteOverride<R> = (context: EntityRouteContext, links: EntityRouteLinks) => Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, R>;
62
+ /** The affordances a route set derives from the verbs it mounts. */
63
+ export interface EntityRouteLinks {
64
+ readonly record: (id: EntityId) => EntifixEnvelopeLink[];
65
+ readonly collection: () => EntifixEnvelopeLink[];
66
+ }
67
+ export interface EntityRoutesOptions<T extends Entity, R, G extends EntityRouteGuard = EntityRouteGuard> {
68
+ readonly verbs: readonly EntityVerb[];
69
+ /** One guard for every verb, or one for the reads and one for the writes. */
70
+ readonly guard: G | {
71
+ readonly read: G;
72
+ readonly write: G;
73
+ };
74
+ /** Defaults to `/api/<entity key>`. */
75
+ readonly basePath?: `/${string}`;
76
+ /** Narrows the reads to what this caller may see. */
77
+ readonly scope?: (context: EntityRouteContext) => Effect.Effect<EntityReadScope<T>, never, R>;
78
+ /**
79
+ * Runs on every create and update, after the body is read and before the
80
+ * save — and may change what is saved, or refuse it.
81
+ */
82
+ readonly prepare?: (entity: T, context: EntityRouteContext & {
83
+ readonly verb: 'create' | 'update';
84
+ }) => Effect.Effect<T, EntityRouteRefusal | EntifixBuildError, R>;
85
+ /**
86
+ * A hand-written handler in place of the generic one for a verb. It keeps
87
+ * the derived guard and receives the derived links, so the escape hatch
88
+ * cannot drift from the rest of the set.
89
+ */
90
+ readonly override?: Partial<Record<EntityVerb, EntityRouteOverride<R>>>;
91
+ }
92
+ /** The `500` for anything a route did not anticipate. */
93
+ export declare const routeFailure: (error: unknown) => Effect.Effect<HttpServerResponse.HttpServerResponse, import("effect/http/HttpBody").HttpBodyError, never>;
94
+ /**
95
+ * The generic routes for one entity — list, read, create, update, delete and
96
+ * its `$metadata` — derived from the entity and bound to storage through the
97
+ * host's {@link EntityRepositoryFactoryTag}.
98
+ *
99
+ * What a route set varies on is stated, not written: which verbs exist, who may
100
+ * reach them, which records a reader sees, and what a save must pass. What it
101
+ * never varies on is the wire: the load request is parsed against the entity's
102
+ * own metadata (so a query can only name a member the entity declared
103
+ * filterable or sortable), every answer is an envelope, and the links name
104
+ * exactly the verbs mounted — never one that would `404`.
105
+ *
106
+ * A path that is not one of these verbs is written by hand beside it, and a
107
+ * verb whose generic form does not fit is replaced with `override`.
108
+ */
109
+ export declare const makeEntityRoutes: <T extends Entity, R = never, const G extends EntityRouteGuard = EntityRouteGuard>(entityConstructor: EntityConstructor<T>, options: EntityRoutesOptions<T, R, G>) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", PolicyDecisionTag> | HttpRouter.Request<"Requires", EntityRepositoryFactoryTag> | HttpRouter.Request<"Requires", TokenServiceTag> | HttpRouter.Request.From<"Requires", Exclude<Exclude<R, HttpRouter.Provided>, HttpRouter.Provided>> | HttpRouter.Request.From<"Requires", Exclude<Exclude<G extends {
110
+ readonly kind: "crossing";
111
+ } ? ServiceCrossingPolicyTag | ServiceCrossingTokenTag : never, HttpRouter.Provided>, HttpRouter.Provided>> | HttpRouter.Request<"Error", unknown>>;
112
+ //# sourceMappingURL=entity-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-routes.d.ts","sourceRoot":"","sources":["../src/entity-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC9B,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAIL,0BAA0B,EAK1B,KAAK,eAAe,EAEpB,KAAK,eAAe,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EAIb,KAAK,WAAW,EAKjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,UAAU,EAAqB,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEhF,OAAO,EACL,KAAK,gBAAgB,EAKrB,KAAK,uBAAuB,EAC7B,MAAM,iBAAiB,CAAC;AAGzB,oDAAoD;AACpD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEzE;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;CAAE,GAC/B;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;CAAE,GACjC;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAA;CAAE,CAAC;AAElE,4DAA4D;AAC5D,MAAM,WAAW,kBAAmB,SAAQ,eAAe;IACzD,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;CACvC;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,MAAM,IACxC,KAAK,GACL,MAAM,GACN;IACE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,OAAO,CAAC;CAC3C,CAAC;AAEN;;;;GAIG;AACH,qBAAa,kBAAkB;IAI3B,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;IAL1B,QAAQ,CAAC,IAAI,wBAAwB;gBAG1B,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,EACvB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,YAAA;CAE3B;AAED,4EAA4E;AAC5E,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAAI,CACnC,OAAO,EAAE,kBAAkB,EAC3B,KAAK,EAAE,gBAAgB,KACpB,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,kBAAkB,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;AAEtE,oEAAoE;AACpE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,QAAQ,KAAK,mBAAmB,EAAE,CAAC;IACzD,QAAQ,CAAC,UAAU,EAAE,MAAM,mBAAmB,EAAE,CAAC;CAClD;AAED,MAAM,WAAW,mBAAmB,CAClC,CAAC,SAAS,MAAM,EAChB,CAAC,EACD,CAAC,SAAS,gBAAgB,GAAG,gBAAgB;IAE7C,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAC;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG;QAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;QAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;KAAE,CAAC;IAC5D,uCAAuC;IACvC,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,MAAM,EAAE,CAAC;IACjC,qDAAqD;IACrD,QAAQ,CAAC,KAAK,CAAC,EAAE,CACf,OAAO,EAAE,kBAAkB,KACxB,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IACjD;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CACjB,MAAM,EAAE,CAAC,EACT,OAAO,EAAE,kBAAkB,GAAG;QAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAA;KAAE,KACjE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,kBAAkB,GAAG,iBAAiB,EAAE,CAAC,CAAC,CAAC;IACjE;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CACzE;AAwBD,yDAAyD;AACzD,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,8GAIxC,CAAC;AA8BJ;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,GAC3B,CAAC,SAAS,MAAM,EAChB,CAAC,GAAG,KAAK,EACT,KAAK,CAAC,CAAC,SAAS,gBAAgB,GAAG,gBAAgB,EAEnD,mBAAmB,iBAAiB,CAAC,CAAC,CAAC,EACvC,SAAS,mBAAmB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;mBA5DP,UAAU;mJA6UzC,CAAC"}
@@ -0,0 +1,302 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { permissionForEntity } from '@entifix/authz';
3
+ import { deleteUCFactory, EntityIdTag, EntityLoadRequestTag, EntityRepositoryFactoryTag, EntityRepositoryTag, EntityTag, getUCFactory, loadUCFactory, saveUCFactory } from '@entifix/business';
4
+ import { EntifixBuildError, envelopeEntityName, extractMetaEntity, makeEntityEnvelope, makeEntityPageEnvelope, parseLoadRequestParams, readEntityEnvelope } from '@entifix/core';
5
+ import { Effect } from 'effect';
6
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';
7
+ import { requireCrossing, requireOrganization, requirePermission, requireServiceCrossing } from './auth/index.js';
8
+ import { entityMetadataRoute } from './entity-metadata-route.js';
9
+ /**
10
+ * A save refused by a rule the entity's own deserializer does not make — a
11
+ * closed set, a rule over the other records — answered as `status` with
12
+ * `code`, which is what a client translates.
13
+ */ export class EntityRouteRefusal {
14
+ constructor(status, code, detail){
15
+ this.status = status;
16
+ this.code = code;
17
+ this.detail = detail;
18
+ this._tag = 'EntityRouteRefusal';
19
+ }
20
+ }
21
+ const ACTION = {
22
+ list: 'read',
23
+ get: 'read',
24
+ create: 'write',
25
+ update: 'write',
26
+ delete: 'delete'
27
+ };
28
+ const READS = new Set([
29
+ 'list',
30
+ 'get'
31
+ ]);
32
+ /** The `500` for anything a route did not anticipate. */ export const routeFailure = (error)=>HttpServerResponse.json({
33
+ error: 'request failed',
34
+ code: 'unexpected',
35
+ detail: String(error)
36
+ }, {
37
+ status: 500
38
+ });
39
+ const invalid = (error, label, code)=>{
40
+ var _refusalCode;
41
+ return error instanceof EntifixBuildError ? HttpServerResponse.json({
42
+ error: label,
43
+ code: (_refusalCode = refusalCode(error)) != null ? _refusalCode : code,
44
+ detail: error.message
45
+ }, {
46
+ status: 400
47
+ }) : error instanceof EntityRouteRefusal ? HttpServerResponse.json(_extends({
48
+ error: 'the record cannot be saved',
49
+ code: error.code
50
+ }, error.detail === undefined ? {} : {
51
+ detail: error.detail
52
+ }), {
53
+ status: error.status
54
+ }) : routeFailure(error);
55
+ };
56
+ /**
57
+ * A domain error may carry its own code (`mediaNotHttps`), so the client can
58
+ * say which rule failed rather than "invalid body".
59
+ */ const refusalCode = (error)=>'code' in error && typeof error.code === 'string' ? error.code : undefined;
60
+ /**
61
+ * The generic routes for one entity — list, read, create, update, delete and
62
+ * its `$metadata` — derived from the entity and bound to storage through the
63
+ * host's {@link EntityRepositoryFactoryTag}.
64
+ *
65
+ * What a route set varies on is stated, not written: which verbs exist, who may
66
+ * reach them, which records a reader sees, and what a save must pass. What it
67
+ * never varies on is the wire: the load request is parsed against the entity's
68
+ * own metadata (so a query can only name a member the entity declared
69
+ * filterable or sortable), every answer is an envelope, and the links name
70
+ * exactly the verbs mounted — never one that would `404`.
71
+ *
72
+ * A path that is not one of these verbs is written by hand beside it, and a
73
+ * verb whose generic form does not fit is replaced with `override`.
74
+ */ export const makeEntityRoutes = (entityConstructor, options)=>{
75
+ var _options_basePath;
76
+ const key = envelopeEntityName(entityConstructor);
77
+ const base = (_options_basePath = options.basePath) != null ? _options_basePath : `/api/${key}`;
78
+ const mounted = new Set(options.verbs);
79
+ const links = {
80
+ record: (id)=>{
81
+ const href = `${base}/${String(id)}`;
82
+ return [
83
+ ...mounted.has('get') ? [
84
+ {
85
+ rel: 'self',
86
+ href,
87
+ method: 'GET'
88
+ }
89
+ ] : [],
90
+ ...mounted.has('list') ? [
91
+ {
92
+ rel: 'list',
93
+ href: base,
94
+ method: 'GET'
95
+ }
96
+ ] : [],
97
+ ...mounted.has('update') ? [
98
+ {
99
+ rel: 'update',
100
+ href,
101
+ method: 'PUT'
102
+ }
103
+ ] : [],
104
+ ...mounted.has('delete') ? [
105
+ {
106
+ rel: 'delete',
107
+ href,
108
+ method: 'DELETE'
109
+ }
110
+ ] : []
111
+ ];
112
+ },
113
+ collection: ()=>[
114
+ ...mounted.has('list') ? [
115
+ {
116
+ rel: 'self',
117
+ href: base,
118
+ method: 'GET'
119
+ }
120
+ ] : [],
121
+ ...mounted.has('create') ? [
122
+ {
123
+ rel: 'create',
124
+ href: base,
125
+ method: 'POST'
126
+ }
127
+ ] : []
128
+ ]
129
+ };
130
+ const guardFor = (verb)=>{
131
+ const guard = options.guard;
132
+ return 'kind' in guard ? guard : READS.has(verb) ? guard.read : guard.write;
133
+ };
134
+ const guarded = (verb, use)=>{
135
+ const guard = guardFor(verb);
136
+ const permission = permissionForEntity(entityConstructor, ACTION[verb]);
137
+ switch(guard.kind){
138
+ case 'public':
139
+ return use({});
140
+ case 'permission':
141
+ return requirePermission(permission)((principal)=>use({
142
+ principal
143
+ }));
144
+ case 'organization':
145
+ return requireOrganization(permission)((organizationId, principal)=>use({
146
+ organizationId,
147
+ principal
148
+ }));
149
+ case 'crossing':
150
+ return guard.organization ? requireServiceCrossing(permission)((organizationId)=>use({
151
+ organizationId
152
+ })) : requireCrossing(permission)(use({}));
153
+ }
154
+ };
155
+ const repository = (context)=>Effect.gen(function*() {
156
+ const factory = yield* EntityRepositoryFactoryTag;
157
+ return yield* factory.for(entityConstructor, _extends({}, context.organizationId === undefined ? {} : {
158
+ organizationId: context.organizationId
159
+ }));
160
+ });
161
+ const scopeOf = (context)=>options.scope ? options.scope(context) : Effect.succeed('all');
162
+ const readLoadRequest = Effect.gen(function*() {
163
+ const request = yield* HttpServerRequest.HttpServerRequest;
164
+ const search = new URL(request.url, 'http://localhost').searchParams;
165
+ return yield* Effect.try({
166
+ try: ()=>parseLoadRequestParams(entityConstructor, search),
167
+ catch: (error)=>error
168
+ });
169
+ });
170
+ const notFound = HttpServerResponse.json({
171
+ message: 'not found',
172
+ code: 'notFound',
173
+ entity: extractMetaEntity(entityConstructor).key
174
+ }, {
175
+ status: 404
176
+ });
177
+ const list = (context)=>Effect.gen(function*() {
178
+ var _parsed_filtering;
179
+ const parsed = yield* readLoadRequest;
180
+ const scope = yield* scopeOf(context);
181
+ if (scope === 'none') {
182
+ // Still parsed: whether a query is well-formed must not depend on who
183
+ // asked.
184
+ return yield* HttpServerResponse.json(makeEntityPageEnvelope(entityConstructor, {
185
+ items: [],
186
+ total: 0,
187
+ request: parsed
188
+ }, links.collection()));
189
+ }
190
+ // Conjoined, never substituted: the translator `$and`s every top-level
191
+ // element, so an added element can only narrow, and no `or` in a query
192
+ // string can escape it.
193
+ const request = scope === 'all' ? parsed : _extends({}, parsed, {
194
+ filtering: [
195
+ ...(_parsed_filtering = parsed.filtering) != null ? _parsed_filtering : [],
196
+ scope.filter
197
+ ]
198
+ });
199
+ const page = yield* loadUCFactory().pipe(Effect.provideService(EntityRepositoryTag, (yield* repository(context))), Effect.provideService(EntityLoadRequestTag, request));
200
+ return yield* HttpServerResponse.json(makeEntityPageEnvelope(entityConstructor, page, links.collection()));
201
+ }).pipe(Effect.catch((error)=>invalid(error, 'invalid query', 'invalidQuery')));
202
+ const get = (context)=>Effect.gen(function*() {
203
+ const params = yield* HttpRouter.params;
204
+ const scope = yield* scopeOf(context);
205
+ if (scope === 'none') return yield* notFound;
206
+ const found = yield* getUCFactory().pipe(Effect.provideService(EntityRepositoryTag, (yield* repository(context))), Effect.provideService(EntityIdTag, params.id), Effect.option);
207
+ // Out of scope answers exactly as absent does: a `403` would confirm the
208
+ // record exists to somebody who may not read it.
209
+ if (found._tag === 'None' || scope !== 'all' && !scope.includes(found.value)) {
210
+ return yield* notFound;
211
+ }
212
+ return yield* HttpServerResponse.json(makeEntityEnvelope(entityConstructor, found.value, links.record(params.id)));
213
+ }).pipe(Effect.catch(routeFailure));
214
+ const save = (verb)=>(context)=>Effect.gen(function*() {
215
+ const request = yield* HttpServerRequest.HttpServerRequest;
216
+ const body = yield* request.json;
217
+ let entity = yield* readEntityEnvelope(entityConstructor, body);
218
+ // On update the URL is authoritative, so a record cannot be renamed
219
+ // onto another one by editing its payload.
220
+ if (verb === 'update') {
221
+ const params = yield* HttpRouter.params;
222
+ entity.id = params.id;
223
+ }
224
+ if (options.prepare) {
225
+ entity = yield* options.prepare(entity, _extends({}, context, {
226
+ verb
227
+ }));
228
+ }
229
+ const saved = yield* saveUCFactory().pipe(Effect.provideService(EntityRepositoryTag, (yield* repository(context))), Effect.provideService(EntityTag, entity));
230
+ return yield* HttpServerResponse.json(makeEntityEnvelope(entityConstructor, saved, links.record(saved.id)));
231
+ }).pipe(Effect.catch((error)=>invalid(error, 'invalid request body', 'invalidBody')));
232
+ /**
233
+ * Answers with an envelope rather than a bare `204`: every message between
234
+ * entifix artifacts is one, and the fetch client parses every response as
235
+ * JSON.
236
+ */ const remove = (context)=>Effect.gen(function*() {
237
+ const params = yield* HttpRouter.params;
238
+ yield* deleteUCFactory().pipe(Effect.provideService(EntityRepositoryTag, (yield* repository(context))), Effect.provideService(EntityIdTag, params.id));
239
+ return yield* HttpServerResponse.json({
240
+ meta: {
241
+ type: 'entity',
242
+ entity: key,
243
+ links: links.collection()
244
+ },
245
+ data: {
246
+ id: params.id
247
+ }
248
+ });
249
+ }).pipe(Effect.catch(routeFailure));
250
+ const generic = (verb, context)=>{
251
+ switch(verb){
252
+ case 'list':
253
+ return list(context);
254
+ case 'get':
255
+ return get(context);
256
+ case 'create':
257
+ return save('create')(context);
258
+ case 'update':
259
+ return save('update')(context);
260
+ case 'delete':
261
+ return remove(context);
262
+ }
263
+ };
264
+ /**
265
+ * Each guard asks for different services, so the branches do not unify on
266
+ * their own; the cast states the union every guard this set can reach
267
+ * actually needs.
268
+ */ const handlerFor = (verb)=>guarded(verb, (context)=>{
269
+ var _options_override;
270
+ const override = (_options_override = options.override) == null ? void 0 : _options_override[verb];
271
+ return override ? override(context, links) : generic(verb, context);
272
+ });
273
+ const ROUTES = {
274
+ list: {
275
+ method: 'GET',
276
+ path: base
277
+ },
278
+ get: {
279
+ method: 'GET',
280
+ path: `${base}/:id`
281
+ },
282
+ create: {
283
+ method: 'POST',
284
+ path: base
285
+ },
286
+ update: {
287
+ method: 'PUT',
288
+ path: `${base}/:id`
289
+ },
290
+ delete: {
291
+ method: 'DELETE',
292
+ path: `${base}/:id`
293
+ }
294
+ };
295
+ return HttpRouter.addAll([
296
+ // A literal, so it wins over `/:id` regardless of order.
297
+ HttpRouter.route('GET', `${base}/$metadata`, entityMetadataRoute(entityConstructor)),
298
+ ...options.verbs.map((verb)=>HttpRouter.route(ROUTES[verb].method, ROUTES[verb].path, handlerFor(verb)))
299
+ ]);
300
+ };
301
+
302
+ //# sourceMappingURL=entity-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/entity-routes.ts"],"sourcesContent":["import {\n type Action,\n permissionForEntity,\n type PolicyDecisionTag,\n type ServiceCrossingPolicyTag,\n} from '@entifix/authz';\nimport {\n deleteUCFactory,\n EntityIdTag,\n EntityLoadRequestTag,\n EntityRepositoryFactoryTag,\n EntityRepositoryTag,\n EntityTag,\n getUCFactory,\n loadUCFactory,\n type RepositoryScope,\n saveUCFactory,\n type TokenServiceTag,\n} from '@entifix/business';\nimport {\n EntifixBuildError,\n type EntifixEnvelopeLink,\n type Entity,\n type EntityConstructor,\n type EntityId,\n type EntityLoadRequest,\n envelopeEntityName,\n extractMetaEntity,\n type FilterGroup,\n makeEntityEnvelope,\n makeEntityPageEnvelope,\n parseLoadRequestParams,\n readEntityEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport {\n type RequestPrincipal,\n requireCrossing,\n requireOrganization,\n requirePermission,\n requireServiceCrossing,\n type ServiceCrossingTokenTag,\n} from './auth/index.js';\nimport { entityMetadataRoute } from './entity-metadata-route.js';\n\n/** The five generic verbs a route set can mount. */\nexport type EntityVerb = 'list' | 'get' | 'create' | 'update' | 'delete';\n\n/**\n * Who may reach a route, and where the organization a per-organization store\n * needs comes from.\n *\n * - `public` — anybody; nothing is checked and no organization is known.\n * - `permission` — a verified session holding the permission the entity\n * derives for the verb.\n * - `organization` — the same, plus the session's active organization, which\n * is what a per-organization store opens.\n * - `crossing` — the fleet's crossing token and no session at all. With\n * `organization: true` the caller names the organization in a header, which\n * only a caller that has proved it is the fleet may do.\n */\nexport type EntityRouteGuard =\n | { readonly kind: 'public' }\n | { readonly kind: 'permission' }\n | { readonly kind: 'organization' }\n | { readonly kind: 'crossing'; readonly organization: boolean };\n\n/** What a route learned about its caller from its guard. */\nexport interface EntityRouteContext extends RepositoryScope {\n readonly principal?: RequestPrincipal;\n}\n\n/**\n * Which records of an entity a caller may see.\n *\n * `filter` narrows a list and `includes` decides a by-id read — both are\n * needed, because `id` is the one member no query can name. `'none'` is a\n * caller who holds the read grant and owns nothing: an empty page and a `404`,\n * never a `403`, because owning no records is not an error.\n */\nexport type EntityReadScope<T extends Entity> =\n | 'all'\n | 'none'\n | {\n readonly filter: FilterGroup<T>;\n readonly includes: (entity: T) => boolean;\n };\n\n/**\n * A save refused by a rule the entity's own deserializer does not make — a\n * closed set, a rule over the other records — answered as `status` with\n * `code`, which is what a client translates.\n */\nexport class EntityRouteRefusal {\n readonly _tag = 'EntityRouteRefusal';\n\n constructor(\n readonly status: 400 | 409 | 422,\n readonly code: string,\n readonly detail?: string,\n ) {}\n}\n\n/** A hand-written handler for one verb, mounted under the derived guard. */\nexport type EntityRouteOverride<R> = (\n context: EntityRouteContext,\n links: EntityRouteLinks,\n) => Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, R>;\n\n/** The affordances a route set derives from the verbs it mounts. */\nexport interface EntityRouteLinks {\n readonly record: (id: EntityId) => EntifixEnvelopeLink[];\n readonly collection: () => EntifixEnvelopeLink[];\n}\n\nexport interface EntityRoutesOptions<\n T extends Entity,\n R,\n G extends EntityRouteGuard = EntityRouteGuard,\n> {\n readonly verbs: readonly EntityVerb[];\n /** One guard for every verb, or one for the reads and one for the writes. */\n readonly guard: G | { readonly read: G; readonly write: G };\n /** Defaults to `/api/<entity key>`. */\n readonly basePath?: `/${string}`;\n /** Narrows the reads to what this caller may see. */\n readonly scope?: (\n context: EntityRouteContext,\n ) => Effect.Effect<EntityReadScope<T>, never, R>;\n /**\n * Runs on every create and update, after the body is read and before the\n * save — and may change what is saved, or refuse it.\n */\n readonly prepare?: (\n entity: T,\n context: EntityRouteContext & { readonly verb: 'create' | 'update' },\n ) => Effect.Effect<T, EntityRouteRefusal | EntifixBuildError, R>;\n /**\n * A hand-written handler in place of the generic one for a verb. It keeps\n * the derived guard and receives the derived links, so the escape hatch\n * cannot drift from the rest of the set.\n */\n readonly override?: Partial<Record<EntityVerb, EntityRouteOverride<R>>>;\n}\n\nconst ACTION: Record<EntityVerb, Action> = {\n list: 'read',\n get: 'read',\n create: 'write',\n update: 'write',\n delete: 'delete',\n};\n\nconst READS: ReadonlySet<EntityVerb> = new Set(['list', 'get']);\n\n/**\n * What a route set needs from the host beyond the repository factory: a\n * session's services always (its `$metadata` reads the caller), the fleet's\n * crossing services only when a guard is a crossing.\n */\ntype GuardServices<G extends EntityRouteGuard> =\n | TokenServiceTag\n | PolicyDecisionTag\n | (G extends { readonly kind: 'crossing' }\n ? ServiceCrossingTokenTag | ServiceCrossingPolicyTag\n : never);\n\n/** The `500` for anything a route did not anticipate. */\nexport const routeFailure = (error: unknown) =>\n HttpServerResponse.json(\n { error: 'request failed', code: 'unexpected', detail: String(error) },\n { status: 500 },\n );\n\nconst invalid = (error: unknown, label: string, code: string) =>\n error instanceof EntifixBuildError\n ? HttpServerResponse.json(\n {\n error: label,\n code: refusalCode(error) ?? code,\n detail: error.message,\n },\n { status: 400 },\n )\n : error instanceof EntityRouteRefusal\n ? HttpServerResponse.json(\n {\n error: 'the record cannot be saved',\n code: error.code,\n ...(error.detail === undefined ? {} : { detail: error.detail }),\n },\n { status: error.status },\n )\n : routeFailure(error);\n\n/**\n * A domain error may carry its own code (`mediaNotHttps`), so the client can\n * say which rule failed rather than \"invalid body\".\n */\nconst refusalCode = (error: EntifixBuildError): string | undefined =>\n 'code' in error && typeof error.code === 'string' ? error.code : undefined;\n\n/**\n * The generic routes for one entity — list, read, create, update, delete and\n * its `$metadata` — derived from the entity and bound to storage through the\n * host's {@link EntityRepositoryFactoryTag}.\n *\n * What a route set varies on is stated, not written: which verbs exist, who may\n * reach them, which records a reader sees, and what a save must pass. What it\n * never varies on is the wire: the load request is parsed against the entity's\n * own metadata (so a query can only name a member the entity declared\n * filterable or sortable), every answer is an envelope, and the links name\n * exactly the verbs mounted — never one that would `404`.\n *\n * A path that is not one of these verbs is written by hand beside it, and a\n * verb whose generic form does not fit is replaced with `override`.\n */\nexport const makeEntityRoutes = <\n T extends Entity,\n R = never,\n const G extends EntityRouteGuard = EntityRouteGuard,\n>(\n entityConstructor: EntityConstructor<T>,\n options: EntityRoutesOptions<T, R, G>,\n) => {\n const key = envelopeEntityName(entityConstructor);\n const base: `/${string}` = options.basePath ?? `/api/${key}`;\n const mounted = new Set(options.verbs);\n\n const links: EntityRouteLinks = {\n record: id => {\n const href = `${base}/${String(id)}`;\n return [\n ...(mounted.has('get')\n ? [{ rel: 'self', href, method: 'GET' } as const]\n : []),\n ...(mounted.has('list')\n ? [{ rel: 'list', href: base, method: 'GET' } as const]\n : []),\n ...(mounted.has('update')\n ? [{ rel: 'update', href, method: 'PUT' } as const]\n : []),\n ...(mounted.has('delete')\n ? [{ rel: 'delete', href, method: 'DELETE' } as const]\n : []),\n ];\n },\n collection: () => [\n ...(mounted.has('list')\n ? [{ rel: 'self', href: base, method: 'GET' } as const]\n : []),\n ...(mounted.has('create')\n ? [{ rel: 'create', href: base, method: 'POST' } as const]\n : []),\n ],\n };\n\n const guardFor = (verb: EntityVerb): EntityRouteGuard => {\n const guard = options.guard as\n | EntityRouteGuard\n | { readonly read: EntityRouteGuard; readonly write: EntityRouteGuard };\n return 'kind' in guard ? guard : READS.has(verb) ? guard.read : guard.write;\n };\n\n const guarded = <A, E, RR>(\n verb: EntityVerb,\n use: (context: EntityRouteContext) => Effect.Effect<A, E, RR>,\n ) => {\n const guard = guardFor(verb);\n const permission = permissionForEntity(entityConstructor, ACTION[verb]);\n switch (guard.kind) {\n case 'public':\n return use({});\n case 'permission':\n return requirePermission(permission)(principal => use({ principal }));\n case 'organization':\n return requireOrganization(permission)((organizationId, principal) =>\n use({ organizationId, principal }),\n );\n case 'crossing':\n return guard.organization\n ? requireServiceCrossing(permission)(organizationId =>\n use({ organizationId }),\n )\n : requireCrossing(permission)(use({}));\n }\n };\n\n const repository = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const factory = yield* EntityRepositoryFactoryTag;\n return yield* factory.for(entityConstructor, {\n ...(context.organizationId === undefined\n ? {}\n : { organizationId: context.organizationId }),\n });\n });\n\n const scopeOf = (context: EntityRouteContext) =>\n options.scope ? options.scope(context) : Effect.succeed('all' as const);\n\n const readLoadRequest = Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const search = new URL(request.url, 'http://localhost').searchParams;\n return yield* Effect.try({\n try: () =>\n parseLoadRequestParams(\n entityConstructor,\n search,\n ) as unknown as EntityLoadRequest<T>,\n catch: error => error as EntifixBuildError,\n });\n });\n\n const notFound = HttpServerResponse.json(\n {\n message: 'not found',\n code: 'notFound',\n entity: extractMetaEntity(entityConstructor).key,\n },\n { status: 404 },\n );\n\n const list = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const parsed = yield* readLoadRequest;\n const scope = yield* scopeOf(context);\n if (scope === 'none') {\n // Still parsed: whether a query is well-formed must not depend on who\n // asked.\n return yield* HttpServerResponse.json(\n makeEntityPageEnvelope(\n entityConstructor,\n { items: [], total: 0, request: parsed },\n links.collection(),\n ),\n );\n }\n // Conjoined, never substituted: the translator `$and`s every top-level\n // element, so an added element can only narrow, and no `or` in a query\n // string can escape it.\n const request: EntityLoadRequest<T> =\n scope === 'all'\n ? parsed\n : {\n ...parsed,\n filtering: [...(parsed.filtering ?? []), scope.filter],\n };\n const page = yield* loadUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(\n EntityLoadRequestTag,\n request as unknown as EntityLoadRequest,\n ),\n );\n return yield* HttpServerResponse.json(\n makeEntityPageEnvelope(entityConstructor, page, links.collection()),\n );\n }).pipe(\n Effect.catch(error => invalid(error, 'invalid query', 'invalidQuery')),\n );\n\n const get = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const params = yield* HttpRouter.params;\n const scope = yield* scopeOf(context);\n if (scope === 'none') return yield* notFound;\n const found = yield* getUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityIdTag, params.id),\n Effect.option,\n );\n // Out of scope answers exactly as absent does: a `403` would confirm the\n // record exists to somebody who may not read it.\n if (\n found._tag === 'None' ||\n (scope !== 'all' && !scope.includes(found.value))\n ) {\n return yield* notFound;\n }\n return yield* HttpServerResponse.json(\n makeEntityEnvelope(\n entityConstructor,\n found.value,\n links.record(params.id),\n ),\n );\n }).pipe(Effect.catch(routeFailure));\n\n const save = (verb: 'create' | 'update') => (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const body = yield* request.json;\n let entity = yield* readEntityEnvelope(entityConstructor, body);\n\n // On update the URL is authoritative, so a record cannot be renamed\n // onto another one by editing its payload.\n if (verb === 'update') {\n const params = yield* HttpRouter.params;\n entity.id = params.id;\n }\n if (options.prepare) {\n entity = yield* options.prepare(entity, { ...context, verb });\n }\n\n const saved = yield* saveUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityTag, entity),\n );\n return yield* HttpServerResponse.json(\n makeEntityEnvelope(entityConstructor, saved, links.record(saved.id)),\n );\n }).pipe(\n Effect.catch(error =>\n invalid(error, 'invalid request body', 'invalidBody'),\n ),\n );\n\n /**\n * Answers with an envelope rather than a bare `204`: every message between\n * entifix artifacts is one, and the fetch client parses every response as\n * JSON.\n */\n const remove = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const params = yield* HttpRouter.params;\n yield* deleteUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityIdTag, params.id),\n );\n return yield* HttpServerResponse.json({\n meta: { type: 'entity', entity: key, links: links.collection() },\n data: { id: params.id },\n });\n }).pipe(Effect.catch(routeFailure));\n\n const generic = (verb: EntityVerb, context: EntityRouteContext) => {\n switch (verb) {\n case 'list':\n return list(context);\n case 'get':\n return get(context);\n case 'create':\n return save('create')(context);\n case 'update':\n return save('update')(context);\n case 'delete':\n return remove(context);\n }\n };\n\n /**\n * Each guard asks for different services, so the branches do not unify on\n * their own; the cast states the union every guard this set can reach\n * actually needs.\n */\n const handlerFor = (verb: EntityVerb) =>\n guarded(verb, context => {\n const override = options.override?.[verb];\n return override ? override(context, links) : generic(verb, context);\n }) as Effect.Effect<\n HttpServerResponse.HttpServerResponse,\n unknown,\n | R\n | EntityRepositoryFactoryTag\n | GuardServices<G>\n | HttpServerRequest.HttpServerRequest\n | HttpRouter.RouteContext\n >;\n\n const ROUTES: Record<\n EntityVerb,\n { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: `/${string}` }\n > = {\n list: { method: 'GET', path: base },\n get: { method: 'GET', path: `${base}/:id` },\n create: { method: 'POST', path: base },\n update: { method: 'PUT', path: `${base}/:id` },\n delete: { method: 'DELETE', path: `${base}/:id` },\n };\n\n return HttpRouter.addAll([\n // A literal, so it wins over `/:id` regardless of order.\n HttpRouter.route(\n 'GET',\n `${base}/$metadata`,\n entityMetadataRoute(entityConstructor),\n ),\n ...options.verbs.map(verb =>\n HttpRouter.route(\n ROUTES[verb].method,\n ROUTES[verb].path,\n handlerFor(verb),\n ),\n ),\n ]);\n};\n"],"names":["permissionForEntity","deleteUCFactory","EntityIdTag","EntityLoadRequestTag","EntityRepositoryFactoryTag","EntityRepositoryTag","EntityTag","getUCFactory","loadUCFactory","saveUCFactory","EntifixBuildError","envelopeEntityName","extractMetaEntity","makeEntityEnvelope","makeEntityPageEnvelope","parseLoadRequestParams","readEntityEnvelope","Effect","HttpRouter","HttpServerRequest","HttpServerResponse","requireCrossing","requireOrganization","requirePermission","requireServiceCrossing","entityMetadataRoute","EntityRouteRefusal","status","code","detail","_tag","ACTION","list","get","create","update","delete","READS","Set","routeFailure","error","json","String","invalid","label","refusalCode","message","undefined","makeEntityRoutes","entityConstructor","options","key","base","basePath","mounted","verbs","links","record","id","href","has","rel","method","collection","guardFor","verb","guard","read","write","guarded","use","permission","kind","principal","organizationId","organization","repository","context","gen","factory","for","scopeOf","scope","succeed","readLoadRequest","request","search","URL","url","searchParams","try","catch","notFound","entity","parsed","items","total","filtering","filter","page","pipe","provideService","params","found","option","includes","value","save","body","prepare","saved","remove","meta","type","data","generic","handlerFor","override","ROUTES","path","addAll","route","map"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './auth/index.js';
2
2
  export * from './entity-metadata-route.js';
3
+ export * from './entity-routes.js';
3
4
  export * from './health-routes.js';
4
5
  export * from './redact-configuration.js';
5
6
  export * from './service-description-route.js';
@@ -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,2BAA2B,CAAC;AAC1C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,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,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './auth/index.js';
2
2
  export * from './entity-metadata-route.js';
3
+ export * from './entity-routes.js';
3
4
  export * from './health-routes.js';
4
5
  export * from './redact-configuration.js';
5
6
  export * from './service-description-route.js';
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 './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"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-metadata-route.js';\nexport * from './entity-routes.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,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,iCAAiC;AAC/C,cAAc,WAAW"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/service-shell",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Effect HTTP service skeleton: server layer, health and metadata routes, permission guards, remote configuration and OpenTelemetry.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -66,10 +66,10 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "@swc/helpers": "0.5.23",
69
- "@entifix/business": "0.7.1",
70
- "@entifix/tooling": "0.7.1",
71
- "@entifix/authz": "0.7.1",
72
- "@entifix/core": "0.7.1"
69
+ "@entifix/authz": "0.7.2",
70
+ "@entifix/core": "0.7.2",
71
+ "@entifix/business": "0.7.2",
72
+ "@entifix/tooling": "0.7.2"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@effect/opentelemetry": "4.0.5",
@@ -81,7 +81,7 @@
81
81
  "@opentelemetry/sdk-metrics": "2.12.0",
82
82
  "@opentelemetry/sdk-trace-base": "2.12.0",
83
83
  "effect": "4.0.5",
84
- "@entifix/testing-unit": "0.7.1"
84
+ "@entifix/testing-unit": "0.7.2"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "@effect/opentelemetry": "^4.0.5",