@entifix/service-shell 0.8.0 → 0.10.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 (64) hide show
  1. package/README.md +7 -7
  2. package/dist/auth/failures.d.ts +23 -0
  3. package/dist/auth/failures.d.ts.map +1 -0
  4. package/dist/auth/failures.js +20 -0
  5. package/dist/auth/failures.js.map +1 -0
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/auth/require-organization.d.ts +1 -2
  11. package/dist/auth/require-organization.d.ts.map +1 -1
  12. package/dist/auth/require-organization.js +2 -7
  13. package/dist/auth/require-organization.js.map +1 -1
  14. package/dist/auth/require-principal.d.ts +3 -3
  15. package/dist/auth/require-principal.d.ts.map +1 -1
  16. package/dist/auth/require-principal.js +6 -14
  17. package/dist/auth/require-principal.js.map +1 -1
  18. package/dist/auth/require-service-crossing.d.ts +3 -3
  19. package/dist/auth/require-service-crossing.d.ts.map +1 -1
  20. package/dist/auth/require-service-crossing.js +18 -21
  21. package/dist/auth/require-service-crossing.js.map +1 -1
  22. package/dist/auth/shared-token.d.ts +2 -2
  23. package/dist/auth/shared-token.d.ts.map +1 -1
  24. package/dist/auth/shared-token.js +7 -8
  25. package/dist/auth/shared-token.js.map +1 -1
  26. package/dist/entity-facet-routes.d.ts +38 -0
  27. package/dist/entity-facet-routes.d.ts.map +1 -0
  28. package/dist/entity-facet-routes.js +67 -0
  29. package/dist/entity-facet-routes.js.map +1 -0
  30. package/dist/entity-metadata-route.d.ts +1 -1
  31. package/dist/entity-metadata-route.d.ts.map +1 -1
  32. package/dist/entity-metadata-route.js +6 -9
  33. package/dist/entity-metadata-route.js.map +1 -1
  34. package/dist/entity-route-guard.d.ts +60 -0
  35. package/dist/entity-route-guard.d.ts.map +1 -0
  36. package/dist/entity-route-guard.js +29 -0
  37. package/dist/entity-route-guard.js.map +1 -0
  38. package/dist/entity-routes.d.ts +17 -61
  39. package/dist/entity-routes.d.ts.map +1 -1
  40. package/dist/entity-routes.js +19 -77
  41. package/dist/entity-routes.js.map +1 -1
  42. package/dist/health-routes.d.ts.map +1 -1
  43. package/dist/health-routes.js +3 -2
  44. package/dist/health-routes.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/make-service.d.ts.map +1 -1
  50. package/dist/make-service.js +4 -2
  51. package/dist/make-service.js.map +1 -1
  52. package/dist/respond.d.ts +92 -0
  53. package/dist/respond.d.ts.map +1 -0
  54. package/dist/respond.js +178 -0
  55. package/dist/respond.js.map +1 -0
  56. package/dist/route-responses.d.ts +44 -0
  57. package/dist/route-responses.d.ts.map +1 -0
  58. package/dist/route-responses.js +104 -0
  59. package/dist/route-responses.js.map +1 -0
  60. package/dist/service-description-route.d.ts +1 -1
  61. package/dist/service-description-route.d.ts.map +1 -1
  62. package/dist/service-description-route.js +2 -1
  63. package/dist/service-description-route.js.map +1 -1
  64. package/package.json +12 -12
@@ -0,0 +1,67 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { EntityFacetCounterFactoryTag, EntityFacetCounterTag, facetCountsUCFactory } from '@entifix/business';
3
+ import { envelopeEntityName, makeEntityFacetsEnvelope, parseFacetQueryParams } from '@entifix/core';
4
+ import { Effect } from 'effect';
5
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';
6
+ import { guardRoute } from './entity-route-guard.js';
7
+ import { defaultFailurePolicy, respond } from './respond.js';
8
+ import { legacyAnswer } from './route-responses.js';
9
+ /**
10
+ * `GET <base>/$facets` — how many of the entity's records hold each value of
11
+ * each `facet` member, each under its own `rsql.<member>` filtering — bound to
12
+ * storage through the host's {@link EntityFacetCounterFactoryTag}.
13
+ *
14
+ * A route of its own beside `makeEntityRoutes` rather than a sixth verb, so it
15
+ * is the one route set that requires a counter: a host that mounts it without
16
+ * binding one fails to compile, and a host that never counts binds nothing.
17
+ *
18
+ * The query is parsed against the entity's metadata, so only a `filterable`
19
+ * member is counted and every malformed request is a `400`. A scope is
20
+ * **conjoined into every facet's filtering**, never substituted, so no query
21
+ * string can count past it; `'none'` answers every member empty.
22
+ */ export const makeEntityFacetRoutes = (entityConstructor, options)=>{
23
+ var _options_basePath, _options_policy;
24
+ const base = (_options_basePath = options.basePath) != null ? _options_basePath : `/api/${envelopeEntityName(entityConstructor)}`;
25
+ const readQueries = Effect.gen(function*() {
26
+ const request = yield* HttpServerRequest.HttpServerRequest;
27
+ const search = new URL(request.url, 'http://localhost').searchParams;
28
+ return yield* Effect.try({
29
+ try: ()=>parseFacetQueryParams(entityConstructor, search),
30
+ catch: (error)=>error
31
+ });
32
+ });
33
+ const scoped = (queries, scope)=>scope === 'all' || scope === 'none' ? queries : queries.map((query)=>{
34
+ var _query_filtering;
35
+ return _extends({}, query, {
36
+ filtering: [
37
+ ...(_query_filtering = query.filtering) != null ? _query_filtering : [],
38
+ scope.filter
39
+ ]
40
+ });
41
+ });
42
+ const count = (context)=>Effect.gen(function*() {
43
+ const queries = yield* readQueries;
44
+ const scope = options.scope ? yield* options.scope(context) : 'all';
45
+ if (scope === 'none') {
46
+ // Still parsed: whether a query is well-formed must not depend on who
47
+ // asked.
48
+ const empty = Object.fromEntries(queries.map(({ member })=>[
49
+ member,
50
+ []
51
+ ]));
52
+ return yield* HttpServerResponse.json(makeEntityFacetsEnvelope(entityConstructor, empty));
53
+ }
54
+ const factory = yield* EntityFacetCounterFactoryTag;
55
+ const counter = yield* factory.for(entityConstructor, _extends({}, context.organizationId === undefined ? {} : {
56
+ organizationId: context.organizationId
57
+ }));
58
+ const counts = yield* facetCountsUCFactory(scoped(queries, scope)).pipe(Effect.provideService(EntityFacetCounterTag, counter));
59
+ return yield* HttpServerResponse.json(makeEntityFacetsEnvelope(entityConstructor, counts));
60
+ }).pipe(Effect.catch(legacyAnswer('invalidQuery')));
61
+ const handler = respond(guardRoute(entityConstructor, options.guard, 'read', count), (_options_policy = options.policy) != null ? _options_policy : defaultFailurePolicy);
62
+ return HttpRouter.addAll([
63
+ HttpRouter.route('GET', `${base}/$facets`, handler)
64
+ ]);
65
+ };
66
+
67
+ //# sourceMappingURL=entity-facet-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/entity-facet-routes.ts"],"sourcesContent":["import {\n EntityFacetCounterFactoryTag,\n EntityFacetCounterTag,\n facetCountsUCFactory,\n} from '@entifix/business';\nimport type { EntifixFailure } from '@entifix/core';\nimport {\n EntifixBuildError,\n type Entity,\n type EntityConstructor,\n type EntityFacetCounts,\n type EntityFacetQuery,\n envelopeEntityName,\n makeEntityFacetsEnvelope,\n parseFacetQueryParams,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport {\n type EntityReadScope,\n type EntityRouteContext,\n type EntityRouteGuard,\n guardRoute,\n type GuardServices,\n} from './entity-route-guard.js';\nimport {\n defaultFailurePolicy,\n type FailurePolicy,\n respond,\n} from './respond.js';\nimport { legacyAnswer } from './route-responses.js';\n\nexport interface EntityFacetRoutesOptions<\n T extends Entity,\n R,\n G extends EntityRouteGuard = EntityRouteGuard,\n> {\n /** Who may count: the guard the entity's `list` has, as a rule. */\n readonly guard: G;\n /** Defaults to `/api/<entity key>`; the route is `<basePath>/$facets`. */\n readonly basePath?: `/${string}`;\n /**\n * Narrows the counts to the records this caller may see — the same scope\n * the entity's `list` takes, so a count never reveals a record the list\n * would not.\n */\n readonly scope?: (\n context: EntityRouteContext,\n ) => Effect.Effect<EntityReadScope<T>, never, R>;\n /** How the count's failures are answered and logged. */\n readonly policy?: FailurePolicy;\n}\n\n/**\n * `GET <base>/$facets` — how many of the entity's records hold each value of\n * each `facet` member, each under its own `rsql.<member>` filtering — bound to\n * storage through the host's {@link EntityFacetCounterFactoryTag}.\n *\n * A route of its own beside `makeEntityRoutes` rather than a sixth verb, so it\n * is the one route set that requires a counter: a host that mounts it without\n * binding one fails to compile, and a host that never counts binds nothing.\n *\n * The query is parsed against the entity's metadata, so only a `filterable`\n * member is counted and every malformed request is a `400`. A scope is\n * **conjoined into every facet's filtering**, never substituted, so no query\n * string can count past it; `'none'` answers every member empty.\n */\nexport const makeEntityFacetRoutes = <\n T extends Entity,\n R = never,\n // `never` for the reason `makeEntityRoutes` gives: a caller naming `R`\n // explicitly must not widen the guard to every kind there is.\n const G extends EntityRouteGuard = never,\n>(\n entityConstructor: EntityConstructor<T>,\n options: EntityFacetRoutesOptions<T, R, G>,\n) => {\n const base: `/${string}` =\n options.basePath ?? `/api/${envelopeEntityName(entityConstructor)}`;\n\n const readQueries = 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: () => parseFacetQueryParams(entityConstructor, search),\n catch: error => error as EntifixBuildError,\n });\n });\n\n const scoped = (\n queries: EntityFacetQuery<T>[],\n scope: EntityReadScope<T>,\n ): EntityFacetQuery<T>[] =>\n scope === 'all' || scope === 'none'\n ? queries\n : queries.map(query => ({\n ...query,\n filtering: [...(query.filtering ?? []), scope.filter],\n }));\n\n const count = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const queries = yield* readQueries;\n const scope = options.scope\n ? yield* options.scope(context)\n : ('all' as const);\n if (scope === 'none') {\n // Still parsed: whether a query is well-formed must not depend on who\n // asked.\n const empty: EntityFacetCounts = Object.fromEntries(\n queries.map(({ member }) => [member, []]),\n );\n return yield* HttpServerResponse.json(\n makeEntityFacetsEnvelope(entityConstructor, empty),\n );\n }\n const factory = yield* EntityFacetCounterFactoryTag;\n const counter = yield* factory.for(entityConstructor, {\n ...(context.organizationId === undefined\n ? {}\n : { organizationId: context.organizationId }),\n });\n const counts = yield* facetCountsUCFactory<T>(\n scoped(queries, scope),\n ).pipe(Effect.provideService(EntityFacetCounterTag, counter));\n return yield* HttpServerResponse.json(\n makeEntityFacetsEnvelope(entityConstructor, counts),\n );\n }).pipe(Effect.catch(legacyAnswer('invalidQuery')));\n\n const handler = respond(\n guardRoute(\n entityConstructor,\n options.guard,\n 'read',\n count,\n ) as Effect.Effect<\n HttpServerResponse.HttpServerResponse,\n EntifixFailure,\n | R\n | EntityFacetCounterFactoryTag\n | GuardServices<G>\n | HttpServerRequest.HttpServerRequest\n | HttpRouter.RouteContext\n >,\n options.policy ?? defaultFailurePolicy,\n );\n\n return HttpRouter.addAll([\n HttpRouter.route('GET', `${base}/$facets`, handler),\n ]);\n};\n"],"names":["EntityFacetCounterFactoryTag","EntityFacetCounterTag","facetCountsUCFactory","envelopeEntityName","makeEntityFacetsEnvelope","parseFacetQueryParams","Effect","HttpRouter","HttpServerRequest","HttpServerResponse","guardRoute","defaultFailurePolicy","respond","legacyAnswer","makeEntityFacetRoutes","entityConstructor","options","base","basePath","readQueries","gen","request","search","URL","url","searchParams","try","catch","error","scoped","queries","scope","map","query","filtering","filter","count","context","empty","Object","fromEntries","member","json","factory","counter","for","organizationId","undefined","counts","pipe","provideService","handler","guard","policy","addAll","route"],"mappings":";AAAA,SACEA,4BAA4B,EAC5BC,qBAAqB,EACrBC,oBAAoB,QACf,oBAAoB;AAE3B,SAMEC,kBAAkB,EAClBC,wBAAwB,EACxBC,qBAAqB,QAChB,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,UAAU,EAAEC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEhF,SAIEC,UAAU,QAEL,0BAA0B;AACjC,SACEC,oBAAoB,EAEpBC,OAAO,QACF,eAAe;AACtB,SAASC,YAAY,QAAQ,uBAAuB;AAuBpD;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMC,wBAAwB,CAOnCC,mBACAC;QAGEA,mBAmEAA;IApEF,MAAMC,QACJD,oBAAAA,QAAQE,QAAQ,YAAhBF,oBAAoB,CAAC,KAAK,EAAEb,mBAAmBY,oBAAoB;IAErE,MAAMI,cAAcb,OAAOc,GAAG,CAAC;QAC7B,MAAMC,UAAU,OAAOb,kBAAkBA,iBAAiB;QAC1D,MAAMc,SAAS,IAAIC,IAAIF,QAAQG,GAAG,EAAE,oBAAoBC,YAAY;QACpE,OAAO,OAAOnB,OAAOoB,GAAG,CAAC;YACvBA,KAAK,IAAMrB,sBAAsBU,mBAAmBO;YACpDK,OAAOC,CAAAA,QAASA;QAClB;IACF;IAEA,MAAMC,SAAS,CACbC,SACAC,QAEAA,UAAU,SAASA,UAAU,SACzBD,UACAA,QAAQE,GAAG,CAACC,CAAAA;gBAEMA;mBAFI,aACjBA;gBACHC,WAAW;wBAAKD,mBAAAA,MAAMC,SAAS,YAAfD,mBAAmB,EAAE;oBAAGF,MAAMI,MAAM;iBAAC;;;IAG7D,MAAMC,QAAQ,CAACC,UACb/B,OAAOc,GAAG,CAAC;YACT,MAAMU,UAAU,OAAOX;YACvB,MAAMY,QAAQf,QAAQe,KAAK,GACvB,OAAOf,QAAQe,KAAK,CAACM,WACpB;YACL,IAAIN,UAAU,QAAQ;gBACpB,sEAAsE;gBACtE,SAAS;gBACT,MAAMO,QAA2BC,OAAOC,WAAW,CACjDV,QAAQE,GAAG,CAAC,CAAC,EAAES,MAAM,EAAE,GAAK;wBAACA;wBAAQ,EAAE;qBAAC;gBAE1C,OAAO,OAAOhC,mBAAmBiC,IAAI,CACnCtC,yBAAyBW,mBAAmBuB;YAEhD;YACA,MAAMK,UAAU,OAAO3C;YACvB,MAAM4C,UAAU,OAAOD,QAAQE,GAAG,CAAC9B,mBAAmB,aAChDsB,QAAQS,cAAc,KAAKC,YAC3B,CAAC,IACD;gBAAED,gBAAgBT,QAAQS,cAAc;YAAC;YAE/C,MAAME,SAAS,OAAO9C,qBACpB2B,OAAOC,SAASC,QAChBkB,IAAI,CAAC3C,OAAO4C,cAAc,CAACjD,uBAAuB2C;YACpD,OAAO,OAAOnC,mBAAmBiC,IAAI,CACnCtC,yBAAyBW,mBAAmBiC;QAEhD,GAAGC,IAAI,CAAC3C,OAAOqB,KAAK,CAACd,aAAa;IAEpC,MAAMsC,UAAUvC,QACdF,WACEK,mBACAC,QAAQoC,KAAK,EACb,QACAhB,SAUFpB,kBAAAA,QAAQqC,MAAM,YAAdrC,kBAAkBL;IAGpB,OAAOJ,WAAW+C,MAAM,CAAC;QACvB/C,WAAWgD,KAAK,CAAC,OAAO,GAAGtC,KAAK,QAAQ,CAAC,EAAEkC;KAC5C;AACH,EAAE"}
@@ -20,5 +20,5 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
20
20
  * database handle here would leave a vendor with no active organization unable
21
21
  * to see their own affordances.
22
22
  */
23
- export declare const entityMetadataRoute: <TEntity extends Entity>(entityConstructor: EntityConstructor<TEntity>) => Effect.Effect<HttpServerResponse.HttpServerResponse, import("effect/http/HttpBody").HttpBodyError, PolicyDecisionTag | import("@entifix/business").TokenServiceTag | HttpServerRequest.HttpServerRequest>;
23
+ export declare const entityMetadataRoute: <TEntity extends Entity>(entityConstructor: EntityConstructor<TEntity>) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, import("@entifix/business").TokenServiceTag | PolicyDecisionTag | HttpServerRequest.HttpServerRequest>;
24
24
  //# sourceMappingURL=entity-metadata-route.d.ts.map
@@ -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;;;;;;;;;;;;;;;;;GAiBG;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,EAKvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAKpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,EACxD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,uKA6D5C,CAAC"}
@@ -1,9 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { parsePermission, permissionForEntity, permissionForUseCase, PolicyDecisionTag } from '@entifix/authz';
3
- import { describeEntityUseCases, ENTITY_ACTIONS, extractMetaEntity, makeEntityMetadataEnvelope } from '@entifix/core';
3
+ import { describeEntityUseCases, ENTITY_ACTIONS, extractMetaEntity, makeEntityMetadataEnvelope, NotFound } from '@entifix/core';
4
4
  import { Effect } from 'effect';
5
5
  import { HttpServerRequest, HttpServerResponse } from 'effect/http';
6
6
  import { requirePrincipal } from './auth/index.js';
7
+ import { respond } from './respond.js';
7
8
  /**
8
9
  * `GET /api/<entity>/$metadata` — what this caller may do with one entity.
9
10
  *
@@ -21,7 +22,7 @@ import { requirePrincipal } from './auth/index.js';
21
22
  * service: the document describes the model, not tenant data, and resolving a
22
23
  * database handle here would leave a vendor with no active organization unable
23
24
  * to see their own affordances.
24
- */ export const entityMetadataRoute = (entityConstructor)=>requirePrincipal((principal)=>Effect.gen(function*() {
25
+ */ export const entityMetadataRoute = (entityConstructor)=>respond(requirePrincipal((principal)=>Effect.gen(function*() {
25
26
  const policy = yield* PolicyDecisionTag;
26
27
  const allows = (permission)=>{
27
28
  const { resource, action } = parsePermission(permission);
@@ -66,7 +67,7 @@ import { requirePrincipal } from './auth/index.js';
66
67
  contentType: 'application/json',
67
68
  headers: cacheHeaders(etag)
68
69
  });
69
- }));
70
+ })));
70
71
  /**
71
72
  * The response varies by principal, so it must never land in a shared cache.
72
73
  * `Vary` names **both** credential carriers because the shell reads the access
@@ -77,12 +78,8 @@ import { requirePrincipal } from './auth/index.js';
77
78
  'cache-control': 'private, no-cache',
78
79
  vary: 'Cookie, Authorization'
79
80
  });
80
- /** Indistinguishable from the `404` a service gives for an entity it does not host. */ const notFound = (entityConstructor)=>HttpServerResponse.json({
81
- message: 'not found',
82
- code: 'notFound',
81
+ /** Indistinguishable from the `404` a service gives for an entity it does not host. */ const notFound = (entityConstructor)=>Effect.fail(new NotFound({
83
82
  entity: extractMetaEntity(entityConstructor).key
84
- }, {
85
- status: 404
86
- });
83
+ }));
87
84
 
88
85
  //# sourceMappingURL=entity-metadata-route.js.map
@@ -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 *\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"}
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 NotFound,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport { requirePrincipal } from './auth/index.js';\nimport { respond } from './respond.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 respond(\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(\n 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/**\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 Effect.fail(\n new NotFound({ entity: extractMetaEntity(entityConstructor).key }),\n );\n"],"names":["createHash","parsePermission","permissionForEntity","permissionForUseCase","PolicyDecisionTag","describeEntityUseCases","ENTITY_ACTIONS","extractMetaEntity","makeEntityMetadataEnvelope","NotFound","Effect","HttpServerRequest","HttpServerResponse","requirePrincipal","respond","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","fail","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,EAC1BC,QAAQ,QACH,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE,SAASC,gBAAgB,QAAQ,kBAAkB;AACnD,SAASC,OAAO,QAAQ,eAAe;AAEvC;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,sBAAsB,CACjCC,oBAEAF,QACED,iBAAiBI,CAAAA,YACfP,OAAOQ,GAAG,CAAC;YACT,MAAMC,SAAS,OAAOf;YACtB,MAAMgB,SAAS,CAACC;gBACd,MAAM,EAAEC,QAAQ,EAAEC,MAAM,EAAE,GAAGtB,gBAAgBoB;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,OAAOlB,oBAAoBc,mBAAmB,UAAU;gBAC3D,OAAO,OAAOW,SAASX;YACzB;YAEA,MAAMY,WAAmC;gBACvCC,SAASvB,eAAewB,MAAM,CAACP,CAAAA,SAC7BH,OAAOlB,oBAAoBc,mBAAmBO;gBAEhDQ,UAAU1B,uBAAuBW,mBAAmBc,MAAM,CACxDE,CAAAA,aACEZ,OAAOjB,qBAAqBa,mBAAmBgB,WAAWC,GAAG;YAEnE;YAEA,sEAAsE;YACtE,0EAA0E;YAC1E,wEAAwE;YACxE,wEAAwE;YACxE,4EAA4E;YAC5E,2EAA2E;YAC3E,mEAAmE;YACnE,0BAA0B;YAC1B,MAAMC,OAAOC,KAAKC,SAAS,CACzB5B,2BAA2BQ,mBAAmBY;YAEhD,MAAMS,OAAO,CAAC,CAAC,EAAErC,WAAW,UAAUsC,MAAM,CAACJ,MAAMK,MAAM,CAAC,OAAO,CAAC,CAAC;YAEnE,MAAMC,UAAU,OAAO7B,kBAAkBA,iBAAiB;YAC1D,IAAI6B,QAAQC,OAAO,CAAC,gBAAgB,KAAKJ,MAAM;gBAC7C,OAAOzB,mBAAmB8B,KAAK,CAAC;oBAAEC,QAAQ;gBAAI,GAAGC,IAAI,CACnDhC,mBAAmBiC,UAAU,CAACC,aAAaT;YAE/C;YAEA,OAAOzB,mBAAmBmC,IAAI,CAACb,MAAM;gBACnCc,aAAa;gBACbP,SAASK,aAAaT;YACxB;QACF,KAEF;AAEJ;;;;;CAKC,GACD,MAAMS,eAAe,CAACT,OAAkB,CAAA;QACtCA;QACA,iBAAiB;QACjBY,MAAM;IACR,CAAA;AAEA,qFAAqF,GACrF,MAAMtB,WAAW,CACfX,oBAEAN,OAAOwC,IAAI,CACT,IAAIzC,SAAS;QAAE0C,QAAQ5C,kBAAkBS,mBAAmBiB,GAAG;IAAC"}
@@ -0,0 +1,60 @@
1
+ import { type Action, type PolicyDecisionTag, type ServiceCrossingPolicyTag } from '@entifix/authz';
2
+ import type { RepositoryScope, TokenServiceTag } from '@entifix/business';
3
+ import type { Entity, EntityConstructor, FilterGroup } from '@entifix/core';
4
+ import type { Effect } from 'effect';
5
+ import { type RequestPrincipal, type ServiceCrossingTokenTag } from './auth/index.js';
6
+ /**
7
+ * Who may reach a route, and where the organization a per-organization store
8
+ * needs comes from.
9
+ *
10
+ * - `public` — anybody; nothing is checked and no organization is known.
11
+ * - `permission` — a verified session holding the permission the entity
12
+ * derives for the verb.
13
+ * - `organization` — the same, plus the session's active organization, which
14
+ * is what a per-organization store opens.
15
+ * - `crossing` — the fleet's crossing token and no session at all. With
16
+ * `organization: true` the caller names the organization in a header, which
17
+ * only a caller that has proved it is the fleet may do.
18
+ */
19
+ export type EntityRouteGuard = {
20
+ readonly kind: 'public';
21
+ } | {
22
+ readonly kind: 'permission';
23
+ } | {
24
+ readonly kind: 'organization';
25
+ } | {
26
+ readonly kind: 'crossing';
27
+ readonly organization: boolean;
28
+ };
29
+ /** What a route learned about its caller from its guard. */
30
+ export interface EntityRouteContext extends RepositoryScope {
31
+ readonly principal?: RequestPrincipal;
32
+ }
33
+ /**
34
+ * Which records of an entity a caller may see.
35
+ *
36
+ * `filter` narrows a list and `includes` decides a by-id read — both are
37
+ * needed, because `id` is the one member no query can name. `'none'` is a
38
+ * caller who holds the read grant and owns nothing: an empty page and a `404`,
39
+ * never a `403`, because owning no records is not an error.
40
+ */
41
+ export type EntityReadScope<T extends Entity> = 'all' | 'none' | {
42
+ readonly filter: FilterGroup<T>;
43
+ readonly includes: (entity: T) => boolean;
44
+ };
45
+ /**
46
+ * What a route needs from the host for its guard: a session's services always
47
+ * (a `$metadata` read and a session guard both read the caller), the fleet's
48
+ * crossing services only when a guard is a crossing.
49
+ */
50
+ export type GuardServices<G extends EntityRouteGuard> = TokenServiceTag | PolicyDecisionTag | (G extends {
51
+ readonly kind: 'crossing';
52
+ } ? ServiceCrossingTokenTag | ServiceCrossingPolicyTag : never);
53
+ /**
54
+ * Runs `use` behind `guard`, checking the permission the entity derives for
55
+ * `action` — the one place a derived route turns a stated guard into the
56
+ * check that enforces it, so the entity routes and the facet route cannot
57
+ * enforce the same guard differently.
58
+ */
59
+ export declare const guardRoute: <T extends Entity, A, E, R>(entityConstructor: EntityConstructor<T>, guard: EntityRouteGuard, action: Action, use: (context: EntityRouteContext) => Effect.Effect<A, E, R>) => Effect.Effect<import("effect/http/HttpServerResponse").HttpServerResponse | A, E, TokenServiceTag | PolicyDecisionTag | import("effect/http/HttpServerRequest").HttpServerRequest | R> | Effect.Effect<import("effect/http/HttpServerResponse").HttpServerResponse | A, E, ServiceCrossingPolicyTag | import("effect/http/HttpServerRequest").HttpServerRequest | ServiceCrossingTokenTag | R>;
60
+ //# sourceMappingURL=entity-route-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-route-guard.d.ts","sourceRoot":"","sources":["../src/entity-route-guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC9B,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAC1E,OAAO,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAErC,OAAO,EACL,KAAK,gBAAgB,EAKrB,KAAK,uBAAuB,EAC7B,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;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,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,gBAAgB,IAChD,eAAe,GACf,iBAAiB,GACjB,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GACpC,uBAAuB,GAAG,wBAAwB,GAClD,KAAK,CAAC,CAAC;AAEf;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAClD,mBAAmB,iBAAiB,CAAC,CAAC,CAAC,EACvC,OAAO,gBAAgB,EACvB,QAAQ,MAAM,EACd,KAAK,CAAC,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,mYAmB7D,CAAC"}
@@ -0,0 +1,29 @@
1
+ import { permissionForEntity } from '@entifix/authz';
2
+ import { requireCrossing, requireOrganization, requirePermission, requireServiceCrossing } from './auth/index.js';
3
+ /**
4
+ * Runs `use` behind `guard`, checking the permission the entity derives for
5
+ * `action` — the one place a derived route turns a stated guard into the
6
+ * check that enforces it, so the entity routes and the facet route cannot
7
+ * enforce the same guard differently.
8
+ */ export const guardRoute = (entityConstructor, guard, action, use)=>{
9
+ const permission = permissionForEntity(entityConstructor, action);
10
+ switch(guard.kind){
11
+ case 'public':
12
+ return use({});
13
+ case 'permission':
14
+ return requirePermission(permission)((principal)=>use({
15
+ principal
16
+ }));
17
+ case 'organization':
18
+ return requireOrganization(permission)((organizationId, principal)=>use({
19
+ organizationId,
20
+ principal
21
+ }));
22
+ case 'crossing':
23
+ return guard.organization ? requireServiceCrossing(permission)((organizationId)=>use({
24
+ organizationId
25
+ })) : requireCrossing(permission)(use({}));
26
+ }
27
+ };
28
+
29
+ //# sourceMappingURL=entity-route-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/entity-route-guard.ts"],"sourcesContent":["import {\n type Action,\n permissionForEntity,\n type PolicyDecisionTag,\n type ServiceCrossingPolicyTag,\n} from '@entifix/authz';\nimport type { RepositoryScope, TokenServiceTag } from '@entifix/business';\nimport type { Entity, EntityConstructor, FilterGroup } from '@entifix/core';\nimport type { Effect } from 'effect';\n\nimport {\n type RequestPrincipal,\n requireCrossing,\n requireOrganization,\n requirePermission,\n requireServiceCrossing,\n type ServiceCrossingTokenTag,\n} from './auth/index.js';\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 * What a route needs from the host for its guard: a session's services always\n * (a `$metadata` read and a session guard both read the caller), the fleet's\n * crossing services only when a guard is a crossing.\n */\nexport type GuardServices<G extends EntityRouteGuard> =\n | TokenServiceTag\n | PolicyDecisionTag\n | (G extends { readonly kind: 'crossing' }\n ? ServiceCrossingTokenTag | ServiceCrossingPolicyTag\n : never);\n\n/**\n * Runs `use` behind `guard`, checking the permission the entity derives for\n * `action` — the one place a derived route turns a stated guard into the\n * check that enforces it, so the entity routes and the facet route cannot\n * enforce the same guard differently.\n */\nexport const guardRoute = <T extends Entity, A, E, R>(\n entityConstructor: EntityConstructor<T>,\n guard: EntityRouteGuard,\n action: Action,\n use: (context: EntityRouteContext) => Effect.Effect<A, E, R>,\n) => {\n const permission = permissionForEntity(entityConstructor, action);\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"],"names":["permissionForEntity","requireCrossing","requireOrganization","requirePermission","requireServiceCrossing","guardRoute","entityConstructor","guard","action","use","permission","kind","principal","organizationId","organization"],"mappings":"AAAA,SAEEA,mBAAmB,QAGd,iBAAiB;AAKxB,SAEEC,eAAe,EACfC,mBAAmB,EACnBC,iBAAiB,EACjBC,sBAAsB,QAEjB,kBAAkB;AAsDzB;;;;;CAKC,GACD,OAAO,MAAMC,aAAa,CACxBC,mBACAC,OACAC,QACAC;IAEA,MAAMC,aAAaV,oBAAoBM,mBAAmBE;IAC1D,OAAQD,MAAMI,IAAI;QAChB,KAAK;YACH,OAAOF,IAAI,CAAC;QACd,KAAK;YACH,OAAON,kBAAkBO,YAAYE,CAAAA,YAAaH,IAAI;oBAAEG;gBAAU;QACpE,KAAK;YACH,OAAOV,oBAAoBQ,YAAY,CAACG,gBAAgBD,YACtDH,IAAI;oBAAEI;oBAAgBD;gBAAU;QAEpC,KAAK;YACH,OAAOL,MAAMO,YAAY,GACrBV,uBAAuBM,YAAYG,CAAAA,iBACjCJ,IAAI;oBAAEI;gBAAe,MAEvBZ,gBAAgBS,YAAYD,IAAI,CAAC;IACzC;AACF,EAAE"}
@@ -1,62 +1,14 @@
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';
1
+ import { EntityRepositoryFactoryTag } from '@entifix/business';
2
+ import { type EntifixBuildError, type EntifixEnvelopeLink, type Entity, type EntityConstructor, type EntityId } from '@entifix/core';
4
3
  import { Effect } from 'effect';
5
4
  import { HttpRouter, HttpServerResponse } from 'effect/http';
6
- import { type RequestPrincipal, type ServiceCrossingTokenTag } from './auth/index.js';
5
+ import { type EntityReadScope, type EntityRouteContext, type EntityRouteGuard } from './entity-route-guard.js';
6
+ import { type FailurePolicy } from './respond.js';
7
+ import { type EntityRouteRefusal, routeFailure } from './route-responses.js';
7
8
  /** The five generic verbs a route set can mount. */
8
9
  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
- }
10
+ export { type EntityReadScope, type EntityRouteContext, type EntityRouteGuard, } from './entity-route-guard.js';
11
+ export { EntityRouteRefusal } from './route-responses.js';
60
12
  /** A hand-written handler for one verb, mounted under the derived guard. */
61
13
  export type EntityRouteOverride<R> = (context: EntityRouteContext, links: EntityRouteLinks) => Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, R>;
62
14
  /** The affordances a route set derives from the verbs it mounts. */
@@ -88,9 +40,12 @@ export interface EntityRoutesOptions<T extends Entity, R, G extends EntityRouteG
88
40
  * cannot drift from the rest of the set.
89
41
  */
90
42
  readonly override?: Partial<Record<EntityVerb, EntityRouteOverride<R>>>;
43
+ /**
44
+ * How every verb's failures are answered and logged. Defaults to
45
+ * {@link defaultFailurePolicy}; a host passes its fleet policy.
46
+ */
47
+ readonly policy?: FailurePolicy;
91
48
  }
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
49
  /**
95
50
  * {@link deriveEntityRoutes}, with a curried form for a caller that has to
96
51
  * name the services its `override`, `scope` or `prepare` handlers need:
@@ -98,11 +53,12 @@ export declare const routeFailure: (error: unknown) => Effect.Effect<HttpServerR
98
53
  * entity and the guard from the value, which a single call with explicit type
99
54
  * arguments cannot do.
100
55
  */
101
- export declare const makeEntityRoutes: (<T extends Entity, R = never, const G extends EntityRouteGuard = never>(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 {
56
+ export { routeFailure };
57
+ export declare const makeEntityRoutes: (<T extends Entity, R = never, const G extends EntityRouteGuard = never>(entityConstructor: EntityConstructor<T>, options: EntityRoutesOptions<T, R, G>) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", import("@entifix/business").TokenServiceTag> | HttpRouter.Request<"Requires", import("@entifix/authz").PolicyDecisionTag> | HttpRouter.Request<"Requires", EntityRepositoryFactoryTag> | HttpRouter.Request.From<"Requires", Exclude<Exclude<R, HttpRouter.Provided>, HttpRouter.Provided>> | HttpRouter.Request.From<"Requires", Exclude<Exclude<G extends {
102
58
  readonly kind: "crossing";
103
- } ? ServiceCrossingPolicyTag | ServiceCrossingTokenTag : never, HttpRouter.Provided>, HttpRouter.Provided>> | HttpRouter.Request<"Error", unknown>>) & {
104
- for: <R>() => <T extends Entity, const G extends 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<"Error", unknown> | HttpRouter.Request.From<"Requires", Exclude<Exclude<R, HttpRouter.Provided>, HttpRouter.Provided>> | HttpRouter.Request.From<"Requires", Exclude<Exclude<G extends {
59
+ } ? import("@entifix/authz").ServiceCrossingPolicyTag | import("./index.js").ServiceCrossingTokenTag : never, HttpRouter.Provided>, HttpRouter.Provided>>>) & {
60
+ for: <R>() => <T extends Entity, const G extends EntityRouteGuard>(entityConstructor: EntityConstructor<T>, options: EntityRoutesOptions<T, R, G>) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", import("@entifix/business").TokenServiceTag> | HttpRouter.Request<"Requires", import("@entifix/authz").PolicyDecisionTag> | HttpRouter.Request<"Requires", EntityRepositoryFactoryTag> | HttpRouter.Request.From<"Requires", Exclude<Exclude<R, HttpRouter.Provided>, HttpRouter.Provided>> | HttpRouter.Request.From<"Requires", Exclude<Exclude<G extends {
105
61
  readonly kind: "crossing";
106
- } ? ServiceCrossingPolicyTag | ServiceCrossingTokenTag : never, HttpRouter.Provided>, HttpRouter.Provided>>>;
62
+ } ? import("@entifix/authz").ServiceCrossingPolicyTag | import("./index.js").ServiceCrossingTokenTag : never, HttpRouter.Provided>, HttpRouter.Provided>>>;
107
63
  };
108
64
  //# sourceMappingURL=entity-routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"entity-routes.d.ts","sourceRoot":"","sources":["../src/entity-routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"entity-routes.d.ts","sourceRoot":"","sources":["../src/entity-routes.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,0BAA0B,EAM3B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EASd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,UAAU,EAAqB,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAGhF,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EAGtB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,KAAK,aAAa,EAEnB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,kBAAkB,EAEvB,YAAY,EACb,MAAM,sBAAsB,CAAC;AAE9B,oDAAoD;AACpD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEzE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,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;IACxE;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;CACjC;AAiSD;;;;;;GAMG;AACH,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,eAAO,MAAM,gBAAgB,IA9Q3B,CAAC,SAAS,MAAM,EAChB,CAAC,gBAOK,CAAC,SAAS,gBAAgB,6BAEb,iBAAiB,CAAC,CAAC,CAAC,WAC9B,mBAAmB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;;;UAqQlC,CAAC,QACD,CAAC,SAAS,MAAM,QAAQ,CAAC,SAAS,gBAAgB,qBAC9B,iBAAiB,CAAC,CAAC,CAAC,WAC9B,mBAAmB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;;;CAGzC,CAAC"}
@@ -1,23 +1,13 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
- import { permissionForEntity } from '@entifix/authz';
3
2
  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';
3
+ import { envelopeEntityName, extractMetaEntity, makeEntityEnvelope, makeEntityPageEnvelope, NotFound, parseLoadRequestParams, readEntityEnvelope } from '@entifix/core';
5
4
  import { Effect } from 'effect';
6
5
  import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';
7
- import { requireCrossing, requireOrganization, requirePermission, requireServiceCrossing } from './auth/index.js';
8
6
  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
- }
7
+ import { guardRoute } from './entity-route-guard.js';
8
+ import { defaultFailurePolicy, respond } from './respond.js';
9
+ import { legacyAnswer, routeFailure } from './route-responses.js';
10
+ export { EntityRouteRefusal } from './route-responses.js';
21
11
  const ACTION = {
22
12
  list: 'read',
23
13
  get: 'read',
@@ -29,34 +19,6 @@ const READS = new Set([
29
19
  'list',
30
20
  'get'
31
21
  ]);
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
22
  /**
61
23
  * The generic routes for one entity — list, read, create, update, delete and
62
24
  * its `$metadata` — derived from the entity and bound to storage through the
@@ -131,27 +93,7 @@ const invalid = (error, label, code)=>{
131
93
  const guard = options.guard;
132
94
  return 'kind' in guard ? guard : READS.has(verb) ? guard.read : guard.write;
133
95
  };
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
- };
96
+ const guarded = (verb, use)=>guardRoute(entityConstructor, guardFor(verb), ACTION[verb], use);
155
97
  const repository = (context)=>Effect.gen(function*() {
156
98
  const factory = yield* EntityRepositoryFactoryTag;
157
99
  return yield* factory.for(entityConstructor, _extends({}, context.organizationId === undefined ? {} : {
@@ -167,13 +109,9 @@ const invalid = (error, label, code)=>{
167
109
  catch: (error)=>error
168
110
  });
169
111
  });
170
- const notFound = HttpServerResponse.json({
171
- message: 'not found',
172
- code: 'notFound',
112
+ const notFound = Effect.fail(new NotFound({
173
113
  entity: extractMetaEntity(entityConstructor).key
174
- }, {
175
- status: 404
176
- });
114
+ }));
177
115
  const list = (context)=>Effect.gen(function*() {
178
116
  var _parsed_filtering;
179
117
  const parsed = yield* readLoadRequest;
@@ -198,7 +136,7 @@ const invalid = (error, label, code)=>{
198
136
  });
199
137
  const page = yield* loadUCFactory().pipe(Effect.provideService(EntityRepositoryTag, (yield* repository(context))), Effect.provideService(EntityLoadRequestTag, request));
200
138
  return yield* HttpServerResponse.json(makeEntityPageEnvelope(entityConstructor, page, links.collection()));
201
- }).pipe(Effect.catch((error)=>invalid(error, 'invalid query', 'invalidQuery')));
139
+ }).pipe(Effect.catch(legacyAnswer('invalidQuery')));
202
140
  const get = (context)=>Effect.gen(function*() {
203
141
  const params = yield* HttpRouter.params;
204
142
  const scope = yield* scopeOf(context);
@@ -210,7 +148,7 @@ const invalid = (error, label, code)=>{
210
148
  return yield* notFound;
211
149
  }
212
150
  return yield* HttpServerResponse.json(makeEntityEnvelope(entityConstructor, found.value, links.record(params.id)));
213
- }).pipe(Effect.catch(routeFailure));
151
+ }).pipe(Effect.catch(legacyAnswer('invalid')));
214
152
  const save = (verb)=>(context)=>Effect.gen(function*() {
215
153
  const request = yield* HttpServerRequest.HttpServerRequest;
216
154
  const body = yield* request.json;
@@ -228,7 +166,7 @@ const invalid = (error, label, code)=>{
228
166
  }
229
167
  const saved = yield* saveUCFactory().pipe(Effect.provideService(EntityRepositoryTag, (yield* repository(context))), Effect.provideService(EntityTag, entity));
230
168
  return yield* HttpServerResponse.json(makeEntityEnvelope(entityConstructor, saved, links.record(saved.id)));
231
- }).pipe(Effect.catch((error)=>invalid(error, 'invalid request body', 'invalidBody')));
169
+ }).pipe(Effect.catch(legacyAnswer('invalidBody')));
232
170
  /**
233
171
  * Answers with an envelope rather than a bare `204`: every message between
234
172
  * entifix artifacts is one, and the fetch client parses every response as
@@ -246,7 +184,7 @@ const invalid = (error, label, code)=>{
246
184
  id: params.id
247
185
  }
248
186
  });
249
- }).pipe(Effect.catch(routeFailure));
187
+ }).pipe(Effect.catch(legacyAnswer('invalid')));
250
188
  const generic = (verb, context)=>{
251
189
  switch(verb){
252
190
  case 'list':
@@ -265,11 +203,14 @@ const invalid = (error, label, code)=>{
265
203
  * Each guard asks for different services, so the branches do not unify on
266
204
  * their own; the cast states the union every guard this set can reach
267
205
  * actually needs.
268
- */ const handlerFor = (verb)=>guarded(verb, (context)=>{
206
+ */ const handlerFor = (verb)=>{
207
+ var _options_policy;
208
+ return respond(guarded(verb, (context)=>{
269
209
  var _options_override;
270
210
  const override = (_options_override = options.override) == null ? void 0 : _options_override[verb];
271
211
  return override ? override(context, links) : generic(verb, context);
272
- });
212
+ }).pipe(Effect.catch(legacyAnswer('invalid'))), (_options_policy = options.policy) != null ? _options_policy : defaultFailurePolicy);
213
+ };
273
214
  const ROUTES = {
274
215
  list: {
275
216
  method: 'GET',
@@ -304,7 +245,8 @@ const invalid = (error, label, code)=>{
304
245
  * `makeEntityRoutes.for<R>()(Entity, options)` names `R` and still infers the
305
246
  * entity and the guard from the value, which a single call with explicit type
306
247
  * arguments cannot do.
307
- */ export const makeEntityRoutes = Object.assign(deriveEntityRoutes, {
248
+ */ export { routeFailure };
249
+ export const makeEntityRoutes = Object.assign(deriveEntityRoutes, {
308
250
  for: ()=>(entityConstructor, options)=>deriveEntityRoutes(entityConstructor, options)
309
251
  });
310
252