@entifix/business 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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/health/health-registry.d.ts +87 -0
- package/dist/health/health-registry.d.ts.map +1 -0
- package/dist/health/health-registry.js +58 -0
- package/dist/health/health-registry.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/repository/config.repository.d.ts +7 -0
- package/dist/repository/config.repository.d.ts.map +1 -0
- package/dist/repository/config.repository.js +5 -0
- package/dist/repository/config.repository.js.map +1 -0
- package/dist/repository/entity-link-resolver.repository.d.ts +34 -0
- package/dist/repository/entity-link-resolver.repository.d.ts.map +1 -0
- package/dist/repository/entity-link-resolver.repository.js +42 -0
- package/dist/repository/entity-link-resolver.repository.js.map +1 -0
- package/dist/repository/entity-mixins.repository.d.ts +13 -0
- package/dist/repository/entity-mixins.repository.d.ts.map +1 -0
- package/dist/repository/entity-mixins.repository.js +9 -0
- package/dist/repository/entity-mixins.repository.js.map +1 -0
- package/dist/repository/entity.repository.d.ts +15 -0
- package/dist/repository/entity.repository.d.ts.map +1 -0
- package/dist/repository/entity.repository.js +5 -0
- package/dist/repository/entity.repository.js.map +1 -0
- package/dist/sessions/one-time-token-store.d.ts +40 -0
- package/dist/sessions/one-time-token-store.d.ts.map +1 -0
- package/dist/sessions/one-time-token-store.js +5 -0
- package/dist/sessions/one-time-token-store.js.map +1 -0
- package/dist/sessions/session-store.d.ts +145 -0
- package/dist/sessions/session-store.d.ts.map +1 -0
- package/dist/sessions/session-store.js +5 -0
- package/dist/sessions/session-store.js.map +1 -0
- package/dist/shutdown/shutdown-registry.d.ts +84 -0
- package/dist/shutdown/shutdown-registry.d.ts.map +1 -0
- package/dist/shutdown/shutdown-registry.js +88 -0
- package/dist/shutdown/shutdown-registry.js.map +1 -0
- package/dist/tenancy/index.d.ts +2 -0
- package/dist/tenancy/index.d.ts.map +1 -0
- package/dist/tenancy/index.js +3 -0
- package/dist/tenancy/index.js.map +1 -0
- package/dist/tenancy/tenant-database-resolver.d.ts +38 -0
- package/dist/tenancy/tenant-database-resolver.d.ts.map +1 -0
- package/dist/tenancy/tenant-database-resolver.js +9 -0
- package/dist/tenancy/tenant-database-resolver.js.map +1 -0
- package/dist/tokens/token-service.d.ts +101 -0
- package/dist/tokens/token-service.d.ts.map +1 -0
- package/dist/tokens/token-service.js +5 -0
- package/dist/tokens/token-service.js.map +1 -0
- package/dist/use-case/delete/delete.uc.d.ts +13 -0
- package/dist/use-case/delete/delete.uc.d.ts.map +1 -0
- package/dist/use-case/delete/delete.uc.js +18 -0
- package/dist/use-case/delete/delete.uc.js.map +1 -0
- package/dist/use-case/delete/index.d.ts +2 -0
- package/dist/use-case/delete/index.d.ts.map +1 -0
- package/dist/use-case/delete/index.js +3 -0
- package/dist/use-case/delete/index.js.map +1 -0
- package/dist/use-case/get/get.uc.d.ts +12 -0
- package/dist/use-case/get/get.uc.d.ts.map +1 -0
- package/dist/use-case/get/get.uc.js +18 -0
- package/dist/use-case/get/get.uc.js.map +1 -0
- package/dist/use-case/get/index.d.ts +2 -0
- package/dist/use-case/get/index.d.ts.map +1 -0
- package/dist/use-case/get/index.js +3 -0
- package/dist/use-case/get/index.js.map +1 -0
- package/dist/use-case/load/index.d.ts +2 -0
- package/dist/use-case/load/index.d.ts.map +1 -0
- package/dist/use-case/load/index.js +3 -0
- package/dist/use-case/load/index.js.map +1 -0
- package/dist/use-case/load/load.uc.d.ts +6 -0
- package/dist/use-case/load/load.uc.d.ts.map +1 -0
- package/dist/use-case/load/load.uc.js +16 -0
- package/dist/use-case/load/load.uc.js.map +1 -0
- package/dist/use-case/save/index.d.ts +2 -0
- package/dist/use-case/save/index.d.ts.map +1 -0
- package/dist/use-case/save/index.js +3 -0
- package/dist/use-case/save/index.js.map +1 -0
- package/dist/use-case/save/save.uc.d.ts +16 -0
- package/dist/use-case/save/save.uc.d.ts.map +1 -0
- package/dist/use-case/save/save.uc.js +24 -0
- package/dist/use-case/save/save.uc.js.map +1 -0
- package/dist/wiring/wiring-registry.d.ts +74 -0
- package/dist/wiring/wiring-registry.d.ts.map +1 -0
- package/dist/wiring/wiring-registry.js +49 -0
- package/dist/wiring/wiring-registry.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { EntifixError, EntityId } from '@entifix/core';
|
|
2
|
+
import { Context } from 'effect';
|
|
3
|
+
import { Effect } from 'effect/Effect';
|
|
4
|
+
/**
|
|
5
|
+
* The subject state a session carries. Deliberately structural (no `Principal`
|
|
6
|
+
* import — that lives in the authn layer above this one) so any domain can
|
|
7
|
+
* store its authenticated subject here. A resolved `Principal` is assignable to
|
|
8
|
+
* it as-is.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* What the session was opened from, for display and for notifying the owner.
|
|
12
|
+
*
|
|
13
|
+
* **A label, never an authorization input.** Every field is client-supplied or
|
|
14
|
+
* client-derived, so a copied cookie copies the device — which is acceptable
|
|
15
|
+
* precisely because nothing here decides anything. It powers "you are signed in
|
|
16
|
+
* on these devices" and "a new device signed in"; it never grants or refuses.
|
|
17
|
+
*/
|
|
18
|
+
export interface DeviceContext {
|
|
19
|
+
/** Opaque, server-minted id carried in a long-lived cookie. */
|
|
20
|
+
readonly deviceId: string;
|
|
21
|
+
readonly browser?: string;
|
|
22
|
+
readonly os?: string;
|
|
23
|
+
/** `desktop` / `mobile` / `tablet`, as the user-agent reports it. */
|
|
24
|
+
readonly type?: string;
|
|
25
|
+
/** Truncated to a /24 — enough for "somewhere else", less PII at rest. */
|
|
26
|
+
readonly ip?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface SessionData {
|
|
29
|
+
readonly userId: EntityId;
|
|
30
|
+
/** Upstream identity subject (e.g. an IdP `sub`, or the canonical user id). */
|
|
31
|
+
readonly subject: string;
|
|
32
|
+
readonly roles: readonly string[];
|
|
33
|
+
readonly attributes: Readonly<Record<string, unknown>>;
|
|
34
|
+
/**
|
|
35
|
+
* The organization this session is acting for. It lives here rather than on
|
|
36
|
+
* the user because a party may belong to several organizations, so switching
|
|
37
|
+
* is a session operation that re-mints the access token — modelling it on the
|
|
38
|
+
* user would make someone serving two vendors keep two accounts.
|
|
39
|
+
*
|
|
40
|
+
* Absent for a session with no organization: a buyer, or an operator, who
|
|
41
|
+
* holds no tenant scope by default.
|
|
42
|
+
*/
|
|
43
|
+
readonly activeOrganizationId?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Which population this session belongs to — buyer, vendor staff, or platform
|
|
46
|
+
* staff. Resolved once when the session opens and carried from there into
|
|
47
|
+
* every access token minted from it, so no service re-derives it per request.
|
|
48
|
+
*
|
|
49
|
+
* A plain string for the same reason `roles` is: the closed set lives in the
|
|
50
|
+
* business layer above this one.
|
|
51
|
+
*/
|
|
52
|
+
readonly partyRole?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The party behind the account — the `Individual` id, not the account id.
|
|
55
|
+
*
|
|
56
|
+
* Resolved once when the session opens, from the only store that holds the
|
|
57
|
+
* user → party mapping, and carried from here into every access token: a
|
|
58
|
+
* service scoping a read to *whose records these are* asks about a party, and
|
|
59
|
+
* has no way to make that hop itself.
|
|
60
|
+
*
|
|
61
|
+
* Absent for an account with no party record, and a consumer must read that
|
|
62
|
+
* as "no party" rather than as "any party".
|
|
63
|
+
*/
|
|
64
|
+
readonly partyId?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The business domains {@link SessionData.activeOrganizationId} is
|
|
67
|
+
* provisioned for. Resolved once when the session opens and carried from here
|
|
68
|
+
* into every access token minted from it, so no service re-reads an
|
|
69
|
+
* entitlement per request — the same arrangement `partyRole` already has.
|
|
70
|
+
*
|
|
71
|
+
* Absent for a session with no organization, which is a different statement
|
|
72
|
+
* from an empty list: the ceiling does not apply rather than denying
|
|
73
|
+
* everything.
|
|
74
|
+
*/
|
|
75
|
+
readonly entitlements?: readonly string[];
|
|
76
|
+
/** Absent for sessions opened by a non-browser caller. */
|
|
77
|
+
readonly device?: DeviceContext;
|
|
78
|
+
}
|
|
79
|
+
/** A stored session as read back — {@link SessionData} plus lifecycle metadata. */
|
|
80
|
+
export interface SessionRecord extends SessionData {
|
|
81
|
+
/** The opaque session id this record is keyed by; the revocation handle. */
|
|
82
|
+
readonly sessionId: string;
|
|
83
|
+
readonly createdAt: string;
|
|
84
|
+
/** End of the current sliding window; moves forward on every {@link SessionStore.touch}. */
|
|
85
|
+
readonly expiresAt: string;
|
|
86
|
+
/**
|
|
87
|
+
* The ceiling, stamped at creation and never moved. A sliding window with no
|
|
88
|
+
* ceiling renews forever, so this is what guarantees every session eventually
|
|
89
|
+
* ends — including one an attacker is keeping warm.
|
|
90
|
+
*/
|
|
91
|
+
readonly absoluteExpiresAt: string;
|
|
92
|
+
}
|
|
93
|
+
/** The two durations a session is opened with. */
|
|
94
|
+
export interface SessionLifetimeSeconds {
|
|
95
|
+
/** Sliding window, renewed on each touch. */
|
|
96
|
+
readonly idleTtlSeconds: number;
|
|
97
|
+
/** Ceiling measured from creation; never extended. */
|
|
98
|
+
readonly absoluteTtlSeconds: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The session persistence seam. An opaque, revocable, TTL-bounded store keyed by
|
|
102
|
+
* a server-minted session id — the single source of truth every service can
|
|
103
|
+
* read a live session from, and the handle an admin/logout revokes through.
|
|
104
|
+
*
|
|
105
|
+
* Framework- and store-agnostic: a Redis adapter backs it in production, a fake
|
|
106
|
+
* in tests, and the use-cases that consume {@link SessionStoreTag} never change.
|
|
107
|
+
*/
|
|
108
|
+
export interface SessionStore {
|
|
109
|
+
/** Persist a new session and return its freshly minted opaque id. */
|
|
110
|
+
create(data: SessionData, lifetime: SessionLifetimeSeconds): Effect<string, EntifixError>;
|
|
111
|
+
/** Read a live session, failing if it is unknown, revoked, or expired. */
|
|
112
|
+
read(sessionId: string): Effect<SessionRecord, EntifixError>;
|
|
113
|
+
/**
|
|
114
|
+
* Renew the sliding window, **clamped to the session's own
|
|
115
|
+
* {@link SessionRecord.absoluteExpiresAt}**. Fails once the ceiling is passed,
|
|
116
|
+
* which is how a long-lived session finally ends.
|
|
117
|
+
*/
|
|
118
|
+
touch(sessionId: string, idleTtlSeconds: number): Effect<void, EntifixError>;
|
|
119
|
+
/**
|
|
120
|
+
* Every live session a user holds.
|
|
121
|
+
*
|
|
122
|
+
* The read side of the index `revokeAllForUser` already walks. Without it
|
|
123
|
+
* there is no way to show someone where they are signed in, and "sign out
|
|
124
|
+
* everywhere" is an action with no visible subject.
|
|
125
|
+
*/
|
|
126
|
+
listForUser(userId: EntityId): Effect<readonly SessionRecord[], EntifixError>;
|
|
127
|
+
/** Revoke a single session — the next {@link read} fails immediately. */
|
|
128
|
+
revoke(sessionId: string): Effect<void, EntifixError>;
|
|
129
|
+
/** Revoke every session for a user (e.g. password change, admin kick). */
|
|
130
|
+
revokeAllForUser(userId: EntityId): Effect<void, EntifixError>;
|
|
131
|
+
/**
|
|
132
|
+
* Revoke every session for a user EXCEPT one.
|
|
133
|
+
*
|
|
134
|
+
* What a password change should do: end every other session, but leave the
|
|
135
|
+
* person who just changed it signed in. `revokeAllForUser` would log them out
|
|
136
|
+
* of the screen they are standing on.
|
|
137
|
+
*/
|
|
138
|
+
revokeAllForUserExcept(userId: EntityId, keepSessionId: string): Effect<void, EntifixError>;
|
|
139
|
+
}
|
|
140
|
+
declare const SessionStoreTag_base: Context.TagClass<SessionStoreTag, "SessionStoreTag", SessionStore>;
|
|
141
|
+
/** DI tag the composition root binds to a concrete {@link SessionStore}. */
|
|
142
|
+
export declare class SessionStoreTag extends SessionStoreTag_base {
|
|
143
|
+
}
|
|
144
|
+
export {};
|
|
145
|
+
//# sourceMappingURL=session-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../../src/sessions/session-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjC,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvC;;;;;GAKG;AACH;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,qEAAqE;IACrE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACvD;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;CACjC;AAED,mFAAmF;AACnF,MAAM,WAAW,aAAc,SAAQ,WAAW;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,4FAA4F;IAC5F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;CACpC;AAED,kDAAkD;AAClD,MAAM,WAAW,sBAAsB;IACrC,6CAA6C;IAC7C,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,sDAAsD;IACtD,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACrC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,MAAM,CACJ,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,sBAAsB,GAC/B,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAChC,0EAA0E;IAC1E,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,aAAa,EAAE,YAAY,CAAC,CAAC;IAC7D;;;;OAIG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC7E;;;;;;OAMG;IACH,WAAW,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC,SAAS,aAAa,EAAE,EAAE,YAAY,CAAC,CAAC;IAC9E,yEAAyE;IACzE,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IACtD,0EAA0E;IAC1E,gBAAgB,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC/D;;;;;;OAMG;IACH,sBAAsB,CACpB,MAAM,EAAE,QAAQ,EAChB,aAAa,EAAE,MAAM,GACpB,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;CAC/B;;AAED,4EAA4E;AAC5E,qBAAa,eAAgB,SAAQ,oBAGlC;CAAG"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/sessions/session-store.ts"],"sourcesContent":["import type { EntifixError, EntityId } from '@entifix/core';\nimport { Context } from 'effect';\nimport { Effect } from 'effect/Effect';\n\n/**\n * The subject state a session carries. Deliberately structural (no `Principal`\n * import — that lives in the authn layer above this one) so any domain can\n * store its authenticated subject here. A resolved `Principal` is assignable to\n * it as-is.\n */\n/**\n * What the session was opened from, for display and for notifying the owner.\n *\n * **A label, never an authorization input.** Every field is client-supplied or\n * client-derived, so a copied cookie copies the device — which is acceptable\n * precisely because nothing here decides anything. It powers \"you are signed in\n * on these devices\" and \"a new device signed in\"; it never grants or refuses.\n */\nexport interface DeviceContext {\n /** Opaque, server-minted id carried in a long-lived cookie. */\n readonly deviceId: string;\n readonly browser?: string;\n readonly os?: string;\n /** `desktop` / `mobile` / `tablet`, as the user-agent reports it. */\n readonly type?: string;\n /** Truncated to a /24 — enough for \"somewhere else\", less PII at rest. */\n readonly ip?: string;\n}\n\nexport interface SessionData {\n readonly userId: EntityId;\n /** Upstream identity subject (e.g. an IdP `sub`, or the canonical user id). */\n readonly subject: string;\n readonly roles: readonly string[];\n readonly attributes: Readonly<Record<string, unknown>>;\n /**\n * The organization this session is acting for. It lives here rather than on\n * the user because a party may belong to several organizations, so switching\n * is a session operation that re-mints the access token — modelling it on the\n * user would make someone serving two vendors keep two accounts.\n *\n * Absent for a session with no organization: a buyer, or an operator, who\n * holds no tenant scope by default.\n */\n readonly activeOrganizationId?: string;\n /**\n * Which population this session belongs to — buyer, vendor staff, or platform\n * staff. Resolved once when the session opens and carried from there into\n * every access token minted from it, so no service re-derives it per request.\n *\n * A plain string for the same reason `roles` is: the closed set lives in the\n * business layer above this one.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id, not the account id.\n *\n * Resolved once when the session opens, from the only store that holds the\n * user → party mapping, and carried from here into every access token: a\n * service scoping a read to *whose records these are* asks about a party, and\n * has no way to make that hop itself.\n *\n * Absent for an account with no party record, and a consumer must read that\n * as \"no party\" rather than as \"any party\".\n */\n readonly partyId?: string;\n /**\n * The business domains {@link SessionData.activeOrganizationId} is\n * provisioned for. Resolved once when the session opens and carried from here\n * into every access token minted from it, so no service re-reads an\n * entitlement per request — the same arrangement `partyRole` already has.\n *\n * Absent for a session with no organization, which is a different statement\n * from an empty list: the ceiling does not apply rather than denying\n * everything.\n */\n readonly entitlements?: readonly string[];\n /** Absent for sessions opened by a non-browser caller. */\n readonly device?: DeviceContext;\n}\n\n/** A stored session as read back — {@link SessionData} plus lifecycle metadata. */\nexport interface SessionRecord extends SessionData {\n /** The opaque session id this record is keyed by; the revocation handle. */\n readonly sessionId: string;\n readonly createdAt: string;\n /** End of the current sliding window; moves forward on every {@link SessionStore.touch}. */\n readonly expiresAt: string;\n /**\n * The ceiling, stamped at creation and never moved. A sliding window with no\n * ceiling renews forever, so this is what guarantees every session eventually\n * ends — including one an attacker is keeping warm.\n */\n readonly absoluteExpiresAt: string;\n}\n\n/** The two durations a session is opened with. */\nexport interface SessionLifetimeSeconds {\n /** Sliding window, renewed on each touch. */\n readonly idleTtlSeconds: number;\n /** Ceiling measured from creation; never extended. */\n readonly absoluteTtlSeconds: number;\n}\n\n/**\n * The session persistence seam. An opaque, revocable, TTL-bounded store keyed by\n * a server-minted session id — the single source of truth every service can\n * read a live session from, and the handle an admin/logout revokes through.\n *\n * Framework- and store-agnostic: a Redis adapter backs it in production, a fake\n * in tests, and the use-cases that consume {@link SessionStoreTag} never change.\n */\nexport interface SessionStore {\n /** Persist a new session and return its freshly minted opaque id. */\n create(\n data: SessionData,\n lifetime: SessionLifetimeSeconds,\n ): Effect<string, EntifixError>;\n /** Read a live session, failing if it is unknown, revoked, or expired. */\n read(sessionId: string): Effect<SessionRecord, EntifixError>;\n /**\n * Renew the sliding window, **clamped to the session's own\n * {@link SessionRecord.absoluteExpiresAt}**. Fails once the ceiling is passed,\n * which is how a long-lived session finally ends.\n */\n touch(sessionId: string, idleTtlSeconds: number): Effect<void, EntifixError>;\n /**\n * Every live session a user holds.\n *\n * The read side of the index `revokeAllForUser` already walks. Without it\n * there is no way to show someone where they are signed in, and \"sign out\n * everywhere\" is an action with no visible subject.\n */\n listForUser(userId: EntityId): Effect<readonly SessionRecord[], EntifixError>;\n /** Revoke a single session — the next {@link read} fails immediately. */\n revoke(sessionId: string): Effect<void, EntifixError>;\n /** Revoke every session for a user (e.g. password change, admin kick). */\n revokeAllForUser(userId: EntityId): Effect<void, EntifixError>;\n /**\n * Revoke every session for a user EXCEPT one.\n *\n * What a password change should do: end every other session, but leave the\n * person who just changed it signed in. `revokeAllForUser` would log them out\n * of the screen they are standing on.\n */\n revokeAllForUserExcept(\n userId: EntityId,\n keepSessionId: string,\n ): Effect<void, EntifixError>;\n}\n\n/** DI tag the composition root binds to a concrete {@link SessionStore}. */\nexport class SessionStoreTag extends Context.Tag('SessionStoreTag')<\n SessionStoreTag,\n SessionStore\n>() {}\n"],"names":["Context","SessionStoreTag","Tag"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AAsJjC,0EAA0E,GAC1E,OAAO,MAAMC,wBAAwBD,QAAQE,GAAG,CAAC;AAG5C"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Context, Effect } from 'effect';
|
|
2
|
+
/**
|
|
3
|
+
* The order a shutdown runs in (ADR 0030).
|
|
4
|
+
*
|
|
5
|
+
* **`stop-intake`** stops the process taking new work — cancelling the bus's
|
|
6
|
+
* consumers, interrupting a polling daemon. **`flush`** carries out what is
|
|
7
|
+
* already committed but not yet delivered: the outbox relay's final pass. The
|
|
8
|
+
* two are separate phases rather than one list because they are ordered against
|
|
9
|
+
* each other and not merely against the connections closing — flushing while
|
|
10
|
+
* consumers are still folding events would race a drain against fresh work, and
|
|
11
|
+
* the relay would go on finding entries the handlers keep writing.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SHUTDOWN_PHASES: readonly ["stop-intake", "flush"];
|
|
14
|
+
export type ShutdownPhase = (typeof SHUTDOWN_PHASES)[number];
|
|
15
|
+
/**
|
|
16
|
+
* One thing to do before this process's connections close.
|
|
17
|
+
*
|
|
18
|
+
* `run` is fully applied, exactly like `HealthProbe.check`: whoever
|
|
19
|
+
* registers the hook has already closed over the connector, the fiber or the
|
|
20
|
+
* store, so the drain can run every hook without knowing what any of them
|
|
21
|
+
* touches.
|
|
22
|
+
*/
|
|
23
|
+
export interface ShutdownHook {
|
|
24
|
+
/** Logged when the hook runs, and when it fails. Never rendered to a caller. */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly phase: ShutdownPhase;
|
|
27
|
+
readonly run: Effect.Effect<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* What a service does on its way out, registered by whoever opened the thing
|
|
31
|
+
* that needs draining.
|
|
32
|
+
*
|
|
33
|
+
* A sibling of `HealthRegistry` and `WiringRegistry`, and for the same reason:
|
|
34
|
+
* a `-service` that gains a bus gains its consumer drain by merging that
|
|
35
|
+
* adapter's layer, and nothing in the service's own `main.ts` has to remember
|
|
36
|
+
* to list it. A hand-written sequence per composition root drifts the first
|
|
37
|
+
* time someone adds a dependency; this cannot, because the hook ships with the
|
|
38
|
+
* client that needs it.
|
|
39
|
+
*/
|
|
40
|
+
export interface ShutdownRegistry {
|
|
41
|
+
/** Add a hook. Called by an adapter layer as it constructs its client. */
|
|
42
|
+
readonly register: (hook: ShutdownHook) => Effect.Effect<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this process has begun terminating.
|
|
45
|
+
*
|
|
46
|
+
* Read by `GET /api/health/ready`, which answers `503` from here **before**
|
|
47
|
+
* it consults any probe: nothing is failing, the process is leaving.
|
|
48
|
+
*/
|
|
49
|
+
readonly terminating: Effect.Effect<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Run every hook, phase by phase, in registration order. Never fails.
|
|
52
|
+
*
|
|
53
|
+
* Called from a `Layer` finalizer, which is what orders it: the drain layer
|
|
54
|
+
* is built after the composition root, so it is released before it, and the
|
|
55
|
+
* connections a hook needs are still open while it runs.
|
|
56
|
+
*/
|
|
57
|
+
readonly drain: Effect.Effect<void>;
|
|
58
|
+
}
|
|
59
|
+
declare const ShutdownRegistryTag_base: Context.TagClass<ShutdownRegistryTag, "ShutdownRegistryTag", ShutdownRegistry>;
|
|
60
|
+
/**
|
|
61
|
+
* DI tag for the shutdown registry.
|
|
62
|
+
*
|
|
63
|
+
* In the contract layer beside `HealthRegistryTag` so an
|
|
64
|
+
* `entifix-ts-*-client` package can register a hook without depending on a
|
|
65
|
+
* shell.
|
|
66
|
+
*/
|
|
67
|
+
export declare class ShutdownRegistryTag extends ShutdownRegistryTag_base {
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Builds the registry and the synchronous latch that flips it.
|
|
71
|
+
*
|
|
72
|
+
* Two halves for two callers, the `makeAmqpConnector` shape: the
|
|
73
|
+
* `registry` goes into the layer graph, while `begin` is called from a
|
|
74
|
+
* `process.on('SIGTERM')` handler — outside Effect, in the same tick the
|
|
75
|
+
* runtime starts interrupting — so `/api/health/ready` stops claiming to be
|
|
76
|
+
* ready the instant the signal lands rather than whenever the finalizers get
|
|
77
|
+
* around to running.
|
|
78
|
+
*/
|
|
79
|
+
export declare const makeShutdownRegistry: () => {
|
|
80
|
+
registry: ShutdownRegistry;
|
|
81
|
+
begin: () => void;
|
|
82
|
+
};
|
|
83
|
+
export {};
|
|
84
|
+
//# sourceMappingURL=shutdown-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shutdown-registry.d.ts","sourceRoot":"","sources":["../../src/shutdown/shutdown-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,mCAAoC,CAAC;AAEjE,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACnC;AAaD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC/D;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC7C;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACrC;;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,wBAGtC;CAAG;AA0BN;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,QAAO;IACtC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,MAAM,IAAI,CAAC;CAsCnB,CAAC"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Cause, Context, Effect } from 'effect';
|
|
2
|
+
/**
|
|
3
|
+
* The order a shutdown runs in (ADR 0030).
|
|
4
|
+
*
|
|
5
|
+
* **`stop-intake`** stops the process taking new work — cancelling the bus's
|
|
6
|
+
* consumers, interrupting a polling daemon. **`flush`** carries out what is
|
|
7
|
+
* already committed but not yet delivered: the outbox relay's final pass. The
|
|
8
|
+
* two are separate phases rather than one list because they are ordered against
|
|
9
|
+
* each other and not merely against the connections closing — flushing while
|
|
10
|
+
* consumers are still folding events would race a drain against fresh work, and
|
|
11
|
+
* the relay would go on finding entries the handlers keep writing.
|
|
12
|
+
*/ export const SHUTDOWN_PHASES = [
|
|
13
|
+
'stop-intake',
|
|
14
|
+
'flush'
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* How long one hook may take before the drain moves on without it.
|
|
18
|
+
*
|
|
19
|
+
* Not belt-and-braces. Kubernetes sends SIGKILL at the end of
|
|
20
|
+
* `terminationGracePeriodSeconds` whatever the process is doing, so an
|
|
21
|
+
* unbounded hook does not buy time — it spends the budget of the hooks behind
|
|
22
|
+
* it and then loses them entirely. A hook that cannot finish in time has
|
|
23
|
+
* already failed; the difference is whether the *next* one gets to run.
|
|
24
|
+
*/ const HOOK_TIMEOUT = '10 seconds';
|
|
25
|
+
/**
|
|
26
|
+
* DI tag for the shutdown registry.
|
|
27
|
+
*
|
|
28
|
+
* In the contract layer beside `HealthRegistryTag` so an
|
|
29
|
+
* `entifix-ts-*-client` package can register a hook without depending on a
|
|
30
|
+
* shell.
|
|
31
|
+
*/ export class ShutdownRegistryTag extends Context.Tag('ShutdownRegistryTag')() {
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Runs one hook and swallows whatever it does.
|
|
35
|
+
*
|
|
36
|
+
* A failure and a timeout are logged the same way on purpose: both mean this
|
|
37
|
+
* hook did not do its job, both leave the ones behind it still worth running,
|
|
38
|
+
* and neither is something a terminating process can act on. `Cause.pretty`
|
|
39
|
+
* keeps them distinguishable in Loki, where someone reading them actually is.
|
|
40
|
+
*/ const runHook = (hook)=>hook.run.pipe(Effect.timeout(HOOK_TIMEOUT), Effect.catchAllCause((cause)=>Effect.logError('shutdown hook did not complete').pipe(// Annotations, not a rendered message: the tooling logger emits these
|
|
41
|
+
// as structured fields, so which hook stalled a rollout is queryable.
|
|
42
|
+
Effect.annotateLogs({
|
|
43
|
+
hook: hook.name,
|
|
44
|
+
phase: hook.phase,
|
|
45
|
+
cause: Cause.pretty(cause)
|
|
46
|
+
}))));
|
|
47
|
+
/**
|
|
48
|
+
* Builds the registry and the synchronous latch that flips it.
|
|
49
|
+
*
|
|
50
|
+
* Two halves for two callers, the `makeAmqpConnector` shape: the
|
|
51
|
+
* `registry` goes into the layer graph, while `begin` is called from a
|
|
52
|
+
* `process.on('SIGTERM')` handler — outside Effect, in the same tick the
|
|
53
|
+
* runtime starts interrupting — so `/api/health/ready` stops claiming to be
|
|
54
|
+
* ready the instant the signal lands rather than whenever the finalizers get
|
|
55
|
+
* around to running.
|
|
56
|
+
*/ export const makeShutdownRegistry = ()=>{
|
|
57
|
+
const hooks = [];
|
|
58
|
+
let terminating = false;
|
|
59
|
+
const registry = {
|
|
60
|
+
register: (hook)=>Effect.sync(()=>{
|
|
61
|
+
hooks.push(hook);
|
|
62
|
+
}),
|
|
63
|
+
terminating: Effect.sync(()=>terminating),
|
|
64
|
+
drain: Effect.gen(function*() {
|
|
65
|
+
terminating = true;
|
|
66
|
+
yield* Effect.logInfo('draining before shutdown').pipe(Effect.annotateLogs({
|
|
67
|
+
hooks: hooks.length
|
|
68
|
+
}));
|
|
69
|
+
for (const phase of SHUTDOWN_PHASES){
|
|
70
|
+
// Sequential, and in registration order: `stop-intake` before `flush`
|
|
71
|
+
// is the whole contract, and within a phase a later hook may depend on
|
|
72
|
+
// an earlier one having stopped.
|
|
73
|
+
yield* Effect.forEach(hooks.filter((hook)=>hook.phase === phase), runHook, {
|
|
74
|
+
discard: true
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
yield* Effect.logInfo('drained');
|
|
78
|
+
})
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
registry,
|
|
82
|
+
begin: ()=>{
|
|
83
|
+
terminating = true;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=shutdown-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shutdown/shutdown-registry.ts"],"sourcesContent":["import { Cause, Context, Effect } from 'effect';\n\n/**\n * The order a shutdown runs in (ADR 0030).\n *\n * **`stop-intake`** stops the process taking new work — cancelling the bus's\n * consumers, interrupting a polling daemon. **`flush`** carries out what is\n * already committed but not yet delivered: the outbox relay's final pass. The\n * two are separate phases rather than one list because they are ordered against\n * each other and not merely against the connections closing — flushing while\n * consumers are still folding events would race a drain against fresh work, and\n * the relay would go on finding entries the handlers keep writing.\n */\nexport const SHUTDOWN_PHASES = ['stop-intake', 'flush'] as const;\n\nexport type ShutdownPhase = (typeof SHUTDOWN_PHASES)[number];\n\n/**\n * One thing to do before this process's connections close.\n *\n * `run` is fully applied, exactly like `HealthProbe.check`: whoever\n * registers the hook has already closed over the connector, the fiber or the\n * store, so the drain can run every hook without knowing what any of them\n * touches.\n */\nexport interface ShutdownHook {\n /** Logged when the hook runs, and when it fails. Never rendered to a caller. */\n readonly name: string;\n readonly phase: ShutdownPhase;\n readonly run: Effect.Effect<void>;\n}\n\n/**\n * How long one hook may take before the drain moves on without it.\n *\n * Not belt-and-braces. Kubernetes sends SIGKILL at the end of\n * `terminationGracePeriodSeconds` whatever the process is doing, so an\n * unbounded hook does not buy time — it spends the budget of the hooks behind\n * it and then loses them entirely. A hook that cannot finish in time has\n * already failed; the difference is whether the *next* one gets to run.\n */\nconst HOOK_TIMEOUT = '10 seconds';\n\n/**\n * What a service does on its way out, registered by whoever opened the thing\n * that needs draining.\n *\n * A sibling of `HealthRegistry` and `WiringRegistry`, and for the same reason:\n * a `-service` that gains a bus gains its consumer drain by merging that\n * adapter's layer, and nothing in the service's own `main.ts` has to remember\n * to list it. A hand-written sequence per composition root drifts the first\n * time someone adds a dependency; this cannot, because the hook ships with the\n * client that needs it.\n */\nexport interface ShutdownRegistry {\n /** Add a hook. Called by an adapter layer as it constructs its client. */\n readonly register: (hook: ShutdownHook) => Effect.Effect<void>;\n /**\n * Whether this process has begun terminating.\n *\n * Read by `GET /api/health/ready`, which answers `503` from here **before**\n * it consults any probe: nothing is failing, the process is leaving.\n */\n readonly terminating: Effect.Effect<boolean>;\n /**\n * Run every hook, phase by phase, in registration order. Never fails.\n *\n * Called from a `Layer` finalizer, which is what orders it: the drain layer\n * is built after the composition root, so it is released before it, and the\n * connections a hook needs are still open while it runs.\n */\n readonly drain: Effect.Effect<void>;\n}\n\n/**\n * DI tag for the shutdown registry.\n *\n * In the contract layer beside `HealthRegistryTag` so an\n * `entifix-ts-*-client` package can register a hook without depending on a\n * shell.\n */\nexport class ShutdownRegistryTag extends Context.Tag('ShutdownRegistryTag')<\n ShutdownRegistryTag,\n ShutdownRegistry\n>() {}\n\n/**\n * Runs one hook and swallows whatever it does.\n *\n * A failure and a timeout are logged the same way on purpose: both mean this\n * hook did not do its job, both leave the ones behind it still worth running,\n * and neither is something a terminating process can act on. `Cause.pretty`\n * keeps them distinguishable in Loki, where someone reading them actually is.\n */\nconst runHook = (hook: ShutdownHook): Effect.Effect<void> =>\n hook.run.pipe(\n Effect.timeout(HOOK_TIMEOUT),\n Effect.catchAllCause(cause =>\n Effect.logError('shutdown hook did not complete').pipe(\n // Annotations, not a rendered message: the tooling logger emits these\n // as structured fields, so which hook stalled a rollout is queryable.\n Effect.annotateLogs({\n hook: hook.name,\n phase: hook.phase,\n cause: Cause.pretty(cause),\n }),\n ),\n ),\n );\n\n/**\n * Builds the registry and the synchronous latch that flips it.\n *\n * Two halves for two callers, the `makeAmqpConnector` shape: the\n * `registry` goes into the layer graph, while `begin` is called from a\n * `process.on('SIGTERM')` handler — outside Effect, in the same tick the\n * runtime starts interrupting — so `/api/health/ready` stops claiming to be\n * ready the instant the signal lands rather than whenever the finalizers get\n * around to running.\n */\nexport const makeShutdownRegistry = (): {\n registry: ShutdownRegistry;\n begin: () => void;\n} => {\n const hooks: ShutdownHook[] = [];\n let terminating = false;\n\n const registry: ShutdownRegistry = {\n register: hook =>\n Effect.sync(() => {\n hooks.push(hook);\n }),\n\n terminating: Effect.sync(() => terminating),\n\n drain: Effect.gen(function* () {\n terminating = true;\n yield* Effect.logInfo('draining before shutdown').pipe(\n Effect.annotateLogs({ hooks: hooks.length }),\n );\n for (const phase of SHUTDOWN_PHASES) {\n // Sequential, and in registration order: `stop-intake` before `flush`\n // is the whole contract, and within a phase a later hook may depend on\n // an earlier one having stopped.\n yield* Effect.forEach(\n hooks.filter(hook => hook.phase === phase),\n runHook,\n { discard: true },\n );\n }\n yield* Effect.logInfo('drained');\n }),\n };\n\n return {\n registry,\n begin: () => {\n terminating = true;\n },\n };\n};\n"],"names":["Cause","Context","Effect","SHUTDOWN_PHASES","HOOK_TIMEOUT","ShutdownRegistryTag","Tag","runHook","hook","run","pipe","timeout","catchAllCause","cause","logError","annotateLogs","name","phase","pretty","makeShutdownRegistry","hooks","terminating","registry","register","sync","push","drain","gen","logInfo","length","forEach","filter","discard","begin"],"mappings":"AAAA,SAASA,KAAK,EAAEC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AAEhD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,kBAAkB;IAAC;IAAe;CAAQ,CAAU;AAmBjE;;;;;;;;CAQC,GACD,MAAMC,eAAe;AAiCrB;;;;;;CAMC,GACD,OAAO,MAAMC,4BAA4BJ,QAAQK,GAAG,CAAC;AAGhD;AAEL;;;;;;;CAOC,GACD,MAAMC,UAAU,CAACC,OACfA,KAAKC,GAAG,CAACC,IAAI,CACXR,OAAOS,OAAO,CAACP,eACfF,OAAOU,aAAa,CAACC,CAAAA,QACnBX,OAAOY,QAAQ,CAAC,kCAAkCJ,IAAI,CACpD,sEAAsE;QACtE,sEAAsE;QACtER,OAAOa,YAAY,CAAC;YAClBP,MAAMA,KAAKQ,IAAI;YACfC,OAAOT,KAAKS,KAAK;YACjBJ,OAAOb,MAAMkB,MAAM,CAACL;QACtB;AAKR;;;;;;;;;CASC,GACD,OAAO,MAAMM,uBAAuB;IAIlC,MAAMC,QAAwB,EAAE;IAChC,IAAIC,cAAc;IAElB,MAAMC,WAA6B;QACjCC,UAAUf,CAAAA,OACRN,OAAOsB,IAAI,CAAC;gBACVJ,MAAMK,IAAI,CAACjB;YACb;QAEFa,aAAanB,OAAOsB,IAAI,CAAC,IAAMH;QAE/BK,OAAOxB,OAAOyB,GAAG,CAAC;YAChBN,cAAc;YACd,OAAOnB,OAAO0B,OAAO,CAAC,4BAA4BlB,IAAI,CACpDR,OAAOa,YAAY,CAAC;gBAAEK,OAAOA,MAAMS,MAAM;YAAC;YAE5C,KAAK,MAAMZ,SAASd,gBAAiB;gBACnC,sEAAsE;gBACtE,uEAAuE;gBACvE,iCAAiC;gBACjC,OAAOD,OAAO4B,OAAO,CACnBV,MAAMW,MAAM,CAACvB,CAAAA,OAAQA,KAAKS,KAAK,KAAKA,QACpCV,SACA;oBAAEyB,SAAS;gBAAK;YAEpB;YACA,OAAO9B,OAAO0B,OAAO,CAAC;QACxB;IACF;IAEA,OAAO;QACLN;QACAW,OAAO;YACLZ,cAAc;QAChB;IACF;AACF,EAAE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tenancy/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/tenancy/index.ts"],"sourcesContent":["export * from './tenant-database-resolver.js';\n"],"names":[],"mappings":"AAAA,cAAc,gCAAgC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Context, type Effect } from 'effect';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the storage handle for one organization.
|
|
5
|
+
*
|
|
6
|
+
* The seam the whole tenancy model turns on. Business entities carry no
|
|
7
|
+
* `organizationId` and no tenant filter, so isolation is *which handle a request
|
|
8
|
+
* resolves to* — which means no query can leak by omission, because there is no
|
|
9
|
+
* column to forget.
|
|
10
|
+
*
|
|
11
|
+
* Two rules the implementations must keep:
|
|
12
|
+
*
|
|
13
|
+
* - **Resolve per request, never per `Layer`.** The connection pool is acquired
|
|
14
|
+
* once at boot (`MongoDatabaseLayer`'s `Layer.scoped`); a handle is not a
|
|
15
|
+
* connection. Building a `Layer` per request would rebuild the pool per
|
|
16
|
+
* request.
|
|
17
|
+
* - **Derive the storage name from the organization id**, never from a mutable
|
|
18
|
+
* attribute such as a slug, so renaming an organization can never strand its
|
|
19
|
+
* data.
|
|
20
|
+
*
|
|
21
|
+
* `THandle` is the datastore's own handle type (`Db` for Mongo). Keeping it
|
|
22
|
+
* generic is what lets a Postgres adapter — where the same idea is a schema on
|
|
23
|
+
* a shared pool rather than a separate database — satisfy the same port without
|
|
24
|
+
* a call-site change.
|
|
25
|
+
*/
|
|
26
|
+
export interface TenantDatabaseResolver<THandle> {
|
|
27
|
+
readonly forOrganization: (organizationId: string) => Effect.Effect<THandle, EntifixConnError>;
|
|
28
|
+
}
|
|
29
|
+
declare const TenantDatabaseResolverTag_base: Context.TagClass<TenantDatabaseResolverTag, "TenantDatabaseResolverTag", TenantDatabaseResolver<unknown>>;
|
|
30
|
+
/**
|
|
31
|
+
* DI tag for the resolver. Declared with an `unknown` handle because the tag is
|
|
32
|
+
* datastore-agnostic; a service narrows it at the composition root, where it
|
|
33
|
+
* also knows which driver it provided.
|
|
34
|
+
*/
|
|
35
|
+
export declare class TenantDatabaseResolverTag extends TenantDatabaseResolverTag_base {
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
38
|
+
//# sourceMappingURL=tenant-database-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-database-resolver.d.ts","sourceRoot":"","sources":["../../src/tenancy/tenant-database-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,EAAE,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,sBAAsB,CAAC,OAAO;IAC7C,QAAQ,CAAC,eAAe,EAAE,CACxB,cAAc,EAAE,MAAM,KACnB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;CAC/C;;AAED;;;;GAIG;AACH,qBAAa,yBAA0B,SAAQ,8BAEgB;CAAG"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Context } from 'effect';
|
|
2
|
+
/**
|
|
3
|
+
* DI tag for the resolver. Declared with an `unknown` handle because the tag is
|
|
4
|
+
* datastore-agnostic; a service narrows it at the composition root, where it
|
|
5
|
+
* also knows which driver it provided.
|
|
6
|
+
*/ export class TenantDatabaseResolverTag extends Context.Tag('TenantDatabaseResolverTag')() {
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//# sourceMappingURL=tenant-database-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/tenancy/tenant-database-resolver.ts"],"sourcesContent":["import type { EntifixConnError } from '@entifix/core';\nimport { Context, type Effect } from 'effect';\n\n/**\n * Resolves the storage handle for one organization.\n *\n * The seam the whole tenancy model turns on. Business entities carry no\n * `organizationId` and no tenant filter, so isolation is *which handle a request\n * resolves to* — which means no query can leak by omission, because there is no\n * column to forget.\n *\n * Two rules the implementations must keep:\n *\n * - **Resolve per request, never per `Layer`.** The connection pool is acquired\n * once at boot (`MongoDatabaseLayer`'s `Layer.scoped`); a handle is not a\n * connection. Building a `Layer` per request would rebuild the pool per\n * request.\n * - **Derive the storage name from the organization id**, never from a mutable\n * attribute such as a slug, so renaming an organization can never strand its\n * data.\n *\n * `THandle` is the datastore's own handle type (`Db` for Mongo). Keeping it\n * generic is what lets a Postgres adapter — where the same idea is a schema on\n * a shared pool rather than a separate database — satisfy the same port without\n * a call-site change.\n */\nexport interface TenantDatabaseResolver<THandle> {\n readonly forOrganization: (\n organizationId: string,\n ) => Effect.Effect<THandle, EntifixConnError>;\n}\n\n/**\n * DI tag for the resolver. Declared with an `unknown` handle because the tag is\n * datastore-agnostic; a service narrows it at the composition root, where it\n * also knows which driver it provided.\n */\nexport class TenantDatabaseResolverTag extends Context.Tag(\n 'TenantDatabaseResolverTag',\n)<TenantDatabaseResolverTag, TenantDatabaseResolver<unknown>>() {}\n"],"names":["Context","TenantDatabaseResolverTag","Tag"],"mappings":"AACA,SAASA,OAAO,QAAqB,SAAS;AA+B9C;;;;CAIC,GACD,OAAO,MAAMC,kCAAkCD,QAAQE,GAAG,CACxD;AAC+D"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { EntifixError, EntityId } from '@entifix/core';
|
|
2
|
+
import { Context } from 'effect';
|
|
3
|
+
import { Effect } from 'effect/Effect';
|
|
4
|
+
/**
|
|
5
|
+
* The claims a stateless access token carries between the perimeter that mints
|
|
6
|
+
* it and the services that verify it. Kept small on purpose: everything here is
|
|
7
|
+
* copied into every request and can only refresh when a new token is minted, so
|
|
8
|
+
* only authorization-critical, stable subject data belongs in it. Richer or
|
|
9
|
+
* volatile state stays in the session record and is read on demand.
|
|
10
|
+
*/
|
|
11
|
+
export interface TokenClaims {
|
|
12
|
+
/** Canonical user id — the value services key their data against. */
|
|
13
|
+
readonly userId: EntityId;
|
|
14
|
+
/** Upstream identity subject that authenticated the session. */
|
|
15
|
+
readonly subject: string;
|
|
16
|
+
/** The session id the token was minted from; the revocation linkage. */
|
|
17
|
+
readonly sessionId: string;
|
|
18
|
+
readonly roles: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* The organization this session is acting for, and the value every
|
|
21
|
+
* tenant-plane storage handle is resolved from.
|
|
22
|
+
*
|
|
23
|
+
* A property of the **session**, not of the user: a party may hold several
|
|
24
|
+
* memberships, and switching re-mints the token. Absent for a session with no
|
|
25
|
+
* organization — a buyer, or an operator, who holds no tenant scope at all.
|
|
26
|
+
*
|
|
27
|
+
* Note this is a *routing* key, not an authorization input. Grants still come
|
|
28
|
+
* from `roles` resolved at the consumer.
|
|
29
|
+
*/
|
|
30
|
+
readonly activeOrganizationId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Which population the session belongs to — a buyer, a vendor's staff, or
|
|
33
|
+
* platform staff. Typed as a plain string here because the closed set lives
|
|
34
|
+
* in the business layer (`PartyRoles` in `business-ts-party-management`),
|
|
35
|
+
* which this layer may not import; consumers narrow it, exactly as they
|
|
36
|
+
* already do for `roles`.
|
|
37
|
+
*
|
|
38
|
+
* It exists because the alternative was inferring it from an *absent*
|
|
39
|
+
* `activeOrganizationId`, which cannot tell a buyer from an operator — two
|
|
40
|
+
* populations whose reach differs enormously. A capability that sensitive
|
|
41
|
+
* must not be signalled by a missing field.
|
|
42
|
+
*
|
|
43
|
+
* Like `activeOrganizationId` this is routing/context, not a grant: what a
|
|
44
|
+
* principal may do still comes from `roles` resolved at the consumer.
|
|
45
|
+
*/
|
|
46
|
+
readonly partyRole?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The party behind the account — the `Individual` id that domains recording a
|
|
49
|
+
* party key against, and the value a `ProductOrder.buyerId` holds.
|
|
50
|
+
*
|
|
51
|
+
* It is carried because the hop from an account to a party exists in exactly
|
|
52
|
+
* one store, the one that mints this token. A service scoping a read to the
|
|
53
|
+
* caller's own records needs the party and cannot derive it from
|
|
54
|
+
* {@link TokenClaims.userId} without reading another slice's collections.
|
|
55
|
+
*
|
|
56
|
+
* Absent for an account with no party record. A consumer must read the
|
|
57
|
+
* absence as *no party* — scoping to nothing — never as *any party*.
|
|
58
|
+
*
|
|
59
|
+
* Like `activeOrganizationId` and `partyRole` this is context, not a grant:
|
|
60
|
+
* it says whose records these are, never what may be done with them.
|
|
61
|
+
*/
|
|
62
|
+
readonly partyId?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The business domains the acting organization is provisioned for — the
|
|
65
|
+
* second assignment ceiling (ADR 0007), carried so a server-rendered menu can
|
|
66
|
+
* shape itself without a lookup on every render.
|
|
67
|
+
*
|
|
68
|
+
* Absent for a session with no organization, and that is **not** the same as
|
|
69
|
+
* an empty list: an operator holds no tenant scope, so the ceiling does not
|
|
70
|
+
* apply at all, while a member of an organization provisioned for nothing is
|
|
71
|
+
* entitled to nothing. Consumers therefore decide from
|
|
72
|
+
* {@link TokenClaims.activeOrganizationId} whether to consult this at all.
|
|
73
|
+
*
|
|
74
|
+
* Plain strings for the same reason `roles` is: the domain names live in the
|
|
75
|
+
* business layer, which this one may not import.
|
|
76
|
+
*
|
|
77
|
+
* Like `activeOrganizationId` and `partyRole` this is context, not a grant.
|
|
78
|
+
*/
|
|
79
|
+
readonly entitlements?: readonly string[];
|
|
80
|
+
/** Room for a few extra stable claims without a type change. */
|
|
81
|
+
readonly [key: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Signs and verifies short-lived access tokens. The perimeter provides it to
|
|
85
|
+
* mint a token from a resolved session; every downstream service provides it to
|
|
86
|
+
* verify one statelessly (no store round trip on the hot path). Vendor-neutral:
|
|
87
|
+
* a `jose`/HS256 adapter backs it now, an RS256/JWKS one can later without
|
|
88
|
+
* touching a consumer.
|
|
89
|
+
*/
|
|
90
|
+
export interface TokenService {
|
|
91
|
+
/** Sign the claims into a compact token that expires after `ttlSeconds`. */
|
|
92
|
+
sign(claims: TokenClaims, ttlSeconds: number): Effect<string, EntifixError>;
|
|
93
|
+
/** Verify signature + expiry and return the claims, or fail. */
|
|
94
|
+
verify(token: string): Effect<TokenClaims, EntifixError>;
|
|
95
|
+
}
|
|
96
|
+
declare const TokenServiceTag_base: Context.TagClass<TokenServiceTag, "TokenServiceTag", TokenService>;
|
|
97
|
+
/** DI tag the composition root binds to a concrete {@link TokenService}. */
|
|
98
|
+
export declare class TokenServiceTag extends TokenServiceTag_base {
|
|
99
|
+
}
|
|
100
|
+
export {};
|
|
101
|
+
//# sourceMappingURL=token-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-service.d.ts","sourceRoot":"","sources":["../../src/tokens/token-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjC,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,gEAAgE;IAChE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC5E,gEAAgE;IAChE,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;CAC1D;;AAED,4EAA4E;AAC5E,qBAAa,eAAgB,SAAQ,oBAGlC;CAAG"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/tokens/token-service.ts"],"sourcesContent":["import type { EntifixError, EntityId } from '@entifix/core';\nimport { Context } from 'effect';\nimport { Effect } from 'effect/Effect';\n\n/**\n * The claims a stateless access token carries between the perimeter that mints\n * it and the services that verify it. Kept small on purpose: everything here is\n * copied into every request and can only refresh when a new token is minted, so\n * only authorization-critical, stable subject data belongs in it. Richer or\n * volatile state stays in the session record and is read on demand.\n */\nexport interface TokenClaims {\n /** Canonical user id — the value services key their data against. */\n readonly userId: EntityId;\n /** Upstream identity subject that authenticated the session. */\n readonly subject: string;\n /** The session id the token was minted from; the revocation linkage. */\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this session is acting for, and the value every\n * tenant-plane storage handle is resolved from.\n *\n * A property of the **session**, not of the user: a party may hold several\n * memberships, and switching re-mints the token. Absent for a session with no\n * organization — a buyer, or an operator, who holds no tenant scope at all.\n *\n * Note this is a *routing* key, not an authorization input. Grants still come\n * from `roles` resolved at the consumer.\n */\n readonly activeOrganizationId?: string;\n /**\n * Which population the session belongs to — a buyer, a vendor's staff, or\n * platform staff. Typed as a plain string here because the closed set lives\n * in the business layer (`PartyRoles` in `business-ts-party-management`),\n * which this layer may not import; consumers narrow it, exactly as they\n * already do for `roles`.\n *\n * It exists because the alternative was inferring it from an *absent*\n * `activeOrganizationId`, which cannot tell a buyer from an operator — two\n * populations whose reach differs enormously. A capability that sensitive\n * must not be signalled by a missing field.\n *\n * Like `activeOrganizationId` this is routing/context, not a grant: what a\n * principal may do still comes from `roles` resolved at the consumer.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id that domains recording a\n * party key against, and the value a `ProductOrder.buyerId` holds.\n *\n * It is carried because the hop from an account to a party exists in exactly\n * one store, the one that mints this token. A service scoping a read to the\n * caller's own records needs the party and cannot derive it from\n * {@link TokenClaims.userId} without reading another slice's collections.\n *\n * Absent for an account with no party record. A consumer must read the\n * absence as *no party* — scoping to nothing — never as *any party*.\n *\n * Like `activeOrganizationId` and `partyRole` this is context, not a grant:\n * it says whose records these are, never what may be done with them.\n */\n readonly partyId?: string;\n /**\n * The business domains the acting organization is provisioned for — the\n * second assignment ceiling (ADR 0007), carried so a server-rendered menu can\n * shape itself without a lookup on every render.\n *\n * Absent for a session with no organization, and that is **not** the same as\n * an empty list: an operator holds no tenant scope, so the ceiling does not\n * apply at all, while a member of an organization provisioned for nothing is\n * entitled to nothing. Consumers therefore decide from\n * {@link TokenClaims.activeOrganizationId} whether to consult this at all.\n *\n * Plain strings for the same reason `roles` is: the domain names live in the\n * business layer, which this one may not import.\n *\n * Like `activeOrganizationId` and `partyRole` this is context, not a grant.\n */\n readonly entitlements?: readonly string[];\n /** Room for a few extra stable claims without a type change. */\n readonly [key: string]: unknown;\n}\n\n/**\n * Signs and verifies short-lived access tokens. The perimeter provides it to\n * mint a token from a resolved session; every downstream service provides it to\n * verify one statelessly (no store round trip on the hot path). Vendor-neutral:\n * a `jose`/HS256 adapter backs it now, an RS256/JWKS one can later without\n * touching a consumer.\n */\nexport interface TokenService {\n /** Sign the claims into a compact token that expires after `ttlSeconds`. */\n sign(claims: TokenClaims, ttlSeconds: number): Effect<string, EntifixError>;\n /** Verify signature + expiry and return the claims, or fail. */\n verify(token: string): Effect<TokenClaims, EntifixError>;\n}\n\n/** DI tag the composition root binds to a concrete {@link TokenService}. */\nexport class TokenServiceTag extends Context.Tag('TokenServiceTag')<\n TokenServiceTag,\n TokenService\n>() {}\n"],"names":["Context","TokenServiceTag","Tag"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AAiGjC,0EAA0E,GAC1E,OAAO,MAAMC,wBAAwBD,QAAQE,GAAG,CAAC;AAG5C"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Entity } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { EntityRepositoryTag } from '../../repository/entity.repository.js';
|
|
4
|
+
import { EntityIdTag } from '../../repository/entity-mixins.repository.js';
|
|
5
|
+
/**
|
|
6
|
+
* Deletes an entity by id.
|
|
7
|
+
*
|
|
8
|
+
* The repository contract also accepts a whole entity, but deleting by id is
|
|
9
|
+
* the only shape a caller ever has at the point of deletion (a table row, a
|
|
10
|
+
* route param), so that is what this exposes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function deleteUCFactory<TEntity extends Entity>(): Effect.Effect<void, import("@entifix/core").EntifixError, import("../../index.js").ConfigurationRepositoryTag | EntityRepositoryTag | EntityIdTag>;
|
|
13
|
+
//# sourceMappingURL=delete.uc.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delete.uc.d.ts","sourceRoot":"","sources":["../../../src/use-case/delete/delete.uc.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EAAE,mBAAmB,EAAE,MAAM,uCAAuC,CAAC;AAC5E,OAAO,EAAE,WAAW,EAAE,MAAM,8CAA8C,CAAC;AAE3E;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,SAAS,MAAM,wJAOrD"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { EntityRepositoryTag } from '../../repository/entity.repository.js';
|
|
3
|
+
import { EntityIdTag } from '../../repository/entity-mixins.repository.js';
|
|
4
|
+
/**
|
|
5
|
+
* Deletes an entity by id.
|
|
6
|
+
*
|
|
7
|
+
* The repository contract also accepts a whole entity, but deleting by id is
|
|
8
|
+
* the only shape a caller ever has at the point of deletion (a table row, a
|
|
9
|
+
* route param), so that is what this exposes.
|
|
10
|
+
*/ export function deleteUCFactory() {
|
|
11
|
+
return Effect.gen(function*() {
|
|
12
|
+
const entityRepository = yield* EntityRepositoryTag;
|
|
13
|
+
const entityId = yield* EntityIdTag;
|
|
14
|
+
yield* entityRepository.delete(entityId);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=delete.uc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/use-case/delete/delete.uc.ts"],"sourcesContent":["import { Entity } from '@entifix/core';\nimport { Effect } from 'effect';\n\nimport { EntityRepositoryTag } from '../../repository/entity.repository.js';\nimport { EntityIdTag } from '../../repository/entity-mixins.repository.js';\n\n/**\n * Deletes an entity by id.\n *\n * The repository contract also accepts a whole entity, but deleting by id is\n * the only shape a caller ever has at the point of deletion (a table row, a\n * route param), so that is what this exposes.\n */\nexport function deleteUCFactory<TEntity extends Entity>() {\n return Effect.gen(function* () {\n const entityRepository = yield* EntityRepositoryTag;\n const entityId = yield* EntityIdTag;\n\n yield* entityRepository.delete<TEntity>(entityId);\n });\n}\n"],"names":["Effect","EntityRepositoryTag","EntityIdTag","deleteUCFactory","gen","entityRepository","entityId","delete"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAEhC,SAASC,mBAAmB,QAAQ,wCAAwC;AAC5E,SAASC,WAAW,QAAQ,+CAA+C;AAE3E;;;;;;CAMC,GACD,OAAO,SAASC;IACd,OAAOH,OAAOI,GAAG,CAAC;QAChB,MAAMC,mBAAmB,OAAOJ;QAChC,MAAMK,WAAW,OAAOJ;QAExB,OAAOG,iBAAiBE,MAAM,CAAUD;IAC1C;AACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/use-case/delete/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC"}
|