@entifix/service-shell 0.1.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +5 -0
  3. package/dist/auth/index.d.ts.map +1 -0
  4. package/dist/auth/index.js +6 -0
  5. package/dist/auth/index.js.map +1 -0
  6. package/dist/auth/require-organization.d.ts +35 -0
  7. package/dist/auth/require-organization.d.ts.map +1 -0
  8. package/dist/auth/require-organization.js +59 -0
  9. package/dist/auth/require-organization.js.map +1 -0
  10. package/dist/auth/require-principal.d.ts +70 -0
  11. package/dist/auth/require-principal.d.ts.map +1 -0
  12. package/dist/auth/require-principal.js +105 -0
  13. package/dist/auth/require-principal.js.map +1 -0
  14. package/dist/auth/require-service-crossing.d.ts +107 -0
  15. package/dist/auth/require-service-crossing.d.ts.map +1 -0
  16. package/dist/auth/require-service-crossing.js +161 -0
  17. package/dist/auth/require-service-crossing.js.map +1 -0
  18. package/dist/auth/service-token.d.ts +37 -0
  19. package/dist/auth/service-token.d.ts.map +1 -0
  20. package/dist/auth/service-token.js +62 -0
  21. package/dist/auth/service-token.js.map +1 -0
  22. package/dist/entity-metadata-route.d.ts +25 -0
  23. package/dist/entity-metadata-route.d.ts.map +1 -0
  24. package/dist/entity-metadata-route.js +89 -0
  25. package/dist/entity-metadata-route.js.map +1 -0
  26. package/dist/health-routes.d.ts +24 -0
  27. package/dist/health-routes.d.ts.map +1 -0
  28. package/dist/health-routes.js +81 -0
  29. package/dist/health-routes.js.map +1 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/load-remote-configuration.d.ts +77 -0
  35. package/dist/load-remote-configuration.d.ts.map +1 -0
  36. package/dist/load-remote-configuration.js +87 -0
  37. package/dist/load-remote-configuration.js.map +1 -0
  38. package/dist/make-service.d.ts +77 -0
  39. package/dist/make-service.d.ts.map +1 -0
  40. package/dist/make-service.js +70 -0
  41. package/dist/make-service.js.map +1 -0
  42. package/dist/observability.d.ts +118 -0
  43. package/dist/observability.d.ts.map +1 -0
  44. package/dist/observability.js +248 -0
  45. package/dist/observability.js.map +1 -0
  46. package/dist/redact-configuration.d.ts +20 -0
  47. package/dist/redact-configuration.d.ts.map +1 -0
  48. package/dist/redact-configuration.js +34 -0
  49. package/dist/redact-configuration.js.map +1 -0
  50. package/dist/serve-test-service.d.ts +37 -0
  51. package/dist/serve-test-service.d.ts.map +1 -0
  52. package/dist/serve-test-service.js +64 -0
  53. package/dist/serve-test-service.js.map +1 -0
  54. package/dist/service-description-route.d.ts +65 -0
  55. package/dist/service-description-route.d.ts.map +1 -0
  56. package/dist/service-description-route.js +59 -0
  57. package/dist/service-description-route.js.map +1 -0
  58. package/dist/sse.d.ts +40 -0
  59. package/dist/sse.d.ts.map +1 -0
  60. package/dist/sse.js +62 -0
  61. package/dist/sse.js.map +1 -0
  62. package/dist/wait-for-http.d.ts +18 -0
  63. package/dist/wait-for-http.d.ts.map +1 -0
  64. package/dist/wait-for-http.js +26 -0
  65. package/dist/wait-for-http.js.map +1 -0
  66. package/package.json +79 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 r10c Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ export * from './require-organization.js';
2
+ export * from './require-principal.js';
3
+ export * from './require-service-crossing.js';
4
+ export * from './service-token.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,wBAAwB,CAAC;AACvC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oBAAoB,CAAC"}
@@ -0,0 +1,6 @@
1
+ export * from './require-organization.js';
2
+ export * from './require-principal.js';
3
+ export * from './require-service-crossing.js';
4
+ export * from './service-token.js';
5
+
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/index.ts"],"sourcesContent":["export * from './require-organization.js';\nexport * from './require-principal.js';\nexport * from './require-service-crossing.js';\nexport * from './service-token.js';\n"],"names":[],"mappings":"AAAA,cAAc,4BAA4B;AAC1C,cAAc,yBAAyB;AACvC,cAAc,gCAAgC;AAC9C,cAAc,qBAAqB"}
@@ -0,0 +1,35 @@
1
+ import { HttpServerResponse } from '@effect/platform';
2
+ import type { Permission } from '@entifix/authz';
3
+ import { Effect } from 'effect';
4
+ import { type RequestPrincipal } from './require-principal.js';
5
+ /**
6
+ * Guard a **tenant-plane** handler: authenticate, check the permission, and
7
+ * require that the session names an organization.
8
+ *
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)).
12
+ * The second is {@link requireServiceCrossing}: an explicit `organizationId`
13
+ * presented with a service token *and* a narrow route permission, for a
14
+ * 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)).
17
+ *
18
+ * There is no third path, no fallback and no operator branch. A request
19
+ * satisfying neither guard never reaches a tenant handle at all, which is what
20
+ * keeps the most sensitive capability in the system from being the *absence* of
21
+ * a condition.
22
+ *
23
+ * The organization is handed to the handler so it can resolve its own storage
24
+ * through a `TenantDatabaseResolver`. Resolution happens **inside the request**
25
+ * — the connection pool is a boot-time `Layer`, and a per-request `Layer` would
26
+ * rebuild the pool per request.
27
+ *
28
+ * Here the value comes from the verified token and nowhere else. A route
29
+ * parameter or a body field naming an organization would be caller-controlled,
30
+ * which is the whole failure this guard exists to prevent — and is exactly why
31
+ * the other path honours a caller-named organization only *after* the caller has
32
+ * proved it is the fleet.
33
+ */
34
+ export declare const requireOrganization: (permission: Permission) => <A, E, R>(use: (organizationId: string, principal: RequestPrincipal) => Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("@effect/platform/HttpBody").HttpBodyError | E, import("@entifix/authz").PolicyDecisionTag | import("@entifix/business").TokenServiceTag | import("@effect/platform/HttpServerRequest").HttpServerRequest | R>;
35
+ //# sourceMappingURL=require-organization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-organization.d.ts","sourceRoot":"","sources":["../../src/auth/require-organization.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,wBAAwB,CAAC;AAkBhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,mBAAmB,GAC7B,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EACN,KAAK,CACH,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,gBAAgB,KACxB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,mRAa1B,CAAC"}
@@ -0,0 +1,59 @@
1
+ import { HttpServerResponse } from '@effect/platform';
2
+ import { Effect } from 'effect';
3
+ import { requirePermission } from './require-principal.js';
4
+ /**
5
+ * `409` rather than `403`: the caller is authenticated and permitted, but the
6
+ * session names no organization, so there is no storage to read. An operator
7
+ * hits this by design — crossing into a tenant is an explicit, audited
8
+ * act-as-organization re-mint, never a widening of the default (ADR 0012) —
9
+ * and a member of several organizations hits it before choosing one. The `code`
10
+ * is a key in the shared `errors` catalog, so a client renders "select an
11
+ * organization" instead of "forbidden" — a claim `@r10c/i18n-check` now holds
12
+ * to, because for a while it was simply false and the code reached the browser
13
+ * as its own literal text.
14
+ */ const noOrganization = HttpServerResponse.json({
15
+ error: 'no active organization',
16
+ code: 'noActiveOrganization'
17
+ }, {
18
+ status: 409
19
+ });
20
+ /**
21
+ * Guard a **tenant-plane** handler: authenticate, check the permission, and
22
+ * require that the session names an organization.
23
+ *
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)).
27
+ * The second is {@link requireServiceCrossing}: an explicit `organizationId`
28
+ * presented with a service token *and* a narrow route permission, for a
29
+ * 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)).
32
+ *
33
+ * There is no third path, no fallback and no operator branch. A request
34
+ * satisfying neither guard never reaches a tenant handle at all, which is what
35
+ * keeps the most sensitive capability in the system from being the *absence* of
36
+ * a condition.
37
+ *
38
+ * The organization is handed to the handler so it can resolve its own storage
39
+ * through a `TenantDatabaseResolver`. Resolution happens **inside the request**
40
+ * — the connection pool is a boot-time `Layer`, and a per-request `Layer` would
41
+ * rebuild the pool per request.
42
+ *
43
+ * Here the value comes from the verified token and nowhere else. A route
44
+ * parameter or a body field naming an organization would be caller-controlled,
45
+ * which is the whole failure this guard exists to prevent — and is exactly why
46
+ * the other path honours a caller-named organization only *after* the caller has
47
+ * proved it is the fleet.
48
+ */ export const requireOrganization = (permission)=>(use)=>requirePermission(permission)((principal)=>// `Effect.gen` rather than a ternary: the two branches have different
49
+ // success types, and a conditional expression would unify them against
50
+ // the first one instead of widening. `requirePrincipal` yields for the
51
+ // same reason.
52
+ Effect.gen(function*() {
53
+ if (principal.organizationId === undefined) {
54
+ return yield* noOrganization;
55
+ }
56
+ return yield* use(principal.organizationId, principal);
57
+ }));
58
+
59
+ //# sourceMappingURL=require-organization.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/require-organization.ts"],"sourcesContent":["import { HttpServerResponse } from '@effect/platform';\nimport type { Permission } from '@entifix/authz';\nimport { Effect } from 'effect';\n\nimport {\n type RequestPrincipal,\n requirePermission,\n} from './require-principal.js';\n\n/**\n * `409` rather than `403`: the caller is authenticated and permitted, but the\n * session names no organization, so there is no storage to read. An operator\n * hits this by design — crossing into a tenant is an explicit, audited\n * act-as-organization re-mint, never a widening of the default (ADR 0012) —\n * and a member of several organizations hits it before choosing one. The `code`\n * is a key in the shared `errors` catalog, so a client renders \"select an\n * organization\" instead of \"forbidden\" — a claim `@r10c/i18n-check` now holds\n * to, because for a while it was simply false and the code reached the browser\n * as its own literal text.\n */\nconst noOrganization = HttpServerResponse.json(\n { error: 'no active organization', code: 'noActiveOrganization' },\n { status: 409 },\n);\n\n/**\n * Guard a **tenant-plane** handler: authenticate, check the permission, and\n * require that the session names an organization.\n *\n * **This is the first of exactly two ways a request resolves the organization\n * it acts for, and the only *user-facing* one**\n * ([ADR 0006](../../../../../../docs/adr/0006-multitenancy-planes-and-tenant-storage.md)).\n * The second is {@link requireServiceCrossing}: an explicit `organizationId`\n * presented with a service token *and* a narrow route permission, for a\n * platform-plane caller acting for an organization it was handed rather than one\n * it picked\n * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md)).\n *\n * There is no third path, no fallback and no operator branch. A request\n * satisfying neither guard never reaches a tenant handle at all, which is what\n * keeps the most sensitive capability in the system from being the *absence* of\n * a condition.\n *\n * The organization is handed to the handler so it can resolve its own storage\n * through a `TenantDatabaseResolver`. Resolution happens **inside the request**\n * — the connection pool is a boot-time `Layer`, and a per-request `Layer` would\n * rebuild the pool per request.\n *\n * Here the value comes from the verified token and nowhere else. A route\n * parameter or a body field naming an organization would be caller-controlled,\n * which is the whole failure this guard exists to prevent — and is exactly why\n * the other path honours a caller-named organization only *after* the caller has\n * proved it is the fleet.\n */\nexport const requireOrganization =\n (permission: Permission) =>\n <A, E, R>(\n use: (\n organizationId: string,\n principal: RequestPrincipal,\n ) => Effect.Effect<A, E, R>,\n ) =>\n requirePermission(permission)(principal =>\n // `Effect.gen` rather than a ternary: the two branches have different\n // success types, and a conditional expression would unify them against\n // the first one instead of widening. `requirePrincipal` yields for the\n // same reason.\n Effect.gen(function* () {\n if (principal.organizationId === undefined) {\n return yield* noOrganization;\n }\n return yield* use(principal.organizationId, principal);\n }),\n );\n"],"names":["HttpServerResponse","Effect","requirePermission","noOrganization","json","error","code","status","requireOrganization","permission","use","principal","gen","organizationId","undefined"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,mBAAmB;AAEtD,SAASC,MAAM,QAAQ,SAAS;AAEhC,SAEEC,iBAAiB,QACZ,yBAAyB;AAEhC;;;;;;;;;;CAUC,GACD,MAAMC,iBAAiBH,mBAAmBI,IAAI,CAC5C;IAAEC,OAAO;IAA0BC,MAAM;AAAuB,GAChE;IAAEC,QAAQ;AAAI;AAGhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,sBACX,CAACC,aACD,CACEC,MAKAR,kBAAkBO,YAAYE,CAAAA,YAC5B,sEAAsE;YACtE,uEAAuE;YACvE,uEAAuE;YACvE,eAAe;YACfV,OAAOW,GAAG,CAAC;gBACT,IAAID,UAAUE,cAAc,KAAKC,WAAW;oBAC1C,OAAO,OAAOX;gBAChB;gBACA,OAAO,OAAOO,IAAIC,UAAUE,cAAc,EAAEF;YAC9C,IACA"}
@@ -0,0 +1,70 @@
1
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
2
+ import { type Permission, PolicyDecisionTag } from '@entifix/authz';
3
+ import { type TokenClaims, TokenServiceTag } from '@entifix/business';
4
+ import { Effect } from 'effect';
5
+ /**
6
+ * The authenticated subject as a service sees it. Structurally identical to
7
+ * `business-ts-authn`'s `Principal`, re-declared here so the shared shell does
8
+ * not depend on a single domain package.
9
+ */
10
+ export interface RequestPrincipal {
11
+ readonly userId: TokenClaims['userId'];
12
+ readonly subject: string;
13
+ readonly sessionId: string;
14
+ readonly roles: readonly string[];
15
+ /**
16
+ * The organization this request acts for — the storage routing key, absent
17
+ * for a caller with no tenant scope (a buyer, or an operator). Kept out of
18
+ * `attributes` deliberately: attributes are policy inputs, this decides which
19
+ * database a handler reads.
20
+ */
21
+ readonly organizationId?: string;
22
+ /**
23
+ * Which population the caller belongs to — buyer, vendor staff, or platform
24
+ * staff. Carried so a handler never has to infer it from an absent
25
+ * `organizationId`, which a buyer and an operator share.
26
+ *
27
+ * Context, not a grant: what the caller may *do* still comes from `roles`
28
+ * through {@link PolicyDecisionTag}.
29
+ */
30
+ readonly partyRole?: string;
31
+ /**
32
+ * The party behind the account — the `Individual` id, absent for an account
33
+ * with no party record.
34
+ *
35
+ * Same rule as the two fields above: it comes from the verified token and
36
+ * never from anything the caller sets on the request. It says *whose* records
37
+ * these are, which is what a route scoping a read to its caller needs; it
38
+ * says nothing about what may be done with them.
39
+ */
40
+ readonly partyId?: string;
41
+ /**
42
+ * When the verified token stops being valid, as a Unix timestamp in seconds.
43
+ *
44
+ * Every REST route is already bounded by it implicitly — verification is
45
+ * stateless, so the next call after expiry simply fails. A **held-open**
46
+ * response has no next call, so `GET /api/transaction/events` reads it and
47
+ * closes the connection there (ADR 0036). Absent when the token carries no
48
+ * `exp`, which a signed one always does; the guard is for the type, not for a
49
+ * case the fleet produces.
50
+ */
51
+ readonly expiresAt?: number;
52
+ readonly attributes: Readonly<Record<string, unknown>>;
53
+ }
54
+ /**
55
+ * Guard a handler behind a valid access token — authentication only. Any
56
+ * missing or invalid token is a `401`; the wrapped handler's own errors are
57
+ * left intact.
58
+ */
59
+ export declare const requirePrincipal: <A, E, R>(use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("@effect/platform/HttpBody").HttpBodyError | E, TokenServiceTag | HttpServerRequest.HttpServerRequest | R>;
60
+ /**
61
+ * Guard a handler behind a permission. Authenticates as above, then asks
62
+ * {@link PolicyDecisionTag} whether this subject may perform the action — so
63
+ * the two failure modes stay distinguishable: `401` means "sign in", `403`
64
+ * means "signed in, not allowed".
65
+ *
66
+ * **This is the authorization boundary.** Filtering a nav menu or hiding a page
67
+ * is presentation; a route that skips this guard is open regardless.
68
+ */
69
+ export declare const requirePermission: (permission: Permission) => <A, E, R>(use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("@effect/platform/HttpBody").HttpBodyError | E, PolicyDecisionTag | TokenServiceTag | HttpServerRequest.HttpServerRequest | R>;
70
+ //# sourceMappingURL=require-principal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-principal.d.ts","sourceRoot":"","sources":["../../src/auth/require-principal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAEL,KAAK,UAAU,EACf,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,KAAK,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEtE,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAExC;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;OASG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxD;AAmED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EACtC,KAAK,CAAC,SAAS,EAAE,gBAAgB,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,+KAQ1D,CAAC;AAEL;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAC3B,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,gBAAgB,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,mMAmBlE,CAAC"}
@@ -0,0 +1,105 @@
1
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
2
+ import { parsePermission, PolicyDecisionTag } from '@entifix/authz';
3
+ import { TokenServiceTag } from '@entifix/business';
4
+ import { ACCESS_COOKIE } from '@entifix/core';
5
+ import { Effect, Option } from 'effect';
6
+ /** Extract a bearer token from an `Authorization` header, if present. */ const bearerToken = (authorization)=>{
7
+ if (authorization === undefined) {
8
+ return undefined;
9
+ }
10
+ const [scheme, value] = authorization.split(' ');
11
+ return (scheme == null ? void 0 : scheme.toLowerCase()) === 'bearer' ? value : undefined;
12
+ };
13
+ /** Rebuild the request principal from verified token claims. */ const claimsToPrincipal = (claims)=>({
14
+ userId: claims.userId,
15
+ subject: claims.subject,
16
+ sessionId: claims.sessionId,
17
+ roles: claims.roles,
18
+ // The organization comes from the *verified* token and nowhere else — never
19
+ // from a route parameter, a query string or a body, all of which the caller
20
+ // controls. That is what makes tenant isolation a property of the session
21
+ // rather than of every handler remembering to check.
22
+ organizationId: claims.activeOrganizationId,
23
+ // Same rule, same reason: it comes from the verified token, never from
24
+ // anything the caller can set on the request.
25
+ partyRole: claims.partyRole,
26
+ partyId: claims.partyId,
27
+ // `TokenClaims` carries an index signature, so `exp` arrives as `unknown`
28
+ // even though jose always sets it — hence the guard rather than a cast.
29
+ expiresAt: typeof claims.exp === 'number' ? claims.exp : undefined,
30
+ // Rich/volatile attributes are not in the token; a handler that needs them
31
+ // reads the session store by `sessionId`.
32
+ attributes: {}
33
+ });
34
+ // `code` is a key in the shared `errors` catalog; `error` stays English for
35
+ // logs. `permission` is deliberately still in the 403 — it says which grant was
36
+ // missing, which is diagnostic, not a leak.
37
+ const unauthenticated = HttpServerResponse.json({
38
+ error: 'unauthenticated',
39
+ code: 'unauthenticated'
40
+ }, {
41
+ status: 401
42
+ });
43
+ const forbidden = (permission)=>HttpServerResponse.json({
44
+ error: 'forbidden',
45
+ code: 'forbidden',
46
+ permission
47
+ }, {
48
+ status: 403
49
+ });
50
+ /**
51
+ * Resolve the caller's principal from the request, or `None`. Reads the token
52
+ * from the `entifix_at` cookie (the Next app forwards it) or an
53
+ * `Authorization: Bearer` header and verifies it statelessly via
54
+ * {@link TokenServiceTag} — no store round trip on the hot path.
55
+ */ const resolvePrincipal = Effect.gen(function*() {
56
+ var _req_cookies_ACCESS_COOKIE;
57
+ const req = yield* HttpServerRequest.HttpServerRequest;
58
+ const token = (_req_cookies_ACCESS_COOKIE = req.cookies[ACCESS_COOKIE]) != null ? _req_cookies_ACCESS_COOKIE : bearerToken(req.headers['authorization']);
59
+ if (token === undefined) {
60
+ return Option.none();
61
+ }
62
+ const tokens = yield* TokenServiceTag;
63
+ const claims = yield* tokens.verify(token).pipe(Effect.option);
64
+ return Option.map(claims, claimsToPrincipal);
65
+ });
66
+ /**
67
+ * Guard a handler behind a valid access token — authentication only. Any
68
+ * missing or invalid token is a `401`; the wrapped handler's own errors are
69
+ * left intact.
70
+ */ export const requirePrincipal = (use)=>Effect.gen(function*() {
71
+ const principal = yield* resolvePrincipal;
72
+ if (Option.isNone(principal)) {
73
+ return yield* unauthenticated;
74
+ }
75
+ return yield* use(principal.value);
76
+ });
77
+ /**
78
+ * Guard a handler behind a permission. Authenticates as above, then asks
79
+ * {@link PolicyDecisionTag} whether this subject may perform the action — so
80
+ * the two failure modes stay distinguishable: `401` means "sign in", `403`
81
+ * means "signed in, not allowed".
82
+ *
83
+ * **This is the authorization boundary.** Filtering a nav menu or hiding a page
84
+ * is presentation; a route that skips this guard is open regardless.
85
+ */ export const requirePermission = (permission)=>(use)=>Effect.gen(function*() {
86
+ const principal = yield* resolvePrincipal;
87
+ if (Option.isNone(principal)) {
88
+ return yield* unauthenticated;
89
+ }
90
+ const { resource, action } = parsePermission(permission);
91
+ const policy = yield* PolicyDecisionTag;
92
+ const allowed = policy.decide({
93
+ subject: {
94
+ roles: principal.value.roles
95
+ },
96
+ resource,
97
+ action
98
+ });
99
+ if (!allowed) {
100
+ return yield* forbidden(permission);
101
+ }
102
+ return yield* use(principal.value);
103
+ });
104
+
105
+ //# sourceMappingURL=require-principal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/require-principal.ts"],"sourcesContent":["import { HttpServerRequest, HttpServerResponse } from '@effect/platform';\nimport {\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';\n\n/**\n * The authenticated subject as a service sees it. Structurally identical to\n * `business-ts-authn`'s `Principal`, re-declared here so the shared shell does\n * not depend on a single domain package.\n */\nexport interface RequestPrincipal {\n readonly userId: TokenClaims['userId'];\n readonly subject: string;\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this request acts for — the storage routing key, absent\n * for a caller with no tenant scope (a buyer, or an operator). Kept out of\n * `attributes` deliberately: attributes are policy inputs, this decides which\n * database a handler reads.\n */\n readonly organizationId?: string;\n /**\n * Which population the caller belongs to — buyer, vendor staff, or platform\n * staff. Carried so a handler never has to infer it from an absent\n * `organizationId`, which a buyer and an operator share.\n *\n * Context, not a grant: what the caller may *do* still comes from `roles`\n * through {@link PolicyDecisionTag}.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id, absent for an account\n * with no party record.\n *\n * Same rule as the two fields above: it comes from the verified token and\n * never from anything the caller sets on the request. It says *whose* records\n * these are, which is what a route scoping a read to its caller needs; it\n * says nothing about what may be done with them.\n */\n readonly partyId?: string;\n /**\n * When the verified token stops being valid, as a Unix timestamp in seconds.\n *\n * Every REST route is already bounded by it implicitly — verification is\n * stateless, so the next call after expiry simply fails. A **held-open**\n * response has no next call, so `GET /api/transaction/events` reads it and\n * closes the connection there (ADR 0036). Absent when the token carries no\n * `exp`, which a signed one always does; the guard is for the type, not for a\n * case the fleet produces.\n */\n readonly expiresAt?: number;\n readonly attributes: Readonly<Record<string, unknown>>;\n}\n\n/** Extract a bearer token from an `Authorization` header, if present. */\nconst bearerToken = (authorization: string | undefined): string | undefined => {\n if (authorization === undefined) {\n return undefined;\n }\n const [scheme, value] = authorization.split(' ');\n return scheme?.toLowerCase() === 'bearer' ? value : undefined;\n};\n\n/** Rebuild the request principal from verified token claims. */\nconst claimsToPrincipal = (claims: TokenClaims): RequestPrincipal => ({\n userId: claims.userId,\n subject: claims.subject,\n sessionId: claims.sessionId,\n roles: claims.roles,\n // The organization comes from the *verified* token and nowhere else — never\n // from a route parameter, a query string or a body, all of which the caller\n // controls. That is what makes tenant isolation a property of the session\n // rather than of every handler remembering to check.\n organizationId: claims.activeOrganizationId,\n // Same rule, same reason: it comes from the verified token, never from\n // anything the caller can set on the request.\n partyRole: claims.partyRole,\n partyId: claims.partyId,\n // `TokenClaims` carries an index signature, so `exp` arrives as `unknown`\n // even though jose always sets it — hence the guard rather than a cast.\n expiresAt: typeof claims.exp === 'number' ? claims.exp : undefined,\n // Rich/volatile attributes are not in the token; a handler that needs them\n // reads the session store by `sessionId`.\n attributes: {},\n});\n\n// `code` is a key in the shared `errors` catalog; `error` stays English for\n// logs. `permission` is deliberately still in the 403 — it says which grant was\n// missing, which is diagnostic, not a leak.\nconst unauthenticated = HttpServerResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n);\n\nconst forbidden = (permission: Permission) =>\n HttpServerResponse.json(\n { error: 'forbidden', code: 'forbidden', permission },\n { status: 403 },\n );\n\n/**\n * Resolve the caller's principal from the request, or `None`. Reads the token\n * from the `entifix_at` cookie (the Next app forwards it) or an\n * `Authorization: Bearer` header and verifies it statelessly via\n * {@link TokenServiceTag} — no store round trip on the hot path.\n */\nconst resolvePrincipal = Effect.gen(function* () {\n const req = yield* HttpServerRequest.HttpServerRequest;\n const token =\n req.cookies[ACCESS_COOKIE] ?? bearerToken(req.headers['authorization']);\n if (token === undefined) {\n return Option.none<RequestPrincipal>();\n }\n\n const tokens = yield* TokenServiceTag;\n const claims = yield* tokens.verify(token).pipe(Effect.option);\n return Option.map(claims, claimsToPrincipal);\n});\n\n/**\n * Guard a handler behind a valid access token — authentication only. Any\n * missing or invalid token is a `401`; the wrapped handler's own errors are\n * left intact.\n */\nexport const requirePrincipal = <A, E, R>(\n use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>,\n) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n return yield* use(principal.value);\n });\n\n/**\n * Guard a handler behind a permission. Authenticates as above, then asks\n * {@link PolicyDecisionTag} whether this subject may perform the action — so\n * the two failure modes stay distinguishable: `401` means \"sign in\", `403`\n * means \"signed in, not allowed\".\n *\n * **This is the authorization boundary.** Filtering a nav menu or hiding a page\n * is presentation; a route that skips this guard is open regardless.\n */\nexport const requirePermission =\n (permission: Permission) =>\n <A, E, R>(use: (principal: RequestPrincipal) => Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const principal = yield* resolvePrincipal;\n if (Option.isNone(principal)) {\n return yield* unauthenticated;\n }\n\n const { resource, action } = parsePermission(permission);\n const policy = yield* PolicyDecisionTag;\n const allowed = policy.decide({\n subject: { roles: principal.value.roles },\n resource,\n action,\n });\n if (!allowed) {\n return yield* forbidden(permission);\n }\n\n return yield* use(principal.value);\n });\n"],"names":["HttpServerRequest","HttpServerResponse","parsePermission","PolicyDecisionTag","TokenServiceTag","ACCESS_COOKIE","Effect","Option","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,SAASA,iBAAiB,EAAEC,kBAAkB,QAAQ,mBAAmB;AACzE,SACEC,eAAe,EAEfC,iBAAiB,QACZ,iBAAiB;AACxB,SAA2BC,eAAe,QAAQ,oBAAoB;AACtE,SAASC,aAAa,QAAQ,gBAAgB;AAC9C,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAoDxC,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,kBAAkB3B,mBAAmB4B,IAAI,CAC7C;IAAEC,OAAO;IAAmBC,MAAM;AAAkB,GACpD;IAAEC,QAAQ;AAAI;AAGhB,MAAMC,YAAY,CAACC,aACjBjC,mBAAmB4B,IAAI,CACrB;QAAEC,OAAO;QAAaC,MAAM;QAAaG;IAAW,GACpD;QAAEF,QAAQ;IAAI;AAGlB;;;;;CAKC,GACD,MAAMG,mBAAmB7B,OAAO8B,GAAG,CAAC;QAGhCC;IAFF,MAAMA,MAAM,OAAOrC,kBAAkBA,iBAAiB;IACtD,MAAMsC,SACJD,6BAAAA,IAAIE,OAAO,CAAClC,cAAc,YAA1BgC,6BAA8B7B,YAAY6B,IAAIG,OAAO,CAAC,gBAAgB;IACxE,IAAIF,UAAU5B,WAAW;QACvB,OAAOH,OAAOkC,IAAI;IACpB;IAEA,MAAMC,SAAS,OAAOtC;IACtB,MAAMY,SAAS,OAAO0B,OAAOC,MAAM,CAACL,OAAOM,IAAI,CAACtC,OAAOuC,MAAM;IAC7D,OAAOtC,OAAOuC,GAAG,CAAC9B,QAAQD;AAC5B;AAEA;;;;CAIC,GACD,OAAO,MAAMgC,mBAAmB,CAC9BC,MAEA1C,OAAO8B,GAAG,CAAC;QACT,MAAMa,YAAY,OAAOd;QACzB,IAAI5B,OAAO2C,MAAM,CAACD,YAAY;YAC5B,OAAO,OAAOrB;QAChB;QACA,OAAO,OAAOoB,IAAIC,UAAUrC,KAAK;IACnC,GAAG;AAEL;;;;;;;;CAQC,GACD,OAAO,MAAMuC,oBACX,CAACjB,aACD,CAAUc,MACR1C,OAAO8B,GAAG,CAAC;YACT,MAAMa,YAAY,OAAOd;YACzB,IAAI5B,OAAO2C,MAAM,CAACD,YAAY;gBAC5B,OAAO,OAAOrB;YAChB;YAEA,MAAM,EAAEwB,QAAQ,EAAEC,MAAM,EAAE,GAAGnD,gBAAgBgC;YAC7C,MAAMoB,SAAS,OAAOnD;YACtB,MAAMoD,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"}
@@ -0,0 +1,107 @@
1
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
2
+ import { type Permission, ServiceCrossingPolicyTag } from '@entifix/authz';
3
+ import { Context, Effect } from 'effect';
4
+ /**
5
+ * The header carrying the crossing secret.
6
+ *
7
+ * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's
8
+ * fleet lookup, and the two secrets are not comparable: one grants a
9
+ * configuration *read*, this one a tenant-data *write* for any organization the
10
+ * caller names. Sharing the header name is how a proxy configured to forward the
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)).
14
+ */
15
+ export declare const CROSSING_TOKEN_HEADER = "x-crossing-token";
16
+ /**
17
+ * The header naming the organization the caller is acting for.
18
+ *
19
+ * It rides beside the token rather than inside the request body because
20
+ * entities are organization-agnostic by rule — no `organizationId` member, no
21
+ * tenant filter — so there is nowhere in an entity envelope for it to live. A
22
+ * header also serves the crossings that carry no body at all (releasing a hold,
23
+ * converting one), which a body field would have needed a second convention for.
24
+ *
25
+ * On its own it grants **nothing**: it is only read after the token has proved
26
+ * the caller is the fleet, so the input the caller controls is never the input
27
+ * that authorizes.
28
+ */
29
+ export declare const ORGANIZATION_HEADER = "x-organization-id";
30
+ declare const ServiceCrossingTokenTag_base: Context.TagClass<ServiceCrossingTokenTag, "ServiceCrossingTokenTag", string>;
31
+ /**
32
+ * The crossing secret this service expects, provided at the composition root
33
+ * from its own configuration.
34
+ *
35
+ * A `Context.Tag` rather than an environment read, which is the difference from
36
+ * {@link requireServiceToken}: the value is a per-service secret held in
37
+ * config-service as an `is_secret` row, so it arrives through the same boot
38
+ * fetch as `mongo.uri` and can be rotated without redeploying a container.
39
+ */
40
+ export declare class ServiceCrossingTokenTag extends ServiceCrossingTokenTag_base {
41
+ }
42
+ /**
43
+ * Guard a **service-to-service** tenant crossing: the second — and only other —
44
+ * way a request resolves the organization it acts for.
45
+ *
46
+ * The first is {@link requireOrganization}, which reads the session's
47
+ * `activeOrganizationId` and is the only *user-facing* resolution. This one
48
+ * exists because a platform-plane caller sometimes acts for an organization the
49
+ * principal is not a member of: checkout reserving a vendor's stock, where the
50
+ * organization comes from the **item** and a buyer's session carries none — and
51
+ * never will, since making a buyer a member of every vendor they buy from would
52
+ * be a far worse breach than the problem it solved.
53
+ *
54
+ * There is no third path, no fallback and no operator branch. A request
55
+ * satisfying neither guard simply never reaches a tenant handle
56
+ * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);
57
+ * ADR 0012's operator crossing is a *different* mechanism — a person choosing an
58
+ * organization, which needs a human's permission, a time box and a `Crossing`
59
+ * record).
60
+ *
61
+ * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is
62
+ * the design.** A route guarded here accepts the crossing token and nothing
63
+ * else: two accepted credentials on one route means the weaker one is the
64
+ * security level. It matters concretely — `super-admin` holds `*:*:*`, which
65
+ * matches every crossing permission, so a route that also took a session would
66
+ * be reachable by an operator's browser tab.
67
+ *
68
+ * Three checks, in this order, so nothing is revealed before fleet membership is
69
+ * proven:
70
+ *
71
+ * 1. the token, compared in constant time → `401`;
72
+ * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token
73
+ * proves the caller is the fleet; it does not say what the caller may do, and
74
+ * fleet membership is not a capability;
75
+ * 3. the organization header, non-blank → `400`.
76
+ *
77
+ * **The recorded residual** (ADR 0023, not argued away here): a shared secret
78
+ * means any process holding it can name any organization. What bounds it is the
79
+ * permission above, the fact that only the slices needing it are configured with
80
+ * the key, and the request log. What would remove it is an RS256 service token
81
+ * carrying a scope claim, which is named work rather than a gap nobody noticed.
82
+ */
83
+ export declare const requireServiceCrossing: (permission: Permission) => <A, E, R>(use: (organizationId: string) => Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("@effect/platform/HttpBody").HttpBodyError | E, ServiceCrossingPolicyTag | HttpServerRequest.HttpServerRequest | ServiceCrossingTokenTag | R>;
84
+ /**
85
+ * The same fleet check, for a participant that has **no organization to name**.
86
+ *
87
+ * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`
88
+ * above ends with an organization header because it is about to resolve a tenant
89
+ * database handle from it, and a blank one there would read some other vendor's
90
+ * storage. order-service names one database at boot, so there is no handle to
91
+ * choose — and demanding a header nothing consumes would be theatre that a
92
+ * caller learns to satisfy with any value.
93
+ *
94
+ * What is unchanged, and is the part that matters: the token proves the caller
95
+ * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,
96
+ * and **no session is accepted**. A saga step is not a person's act — the buyer
97
+ * behind a checkout holds no grant over the order the coordinator is writing on
98
+ * their behalf, and accepting their session here would make the weaker
99
+ * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).
100
+ *
101
+ * The recorded residual carries over verbatim: a shared secret means any process
102
+ * holding it can act, bounded by the permission and by which slices are
103
+ * configured with the key.
104
+ */
105
+ export declare const requireCrossing: (permission: Permission) => <A, E, R>(route: Effect.Effect<A, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse | A, import("@effect/platform/HttpBody").HttpBodyError | E, ServiceCrossingPolicyTag | HttpServerRequest.HttpServerRequest | ServiceCrossingTokenTag | R>;
106
+ export {};
107
+ //# sourceMappingURL=require-service-crossing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-service-crossing.d.ts","sourceRoot":"","sources":["../../src/auth/require-service-crossing.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAAE,KAAK,UAAU,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;;AAEvD;;;;;;;;GAQG;AACH,qBAAa,uBAAwB,SAAQ,4BAET;CAAG;AAgCvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,sBAAsB,GAChC,YAAY,UAAU,MACtB,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,cAAc,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,kNAwB7D,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,kNAenC,CAAC"}
@@ -0,0 +1,161 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { HttpServerRequest, HttpServerResponse } from '@effect/platform';
3
+ import { ServiceCrossingPolicyTag } from '@entifix/authz';
4
+ import { Context, Effect } from 'effect';
5
+ /**
6
+ * The header carrying the crossing secret.
7
+ *
8
+ * ⚠️ **Deliberately not `x-service-token`.** That header is config-service's
9
+ * fleet lookup, and the two secrets are not comparable: one grants a
10
+ * configuration *read*, this one a tenant-data *write* for any organization the
11
+ * caller names. Sharing the header name is how a proxy configured to forward the
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)).
15
+ */ export const CROSSING_TOKEN_HEADER = 'x-crossing-token';
16
+ /**
17
+ * The header naming the organization the caller is acting for.
18
+ *
19
+ * It rides beside the token rather than inside the request body because
20
+ * entities are organization-agnostic by rule — no `organizationId` member, no
21
+ * tenant filter — so there is nowhere in an entity envelope for it to live. A
22
+ * header also serves the crossings that carry no body at all (releasing a hold,
23
+ * converting one), which a body field would have needed a second convention for.
24
+ *
25
+ * On its own it grants **nothing**: it is only read after the token has proved
26
+ * the caller is the fleet, so the input the caller controls is never the input
27
+ * that authorizes.
28
+ */ export const ORGANIZATION_HEADER = 'x-organization-id';
29
+ /**
30
+ * The crossing secret this service expects, provided at the composition root
31
+ * from its own configuration.
32
+ *
33
+ * A `Context.Tag` rather than an environment read, which is the difference from
34
+ * {@link requireServiceToken}: the value is a per-service secret held in
35
+ * config-service as an `is_secret` row, so it arrives through the same boot
36
+ * fetch as `mongo.uri` and can be rotated without redeploying a container.
37
+ */ export class ServiceCrossingTokenTag extends Context.Tag('ServiceCrossingTokenTag')() {
38
+ }
39
+ /** Constant-time comparison, so the secret cannot be recovered byte by byte. */ const matches = (provided, expected)=>{
40
+ if (provided === undefined) return false;
41
+ const left = Buffer.from(provided);
42
+ const right = Buffer.from(expected);
43
+ // `timingSafeEqual` throws on a length mismatch, so lengths are compared
44
+ // first — the length of a shared secret is not what protects it.
45
+ return left.length === right.length && timingSafeEqual(left, right);
46
+ };
47
+ const unauthenticated = HttpServerResponse.json({
48
+ error: 'unauthenticated',
49
+ code: 'unauthenticated'
50
+ }, {
51
+ status: 401
52
+ });
53
+ const forbidden = (permission)=>HttpServerResponse.json({
54
+ error: 'forbidden',
55
+ code: 'forbidden',
56
+ permission
57
+ }, {
58
+ status: 403
59
+ });
60
+ const organizationRequired = HttpServerResponse.json({
61
+ error: 'the crossing must name an organization',
62
+ code: 'invalidRequest',
63
+ detail: `${ORGANIZATION_HEADER} is required`
64
+ }, {
65
+ status: 400
66
+ });
67
+ /**
68
+ * Guard a **service-to-service** tenant crossing: the second — and only other —
69
+ * way a request resolves the organization it acts for.
70
+ *
71
+ * The first is {@link requireOrganization}, which reads the session's
72
+ * `activeOrganizationId` and is the only *user-facing* resolution. This one
73
+ * exists because a platform-plane caller sometimes acts for an organization the
74
+ * principal is not a member of: checkout reserving a vendor's stock, where the
75
+ * organization comes from the **item** and a buyer's session carries none — and
76
+ * never will, since making a buyer a member of every vendor they buy from would
77
+ * be a far worse breach than the problem it solved.
78
+ *
79
+ * There is no third path, no fallback and no operator branch. A request
80
+ * satisfying neither guard simply never reaches a tenant handle
81
+ * ([ADR 0023](../../../../../../docs/adr/0023-service-to-service-tenant-crossing.md);
82
+ * ADR 0012's operator crossing is a *different* mechanism — a person choosing an
83
+ * organization, which needs a human's permission, a time box and a `Crossing`
84
+ * record).
85
+ *
86
+ * ⚠️ **This guard reads no cookie and no `Authorization` header, and that is
87
+ * the design.** A route guarded here accepts the crossing token and nothing
88
+ * else: two accepted credentials on one route means the weaker one is the
89
+ * security level. It matters concretely — `super-admin` holds `*:*:*`, which
90
+ * matches every crossing permission, so a route that also took a session would
91
+ * be reachable by an operator's browser tab.
92
+ *
93
+ * Three checks, in this order, so nothing is revealed before fleet membership is
94
+ * proven:
95
+ *
96
+ * 1. the token, compared in constant time → `401`;
97
+ * 2. the permission, against `ServiceCrossingPolicyTag` → `403`. The token
98
+ * proves the caller is the fleet; it does not say what the caller may do, and
99
+ * fleet membership is not a capability;
100
+ * 3. the organization header, non-blank → `400`.
101
+ *
102
+ * **The recorded residual** (ADR 0023, not argued away here): a shared secret
103
+ * means any process holding it can name any organization. What bounds it is the
104
+ * permission above, the fact that only the slices needing it are configured with
105
+ * the key, and the request log. What would remove it is an RS256 service token
106
+ * carrying a scope claim, which is named work rather than a gap nobody noticed.
107
+ */ export const requireServiceCrossing = (permission)=>(use)=>// The return type is inferred rather than written out, for the reason
108
+ // `requireServiceToken` records: `HttpServerResponse.json` contributes an
109
+ // `HttpBodyError` to the failure channel, and spelling the type out while
110
+ // omitting it is what makes the declaration emit fail invisibly.
111
+ Effect.gen(function*() {
112
+ var _request_headers_ORGANIZATION_HEADER;
113
+ const request = yield* HttpServerRequest.HttpServerRequest;
114
+ const expected = yield* ServiceCrossingTokenTag;
115
+ if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {
116
+ return yield* unauthenticated;
117
+ }
118
+ const crossing = yield* ServiceCrossingPolicyTag;
119
+ if (!crossing.allows(permission)) {
120
+ return yield* forbidden(permission);
121
+ }
122
+ const organizationId = (_request_headers_ORGANIZATION_HEADER = request.headers[ORGANIZATION_HEADER]) == null ? void 0 : _request_headers_ORGANIZATION_HEADER.trim();
123
+ if (organizationId === undefined || organizationId === '') {
124
+ return yield* organizationRequired;
125
+ }
126
+ return yield* use(organizationId);
127
+ });
128
+ /**
129
+ * The same fleet check, for a participant that has **no organization to name**.
130
+ *
131
+ * ⚠️ **A platform-plane store is the whole difference.** `requireServiceCrossing`
132
+ * above ends with an organization header because it is about to resolve a tenant
133
+ * database handle from it, and a blank one there would read some other vendor's
134
+ * storage. order-service names one database at boot, so there is no handle to
135
+ * choose — and demanding a header nothing consumes would be theatre that a
136
+ * caller learns to satisfy with any value.
137
+ *
138
+ * What is unchanged, and is the part that matters: the token proves the caller
139
+ * is the fleet, the host's `ServiceCrossingPolicy` says what the fleet may do,
140
+ * and **no session is accepted**. A saga step is not a person's act — the buyer
141
+ * behind a checkout holds no grant over the order the coordinator is writing on
142
+ * their behalf, and accepting their session here would make the weaker
143
+ * credential the security level ([ADR 0052](../../../../../../docs/adr/0052-the-checkout-saga.md)).
144
+ *
145
+ * The recorded residual carries over verbatim: a shared secret means any process
146
+ * holding it can act, bounded by the permission and by which slices are
147
+ * configured with the key.
148
+ */ export const requireCrossing = (permission)=>(route)=>Effect.gen(function*() {
149
+ const request = yield* HttpServerRequest.HttpServerRequest;
150
+ const expected = yield* ServiceCrossingTokenTag;
151
+ if (!matches(request.headers[CROSSING_TOKEN_HEADER], expected)) {
152
+ return yield* unauthenticated;
153
+ }
154
+ const crossing = yield* ServiceCrossingPolicyTag;
155
+ if (!crossing.allows(permission)) {
156
+ return yield* forbidden(permission);
157
+ }
158
+ return yield* route;
159
+ });
160
+
161
+ //# sourceMappingURL=require-service-crossing.js.map