@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.
- package/README.md +7 -7
- package/dist/auth/failures.d.ts +23 -0
- package/dist/auth/failures.d.ts.map +1 -0
- package/dist/auth/failures.js +20 -0
- package/dist/auth/failures.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/require-organization.d.ts +1 -2
- package/dist/auth/require-organization.d.ts.map +1 -1
- package/dist/auth/require-organization.js +2 -7
- package/dist/auth/require-organization.js.map +1 -1
- package/dist/auth/require-principal.d.ts +3 -3
- package/dist/auth/require-principal.d.ts.map +1 -1
- package/dist/auth/require-principal.js +6 -14
- package/dist/auth/require-principal.js.map +1 -1
- package/dist/auth/require-service-crossing.d.ts +3 -3
- package/dist/auth/require-service-crossing.d.ts.map +1 -1
- package/dist/auth/require-service-crossing.js +18 -21
- package/dist/auth/require-service-crossing.js.map +1 -1
- package/dist/auth/shared-token.d.ts +2 -2
- package/dist/auth/shared-token.d.ts.map +1 -1
- package/dist/auth/shared-token.js +7 -8
- package/dist/auth/shared-token.js.map +1 -1
- package/dist/entity-facet-routes.d.ts +38 -0
- package/dist/entity-facet-routes.d.ts.map +1 -0
- package/dist/entity-facet-routes.js +67 -0
- package/dist/entity-facet-routes.js.map +1 -0
- package/dist/entity-metadata-route.d.ts +1 -1
- package/dist/entity-metadata-route.d.ts.map +1 -1
- package/dist/entity-metadata-route.js +6 -9
- package/dist/entity-metadata-route.js.map +1 -1
- package/dist/entity-route-guard.d.ts +60 -0
- package/dist/entity-route-guard.d.ts.map +1 -0
- package/dist/entity-route-guard.js +29 -0
- package/dist/entity-route-guard.js.map +1 -0
- package/dist/entity-routes.d.ts +17 -61
- package/dist/entity-routes.d.ts.map +1 -1
- package/dist/entity-routes.js +19 -77
- package/dist/entity-routes.js.map +1 -1
- package/dist/health-routes.d.ts.map +1 -1
- package/dist/health-routes.js +3 -2
- package/dist/health-routes.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/make-service.d.ts.map +1 -1
- package/dist/make-service.js +4 -2
- package/dist/make-service.js.map +1 -1
- package/dist/respond.d.ts +92 -0
- package/dist/respond.d.ts.map +1 -0
- package/dist/respond.js +178 -0
- package/dist/respond.js.map +1 -0
- package/dist/route-responses.d.ts +44 -0
- package/dist/route-responses.d.ts.map +1 -0
- package/dist/route-responses.js +104 -0
- package/dist/route-responses.js.map +1 -0
- package/dist/service-description-route.d.ts +1 -1
- package/dist/service-description-route.d.ts.map +1 -1
- package/dist/service-description-route.js +2 -1
- package/dist/service-description-route.js.map +1 -1
- 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,
|
|
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,
|
|
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)=>
|
|
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
|
|
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"}
|
package/dist/entity-routes.d.ts
CHANGED
|
@@ -1,62 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
|
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":"
|
|
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"}
|
package/dist/entity-routes.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 =
|
|
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((
|
|
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(
|
|
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((
|
|
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(
|
|
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)=>
|
|
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
|
|
248
|
+
*/ export { routeFailure };
|
|
249
|
+
export const makeEntityRoutes = Object.assign(deriveEntityRoutes, {
|
|
308
250
|
for: ()=>(entityConstructor, options)=>deriveEntityRoutes(entityConstructor, options)
|
|
309
251
|
});
|
|
310
252
|
|