@entifix/service-shell 0.5.0 → 0.7.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/dist/auth/require-organization.d.ts +2 -4
- package/dist/auth/require-organization.d.ts.map +1 -1
- package/dist/auth/require-organization.js +6 -8
- package/dist/auth/require-organization.js.map +1 -1
- package/dist/auth/require-principal.d.ts +1 -1
- package/dist/auth/require-principal.js.map +1 -1
- package/dist/auth/require-service-crossing.d.ts +7 -9
- package/dist/auth/require-service-crossing.d.ts.map +1 -1
- package/dist/auth/require-service-crossing.js +7 -9
- package/dist/auth/require-service-crossing.js.map +1 -1
- package/dist/entity-metadata-route.d.ts +1 -2
- package/dist/entity-metadata-route.d.ts.map +1 -1
- package/dist/entity-metadata-route.js +1 -2
- package/dist/entity-metadata-route.js.map +1 -1
- package/dist/load-remote-configuration.d.ts +2 -2
- package/dist/load-remote-configuration.js +2 -2
- package/dist/load-remote-configuration.js.map +1 -1
- package/dist/make-service.d.ts +4 -4
- package/dist/make-service.js +2 -2
- package/dist/make-service.js.map +1 -1
- package/dist/service-description-route.d.ts +3 -3
- package/dist/service-description-route.js +1 -1
- package/dist/service-description-route.js.map +1 -1
- package/dist/sse.d.ts +2 -2
- package/dist/sse.js +1 -1
- package/dist/sse.js.map +1 -1
- package/package.json +6 -6
|
@@ -7,13 +7,11 @@ import { type RequestPrincipal } from './require-principal.js';
|
|
|
7
7
|
* require that the session names an organization.
|
|
8
8
|
*
|
|
9
9
|
* **This is the first of exactly two ways a request resolves the organization
|
|
10
|
-
* it acts for, and the only *user-facing* one
|
|
11
|
-
* ([ADR 0006](../../../../../../docs/adr/0006-multitenancy-planes-and-tenant-storage.md)).
|
|
10
|
+
* it acts for, and the only *user-facing* one**.
|
|
12
11
|
* The second is {@link requireServiceCrossing}: an explicit `organizationId`
|
|
13
12
|
* presented with a service token *and* a narrow route permission, for a
|
|
14
13
|
* platform-plane caller acting for an organization it was handed rather than one
|
|
15
|
-
* it picked
|
|
16
|
-
* ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
|
|
14
|
+
* it picked.
|
|
17
15
|
*
|
|
18
16
|
* There is no third path, no fallback and no operator branch. A request
|
|
19
17
|
* satisfying neither guard never reaches a tenant handle at all, which is what
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"require-organization.d.ts","sourceRoot":"","sources":["../../src/auth/require-organization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,wBAAwB,CAAC;AAkBhC
|
|
1
|
+
{"version":3,"file":"require-organization.d.ts","sourceRoot":"","sources":["../../src/auth/require-organization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,wBAAwB,CAAC;AAkBhC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,mBAAmB,GAC7B,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EACN,KAAK,CACH,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,gBAAgB,KACxB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,yQAa1B,CAAC"}
|
|
@@ -5,12 +5,12 @@ import { requirePermission } from './require-principal.js';
|
|
|
5
5
|
* `409` rather than `403`: the caller is authenticated and permitted, but the
|
|
6
6
|
* session names no organization, so there is no storage to read. An operator
|
|
7
7
|
* hits this by design — crossing into a tenant is an explicit, audited
|
|
8
|
-
* act-as-organization re-mint, never a widening of the default
|
|
8
|
+
* act-as-organization re-mint, never a widening of the default —
|
|
9
9
|
* and a member of several organizations hits it before choosing one. The `code`
|
|
10
10
|
* is a key in the shared `errors` catalog, so a client renders "select an
|
|
11
|
-
* organization" instead of "forbidden" — a claim
|
|
12
|
-
*
|
|
13
|
-
* as its own literal text.
|
|
11
|
+
* organization" instead of "forbidden" — a claim a host has to check its
|
|
12
|
+
* catalog against, because for a while it was simply false and the code
|
|
13
|
+
* reached the browser as its own literal text.
|
|
14
14
|
*/ const noOrganization = HttpServerResponse.json({
|
|
15
15
|
error: 'no active organization',
|
|
16
16
|
code: 'noActiveOrganization'
|
|
@@ -22,13 +22,11 @@ import { requirePermission } from './require-principal.js';
|
|
|
22
22
|
* require that the session names an organization.
|
|
23
23
|
*
|
|
24
24
|
* **This is the first of exactly two ways a request resolves the organization
|
|
25
|
-
* it acts for, and the only *user-facing* one
|
|
26
|
-
* ([ADR 0006](../../../../../../docs/adr/0006-multitenancy-planes-and-tenant-storage.md)).
|
|
25
|
+
* it acts for, and the only *user-facing* one**.
|
|
27
26
|
* The second is {@link requireServiceCrossing}: an explicit `organizationId`
|
|
28
27
|
* presented with a service token *and* a narrow route permission, for a
|
|
29
28
|
* platform-plane caller acting for an organization it was handed rather than one
|
|
30
|
-
* it picked
|
|
31
|
-
* ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
|
|
29
|
+
* it picked.
|
|
32
30
|
*
|
|
33
31
|
* There is no third path, no fallback and no operator branch. A request
|
|
34
32
|
* satisfying neither guard never reaches a tenant handle at all, which is what
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/require-organization.ts"],"sourcesContent":["import type { Permission } from '@entifix/authz';\nimport { Effect } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\nimport {\n type RequestPrincipal,\n requirePermission,\n} from './require-principal.js';\n\n/**\n * `409` rather than `403`: the caller is authenticated and permitted, but the\n * session names no organization, so there is no storage to read. An operator\n * hits this by design — crossing into a tenant is an explicit, audited\n * act-as-organization re-mint, never a widening of the default
|
|
1
|
+
{"version":3,"sources":["../../src/auth/require-organization.ts"],"sourcesContent":["import type { Permission } from '@entifix/authz';\nimport { Effect } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\nimport {\n type RequestPrincipal,\n requirePermission,\n} from './require-principal.js';\n\n/**\n * `409` rather than `403`: the caller is authenticated and permitted, but the\n * session names no organization, so there is no storage to read. An operator\n * hits this by design — crossing into a tenant is an explicit, audited\n * act-as-organization re-mint, never a widening of the default —\n * and a member of several organizations hits it before choosing one. The `code`\n * is a key in the shared `errors` catalog, so a client renders \"select an\n * organization\" instead of \"forbidden\" — a claim a host has to check its\n * catalog against, because for a while it was simply false and the code\n * reached the browser as its own literal text.\n */\nconst noOrganization = HttpServerResponse.json(\n { error: 'no active organization', code: 'noActiveOrganization' },\n { status: 409 },\n);\n\n/**\n * Guard a **tenant-plane** handler: authenticate, check the permission, and\n * require that the session names an organization.\n *\n * **This is the first of exactly two ways a request resolves the organization\n * it acts for, and the only *user-facing* one**.\n * The second is {@link requireServiceCrossing}: an explicit `organizationId`\n * presented with a service token *and* a narrow route permission, for a\n * platform-plane caller acting for an organization it was handed rather than one\n * it picked.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard never reaches a tenant handle at all, which is what\n * keeps the most sensitive capability in the system from being the *absence* of\n * a condition.\n *\n * The organization is handed to the handler so it can resolve its own storage\n * through a `TenantDatabaseResolver`. Resolution happens **inside the request**\n * — the connection pool is a boot-time `Layer`, and a per-request `Layer` would\n * rebuild the pool per request.\n *\n * Here the value comes from the verified token and nowhere else. A route\n * parameter or a body field naming an organization would be caller-controlled,\n * which is the whole failure this guard exists to prevent — and is exactly why\n * the other path honours a caller-named organization only *after* the caller has\n * proved it is the fleet.\n */\nexport const requireOrganization =\n (permission: Permission) =>\n <A, E, R>(\n use: (\n organizationId: string,\n principal: RequestPrincipal,\n ) => Effect.Effect<A, E, R>,\n ) =>\n requirePermission(permission)(principal =>\n // `Effect.gen` rather than a ternary: the two branches have different\n // success types, and a conditional expression would unify them against\n // the first one instead of widening. `requirePrincipal` yields for the\n // same reason.\n Effect.gen(function* () {\n if (principal.organizationId === undefined) {\n return yield* noOrganization;\n }\n return yield* use(principal.organizationId, principal);\n }),\n );\n"],"names":["Effect","HttpServerResponse","requirePermission","noOrganization","json","error","code","status","requireOrganization","permission","use","principal","gen","organizationId","undefined"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAChC,SAASC,kBAAkB,QAAQ,cAAc;AAEjD,SAEEC,iBAAiB,QACZ,yBAAyB;AAEhC;;;;;;;;;;CAUC,GACD,MAAMC,iBAAiBF,mBAAmBG,IAAI,CAC5C;IAAEC,OAAO;IAA0BC,MAAM;AAAuB,GAChE;IAAEC,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMC,sBACX,CAACC,aACD,CACEC,MAKAR,kBAAkBO,YAAYE,CAAAA,YAC5B,sEAAsE;YACtE,uEAAuE;YACvE,uEAAuE;YACvE,eAAe;YACfX,OAAOY,GAAG,CAAC;gBACT,IAAID,UAAUE,cAAc,KAAKC,WAAW;oBAC1C,OAAO,OAAOX;gBAChB;gBACA,OAAO,OAAOO,IAAIC,UAAUE,cAAc,EAAEF;YAC9C,IACA"}
|
|
@@ -44,7 +44,7 @@ export interface RequestPrincipal {
|
|
|
44
44
|
* Every REST route is already bounded by it implicitly — verification is
|
|
45
45
|
* stateless, so the next call after expiry simply fails. A **held-open**
|
|
46
46
|
* response has no next call, so `GET /api/transaction/events` reads it and
|
|
47
|
-
* closes the connection there
|
|
47
|
+
* closes the connection there. Absent when the token carries no
|
|
48
48
|
* `exp`, which a signed one always does; the guard is for the type, not for a
|
|
49
49
|
* case the fleet produces.
|
|
50
50
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/require-principal.ts"],"sourcesContent":["import {\n parsePermission,\n type Permission,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport { type TokenClaims, TokenServiceTag } from '@entifix/business';\nimport { ACCESS_COOKIE } from '@entifix/core';\nimport { Effect, Option } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The authenticated subject as a service sees it. Structurally identical to\n * `business-ts-authn`'s `Principal`, re-declared here so the shared shell does\n * not depend on a single domain package.\n */\nexport interface RequestPrincipal {\n readonly userId: TokenClaims['userId'];\n readonly subject: string;\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this request acts for — the storage routing key, absent\n * for a caller with no tenant scope (a buyer, or an operator). Kept out of\n * `attributes` deliberately: attributes are policy inputs, this decides which\n * database a handler reads.\n */\n readonly organizationId?: string;\n /**\n * Which population the caller belongs to — buyer, vendor staff, or platform\n * staff. Carried so a handler never has to infer it from an absent\n * `organizationId`, which a buyer and an operator share.\n *\n * Context, not a grant: what the caller may *do* still comes from `roles`\n * through {@link PolicyDecisionTag}.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id, absent for an account\n * with no party record.\n *\n * Same rule as the two fields above: it comes from the verified token and\n * never from anything the caller sets on the request. It says *whose* records\n * these are, which is what a route scoping a read to its caller needs; it\n * says nothing about what may be done with them.\n */\n readonly partyId?: string;\n /**\n * When the verified token stops being valid, as a Unix timestamp in seconds.\n *\n * Every REST route is already bounded by it implicitly — verification is\n * stateless, so the next call after expiry simply fails. A **held-open**\n * response has no next call, so `GET /api/transaction/events` reads it and\n * closes the connection there
|
|
1
|
+
{"version":3,"sources":["../../src/auth/require-principal.ts"],"sourcesContent":["import {\n parsePermission,\n type Permission,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport { type TokenClaims, TokenServiceTag } from '@entifix/business';\nimport { ACCESS_COOKIE } from '@entifix/core';\nimport { Effect, Option } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The authenticated subject as a service sees it. Structurally identical to\n * `business-ts-authn`'s `Principal`, re-declared here so the shared shell does\n * not depend on a single domain package.\n */\nexport interface RequestPrincipal {\n readonly userId: TokenClaims['userId'];\n readonly subject: string;\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this request acts for — the storage routing key, absent\n * for a caller with no tenant scope (a buyer, or an operator). Kept out of\n * `attributes` deliberately: attributes are policy inputs, this decides which\n * database a handler reads.\n */\n readonly organizationId?: string;\n /**\n * Which population the caller belongs to — buyer, vendor staff, or platform\n * staff. Carried so a handler never has to infer it from an absent\n * `organizationId`, which a buyer and an operator share.\n *\n * Context, not a grant: what the caller may *do* still comes from `roles`\n * through {@link PolicyDecisionTag}.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id, absent for an account\n * with no party record.\n *\n * Same rule as the two fields above: it comes from the verified token and\n * never from anything the caller sets on the request. It says *whose* records\n * these are, which is what a route scoping a read to its caller needs; it\n * says nothing about what may be done with them.\n */\n readonly partyId?: string;\n /**\n * When the verified token stops being valid, as a Unix timestamp in seconds.\n *\n * Every REST route is already bounded by it implicitly — verification is\n * stateless, so the next call after expiry simply fails. A **held-open**\n * response has no next call, so `GET /api/transaction/events` reads it and\n * closes the connection there. Absent when the token carries no\n * `exp`, which a signed one always does; the guard is for the type, not for a\n * case the fleet produces.\n */\n readonly expiresAt?: number;\n readonly attributes: Readonly<Record<string, unknown>>;\n}\n\n/** Extract a bearer token from an `Authorization` header, if present. */\nconst bearerToken = (authorization: string | undefined): string | undefined => {\n if (authorization === undefined) {\n return undefined;\n }\n const [scheme, value] = authorization.split(' ');\n return scheme?.toLowerCase() === 'bearer' ? value : undefined;\n};\n\n/** Rebuild the request principal from verified token claims. */\nconst claimsToPrincipal = (claims: TokenClaims): RequestPrincipal => ({\n userId: claims.userId,\n subject: claims.subject,\n sessionId: claims.sessionId,\n roles: claims.roles,\n // The organization comes from the *verified* token and nowhere else — never\n // from a route parameter, a query string or a body, all of which the caller\n // controls. That is what makes tenant isolation a property of the session\n // rather than of every handler remembering to check.\n organizationId: claims.activeOrganizationId,\n // Same rule, same reason: it comes from the verified token, never from\n // anything the caller can set on the request.\n partyRole: claims.partyRole,\n partyId: claims.partyId,\n // `TokenClaims` carries an index signature, so `exp` arrives as `unknown`\n // even though jose always sets it — hence the guard rather than a cast.\n expiresAt: typeof claims.exp === 'number' ? claims.exp : undefined,\n // Rich/volatile attributes are not in the token; a handler that needs them\n // reads the session store by `sessionId`.\n attributes: {},\n});\n\n// `code` is a key in the shared `errors` catalog; `error` stays English for\n// logs. `permission` is deliberately still in the 403 — it says which grant was\n// missing, which is diagnostic, not a leak.\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\n/**\n * Resolve the caller's principal from the request, or `None`. Reads the token\n * from the `entifix_at` cookie (the Next app forwards it) or an\n * `Authorization: Bearer` header and verifies it statelessly via\n * {@link TokenServiceTag} — no store round trip on the hot path.\n */\nconst resolvePrincipal = Effect.gen(function* () {\n const req = yield* HttpServerRequest.HttpServerRequest;\n const token =\n req.cookies[ACCESS_COOKIE] ?? bearerToken(req.headers['authorization']);\n if (token === undefined) {\n return Option.none<RequestPrincipal>();\n }\n\n const tokens = yield* TokenServiceTag;\n const claims = yield* tokens.verify(token).pipe(Effect.option);\n return Option.map(claims, claimsToPrincipal);\n});\n\n/**\n * Guard a handler behind a valid access token — authentication only. Any\n * missing or invalid token is a `401`; the wrapped handler's own errors are\n * left intact.\n */\nexport const requirePrincipal = <A, E, R>(\n use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>,\n) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n return yield* use(principal.value);\n });\n\n/**\n * Guard a handler behind a permission. Authenticates as above, then asks\n * {@link PolicyDecisionTag} whether this subject may perform the action — so\n * the two failure modes stay distinguishable: `401` means \"sign in\", `403`\n * means \"signed in, not allowed\".\n *\n * **This is the authorization boundary.** Filtering a nav menu or hiding a page\n * is presentation; a route that skips this guard is open regardless.\n */\nexport const requirePermission =\n (permission: Permission) =>\n <A, E, R>(use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n\n const { resource, action } = parsePermission(permission);\n const policy = yield* PolicyDecisionTag;\n const allowed = policy.decide({\n subject: { roles: principal.value.roles },\n resource,\n action,\n });\n if (!allowed) {\n return yield* forbidden(permission);\n }\n\n return yield* use(principal.value);\n });\n"],"names":["parsePermission","PolicyDecisionTag","TokenServiceTag","ACCESS_COOKIE","Effect","Option","HttpServerRequest","HttpServerResponse","bearerToken","authorization","undefined","scheme","value","split","toLowerCase","claimsToPrincipal","claims","userId","subject","sessionId","roles","organizationId","activeOrganizationId","partyRole","partyId","expiresAt","exp","attributes","unauthenticated","json","error","code","status","forbidden","permission","resolvePrincipal","gen","req","token","cookies","headers","none","tokens","verify","pipe","option","map","requirePrincipal","use","principal","isNone","requirePermission","resource","action","policy","allowed","decide"],"mappings":"AAAA,SACEA,eAAe,EAEfC,iBAAiB,QACZ,iBAAiB;AACxB,SAA2BC,eAAe,QAAQ,oBAAoB;AACtE,SAASC,aAAa,QAAQ,gBAAgB;AAC9C,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AACxC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAoDpE,uEAAuE,GACvE,MAAMC,cAAc,CAACC;IACnB,IAAIA,kBAAkBC,WAAW;QAC/B,OAAOA;IACT;IACA,MAAM,CAACC,QAAQC,MAAM,GAAGH,cAAcI,KAAK,CAAC;IAC5C,OAAOF,CAAAA,0BAAAA,OAAQG,WAAW,QAAO,WAAWF,QAAQF;AACtD;AAEA,8DAA8D,GAC9D,MAAMK,oBAAoB,CAACC,SAA2C,CAAA;QACpEC,QAAQD,OAAOC,MAAM;QACrBC,SAASF,OAAOE,OAAO;QACvBC,WAAWH,OAAOG,SAAS;QAC3BC,OAAOJ,OAAOI,KAAK;QACnB,4EAA4E;QAC5E,4EAA4E;QAC5E,0EAA0E;QAC1E,qDAAqD;QACrDC,gBAAgBL,OAAOM,oBAAoB;QAC3C,uEAAuE;QACvE,8CAA8C;QAC9CC,WAAWP,OAAOO,SAAS;QAC3BC,SAASR,OAAOQ,OAAO;QACvB,0EAA0E;QAC1E,wEAAwE;QACxEC,WAAW,OAAOT,OAAOU,GAAG,KAAK,WAAWV,OAAOU,GAAG,GAAGhB;QACzD,2EAA2E;QAC3E,0CAA0C;QAC1CiB,YAAY,CAAC;IACf,CAAA;AAEA,4EAA4E;AAC5E,gFAAgF;AAChF,4CAA4C;AAC5C,MAAMC,kBAAkBrB,mBAAmBsB,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjB3B,mBAAmBsB,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB;;;;;CAKC,GACD,MAAMG,mBAAmB/B,OAAOgC,GAAG,CAAC;QAGhCC;IAFF,MAAMA,MAAM,OAAO/B,kBAAkBA,iBAAiB;IACtD,MAAMgC,SACJD,6BAAAA,IAAIE,OAAO,CAACpC,cAAc,YAA1BkC,6BAA8B7B,YAAY6B,IAAIG,OAAO,CAAC,gBAAgB;IACxE,IAAIF,UAAU5B,WAAW;QACvB,OAAOL,OAAOoC,IAAI;IACpB;IAEA,MAAMC,SAAS,OAAOxC;IACtB,MAAMc,SAAS,OAAO0B,OAAOC,MAAM,CAACL,OAAOM,IAAI,CAACxC,OAAOyC,MAAM;IAC7D,OAAOxC,OAAOyC,GAAG,CAAC9B,QAAQD;AAC5B;AAEA;;;;CAIC,GACD,OAAO,MAAMgC,mBAAmB,CAC9BC,MAEA5C,OAAOgC,GAAG,CAAC;QACT,MAAMa,YAAY,OAAOd;QACzB,IAAI9B,OAAO6C,MAAM,CAACD,YAAY;YAC5B,OAAO,OAAOrB;QAChB;QACA,OAAO,OAAOoB,IAAIC,UAAUrC,KAAK;IACnC,GAAG;AAEL;;;;;;;;CAQC,GACD,OAAO,MAAMuC,oBACX,CAACjB,aACD,CAAUc,MACR5C,OAAOgC,GAAG,CAAC;YACT,MAAMa,YAAY,OAAOd;YACzB,IAAI9B,OAAO6C,MAAM,CAACD,YAAY;gBAC5B,OAAO,OAAOrB;YAChB;YAEA,MAAM,EAAEwB,QAAQ,EAAEC,MAAM,EAAE,GAAGrD,gBAAgBkC;YAC7C,MAAMoB,SAAS,OAAOrD;YACtB,MAAMsD,UAAUD,OAAOE,MAAM,CAAC;gBAC5BtC,SAAS;oBAAEE,OAAO6B,UAAUrC,KAAK,CAACQ,KAAK;gBAAC;gBACxCgC;gBACAC;YACF;YACA,IAAI,CAACE,SAAS;gBACZ,OAAO,OAAOtB,UAAUC;YAC1B;YAEA,OAAO,OAAOc,IAAIC,UAAUrC,KAAK;QACnC,GAAG"}
|
|
@@ -9,8 +9,7 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
|
|
|
9
9
|
* configuration *read*, this one a tenant-data *write* for any organization the
|
|
10
10
|
* caller names. Sharing the header name is how a proxy configured to forward the
|
|
11
11
|
* fleet token ends up presenting it to a route that writes stock, and how a
|
|
12
|
-
* copy-pasted value looks right while being the wrong secret entirely
|
|
13
|
-
* ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
|
|
12
|
+
* copy-pasted value looks right while being the wrong secret entirely.
|
|
14
13
|
*/
|
|
15
14
|
export declare const CROSSING_TOKEN_HEADER = "x-crossing-token";
|
|
16
15
|
/**
|
|
@@ -52,11 +51,10 @@ export declare class ServiceCrossingTokenTag extends ServiceCrossingTokenTag_bas
|
|
|
52
51
|
* be a far worse breach than the problem it solved.
|
|
53
52
|
*
|
|
54
53
|
* There is no third path, no fallback and no operator branch. A request
|
|
55
|
-
* satisfying neither guard simply never reaches a tenant handle
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* record).
|
|
54
|
+
* satisfying neither guard simply never reaches a tenant handle. An operator
|
|
55
|
+
* reaching into a tenant is a *different* mechanism — a person choosing an
|
|
56
|
+
* organization, which needs a human's permission, a time box and a record of
|
|
57
|
+
* the crossing — and does not belong in this guard.
|
|
60
58
|
*
|
|
61
59
|
* ⚠️ **This guard reads no cookie and no `Authorization` header, and that is
|
|
62
60
|
* the design.** A route guarded here accepts the crossing token and nothing
|
|
@@ -74,7 +72,7 @@ export declare class ServiceCrossingTokenTag extends ServiceCrossingTokenTag_bas
|
|
|
74
72
|
* fleet membership is not a capability;
|
|
75
73
|
* 3. the organization header, non-blank → `400`.
|
|
76
74
|
*
|
|
77
|
-
* **The
|
|
75
|
+
* **The residual, stated rather than argued away**: a shared secret
|
|
78
76
|
* means any process holding it can name any organization. What bounds it is the
|
|
79
77
|
* permission above, the fact that only the slices needing it are configured with
|
|
80
78
|
* the key, and the request log. What would remove it is an RS256 service token
|
|
@@ -96,7 +94,7 @@ export declare const requireServiceCrossing: (permission: Permission) => <A, E,
|
|
|
96
94
|
* and **no session is accepted**. A saga step is not a person's act — the buyer
|
|
97
95
|
* behind a checkout holds no grant over the order the coordinator is writing on
|
|
98
96
|
* their behalf, and accepting their session here would make the weaker
|
|
99
|
-
* credential the security level
|
|
97
|
+
* credential the security level.
|
|
100
98
|
*
|
|
101
99
|
* The recorded residual carries over verbatim: a shared secret means any process
|
|
102
100
|
* holding it can act, bounded by the permission and by which slices are
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"require-service-crossing.d.ts","sourceRoot":"","sources":["../../src/auth/require-service-crossing.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE
|
|
1
|
+
{"version":3,"file":"require-service-crossing.d.ts","sourceRoot":"","sources":["../../src/auth/require-service-crossing.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;;AAEvD;;;;;;;;GAQG;AACH,qBAAa,uBAAwB,SAAQ,4BAGf;CAAG;AAgCjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,sBAAsB,GAChC,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,cAAc,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6MAwB7D,CAAC;AAEP;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,eAAe,GACzB,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6MAenC,CAAC"}
|
|
@@ -10,8 +10,7 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
|
|
|
10
10
|
* configuration *read*, this one a tenant-data *write* for any organization the
|
|
11
11
|
* caller names. Sharing the header name is how a proxy configured to forward the
|
|
12
12
|
* fleet token ends up presenting it to a route that writes stock, and how a
|
|
13
|
-
* copy-pasted value looks right while being the wrong secret entirely
|
|
14
|
-
* ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).
|
|
13
|
+
* copy-pasted value looks right while being the wrong secret entirely.
|
|
15
14
|
*/ export const CROSSING_TOKEN_HEADER = 'x-crossing-token';
|
|
16
15
|
/**
|
|
17
16
|
* The header naming the organization the caller is acting for.
|
|
@@ -77,11 +76,10 @@ const organizationRequired = HttpServerResponse.json({
|
|
|
77
76
|
* be a far worse breach than the problem it solved.
|
|
78
77
|
*
|
|
79
78
|
* There is no third path, no fallback and no operator branch. A request
|
|
80
|
-
* satisfying neither guard simply never reaches a tenant handle
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* record).
|
|
79
|
+
* satisfying neither guard simply never reaches a tenant handle. An operator
|
|
80
|
+
* reaching into a tenant is a *different* mechanism — a person choosing an
|
|
81
|
+
* organization, which needs a human's permission, a time box and a record of
|
|
82
|
+
* the crossing — and does not belong in this guard.
|
|
85
83
|
*
|
|
86
84
|
* ⚠️ **This guard reads no cookie and no `Authorization` header, and that is
|
|
87
85
|
* the design.** A route guarded here accepts the crossing token and nothing
|
|
@@ -99,7 +97,7 @@ const organizationRequired = HttpServerResponse.json({
|
|
|
99
97
|
* fleet membership is not a capability;
|
|
100
98
|
* 3. the organization header, non-blank → `400`.
|
|
101
99
|
*
|
|
102
|
-
* **The
|
|
100
|
+
* **The residual, stated rather than argued away**: a shared secret
|
|
103
101
|
* means any process holding it can name any organization. What bounds it is the
|
|
104
102
|
* permission above, the fact that only the slices needing it are configured with
|
|
105
103
|
* the key, and the request log. What would remove it is an RS256 service token
|
|
@@ -140,7 +138,7 @@ const organizationRequired = HttpServerResponse.json({
|
|
|
140
138
|
* and **no session is accepted**. A saga step is not a person's act — the buyer
|
|
141
139
|
* behind a checkout holds no grant over the order the coordinator is writing on
|
|
142
140
|
* their behalf, and accepting their session here would make the weaker
|
|
143
|
-
* credential the security level
|
|
141
|
+
* credential the security level.
|
|
144
142
|
*
|
|
145
143
|
* The recorded residual carries over verbatim: a shared secret means any process
|
|
146
144
|
* holding it can act, bounded by the permission and by which slices are
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/require-service-crossing.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { type Permission, ServiceCrossingPolicyTag } from '@entifix/authz';\nimport { Context, Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The header carrying the crossing secret.\n *\n * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's\n * fleet lookup, and the two secrets are not comparable: one grants a\n * configuration *read*, this one a tenant-data *write* for any organization the\n * caller names. Sharing the header name is how a proxy configured to forward the\n * fleet token ends up presenting it to a route that writes stock, and how a\n * copy-pasted value looks right while being the wrong secret entirely\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).\n */\nexport const CROSSING_TOKEN_HEADER = 'x-crossing-token';\n\n/**\n * The header naming the organization the caller is acting for.\n *\n * It rides beside the token rather than inside the request body because\n * entities are organization-agnostic by rule — no `organizationId` member, no\n * tenant filter — so there is nowhere in an entity envelope for it to live. A\n * header also serves the crossings that carry no body at all (releasing a hold,\n * converting one), which a body field would have needed a second convention for.\n *\n * On its own it grants **nothing**: it is only read after the token has proved\n * the caller is the fleet, so the input the caller controls is never the input\n * that authorizes.\n */\nexport const ORGANIZATION_HEADER = 'x-organization-id';\n\n/**\n * The crossing secret this service expects, provided at the composition root\n * from its own configuration.\n *\n * A `Context.Tag` rather than an environment read, which is the difference from\n * {@link requireServiceToken}: the value is a per-service secret held in\n * config-service as an `is_secret` row, so it arrives through the same boot\n * fetch as `mongo.uri` and can be rotated without redeploying a container.\n */\nexport class ServiceCrossingTokenTag extends Context.Service<\n ServiceCrossingTokenTag,\n string\n>()('ServiceCrossingTokenTag') {}\n\n/** Constant-time comparison, so the secret cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined, expected: string): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(expected);\n // `timingSafeEqual` throws on a length mismatch, so lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\nconst organizationRequired = HttpServerResponse.json(\n {\n error: 'the crossing must name an organization',\n code: 'invalidRequest',\n detail: `${ORGANIZATION_HEADER} is required`,\n },\n { status: 400 },\n);\n\n/**\n * Guard a **service-to-service** tenant crossing: the second — and only other —\n * way a request resolves the organization it acts for.\n *\n * The first is {@link requireOrganization}, which reads the session's\n * `activeOrganizationId` and is the only *user-facing* resolution. This one\n * exists because a platform-plane caller sometimes acts for an organization the\n * principal is not a member of: checkout reserving a vendor's stock, where the\n * organization comes from the **item** and a buyer's session carries none — and\n * never will, since making a buyer a member of every vendor they buy from would\n * be a far worse breach than the problem it solved.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard simply never reaches a tenant handle\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);\n * ADR 0012's operator crossing is a *different* mechanism — a person choosing an\n * organization, which needs a human's permission, a time box and a `Crossing`\n * record).\n *\n * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is\n * the design.** A route guarded here accepts the crossing token and nothing\n * else: two accepted credentials on one route means the weaker one is the\n * security level. It matters concretely — `super-admin` holds `*:*:*`, which\n * matches every crossing permission, so a route that also took a session would\n * be reachable by an operator's browser tab.\n *\n * Three checks, in this order, so nothing is revealed before fleet membership is\n * proven:\n *\n * 1. the token, compared in constant time → `401`;\n * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token\n * proves the caller is the fleet; it does not say what the caller may do, and\n * fleet membership is not a capability;\n * 3. the organization header, non-blank → `400`.\n *\n * **The recorded residual** (ADR 0023, not argued away here): a shared secret\n * means any process holding it can name any organization. What bounds it is the\n * permission above, the fact that only the slices needing it are configured with\n * the key, and the request log. What would remove it is an RS256 service token\n * carrying a scope claim, which is named work rather than a gap nobody noticed.\n */\nexport const requireServiceCrossing =\n (permission: Permission) =>\n <A, E, R>(use: (organizationId: string) => Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out, for the reason\n // `requireServiceToken` records: `HttpServerResponse.json` contributes an\n // `HttpBodyError` to the failure channel, and spelling the type out while\n // omitting it is what makes the declaration emit fail invisibly.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n const organizationId = request.headers[ORGANIZATION_HEADER]?.trim();\n if (organizationId === undefined || organizationId === '') {\n return yield* organizationRequired;\n }\n\n return yield* use(organizationId);\n });\n\n/**\n * The same fleet check, for a participant that has **no organization to name**.\n *\n * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`\n * above ends with an organization header because it is about to resolve a tenant\n * database handle from it, and a blank one there would read some other vendor's\n * storage. order-service names one database at boot, so there is no handle to\n * choose — and demanding a header nothing consumes would be theatre that a\n * caller learns to satisfy with any value.\n *\n * What is unchanged, and is the part that matters: the token proves the caller\n * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,\n * and **no session is accepted**. A saga step is not a person's act — the buyer\n * behind a checkout holds no grant over the order the coordinator is writing on\n * their behalf, and accepting their session here would make the weaker\n * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).\n *\n * The recorded residual carries over verbatim: a shared secret means any process\n * holding it can act, bounded by the permission and by which slices are\n * configured with the key.\n */\nexport const requireCrossing =\n (permission: Permission) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","ServiceCrossingPolicyTag","Context","Effect","HttpServerRequest","HttpServerResponse","CROSSING_TOKEN_HEADER","ORGANIZATION_HEADER","ServiceCrossingTokenTag","Service","matches","provided","expected","undefined","left","Buffer","from","right","length","unauthenticated","json","error","code","status","forbidden","permission","organizationRequired","detail","requireServiceCrossing","use","gen","request","headers","crossing","allows","organizationId","trim","requireCrossing","route"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAA0BC,wBAAwB,QAAQ,iBAAiB;AAC3E,SAASC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AACzC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,wBAAwB,mBAAmB;AAExD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,sBAAsB,oBAAoB;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgCN,QAAQO,OAAO,GAGxD;AAA4B;AAEhC,8EAA8E,GAC9E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,yEAAyE;IACzE,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIlB,gBAAgBc,MAAMG;AAC/D;AAEA,MAAME,kBAAkBd,mBAAmBe,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBpB,mBAAmBe,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB,MAAMG,uBAAuBrB,mBAAmBe,IAAI,CAClD;IACEC,OAAO;IACPC,MAAM;IACNK,QAAQ,GAAGpB,oBAAoB,YAAY,CAAC;AAC9C,GACA;IAAEgB,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GACD,OAAO,MAAMK,yBACX,CAACH,aACD,CAAUI,MACR,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjE1B,OAAO2B,GAAG,CAAC;gBAacC;YAZvB,MAAMA,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,MAAMU,kBAAiBJ,uCAAAA,QAAQC,OAAO,CAACzB,oBAAoB,qBAApCwB,qCAAsCK,IAAI;YACjE,IAAID,mBAAmBtB,aAAasB,mBAAmB,IAAI;gBACzD,OAAO,OAAOT;YAChB;YAEA,OAAO,OAAOG,IAAIM;QACpB,GAAG;AAEP;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAME,kBACX,CAACZ,aACD,CAAUa,QACRnC,OAAO2B,GAAG,CAAC;YACT,MAAMC,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,OAAO,OAAOa;QAChB,GAAG"}
|
|
1
|
+
{"version":3,"sources":["../../src/auth/require-service-crossing.ts"],"sourcesContent":["import { timingSafeEqual } from 'node:crypto';\n\nimport { type Permission, ServiceCrossingPolicyTag } from '@entifix/authz';\nimport { Context, Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\n/**\n * The header carrying the crossing secret.\n *\n * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's\n * fleet lookup, and the two secrets are not comparable: one grants a\n * configuration *read*, this one a tenant-data *write* for any organization the\n * caller names. Sharing the header name is how a proxy configured to forward the\n * fleet token ends up presenting it to a route that writes stock, and how a\n * copy-pasted value looks right while being the wrong secret entirely.\n */\nexport const CROSSING_TOKEN_HEADER = 'x-crossing-token';\n\n/**\n * The header naming the organization the caller is acting for.\n *\n * It rides beside the token rather than inside the request body because\n * entities are organization-agnostic by rule — no `organizationId` member, no\n * tenant filter — so there is nowhere in an entity envelope for it to live. A\n * header also serves the crossings that carry no body at all (releasing a hold,\n * converting one), which a body field would have needed a second convention for.\n *\n * On its own it grants **nothing**: it is only read after the token has proved\n * the caller is the fleet, so the input the caller controls is never the input\n * that authorizes.\n */\nexport const ORGANIZATION_HEADER = 'x-organization-id';\n\n/**\n * The crossing secret this service expects, provided at the composition root\n * from its own configuration.\n *\n * A `Context.Tag` rather than an environment read, which is the difference from\n * {@link requireServiceToken}: the value is a per-service secret held in\n * config-service as an `is_secret` row, so it arrives through the same boot\n * fetch as `mongo.uri` and can be rotated without redeploying a container.\n */\nexport class ServiceCrossingTokenTag extends Context.Service<\n ServiceCrossingTokenTag,\n string\n>()('ServiceCrossingTokenTag') {}\n\n/** Constant-time comparison, so the secret cannot be recovered byte by byte. */\nconst matches = (provided: string | undefined, expected: string): boolean => {\n if (provided === undefined) return false;\n const left = Buffer.from(provided);\n const right = Buffer.from(expected);\n // `timingSafeEqual` throws on a length mismatch, so lengths are compared\n // first — the length of a shared secret is not what protects it.\n return left.length === right.length && timingSafeEqual(left, right);\n};\n\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\nconst organizationRequired = HttpServerResponse.json(\n {\n error: 'the crossing must name an organization',\n code: 'invalidRequest',\n detail: `${ORGANIZATION_HEADER} is required`,\n },\n { status: 400 },\n);\n\n/**\n * Guard a **service-to-service** tenant crossing: the second — and only other —\n * way a request resolves the organization it acts for.\n *\n * The first is {@link requireOrganization}, which reads the session's\n * `activeOrganizationId` and is the only *user-facing* resolution. This one\n * exists because a platform-plane caller sometimes acts for an organization the\n * principal is not a member of: checkout reserving a vendor's stock, where the\n * organization comes from the **item** and a buyer's session carries none — and\n * never will, since making a buyer a member of every vendor they buy from would\n * be a far worse breach than the problem it solved.\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard simply never reaches a tenant handle. An operator\n * reaching into a tenant is a *different* mechanism — a person choosing an\n * organization, which needs a human's permission, a time box and a record of\n * the crossing — and does not belong in this guard.\n *\n * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is\n * the design.** A route guarded here accepts the crossing token and nothing\n * else: two accepted credentials on one route means the weaker one is the\n * security level. It matters concretely — `super-admin` holds `*:*:*`, which\n * matches every crossing permission, so a route that also took a session would\n * be reachable by an operator's browser tab.\n *\n * Three checks, in this order, so nothing is revealed before fleet membership is\n * proven:\n *\n * 1. the token, compared in constant time → `401`;\n * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token\n * proves the caller is the fleet; it does not say what the caller may do, and\n * fleet membership is not a capability;\n * 3. the organization header, non-blank → `400`.\n *\n * **The residual, stated rather than argued away**: a shared secret\n * means any process holding it can name any organization. What bounds it is the\n * permission above, the fact that only the slices needing it are configured with\n * the key, and the request log. What would remove it is an RS256 service token\n * carrying a scope claim, which is named work rather than a gap nobody noticed.\n */\nexport const requireServiceCrossing =\n (permission: Permission) =>\n <A, E, R>(use: (organizationId: string) => Effect.Effect<A, E, R>) =>\n // The return type is inferred rather than written out, for the reason\n // `requireServiceToken` records: `HttpServerResponse.json` contributes an\n // `HttpBodyError` to the failure channel, and spelling the type out while\n // omitting it is what makes the declaration emit fail invisibly.\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n const organizationId = request.headers[ORGANIZATION_HEADER]?.trim();\n if (organizationId === undefined || organizationId === '') {\n return yield* organizationRequired;\n }\n\n return yield* use(organizationId);\n });\n\n/**\n * The same fleet check, for a participant that has **no organization to name**.\n *\n * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`\n * above ends with an organization header because it is about to resolve a tenant\n * database handle from it, and a blank one there would read some other vendor's\n * storage. order-service names one database at boot, so there is no handle to\n * choose — and demanding a header nothing consumes would be theatre that a\n * caller learns to satisfy with any value.\n *\n * What is unchanged, and is the part that matters: the token proves the caller\n * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,\n * and **no session is accepted**. A saga step is not a person's act — the buyer\n * behind a checkout holds no grant over the order the coordinator is writing on\n * their behalf, and accepting their session here would make the weaker\n * credential the security level.\n *\n * The recorded residual carries over verbatim: a shared secret means any process\n * holding it can act, bounded by the permission and by which slices are\n * configured with the key.\n */\nexport const requireCrossing =\n (permission: Permission) =>\n <A, E, R>(route: Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const expected = yield* ServiceCrossingTokenTag;\n\n if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {\n return yield* unauthenticated;\n }\n\n const crossing = yield* ServiceCrossingPolicyTag;\n if (!crossing.allows(permission)) {\n return yield* forbidden(permission);\n }\n\n return yield* route;\n });\n"],"names":["timingSafeEqual","ServiceCrossingPolicyTag","Context","Effect","HttpServerRequest","HttpServerResponse","CROSSING_TOKEN_HEADER","ORGANIZATION_HEADER","ServiceCrossingTokenTag","Service","matches","provided","expected","undefined","left","Buffer","from","right","length","unauthenticated","json","error","code","status","forbidden","permission","organizationRequired","detail","requireServiceCrossing","use","gen","request","headers","crossing","allows","organizationId","trim","requireCrossing","route"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc;AAE9C,SAA0BC,wBAAwB,QAAQ,iBAAiB;AAC3E,SAASC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AACzC,SAASC,iBAAiB,EAAEC,kBAAkB,QAAQ,cAAc;AAEpE;;;;;;;;;CASC,GACD,OAAO,MAAMC,wBAAwB,mBAAmB;AAExD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,sBAAsB,oBAAoB;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgCN,QAAQO,OAAO,GAGxD;AAA4B;AAEhC,8EAA8E,GAC9E,MAAMC,UAAU,CAACC,UAA8BC;IAC7C,IAAID,aAAaE,WAAW,OAAO;IACnC,MAAMC,OAAOC,OAAOC,IAAI,CAACL;IACzB,MAAMM,QAAQF,OAAOC,IAAI,CAACJ;IAC1B,yEAAyE;IACzE,iEAAiE;IACjE,OAAOE,KAAKI,MAAM,KAAKD,MAAMC,MAAM,IAAIlB,gBAAgBc,MAAMG;AAC/D;AAEA,MAAME,kBAAkBd,mBAAmBe,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBpB,mBAAmBe,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB,MAAMG,uBAAuBrB,mBAAmBe,IAAI,CAClD;IACEC,OAAO;IACPC,MAAM;IACNK,QAAQ,GAAGpB,oBAAoB,YAAY,CAAC;AAC9C,GACA;IAAEgB,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,OAAO,MAAMK,yBACX,CAACH,aACD,CAAUI,MACR,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjE1B,OAAO2B,GAAG,CAAC;gBAacC;YAZvB,MAAMA,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,MAAMU,kBAAiBJ,uCAAAA,QAAQC,OAAO,CAACzB,oBAAoB,qBAApCwB,qCAAsCK,IAAI;YACjE,IAAID,mBAAmBtB,aAAasB,mBAAmB,IAAI;gBACzD,OAAO,OAAOT;YAChB;YAEA,OAAO,OAAOG,IAAIM;QACpB,GAAG;AAEP;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAME,kBACX,CAACZ,aACD,CAAUa,QACRnC,OAAO2B,GAAG,CAAC;YACT,MAAMC,UAAU,OAAO3B,kBAAkBA,iBAAiB;YAC1D,MAAMQ,WAAW,OAAOJ;YAExB,IAAI,CAACE,QAAQqB,QAAQC,OAAO,CAAC1B,sBAAsB,EAAEM,WAAW;gBAC9D,OAAO,OAAOO;YAChB;YAEA,MAAMc,WAAW,OAAOhC;YACxB,IAAI,CAACgC,SAASC,MAAM,CAACT,aAAa;gBAChC,OAAO,OAAOD,UAAUC;YAC1B;YAEA,OAAO,OAAOa;QAChB,GAAG"}
|
|
@@ -13,8 +13,7 @@ import { HttpServerRequest, HttpServerResponse } from 'effect/http';
|
|
|
13
13
|
* `id === "$metadata"`, misses, and answers its own `404`. The endpoint would
|
|
14
14
|
* read as "this entity has no metadata" while appearing mounted. A literal wins
|
|
15
15
|
* regardless of registration order, which is why the base exports a route for
|
|
16
|
-
* one entity and each service registers it for each of its own
|
|
17
|
-
* ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).
|
|
16
|
+
* one entity and each service registers it for each of its own.
|
|
18
17
|
*
|
|
19
18
|
* Deliberately **not** behind `requireOrganization`, even on a tenant-plane
|
|
20
19
|
* service: the document describes the model, not tenant data, and resolving a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-metadata-route.d.ts","sourceRoot":"","sources":["../src/entity-metadata-route.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EAIvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIpE
|
|
1
|
+
{"version":3,"file":"entity-metadata-route.d.ts","sourceRoot":"","sources":["../src/entity-metadata-route.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EAIvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,EACxD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,8MA0D5C,CAAC"}
|
|
@@ -15,8 +15,7 @@ import { requirePrincipal } from './auth/index.js';
|
|
|
15
15
|
* `id === "$metadata"`, misses, and answers its own `404`. The endpoint would
|
|
16
16
|
* read as "this entity has no metadata" while appearing mounted. A literal wins
|
|
17
17
|
* regardless of registration order, which is why the base exports a route for
|
|
18
|
-
* one entity and each service registers it for each of its own
|
|
19
|
-
* ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).
|
|
18
|
+
* one entity and each service registers it for each of its own.
|
|
20
19
|
*
|
|
21
20
|
* Deliberately **not** behind `requireOrganization`, even on a tenant-plane
|
|
22
21
|
* service: the document describes the model, not tenant data, and resolving a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/entity-metadata-route.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport {\n parsePermission,\n type Permission,\n permissionForEntity,\n permissionForUseCase,\n PolicyDecisionTag,\n} from '@entifix/authz';\nimport {\n describeEntityUseCases,\n type Entity,\n ENTITY_ACTIONS,\n type EntityConstructor,\n type EntityMetadataDocument,\n extractMetaEntity,\n makeEntityMetadataEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport { requirePrincipal } from './auth/index.js';\n\n/**\n * `GET /api/<entity>/$metadata` — what this caller may do with one entity.\n *\n * Mounted **per entity, at a literal path**, not as a `/api/:entity/$metadata`\n * wildcard. `HttpRouter` resolves through `find-my-way-ts`, where a static\n * segment beats a parametric one and there is no backtracking once the\n * parametric branch has matched: alongside an existing `/api/<entity>/:id`, a\n * wildcard `$metadata` route never runs at all — the by-id handler wins with\n * `id === \"$metadata\"`, misses, and answers its own `404`. The endpoint would\n * read as \"this entity has no metadata\" while appearing mounted. A literal wins\n * regardless of registration order, which is why the base exports a route for\n * one entity and each service registers it for each of its own
|
|
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"}
|
|
@@ -26,8 +26,8 @@ export declare const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unkn
|
|
|
26
26
|
* listener dying mid-flight, a half-open socket — leaves it waiting forever.
|
|
27
27
|
* The schedule never sees a failure, never retries, and the service hangs in its
|
|
28
28
|
* boot layer with its port bound and nothing served: measured twice on
|
|
29
|
-
* 2026-08-14, on
|
|
30
|
-
*
|
|
29
|
+
* 2026-08-14, on two different services, and recovered only by recreating the
|
|
30
|
+
* datastores.
|
|
31
31
|
*
|
|
32
32
|
* So an attempt is deadlined, for the same reason a readiness probe is: a check
|
|
33
33
|
* that hangs is a check that failed. The ordinary race this retry was written
|
|
@@ -26,8 +26,8 @@ import { Context, Effect, Schedule } from 'effect';
|
|
|
26
26
|
* listener dying mid-flight, a half-open socket — leaves it waiting forever.
|
|
27
27
|
* The schedule never sees a failure, never retries, and the service hangs in its
|
|
28
28
|
* boot layer with its port bound and nothing served: measured twice on
|
|
29
|
-
* 2026-08-14, on
|
|
30
|
-
*
|
|
29
|
+
* 2026-08-14, on two different services, and recovered only by recreating the
|
|
30
|
+
* datastores.
|
|
31
31
|
*
|
|
32
32
|
* So an attempt is deadlined, for the same reason a readiness probe is: a check
|
|
33
33
|
* that hangs is a check that failed. The ordinary race this retry was written
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Service<\n LoadedConfigurationTag,\n ConfigurationPlain\n>()('LoadedConfigurationTag') {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.max([Schedule.spaced('1 seconds'), Schedule.recurs(20)]);\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on
|
|
1
|
+
{"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Service<\n LoadedConfigurationTag,\n ConfigurationPlain\n>()('LoadedConfigurationTag') {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.max([Schedule.spaced('1 seconds'), Schedule.recurs(20)]);\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on two different services, and recovered only by recreating the\n * datastores.\n *\n * So an attempt is deadlined, for the same reason a readiness probe is: a check\n * that hangs is a check that failed. The ordinary race this retry was written\n * for — config-service not listening yet — still fails instantly with\n * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:\n * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits\n * loudly instead of hanging silently.\n */\nexport const defaultConfigAttemptTimeoutMs = 5_000;\n\n/** Options for {@link loadRemoteConfiguration}. */\nexport interface LoadRemoteConfigurationOptions {\n /**\n * Retry policy for the boot fetch. Defaults to\n * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a\n * single attempt, no retry) — used by tests and callers that want the error\n * immediately.\n */\n readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;\n /**\n * Deadline for one attempt, in milliseconds. Defaults to\n * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to\n * disable it — an attempt that cannot time out is the bug this exists for.\n */\n readonly attemptTimeoutMs?: number;\n}\n\n/**\n * Where a service's configuration is fetched from, and what the request\n * carries.\n *\n * Both are the host's: the path a configuration service answers on and the\n * credential it asks for are that deployment's protocol, so the framework takes\n * the finished URL and headers rather than composing either.\n */\nexport interface RemoteConfigurationSource {\n /** The full URL, e.g. `http://config:3190/api/config/order-service`. */\n readonly url: string;\n readonly headers?: Readonly<Record<string, string>>;\n}\n\n/**\n * Fetches a service's configuration from a configuration service and returns\n * it as a {@link ConfigurationPlain}. Backends call this at boot to resolve\n * their own parameters (e.g. `mongo.uri`) instead of hardcoding them.\n *\n * Uses Node's global `fetch` (same transport approach as the REST client); any\n * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each\n * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is\n * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a\n * dependent that boots before config-service is HTTP-ready waits it out — and\n * one that reaches a socket nobody is answering gives up rather than hanging.\n */\nexport const loadRemoteConfiguration = (\n source: RemoteConfigurationSource,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationPlain, EntifixConnError> => {\n const { url } = source;\n const attemptTimeoutMs =\n options.attemptTimeoutMs ?? defaultConfigAttemptTimeoutMs;\n\n const fetchOnce = Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n cache: 'no-store',\n headers: source.headers,\n signal: AbortSignal.timeout(attemptTimeoutMs),\n }).catch((error: unknown) => {\n // Rewritten because the raw abort says `This operation was aborted`,\n // which reads like a bug in the caller rather than a silent peer.\n if (error instanceof Error && error.name === 'TimeoutError') {\n throw new Error(\n `the configuration source did not answer within ${attemptTimeoutMs}ms at ${url}`,\n );\n }\n throw error;\n });\n if (!response.ok) {\n throw new Error(\n `the configuration source responded ${response.status} for ${url}`,\n );\n }\n return (await response.json()) as ConfigurationPlain;\n },\n catch: error =>\n new EntifixConnError(`Failed to load configuration from ${url}`, error, {\n url,\n }),\n });\n\n return Effect.retry(\n fetchOnce,\n options.retrySchedule ?? defaultConfigRetrySchedule,\n );\n};\n\n/**\n * Convenience: load the remote configuration and wrap it in a\n * {@link ConfigurationClientInMemory}, to read the service's own parameters via\n * `.in(group).getString(key)` or to hand to an adapter that is built with one.\n */\nexport const loadRemoteConfigurationClient = (\n source: RemoteConfigurationSource,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationClientInMemory, EntifixConnError> =>\n loadRemoteConfiguration(source, options).pipe(\n Effect.map(plain => new ConfigurationClientInMemory(plain)),\n );\n"],"names":["ConfigurationClientInMemory","EntifixConnError","Context","Effect","Schedule","LoadedConfigurationTag","Service","defaultConfigRetrySchedule","max","spaced","recurs","defaultConfigAttemptTimeoutMs","loadRemoteConfiguration","source","options","url","attemptTimeoutMs","fetchOnce","tryPromise","try","response","fetch","cache","headers","signal","AbortSignal","timeout","catch","error","Error","name","ok","status","json","retry","retrySchedule","loadRemoteConfigurationClient","pipe","map","plain"],"mappings":"AAAA,SACEA,2BAA2B,EAE3BC,gBAAgB,QACX,gBAAgB;AACvB,SAASC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAEnD;;;CAGC,GACD,OAAO,MAAMC,+BAA+BH,QAAQI,OAAO,GAGvD;AAA2B;AAE/B;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BACXH,SAASI,GAAG,CAAC;IAACJ,SAASK,MAAM,CAAC;IAAcL,SAASM,MAAM,CAAC;CAAI,EAAE;AAEpE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,gCAAgC,KAAM;AAiCnD;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,QACAC,UAA0C,CAAC,CAAC;QAI1CA,2BAiCAA;IAnCF,MAAM,EAAEC,GAAG,EAAE,GAAGF;IAChB,MAAMG,oBACJF,4BAAAA,QAAQE,gBAAgB,YAAxBF,4BAA4BH;IAE9B,MAAMM,YAAYd,OAAOe,UAAU,CAAC;QAClCC,KAAK;YACH,MAAMC,WAAW,MAAMC,MAAMN,KAAK;gBAChCO,OAAO;gBACPC,SAASV,OAAOU,OAAO;gBACvBC,QAAQC,YAAYC,OAAO,CAACV;YAC9B,GAAGW,KAAK,CAAC,CAACC;gBACR,qEAAqE;gBACrE,kEAAkE;gBAClE,IAAIA,iBAAiBC,SAASD,MAAME,IAAI,KAAK,gBAAgB;oBAC3D,MAAM,IAAID,MACR,CAAC,+CAA+C,EAAEb,iBAAiB,MAAM,EAAED,KAAK;gBAEpF;gBACA,MAAMa;YACR;YACA,IAAI,CAACR,SAASW,EAAE,EAAE;gBAChB,MAAM,IAAIF,MACR,CAAC,mCAAmC,EAAET,SAASY,MAAM,CAAC,KAAK,EAAEjB,KAAK;YAEtE;YACA,OAAQ,MAAMK,SAASa,IAAI;QAC7B;QACAN,OAAOC,CAAAA,QACL,IAAI3B,iBAAiB,CAAC,kCAAkC,EAAEc,KAAK,EAAEa,OAAO;gBACtEb;YACF;IACJ;IAEA,OAAOZ,OAAO+B,KAAK,CACjBjB,YACAH,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyBP;AAE7B,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAM6B,gCAAgC,CAC3CvB,QACAC,UAA0C,CAAC,CAAC,GAE5CF,wBAAwBC,QAAQC,SAASuB,IAAI,CAC3ClC,OAAOmC,GAAG,CAACC,CAAAA,QAAS,IAAIvC,4BAA4BuC,SACpD"}
|
package/dist/make-service.d.ts
CHANGED
|
@@ -36,9 +36,9 @@ export interface ServiceDefinition<RRoutes> {
|
|
|
36
36
|
* The slices this process hosts, by their `tools/slices/` name.
|
|
37
37
|
*
|
|
38
38
|
* It cannot be derived. `EventSourceTag` names only the *emitting* slice, and
|
|
39
|
-
* one deployment may host several
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* one deployment may host several, and a host that co-deploys two slices in
|
|
40
|
+
* one process names both. A host that registers its deployments can check
|
|
41
|
+
* each name against that register, so a wrong one fails the diff rather than
|
|
42
42
|
* quietly mislabelling the fleet.
|
|
43
43
|
*/
|
|
44
44
|
readonly slices: readonly string[];
|
|
@@ -93,7 +93,7 @@ export declare const makeServerLayer: <RRoutes>(def: ServiceDefinition<RRoutes>,
|
|
|
93
93
|
* finalizer's, because only layer ordering can guarantee the connections are
|
|
94
94
|
* still open while it happens.
|
|
95
95
|
*
|
|
96
|
-
*
|
|
96
|
+
* A `preStop` grace period is **not** here and could not
|
|
97
97
|
* be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
|
|
98
98
|
* Deployment for a service. It lands with the first one.
|
|
99
99
|
*/
|
package/dist/make-service.js
CHANGED
|
@@ -28,7 +28,7 @@ import { serviceDescriptionRoute } from './service-description-route.js';
|
|
|
28
28
|
// registered into are the same instances the readiness and description routes
|
|
29
29
|
// read.
|
|
30
30
|
const appLayer = def.appLayer.pipe(Layer.provideMerge(Layer.mergeAll(HealthRegistryLayer, WiringRegistryLayer, Layer.succeed(ShutdownRegistryTag, shutdown))));
|
|
31
|
-
// The graceful shutdown, and its **position** is the mechanism
|
|
31
|
+
// The graceful shutdown, and its **position** is the mechanism.
|
|
32
32
|
// Build order is `appLayer` → this → `serve`, so release order is the
|
|
33
33
|
// reverse: the drain runs after the server has stopped taking requests and
|
|
34
34
|
// *before* the connections it needs are closed. A signal handler could not
|
|
@@ -57,7 +57,7 @@ import { serviceDescriptionRoute } from './service-description-route.js';
|
|
|
57
57
|
* finalizer's, because only layer ordering can guarantee the connections are
|
|
58
58
|
* still open while it happens.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
60
|
+
* A `preStop` grace period is **not** here and could not
|
|
61
61
|
* be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
|
|
62
62
|
* Deployment for a service. It lands with the first one.
|
|
63
63
|
*/ export const makeService = (def)=>{
|
package/dist/make-service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport type { SharedToken } from './auth/shared-token.js';\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several: ADR 0021 co-deploys `transaction` inside\n * marketplace-admin-service. `dev-infra:map` checks each name against the\n * register's own `deployments`, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /**\n * The token that gates `GET /api/$service`, the service's wiring document.\n *\n * Optional, and **absent means the route is not mounted** rather than served\n * open: the document names every store, exchange and upstream the service\n * has, and an unguarded reconnaissance map is worse than none.\n */\n readonly description?: SharedToken;\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n def.description === undefined\n ? Layer.empty\n : serviceDescriptionRoute(def.name, def.slices, def.description),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism (ADR 0030).\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * The `preStop` grace period ADR 0030 also names is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","description","undefined","empty","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAGzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AA4EzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBP,IAAIQ,WAAW,KAAKC,YAChBd,MAAMe,KAAK,GACXZ,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIW,MAAM,EAAEX,IAAIQ,WAAW,GACjE,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWgB,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWb,IAAIa,QAAQ,CAACC,IAAI,CAChCnB,MAAMoB,YAAY,CAChBpB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMqB,OAAO,CAACxB,qBAAqBU;IAKzC,2EAA2E;IAC3E,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMe,UAAUtB,MAAMoB,YAAY,CAChCpB,MAAMuB,aAAa,CACjBxB,OAAOyB,cAAc,CAACzB,OAAO0B,IAAI,EAAE,IAAMlB,SAASmB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAAS1B,WAAW2B,KAAK,CAACnB,QAAQU,IAAI,CAC1CnB,MAAM6B,OAAO,CAACP,UACdtB,MAAM6B,OAAO,CAACpC,eAAeqC,KAAK,CAAC,IAAMtC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOqB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAU1B;IACnC,MAAME,WAAWX;IACjBoC,QAAQC,IAAI,CAAC,WAAW1B,SAAS2B,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAU1B,SAAS2B,KAAK;IAErCxC,YAAYyC,OAAO,CACjBnC,MAAMoC,MAAM,CAAChC,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
|
|
1
|
+
{"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport type { SharedToken } from './auth/shared-token.js';\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several, and a host that co-deploys two slices in\n * one process names both. A host that registers its deployments can check\n * each name against that register, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /**\n * The token that gates `GET /api/$service`, the service's wiring document.\n *\n * Optional, and **absent means the route is not mounted** rather than served\n * open: the document names every store, exchange and upstream the service\n * has, and an unguarded reconnaissance map is worse than none.\n */\n readonly description?: SharedToken;\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n def.description === undefined\n ? Layer.empty\n : serviceDescriptionRoute(def.name, def.slices, def.description),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism.\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * A `preStop` grace period is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","description","undefined","empty","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAGzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AA4EzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBP,IAAIQ,WAAW,KAAKC,YAChBd,MAAMe,KAAK,GACXZ,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIW,MAAM,EAAEX,IAAIQ,WAAW,GACjE,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWgB,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWb,IAAIa,QAAQ,CAACC,IAAI,CAChCnB,MAAMoB,YAAY,CAChBpB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMqB,OAAO,CAACxB,qBAAqBU;IAKzC,gEAAgE;IAChE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMe,UAAUtB,MAAMoB,YAAY,CAChCpB,MAAMuB,aAAa,CACjBxB,OAAOyB,cAAc,CAACzB,OAAO0B,IAAI,EAAE,IAAMlB,SAASmB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAAS1B,WAAW2B,KAAK,CAACnB,QAAQU,IAAI,CAC1CnB,MAAM6B,OAAO,CAACP,UACdtB,MAAM6B,OAAO,CAACpC,eAAeqC,KAAK,CAAC,IAAMtC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOqB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAU1B;IACnC,MAAME,WAAWX;IACjBoC,QAAQC,IAAI,CAAC,WAAW1B,SAAS2B,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAU1B,SAAS2B,KAAK;IAErCxC,YAAYyC,OAAO,CACjBnC,MAAMoC,MAAM,CAAChC,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
|
|
@@ -9,7 +9,7 @@ import { type SharedToken } from './auth/shared-token.js';
|
|
|
9
9
|
* `find-my-way-ts`, where a static segment beats a parametric one but there is
|
|
10
10
|
* no backtracking once a parametric branch has matched. A `/api/:something`
|
|
11
11
|
* route registered anywhere in the same router would otherwise swallow this one
|
|
12
|
-
* and answer its own `404` — the failure
|
|
12
|
+
* and answer its own `404` — the failure the `$metadata` route hit first.
|
|
13
13
|
*/
|
|
14
14
|
export declare const SERVICE_DESCRIPTION_PATH = "/api/$service";
|
|
15
15
|
/** One thing this process is connected to, named logically. */
|
|
@@ -25,13 +25,13 @@ export interface DescribedTarget {
|
|
|
25
25
|
* Shape, not values — `/api/config` is the sibling that serves the parameters a
|
|
26
26
|
* service was given, redacted. Keeping them apart is deliberate: one is redacted
|
|
27
27
|
* because it holds credentials, the other gated because it holds a map, and
|
|
28
|
-
* merging them forces the stricter rule onto both
|
|
28
|
+
* merging them forces the stricter rule onto both.
|
|
29
29
|
*/
|
|
30
30
|
export interface ServiceDescription {
|
|
31
31
|
readonly service: string;
|
|
32
32
|
/**
|
|
33
33
|
* The slices this process hosts. More than one means co-deployment
|
|
34
|
-
*
|
|
34
|
+
* — `marketplace-admin-service` runs the `transaction` slice too.
|
|
35
35
|
*/
|
|
36
36
|
readonly slices: readonly string[];
|
|
37
37
|
readonly stores: readonly DescribedTarget[];
|
|
@@ -9,7 +9,7 @@ import { requireSharedToken } from './auth/shared-token.js';
|
|
|
9
9
|
* `find-my-way-ts`, where a static segment beats a parametric one but there is
|
|
10
10
|
* no backtracking once a parametric branch has matched. A `/api/:something`
|
|
11
11
|
* route registered anywhere in the same router would otherwise swallow this one
|
|
12
|
-
* and answer its own `404` — the failure
|
|
12
|
+
* and answer its own `404` — the failure the `$metadata` route hit first.
|
|
13
13
|
*/ export const SERVICE_DESCRIPTION_PATH = '/api/$service';
|
|
14
14
|
/**
|
|
15
15
|
* Everything the two registries know, sorted by what it is.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireSharedToken, type SharedToken } from './auth/shared-token.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure
|
|
1
|
+
{"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireSharedToken, type SharedToken } from './auth/shared-token.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure the `$metadata` route hit first.\n */\nexport const SERVICE_DESCRIPTION_PATH = '/api/$service';\n\n/** One thing this process is connected to, named logically. */\nexport interface DescribedTarget {\n /** The Store's register name, an exchange, or a service. Never a URI. */\n readonly name: string;\n /** The readiness probe that covers it, so the two documents line up. */\n readonly probe: string;\n}\n\n/**\n * What a service says about its own wiring.\n *\n * Shape, not values — `/api/config` is the sibling that serves the parameters a\n * service was given, redacted. Keeping them apart is deliberate: one is redacted\n * because it holds credentials, the other gated because it holds a map, and\n * merging them forces the stricter rule onto both.\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * — `marketplace-admin-service` runs the `transaction` slice too.\n */\n readonly slices: readonly string[];\n readonly stores: readonly DescribedTarget[];\n readonly brokers: readonly DescribedTarget[];\n readonly upstreams: readonly DescribedTarget[];\n /** Distinct event names this process has emitted since boot. */\n readonly published: readonly string[];\n /** The bus bindings this process actually made. */\n readonly subscriptions: readonly BoundSubscription[];\n}\n\n/**\n * Everything the two registries know, sorted by what it is.\n *\n * Generated from the **health probe registrations**, which is the one source\n * that cannot drift: a service that gains a datastore gains its probe by merging\n * the client's probe layer, so it gains a line here with no edit of its own.\n */\nconst describe = (\n service: string,\n slices: readonly string[],\n): Effect.Effect<\n ServiceDescription,\n never,\n HealthRegistryTag | WiringRegistryTag\n> =>\n Effect.gen(function* () {\n const health = yield* HealthRegistryTag;\n const wiring = yield* WiringRegistryTag;\n const probes = yield* health.probes;\n\n const ofKind = (kind: ProbeKind): readonly DescribedTarget[] =>\n probes\n .filter(probe => probe.kind === kind)\n .flatMap(probe =>\n probe.targets.map(name => ({ name, probe: probe.name })),\n );\n\n return {\n service,\n slices,\n stores: ofKind('datastore'),\n brokers: ofKind('broker'),\n upstreams: ofKind('upstream'),\n published: yield* wiring.published,\n subscriptions: yield* wiring.subscriptions,\n };\n });\n\n/**\n * `GET /api/$service`, as a route layer to merge beside a service's own.\n *\n * **Gated by a shared token**, which the host names. This names every store, exchange and upstream\n * a service has, which is a reconnaissance map rather than diagnostics. The\n * repository's precedent on maps of the model is uniform: `$metadata` answers\n * `404` rather than `403` so it cannot be walked as an oracle, readiness serves\n * probe names only, and `redactConfiguration` blanks anything flagged secret\n * because `/api/config` is unauthenticated. The health endpoints stay open —\n * a probe cannot hold a credential.\n *\n * The token is fleet membership, which is the right shape here: the readers are\n * tooling and CI, not a person and not a browser.\n *\n * **Logical names only.** Nothing in the response is a URI, and emphatically\n * nothing is `tenant_<organizationId>` — the tenant databases are named after\n * organizations, so leaking one turns a wiring document into a customer list.\n * The names come from a probe's `targets`, which are Store register names by\n * construction.\n */\nexport const serviceDescriptionRoute = (\n serviceName: string,\n slices: readonly string[],\n guard: SharedToken,\n) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n requireSharedToken(guard)(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n );\n"],"names":["HealthRegistryTag","WiringRegistryTag","Effect","HttpRouter","HttpServerResponse","requireSharedToken","SERVICE_DESCRIPTION_PATH","describe","service","slices","gen","health","wiring","probes","ofKind","kind","filter","probe","flatMap","targets","map","name","stores","brokers","upstreams","published","subscriptions","serviceDescriptionRoute","serviceName","guard","add","description","json"],"mappings":"AACA,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,oBAAoB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,UAAU,EAAEC,kBAAkB,QAAQ,cAAc;AAE7D,SAASC,kBAAkB,QAA0B,yBAAyB;AAE9E;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAkCxD;;;;;;CAMC,GACD,MAAMC,WAAW,CACfC,SACAC,SAMAP,OAAOQ,GAAG,CAAC;QACT,MAAMC,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOF,OAAOE,MAAM;QAEnC,MAAMC,SAAS,CAACC,OACdF,OACGG,MAAM,CAACC,CAAAA,QAASA,MAAMF,IAAI,KAAKA,MAC/BG,OAAO,CAACD,CAAAA,QACPA,MAAME,OAAO,CAACC,GAAG,CAACC,CAAAA,OAAS,CAAA;wBAAEA;wBAAMJ,OAAOA,MAAMI,IAAI;oBAAC,CAAA;QAG3D,OAAO;YACLb;YACAC;YACAa,QAAQR,OAAO;YACfS,SAAST,OAAO;YAChBU,WAAWV,OAAO;YAClBW,WAAW,OAAOb,OAAOa,SAAS;YAClCC,eAAe,OAAOd,OAAOc,aAAa;QAC5C;IACF;AAEF;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,aACAnB,QACAoB,QAEA1B,WAAW2B,GAAG,CACZ,OACAxB,0BACAD,mBAAmBwB,OACjB3B,OAAOgB,OAAO,CAACX,SAASqB,aAAanB,SAASsB,CAAAA,cAC5C3B,mBAAmB4B,IAAI,CAACD,gBAG5B"}
|
package/dist/sse.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { HttpServerResponse } from 'effect/http';
|
|
|
7
7
|
* `id:` carries the message's own id — `<transactionId>:<step>` for a
|
|
8
8
|
* transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
|
|
9
9
|
* names a message that actually exists, rather than a counter invented for the
|
|
10
|
-
* wire. The server does not replay it
|
|
10
|
+
* wire. The server does not replay it; the client reconciles by
|
|
11
11
|
* re-reading the record.
|
|
12
12
|
*
|
|
13
13
|
* `data:` is a full `event` envelope, which is what keeps the stream from
|
|
@@ -21,7 +21,7 @@ export interface SseResponseOptions {
|
|
|
21
21
|
* A held-open response has no next request to fail verification, so the bound
|
|
22
22
|
* every REST route gets implicitly has to be applied explicitly here. The
|
|
23
23
|
* browser reconnects with the cookie the app refreshed in the meantime
|
|
24
|
-
|
|
24
|
+
*. Omitted, the stream runs until the client goes away.
|
|
25
25
|
*/
|
|
26
26
|
readonly expiresAt?: number;
|
|
27
27
|
/** Injectable for tests; defaults to the wall clock. */
|
package/dist/sse.js
CHANGED
|
@@ -16,7 +16,7 @@ const encoder = new TextEncoder();
|
|
|
16
16
|
* `id:` carries the message's own id — `<transactionId>:<step>` for a
|
|
17
17
|
* transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
|
|
18
18
|
* names a message that actually exists, rather than a counter invented for the
|
|
19
|
-
* wire. The server does not replay it
|
|
19
|
+
* wire. The server does not replay it; the client reconciles by
|
|
20
20
|
* re-reading the record.
|
|
21
21
|
*
|
|
22
22
|
* `data:` is a full `event` envelope, which is what keeps the stream from
|
package/dist/sse.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sse.ts"],"sourcesContent":["import { type DomainEvent, makeEventEnvelope } from '@entifix/core';\nimport { Duration, Effect, Stream } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\n/**\n * How often an idle connection emits a comment frame.\n *\n * An SSE connection that sends nothing for minutes is indistinguishable from a\n * dead one to anything between the browser and the service, and a proxy that\n * decides it is dead closes it with no error either side can report. A comment\n * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.\n */\nconst KEEPALIVE = Duration.seconds(20);\n\nconst encoder = new TextEncoder();\n\n/**\n * One `EventSource` frame.\n *\n * `id:` carries the message's own id — `<transactionId>:<step>` for a\n * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect\n * names a message that actually exists, rather than a counter invented for the\n * wire. The server does not replay it
|
|
1
|
+
{"version":3,"sources":["../src/sse.ts"],"sourcesContent":["import { type DomainEvent, makeEventEnvelope } from '@entifix/core';\nimport { Duration, Effect, Stream } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\n/**\n * How often an idle connection emits a comment frame.\n *\n * An SSE connection that sends nothing for minutes is indistinguishable from a\n * dead one to anything between the browser and the service, and a proxy that\n * decides it is dead closes it with no error either side can report. A comment\n * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.\n */\nconst KEEPALIVE = Duration.seconds(20);\n\nconst encoder = new TextEncoder();\n\n/**\n * One `EventSource` frame.\n *\n * `id:` carries the message's own id — `<transactionId>:<step>` for a\n * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect\n * names a message that actually exists, rather than a counter invented for the\n * wire. The server does not replay it; the client reconciles by\n * re-reading the record.\n *\n * `data:` is a full `event` envelope, which is what keeps the stream from\n * becoming a fourth framing: the same bytes a bus consumer would read.\n */\nexport const sseFrame = (event: DomainEvent): string =>\n `id: ${event.id}\\ndata: ${JSON.stringify(makeEventEnvelope(event))}\\n\\n`;\n\nexport interface SseResponseOptions {\n /**\n * Unix seconds at which to close the connection — the verified token's `exp`.\n *\n * A held-open response has no next request to fail verification, so the bound\n * every REST route gets implicitly has to be applied explicitly here. The\n * browser reconnects with the cookie the app refreshed in the meantime\n *. Omitted, the stream runs until the client goes away.\n */\n readonly expiresAt?: number;\n /** Injectable for tests; defaults to the wall clock. */\n readonly now?: () => number;\n}\n\n/**\n * Serves a stream of domain events as `text/event-stream`.\n *\n * `cache-control: no-store` is not politeness — a cached event stream is served\n * to whoever asks next, and this one is scoped to a single principal. The\n * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has\n * a pass-through arm for exactly this content type, because reading the body to\n * completion first holds the request open forever and delivers nothing.\n */\nexport const sseResponse = (\n events: Stream.Stream<DomainEvent>,\n { expiresAt, now = Date.now }: SseResponseOptions = {},\n): HttpServerResponse.HttpServerResponse => {\n const keepalive = Stream.fromEffectRepeat(\n Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\\n\\n'),\n );\n\n const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {\n haltStrategy: 'left',\n });\n\n // An opening comment, emitted before anything has happened.\n //\n // Not cosmetic: a chunked response writes its headers with its first chunk,\n // so a stream that stays silent until something occurs leaves the client in\n // `CONNECTING` — indefinitely, since a stream whose whole purpose is to be\n // idle most of the time may have nothing to say for hours. Measured: without\n // it, `fetch` against this route does not resolve at all.\n const opened = Stream.concat(Stream.make(': open\\n\\n'), frames);\n\n const bounded =\n expiresAt === undefined\n ? opened\n : Stream.interruptWhen(\n opened,\n // Never negative: an already-expired token closes at once rather than\n // scheduling a sleep into the past, which `Effect.sleep` treats as\n // zero anyway but reads as an oversight.\n Effect.sleep(Duration.millis(Math.max(0, expiresAt * 1000 - now()))),\n );\n\n return HttpServerResponse.stream(\n Stream.map(bounded, frame => encoder.encode(frame)),\n {\n contentType: 'text/event-stream',\n headers: {\n 'cache-control': 'no-store',\n connection: 'keep-alive',\n // nginx buffers a proxied response by default, which would hold every\n // frame until the stream ends — that is, forever.\n 'x-accel-buffering': 'no',\n },\n },\n );\n};\n"],"names":["makeEventEnvelope","Duration","Effect","Stream","HttpServerResponse","KEEPALIVE","seconds","encoder","TextEncoder","sseFrame","event","id","JSON","stringify","sseResponse","events","expiresAt","now","Date","keepalive","fromEffectRepeat","as","sleep","frames","merge","map","haltStrategy","opened","concat","make","bounded","undefined","interruptWhen","millis","Math","max","stream","frame","encode","contentType","headers","connection"],"mappings":"AAAA,SAA2BA,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAClD,SAASC,kBAAkB,QAAQ,cAAc;AAEjD;;;;;;;CAOC,GACD,MAAMC,YAAYJ,SAASK,OAAO,CAAC;AAEnC,MAAMC,UAAU,IAAIC;AAEpB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,WAAW,CAACC,QACvB,CAAC,IAAI,EAAEA,MAAMC,EAAE,CAAC,QAAQ,EAAEC,KAAKC,SAAS,CAACb,kBAAkBU,QAAQ,IAAI,CAAC,CAAC;AAgB3E;;;;;;;;CAQC,GACD,OAAO,MAAMI,cAAc,CACzBC,QACA,EAAEC,SAAS,EAAEC,MAAMC,KAAKD,GAAG,EAAsB,GAAG,CAAC,CAAC;IAEtD,MAAME,YAAYhB,OAAOiB,gBAAgB,CACvClB,OAAOmB,EAAE,CAACnB,OAAOoB,KAAK,CAACjB,YAAY;IAGrC,MAAMkB,SAASpB,OAAOqB,KAAK,CAACrB,OAAOsB,GAAG,CAACV,QAAQN,WAAWU,WAAW;QACnEO,cAAc;IAChB;IAEA,4DAA4D;IAC5D,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,0DAA0D;IAC1D,MAAMC,SAASxB,OAAOyB,MAAM,CAACzB,OAAO0B,IAAI,CAAC,eAAeN;IAExD,MAAMO,UACJd,cAAce,YACVJ,SACAxB,OAAO6B,aAAa,CAClBL,QACA,sEAAsE;IACtE,mEAAmE;IACnE,yCAAyC;IACzCzB,OAAOoB,KAAK,CAACrB,SAASgC,MAAM,CAACC,KAAKC,GAAG,CAAC,GAAGnB,YAAY,OAAOC;IAGpE,OAAOb,mBAAmBgC,MAAM,CAC9BjC,OAAOsB,GAAG,CAACK,SAASO,CAAAA,QAAS9B,QAAQ+B,MAAM,CAACD,SAC5C;QACEE,aAAa;QACbC,SAAS;YACP,iBAAiB;YACjBC,YAAY;YACZ,sEAAsE;YACtE,kDAAkD;YAClD,qBAAqB;QACvB;IACF;AAEJ,EAAE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/service-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Effect HTTP service skeleton: server layer, health and metadata routes, permission guards, remote configuration and OpenTelemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@swc/helpers": "0.5.23",
|
|
69
|
-
"@entifix/authz": "0.
|
|
70
|
-
"@entifix/core": "0.
|
|
71
|
-
"@entifix/
|
|
72
|
-
"@entifix/
|
|
69
|
+
"@entifix/authz": "0.7.0",
|
|
70
|
+
"@entifix/core": "0.7.0",
|
|
71
|
+
"@entifix/tooling": "0.7.0",
|
|
72
|
+
"@entifix/business": "0.7.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@effect/opentelemetry": "4.0.5",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"@opentelemetry/sdk-metrics": "2.12.0",
|
|
82
82
|
"@opentelemetry/sdk-trace-base": "2.12.0",
|
|
83
83
|
"effect": "4.0.5",
|
|
84
|
-
"@entifix/testing-unit": "0.
|
|
84
|
+
"@entifix/testing-unit": "0.7.0"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"@effect/opentelemetry": "^4.0.5",
|