@astram/guard 0.7.0 → 0.8.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 (184) hide show
  1. package/dist/auth/account.controller.d.ts +3 -1
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +12 -5
  4. package/dist/auth/auth.controller.d.ts +130 -21
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +537 -143
  7. package/dist/auth/auth.module.d.ts +15 -1
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +212 -30
  10. package/dist/auth/connections.d.ts +1 -1
  11. package/dist/auth/connections.d.ts.map +1 -1
  12. package/dist/auth/connections.js +14 -3
  13. package/dist/auth/default-session-facts-projector.d.ts +26 -0
  14. package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
  15. package/dist/auth/default-session-facts-projector.js +21 -0
  16. package/dist/auth/mfa-step-up.d.ts +32 -0
  17. package/dist/auth/mfa-step-up.d.ts.map +1 -0
  18. package/dist/auth/mfa-step-up.js +109 -0
  19. package/dist/auth/pending-mfa-store.d.ts +50 -52
  20. package/dist/auth/pending-mfa-store.d.ts.map +1 -1
  21. package/dist/auth/pending-mfa-store.js +103 -61
  22. package/dist/auth/ports/session-facts-projector.d.ts +5 -0
  23. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
  24. package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
  25. package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
  26. package/dist/auth/rate-limit/canonical-hint.js +63 -0
  27. package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
  28. package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
  29. package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
  30. package/dist/auth/tokens.d.ts +70 -11
  31. package/dist/auth/tokens.d.ts.map +1 -1
  32. package/dist/auth/tokens.js +71 -12
  33. package/dist/authentication/log-email-sender.d.ts +9 -4
  34. package/dist/authentication/log-email-sender.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-api-key-store.d.ts +4 -1
  36. package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
  37. package/dist/authentication/mariadb-api-key-store.js +31 -7
  38. package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
  39. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  40. package/dist/authentication/mariadb-credential-resolver.js +116 -73
  41. package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
  42. package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
  43. package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
  44. package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
  45. package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
  46. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  47. package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
  48. package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
  49. package/dist/authentication/mariadb-session-sweeper.js +29 -11
  50. package/dist/authentication/noop-email-sender.d.ts +12 -0
  51. package/dist/authentication/noop-email-sender.d.ts.map +1 -0
  52. package/dist/authentication/noop-email-sender.js +9 -0
  53. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
  54. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
  55. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  56. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
  57. package/dist/authz/mariadb-policy-store.js +1 -1
  58. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  59. package/dist/authz/mariadb-record-rule-adapter.js +1 -4
  60. package/dist/authz/permission-version-cache.d.ts +38 -0
  61. package/dist/authz/permission-version-cache.d.ts.map +1 -0
  62. package/dist/authz/permission-version-cache.js +81 -0
  63. package/dist/authz/wire-record-authorization.d.ts +25 -1
  64. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  65. package/dist/authz/wire-record-authorization.js +55 -2
  66. package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
  67. package/dist/config/app-config.d.ts +36 -0
  68. package/dist/config/app-config.d.ts.map +1 -1
  69. package/dist/config/app-config.js +44 -4
  70. package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
  71. package/dist/enforcement/enforcement.module.d.ts.map +1 -1
  72. package/dist/enforcement/enforcement.module.js +9 -0
  73. package/dist/enforcement/engine-error.filter.d.ts +57 -0
  74. package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
  75. package/dist/enforcement/engine-error.filter.js +118 -0
  76. package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
  77. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  78. package/dist/enforcement/global-enforcement.guard.js +143 -5
  79. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  80. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  81. package/dist/identity/permission-facts.adapter.js +2 -3
  82. package/dist/index.d.ts +12 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +65 -25
  85. package/dist/module/shield.module.d.ts +9 -2
  86. package/dist/module/shield.module.d.ts.map +1 -1
  87. package/dist/module/shield.module.js +47 -38
  88. package/dist/request-auth/session-facts-als.d.ts +16 -1
  89. package/dist/request-auth/session-facts-als.d.ts.map +1 -1
  90. package/dist/request-auth/session-facts-als.js +23 -0
  91. package/dist/request-auth/session-token-binder.d.ts +166 -10
  92. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  93. package/dist/request-auth/session-token-binder.js +408 -28
  94. package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
  95. package/dist/secrets/encrypted-file-secrets.js +9 -3
  96. package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
  97. package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
  98. package/dist/session/hot-revoking-session-record-store.js +58 -0
  99. package/dist/session/index.d.ts +3 -1
  100. package/dist/session/index.d.ts.map +1 -1
  101. package/dist/session/index.js +8 -1
  102. package/dist/session/mariadb-session-record-store.d.ts +6 -0
  103. package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
  104. package/dist/session/mariadb-session-record-store.js +16 -1
  105. package/dist/session/session-lifetimes.d.ts +30 -0
  106. package/dist/session/session-lifetimes.d.ts.map +1 -0
  107. package/dist/session/session-lifetimes.js +21 -0
  108. package/dist/session/session-restriction-store.d.ts +43 -0
  109. package/dist/session/session-restriction-store.d.ts.map +1 -0
  110. package/dist/session/session-restriction-store.js +35 -0
  111. package/package.json +3 -3
  112. package/dist/auth/account.controller.js.map +0 -1
  113. package/dist/auth/auth.controller.js.map +0 -1
  114. package/dist/auth/auth.module.js.map +0 -1
  115. package/dist/auth/connections.js.map +0 -1
  116. package/dist/auth/dto.js.map +0 -1
  117. package/dist/auth/pending-mfa-store.js.map +0 -1
  118. package/dist/auth/persistence.module.js.map +0 -1
  119. package/dist/auth/ports/audit-sink.js.map +0 -1
  120. package/dist/auth/ports/credential-writer.js.map +0 -1
  121. package/dist/auth/ports/email-sender.js.map +0 -1
  122. package/dist/auth/ports/membership-checker.js.map +0 -1
  123. package/dist/auth/ports/mfa-device-store.js.map +0 -1
  124. package/dist/auth/ports/profile-writer.js.map +0 -1
  125. package/dist/auth/ports/recovery-code-store.js.map +0 -1
  126. package/dist/auth/ports/reset-token-store.js.map +0 -1
  127. package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
  128. package/dist/auth/ports/session-facts-projector.js.map +0 -1
  129. package/dist/auth/ports/session-sweeper.js.map +0 -1
  130. package/dist/auth/profile-resolver.js.map +0 -1
  131. package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
  132. package/dist/auth/tokens.js.map +0 -1
  133. package/dist/auth/totp-enrollment.js.map +0 -1
  134. package/dist/authentication/argon2id-password-hasher.js.map +0 -1
  135. package/dist/authentication/log-email-sender.js.map +0 -1
  136. package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
  137. package/dist/authentication/mariadb-credential-writer.js.map +0 -1
  138. package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
  139. package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
  140. package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
  141. package/dist/authentication/mariadb-profile-writer.js.map +0 -1
  142. package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
  143. package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
  144. package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
  145. package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
  146. package/dist/authz/list-scope-sql.js.map +0 -1
  147. package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
  148. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
  149. package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
  150. package/dist/authz/mariadb-policy-store.js.map +0 -1
  151. package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
  152. package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
  153. package/dist/authz/record-share-cascade.js.map +0 -1
  154. package/dist/authz/record-share-writer.js.map +0 -1
  155. package/dist/authz/wire-record-authorization.js.map +0 -1
  156. package/dist/boot/boot-sequence.js.map +0 -1
  157. package/dist/boot/boot.module.js.map +0 -1
  158. package/dist/completeness/catalog-reconciler.js.map +0 -1
  159. package/dist/completeness/operation-inventory-scanner.js.map +0 -1
  160. package/dist/completeness/ports/catalog-store.js.map +0 -1
  161. package/dist/config/app-config.js.map +0 -1
  162. package/dist/config/config.module.js.map +0 -1
  163. package/dist/enforcement/classification.decorator.js.map +0 -1
  164. package/dist/enforcement/core-seam.js.map +0 -1
  165. package/dist/enforcement/enforcement.module.js.map +0 -1
  166. package/dist/enforcement/global-enforcement.guard.js.map +0 -1
  167. package/dist/enforcement/reenroll-allowlist.js.map +0 -1
  168. package/dist/identity/auth-facts.controller.js.map +0 -1
  169. package/dist/identity/identity-actions.js.map +0 -1
  170. package/dist/identity/module-registry.bridge.js.map +0 -1
  171. package/dist/identity/permission-facts.adapter.js.map +0 -1
  172. package/dist/index.js.map +0 -1
  173. package/dist/migrations-dir.js.map +0 -1
  174. package/dist/module/shield-tokens.js.map +0 -1
  175. package/dist/module/shield.module.js.map +0 -1
  176. package/dist/request-auth/request-principal.js.map +0 -1
  177. package/dist/request-auth/session-facts-als.js.map +0 -1
  178. package/dist/request-auth/session-token-binder.js.map +0 -1
  179. package/dist/request-auth/tokens.js.map +0 -1
  180. package/dist/scope/closure-builder.js.map +0 -1
  181. package/dist/session/crypto-adapters.js.map +0 -1
  182. package/dist/session/index.js.map +0 -1
  183. package/dist/session/mariadb-session-record-store.js.map +0 -1
  184. package/dist/session/redis-hot-session-store.js.map +0 -1
@@ -12,7 +12,10 @@
12
12
  * establish/rotate time and reads it per request. Zero Core changes.
13
13
  *
14
14
  * The binding: `shield:at:<accessToken>` → JSON {@link BoundPrincipalClaims}
15
- * ({ id, tenantId, roleIds, kind }), TTL = the access-token lifetime. The token
15
+ * ({ id, tenantId, roleIds, kind }), TTL ≤ the access-token lifetime (security hardening
16
+ * #8). Alongside it: `shield:sessclaims:<sessionId>` (the liveness entry, TTL = the refresh
17
+ * idle window when configured) and `shield:sessat:<sessionId>` (the session's current
18
+ * access token, so a rotation can grace-expire the old one and a revoke can drop it). The token
16
19
  * stays fully opaque (carries no data itself); the mapping is server-side, so the
17
20
  * session is server-REVOCABLE (drop the key) and the read is FAIL-CLOSED (absent or
18
21
  * Redis-unavailable → no principal → the guard denies).
@@ -22,6 +25,19 @@
22
25
  */
23
26
  Object.defineProperty(exports, "__esModule", { value: true });
24
27
  exports.SessionTokenBinder = void 0;
28
+ exports.factsSlot = factsSlot;
29
+ exports.liveRoleIds = liveRoleIds;
30
+ /** A valid stamp: a non-negative safe integer. */
31
+ function isSubtreeVersion(v) {
32
+ return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
33
+ }
34
+ /** Return the claims with any `scopeSubtreeVersion` stamp removed. */
35
+ function stripSubtreeVersion(claims) {
36
+ if (!('scopeSubtreeVersion' in claims))
37
+ return claims;
38
+ const { scopeSubtreeVersion: _drop, ...rest } = claims;
39
+ return rest;
40
+ }
25
41
  /**
26
42
  * Structurally validate a value pulled from the (untrusted) Redis payload as
27
43
  * {@link CoarseGrants}. Returns the parsed facts or `undefined` — a `null`/junk/legacy
@@ -40,11 +56,52 @@ function parseCachedFacts(slot) {
40
56
  for (const k of c.grantedPermissionKeys)
41
57
  if (typeof k !== 'string')
42
58
  return undefined;
59
+ // The OPTIONAL time bound (ADR-0035): kept only when it is a finite number. A junk bound is
60
+ // dropped together with the facts (fail-closed: unusable facts → reload), never ignored.
61
+ if (c.validUntilMs !== undefined && !Number.isFinite(c.validUntilMs))
62
+ return undefined;
43
63
  return {
44
64
  permissionsVersion: c.permissionsVersion,
45
65
  grantedPermissionKeys: [...c.grantedPermissionKeys],
66
+ ...(typeof c.validUntilMs === 'number' ? { validUntilMs: c.validUntilMs } : {}),
46
67
  };
47
68
  }
69
+ /**
70
+ * The `cachedFacts` slot a binding stores (ADR-0035): the version, the keys and the optional time
71
+ * bound. The projection's `roleIds` are NOT kept here — they are the claims' own `roleIds`, written
72
+ * in the same payload, so there is one copy of the roles.
73
+ */
74
+ function factsSlot(grants) {
75
+ return {
76
+ permissionsVersion: grants.permissionsVersion,
77
+ grantedPermissionKeys: [...grants.grantedPermissionKeys],
78
+ ...(typeof grants.validUntilMs === 'number' && Number.isFinite(grants.validUntilMs)
79
+ ? { validUntilMs: grants.validUntilMs }
80
+ : {}),
81
+ };
82
+ }
83
+ /** The projection's live roles when it reported them (a string array), else `undefined`. */
84
+ function liveRoleIds(grants) {
85
+ const roles = grants?.roleIds;
86
+ if (!Array.isArray(roles))
87
+ return undefined;
88
+ return roles.filter((r) => typeof r === 'string' && r.length > 0);
89
+ }
90
+ /**
91
+ * Whether `next` is at least as fresh as the binding's current facts `current` (ADR-0035): a newer
92
+ * version, or the same version with a later (or no) time bound. A slower request that reloaded
93
+ * older facts must never overwrite fresher ones.
94
+ */
95
+ function isFresher(next, current) {
96
+ if (current === undefined)
97
+ return true;
98
+ if (next.permissionsVersion !== current.permissionsVersion) {
99
+ return next.permissionsVersion > current.permissionsVersion;
100
+ }
101
+ const a = next.validUntilMs ?? Number.POSITIVE_INFINITY;
102
+ const b = current.validUntilMs ?? Number.POSITIVE_INFINITY;
103
+ return a > b;
104
+ }
48
105
  /**
49
106
  * Structurally validate the OPTIONAL `scopeSubtreeNodeIds` slot from the (untrusted) Redis
50
107
  * payload. Returns a fresh string[] of the non-empty node ids, or `undefined` when the slot is
@@ -80,16 +137,34 @@ function stripCachedFacts(claims) {
80
137
  const AT_KEY_PREFIX = 'shield:at:';
81
138
  /** Redis key prefix for the session-id → claims binding (survives token rotation). */
82
139
  const SESS_CLAIMS_PREFIX = 'shield:sessclaims:';
140
+ /** Redis key prefix for the session-id → its CURRENT access token (for grace/unbind). */
141
+ const SESS_AT_PREFIX = 'shield:sessat:';
83
142
  function atKey(accessToken) {
84
143
  return `${AT_KEY_PREFIX}${accessToken}`;
85
144
  }
86
145
  function sessClaimsKey(sessionId) {
87
146
  return `${SESS_CLAIMS_PREFIX}${sessionId}`;
88
147
  }
148
+ function sessAtKey(sessionId) {
149
+ return `${SESS_AT_PREFIX}${sessionId}`;
150
+ }
151
+ /** Default grace for the previous access token after a rotation (plan decision D7). */
152
+ const DEFAULT_ROTATE_GRACE_MS = 30_000;
153
+ /** How long refresh waits for Redis to answer PING before answering 503 (ADR-0035, F1). */
154
+ const DEFAULT_PING_TIMEOUT_MS = 1_000;
89
155
  class SessionTokenBinder {
90
156
  redis;
91
- constructor(redis) {
157
+ options;
158
+ constructor(redis, options = {}) {
92
159
  this.redis = redis;
160
+ this.options = options;
161
+ }
162
+ /** TTL for the session-claims entry + current-token pointer. */
163
+ sessionTtl(accessTtlMs) {
164
+ const configured = this.options.sessionClaimsTtlMs;
165
+ return configured !== undefined && configured > 0
166
+ ? configured
167
+ : Math.max(accessTtlMs * 4, accessTtlMs);
93
168
  }
94
169
  /**
95
170
  * Bind an access token to its principal claims for `ttlMs` (the access-token
@@ -97,22 +172,47 @@ class SessionTokenBinder {
97
172
  * controller (it holds both the freshly-minted access token and the Principal).
98
173
  * ALSO writes a session-id → claims entry so a later `rebindOnRotate` can carry the
99
174
  * full claims (roles/kind/root) forward across a token rotation, where the
100
- * durable session row alone lacks them.
175
+ * durable session row alone lacks them, and records the session's CURRENT access
176
+ * token so a rotation can grace-expire it and a revoke can drop it.
101
177
  */
102
178
  async bind(accessToken, claims, ttlMs) {
103
179
  const payload = JSON.stringify(claims);
180
+ const sessTtl = this.sessionTtl(ttlMs);
104
181
  await this.redis.set(atKey(accessToken), payload, 'PX', ttlMs);
105
- // The session-claims entry gets a longer TTL cushion so it survives across
106
- // multiple short-lived access tokens within one session's lifetime.
107
- await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', Math.max(ttlMs * 4, ttlMs));
182
+ // The session-claims entry lives for the refresh idle window (configured), so it
183
+ // survives across the session's short-lived access tokens — and no longer.
184
+ await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', sessTtl);
185
+ await this.redis.set(sessAtKey(claims.sessionId), accessToken, 'PX', sessTtl);
186
+ }
187
+ /**
188
+ * Grace-expire the session's previous access token after a rotation (plan D7): it stays
189
+ * usable for at most `rotateGraceMs` (never longer than it already had), then expires.
190
+ * Best-effort — a failure leaves the old binding to its own (≤ access TTL) expiry.
191
+ */
192
+ async graceExpire(previousToken, newToken) {
193
+ if (previousToken === null || previousToken === '' || previousToken === newToken)
194
+ return;
195
+ const grace = this.options.rotateGraceMs ?? DEFAULT_ROTATE_GRACE_MS;
196
+ try {
197
+ const remaining = await this.redis.pttl(atKey(previousToken));
198
+ if (remaining === -1 || remaining > grace) {
199
+ await this.redis.pexpire(atKey(previousToken), grace);
200
+ }
201
+ }
202
+ catch {
203
+ // best-effort; the old binding still expires at its own access TTL.
204
+ }
108
205
  }
109
206
  /**
110
207
  * On refresh/rotate the controller has the NEW access token + the session, but not
111
208
  * the full Principal (roles/kind/root live only in the binding, not the
112
209
  * durable session row). Re-bind by reading the session-claims entry and writing the
113
210
  * new access-token binding with the SAME claims. Returns true if it carried claims
114
- * forward, false if the session-claims entry was gone (then the caller has nothing
115
- * to bind — a later request will 401 and the user re-logs in; fail-closed).
211
+ * forward, false if the session-claims entry was gone or unreadable — the caller MUST
212
+ * then refuse the refresh (the auth controller answers 401: an unbound token is never
213
+ * handed out). A Redis error on the writes propagates (the caller fails closed too).
214
+ * The new binding's TTL is `ttlMs` (≤ the access TTL); the session's previous access
215
+ * token is grace-expired (≤ 30 s by default, plan D7).
116
216
  *
117
217
  * `freshFacts` (ADR-0011 eager projection at REFRESH):
118
218
  * - `undefined` (omitted, the default) → carry whatever `cachedFacts` the session
@@ -124,7 +224,27 @@ class SessionTokenBinder {
124
224
  * - `null` → STRIP `cachedFacts` (a projection that yielded nothing → the next
125
225
  * request DB-reloads; fail-closed).
126
226
  */
127
- async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts, freshSubtree) {
227
+ async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts, freshSubtree,
228
+ /**
229
+ * The permission version read just before `freshSubtree` was resolved (Phase 1C). Stamped
230
+ * when the fresh subtree is applied; absent → the stamp is dropped (unknown → re-derive once
231
+ * at the next reload). Ignored when no fresh subtree is applied (the carried subtree keeps its
232
+ * own stamp).
233
+ */
234
+ freshSubtreeVersion,
235
+ /**
236
+ * The principal's LIVE role ids from the same projection as `freshFacts` (ADR-0035, GAPS #4).
237
+ * Given → they REPLACE the carried roles (roles and facts are written together). Absent → the
238
+ * carried roles stay (a projector that does not report roles, or no projection at all).
239
+ */
240
+ freshRoleIds,
241
+ /**
242
+ * The node the fresh facts/roles/subtree were projected for (review follow-up: a scope-switch
243
+ * moves the claims off the login tenant). Given and the claims are now on a DIFFERENT node (a
244
+ * switch raced the refresh) → nothing fresh is applied and the cached facts are stripped (the
245
+ * next request reloads for the right node). Absent → applied as before.
246
+ */
247
+ freshForTenantId) {
128
248
  let raw;
129
249
  try {
130
250
  raw = await this.redis.get(sessClaimsKey(sessionId));
@@ -134,9 +254,23 @@ class SessionTokenBinder {
134
254
  }
135
255
  if (raw === null)
136
256
  return false;
257
+ if (freshForTenantId !== undefined) {
258
+ let onNode = false;
259
+ try {
260
+ onNode = JSON.parse(raw)?.tenantId === freshForTenantId;
261
+ }
262
+ catch {
263
+ onNode = false;
264
+ }
265
+ if (!onNode) {
266
+ freshFacts = null;
267
+ freshSubtree = undefined;
268
+ freshRoleIds = undefined;
269
+ }
270
+ }
137
271
  // Default: carry the existing payload forward byte-for-byte (facts + subtree unchanged).
138
272
  let payload = raw;
139
- if (freshFacts !== undefined || freshSubtree !== undefined) {
273
+ if (freshFacts !== undefined || freshSubtree !== undefined || freshRoleIds !== undefined) {
140
274
  // A refresh-time projection and/or subtree was supplied → rewrite those slots on the
141
275
  // carried-forward claims. A parse failure here is treated as "nothing to update" and we
142
276
  // carry the original payload forward (fail-safe: never drop the whole binding on a
@@ -146,7 +280,12 @@ class SessionTokenBinder {
146
280
  let next = parsed;
147
281
  if (freshFacts !== undefined) {
148
282
  next =
149
- freshFacts === null ? stripCachedFacts(next) : { ...next, cachedFacts: freshFacts };
283
+ freshFacts === null
284
+ ? stripCachedFacts(next)
285
+ : { ...next, cachedFacts: factsSlot(freshFacts) };
286
+ }
287
+ if (freshRoleIds !== undefined) {
288
+ next = { ...next, roleIds: [...freshRoleIds] };
150
289
  }
151
290
  // The subtree (the stale-subtree-after-move fix): a non-empty fresh set REPLACES the
152
291
  // carried-forward one (a re-parented session gets its correct, possibly-smaller subtree).
@@ -154,7 +293,10 @@ class SessionTokenBinder {
154
293
  // forward (fail-safe, non-widening — never narrow to self-node on a transient resolver
155
294
  // hiccup, never fail the refresh).
156
295
  if (freshSubtree !== undefined && freshSubtree.length > 0) {
157
- next = { ...next, scopeSubtreeNodeIds: [...freshSubtree] };
296
+ next = stripSubtreeVersion({ ...next, scopeSubtreeNodeIds: [...freshSubtree] });
297
+ if (isSubtreeVersion(freshSubtreeVersion)) {
298
+ next = { ...next, scopeSubtreeVersion: freshSubtreeVersion };
299
+ }
158
300
  }
159
301
  payload = JSON.stringify(next);
160
302
  }
@@ -162,11 +304,140 @@ class SessionTokenBinder {
162
304
  payload = raw;
163
305
  }
164
306
  }
307
+ // The session's previous access token (best-effort read — a miss only skips the grace).
308
+ let previousToken = null;
309
+ try {
310
+ previousToken = await this.redis.get(sessAtKey(sessionId));
311
+ }
312
+ catch {
313
+ previousToken = null;
314
+ }
315
+ const sessTtl = this.sessionTtl(ttlMs);
165
316
  await this.redis.set(atKey(newAccessToken), payload, 'PX', ttlMs);
166
- // Refresh the session-claims TTL cushion too (with the same, possibly-updated payload).
167
- await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', Math.max(ttlMs * 4, ttlMs));
317
+ // Refresh the session-claims TTL (with the same, possibly-updated payload) + pointer.
318
+ // `XX` (Phase 2 review F2): only while the entry still exists. A logout that deleted it
319
+ // between our read and this write wins — the claims are NOT re-created, the new token's
320
+ // binding is dropped, and the caller refuses the refresh (no resurrected session).
321
+ const kept = await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', sessTtl, 'XX');
322
+ if (kept !== 'OK') {
323
+ await this.unbind(newAccessToken);
324
+ return false;
325
+ }
326
+ await this.redis.set(sessAtKey(sessionId), newAccessToken, 'PX', sessTtl);
327
+ await this.graceExpire(previousToken, newAccessToken);
328
+ return true;
329
+ }
330
+ /**
331
+ * SILENT RECOVERY (ADR-0013 / ADR-0035, GAPS #5): write a session binding REBUILT from durable
332
+ * state after the session-claims entry was lost (Redis restart/flush). The caller (refresh) has
333
+ * already rotated the durable refresh token and assembled `claims` from the durable session
334
+ * record + live roles + the root allowlist + the subtree resolver.
335
+ *
336
+ * The claims entry is written with `NX`: if one appeared meanwhile, nothing is overwritten and
337
+ * the rebuild is refused (`false`). The caller MUST then re-read the durable session and call
338
+ * {@link unbindSession} if it is no longer active (a logout racing the rebuild). A Redis error
339
+ * propagates (the caller fails closed).
340
+ */
341
+ async bindRecovered(accessToken, claims, ttlMs) {
342
+ const payload = JSON.stringify(claims);
343
+ const sessTtl = this.sessionTtl(ttlMs);
344
+ const created = await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', sessTtl, 'NX');
345
+ if (created !== 'OK')
346
+ return false;
347
+ await this.redis.set(atKey(accessToken), payload, 'PX', ttlMs);
348
+ await this.redis.set(sessAtKey(claims.sessionId), accessToken, 'PX', sessTtl);
168
349
  return true;
169
350
  }
351
+ /**
352
+ * The node a live session currently acts under (its claims' `tenantId` — the scope-switch target
353
+ * when it switched), or `null` when the claims are gone, unreadable, or Redis errors. Refresh uses
354
+ * it to project for the ACTIVE node; `null` → the durable login tenant.
355
+ */
356
+ async sessionTenant(sessionId) {
357
+ try {
358
+ const raw = await this.redis.get(sessClaimsKey(sessionId));
359
+ if (raw === null)
360
+ return null;
361
+ const tenantId = JSON.parse(raw)?.tenantId;
362
+ return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : null;
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ }
368
+ /**
369
+ * Whether Redis answers right now (Phase 2 review F1). Refresh asks BEFORE the durable rotate
370
+ * spends the refresh token, so a Redis outage answers a retryable 503 instead of burning the
371
+ * token on a binding that cannot be written. A client without `ping` (a test double) counts as
372
+ * reachable.
373
+ *
374
+ * Bounded by `timeoutMs` (default 1 s): the framework's client queues commands while
375
+ * disconnected (`maxRetriesPerRequest: null`), so an unbounded PING would wait for Redis to come
376
+ * back and the refresh would then spend the token for a client that already gave up.
377
+ */
378
+ async isAvailable(timeoutMs = DEFAULT_PING_TIMEOUT_MS) {
379
+ const client = this.redis;
380
+ if (typeof client.ping !== 'function')
381
+ return true;
382
+ let timer;
383
+ try {
384
+ const timedOut = new Promise((resolve) => {
385
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
386
+ });
387
+ const answer = await Promise.race([
388
+ client.ping().then(() => 'ok', () => 'error'),
389
+ timedOut,
390
+ ]);
391
+ return answer === 'ok';
392
+ }
393
+ finally {
394
+ if (timer !== undefined)
395
+ clearTimeout(timer);
396
+ }
397
+ }
398
+ /**
399
+ * LIVE GRANTS re-bind (ADR-0035, GAPS #4/#6). Gate 5 reloaded this session's coarse grants (a
400
+ * version bump, an expired time bound, or no cache yet); write them, and the live roles from the
401
+ * same projection, onto the access-token entry AND the session-claims entry, so the next requests
402
+ * are served from the cache and every role-keyed path sees the new roles. Nothing else changes.
403
+ *
404
+ * Each entry is rewritten only when it still belongs to `sessionId` + `tenantId` (a concurrent
405
+ * scope-switch moved it → left alone) and `facts` is fresher than what it holds (a slower request
406
+ * never overwrites newer facts). Each keeps its remaining TTL and is written with `XX` (a logout
407
+ * that deleted it wins). Best-effort: any Redis error or miss → that entry is skipped; the caller
408
+ * still uses the reloaded roles for the current request. Returns `true` when the access-token
409
+ * entry was rewritten.
410
+ */
411
+ async refreshGrants(accessToken, sessionId, tenantId, facts, roleIds) {
412
+ const slot = factsSlot(facts);
413
+ const rewrite = async (key) => {
414
+ try {
415
+ const raw = await this.redis.get(key);
416
+ if (raw === null)
417
+ return false;
418
+ const current = JSON.parse(raw);
419
+ if (current?.sessionId !== sessionId || current?.tenantId !== tenantId)
420
+ return false;
421
+ if (!isFresher(slot, parseCachedFacts(current.cachedFacts)))
422
+ return false;
423
+ const ttl = await this.redis.pttl(key);
424
+ if (ttl <= 0)
425
+ return false;
426
+ const next = {
427
+ ...current,
428
+ cachedFacts: slot,
429
+ ...(roleIds !== undefined ? { roleIds: [...roleIds] } : {}),
430
+ };
431
+ return (await this.redis.set(key, JSON.stringify(next), 'PX', ttl, 'XX')) === 'OK';
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ };
437
+ const tokenRewritten = await rewrite(atKey(accessToken));
438
+ await rewrite(sessClaimsKey(sessionId));
439
+ return tokenRewritten;
440
+ }
170
441
  /**
171
442
  * SCOPE-SWITCH re-bind (Task 5.2, ADR-0027 / ADR-0024). A principal is switching which
172
443
  * active node it acts under; re-bind its CURRENT session (the same access token — no
@@ -186,7 +457,25 @@ class SessionTokenBinder {
186
457
  * Returns `true` on a successful re-bind, `false` when the binding is gone (logged out /
187
458
  * expired) or Redis is unavailable (the caller then denies the switch — fail-closed).
188
459
  */
189
- async rebindActiveScope(accessToken, sessionId, targetNodeId, subtree) {
460
+ async rebindActiveScope(accessToken, sessionId, targetNodeId, subtree,
461
+ /**
462
+ * The TARGET node's permission version read just before `subtree` was resolved (Phase 1C).
463
+ * The old node's stamp is always dropped (versions are per tenant); this one is stamped when
464
+ * given, else the stamp stays absent (→ one re-derive at the next reload).
465
+ */
466
+ subtreeVersion,
467
+ /**
468
+ * The coarse grants projected for the TARGET node (ADR-0035). Given → cached on the re-scoped
469
+ * binding instead of stripped (the first request under the new scope is served from cache).
470
+ * Absent/null → stripped (the next request reloads).
471
+ */
472
+ targetFacts,
473
+ /**
474
+ * The principal's LIVE roles at the TARGET node (ADR-0035, GAPS #4), from the same projection
475
+ * as `targetFacts`. Given → they replace the old node's roles (roles are per node). Absent →
476
+ * the carried roles stay until the first request's reload replaces them.
477
+ */
478
+ targetRoleIds) {
190
479
  // Read the current claims (from the per-access-token entry) + confirm the session is
191
480
  // still live (the session-claims entry exists). Any miss / Redis error → fail closed.
192
481
  let rawAt;
@@ -210,12 +499,21 @@ class SessionTokenBinder {
210
499
  // Build the re-scoped claims: new active node (tenantId) + new subtree, facts stripped.
211
500
  // An empty subtree is stored as absent (→ per-request self-node fallback, non-widening) —
212
501
  // but the switch caller always passes at least `[targetNodeId]`, so this is defensive.
213
- const next = stripCachedFacts({
214
- ...stripScopeSubtree(current),
502
+ const rescoped = stripCachedFacts({
503
+ ...stripSubtreeVersion(stripScopeSubtree(current)),
215
504
  tenantId: targetNodeId,
216
505
  sessionId,
217
506
  ...(subtree.length > 0 ? { scopeSubtreeNodeIds: [...subtree] } : {}),
507
+ ...(subtree.length > 0 && isSubtreeVersion(subtreeVersion)
508
+ ? { scopeSubtreeVersion: subtreeVersion }
509
+ : {}),
218
510
  });
511
+ // ADR-0035: the target node's facts + live roles, written together, when projected.
512
+ const next = {
513
+ ...rescoped,
514
+ ...(targetFacts ? { cachedFacts: factsSlot(targetFacts) } : {}),
515
+ ...(targetRoleIds !== undefined ? { roleIds: [...targetRoleIds] } : {}),
516
+ };
219
517
  const payload = JSON.stringify(next);
220
518
  // Preserve each key's remaining TTL — a scope-switch must never extend the session.
221
519
  let atTtl;
@@ -230,14 +528,66 @@ class SessionTokenBinder {
230
528
  if (atTtl <= 0 || sessTtl <= 0)
231
529
  return false; // expired between reads → fail closed.
232
530
  try {
233
- await this.redis.set(atKey(accessToken), payload, 'PX', atTtl);
234
- await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', sessTtl);
531
+ // `XX` on both: a logout that deleted either entry since the reads wins (never re-created).
532
+ const at = await this.redis.set(atKey(accessToken), payload, 'PX', atTtl, 'XX');
533
+ const sess = await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', sessTtl, 'XX');
534
+ if (at !== 'OK' || sess !== 'OK') {
535
+ await this.unbind(accessToken);
536
+ return false;
537
+ }
235
538
  }
236
539
  catch {
237
540
  return false;
238
541
  }
239
542
  return true;
240
543
  }
544
+ /**
545
+ * RE-DERIVED SUBTREE re-bind (security hardening Phase 1C). The enforcement guard saw gate 5
546
+ * reload the facts at a permission version NEWER than the bound subtree's stamp and resolved
547
+ * the subtree again; write it (and its stamp) onto the access-token entry AND the session-claims
548
+ * entry, so this token and any later rotation carry it. Nothing else in the claims changes.
549
+ *
550
+ * Each entry is rewritten only when it still belongs to `sessionId` + `tenantId` (a concurrent
551
+ * scope-switch moved it elsewhere → left alone) and its stamp is absent or OLDER than `version`
552
+ * (a concurrent refresh already wrote something at least as fresh → left alone). Each keeps its
553
+ * remaining TTL (a re-derive never extends a session). Best-effort: any Redis error or miss →
554
+ * `false`; the caller still uses the fresh subtree for the current request and a later reload
555
+ * retries. Returns `true` when the access-token entry was rewritten.
556
+ */
557
+ async rederiveScopeSubtree(accessToken, sessionId, tenantId, subtree, version) {
558
+ if (subtree.length === 0 || !isSubtreeVersion(version))
559
+ return false;
560
+ const rewrite = async (key) => {
561
+ try {
562
+ const raw = await this.redis.get(key);
563
+ if (raw === null)
564
+ return false;
565
+ const current = JSON.parse(raw);
566
+ if (current?.sessionId !== sessionId || current?.tenantId !== tenantId)
567
+ return false;
568
+ if (isSubtreeVersion(current.scopeSubtreeVersion) &&
569
+ current.scopeSubtreeVersion >= version) {
570
+ return false;
571
+ }
572
+ const ttl = await this.redis.pttl(key);
573
+ if (ttl <= 0)
574
+ return false;
575
+ const next = {
576
+ ...current,
577
+ scopeSubtreeNodeIds: [...subtree],
578
+ scopeSubtreeVersion: version,
579
+ };
580
+ await this.redis.set(key, JSON.stringify(next), 'PX', ttl);
581
+ return true;
582
+ }
583
+ catch {
584
+ return false;
585
+ }
586
+ };
587
+ const tokenRewritten = await rewrite(atKey(accessToken));
588
+ await rewrite(sessClaimsKey(sessionId));
589
+ return tokenRewritten;
590
+ }
241
591
  /**
242
592
  * Resolve an access token to its bound claims, or `null` if the binding is
243
593
  * absent/expired. Fail-closed: any Redis error → `null` (the caller denies), never
@@ -275,17 +625,22 @@ class SessionTokenBinder {
275
625
  // null` → gate 5 DB-reloads). Present + well-formed → the validated CoarseGrants.
276
626
  // Never fabricated. Rebuild the object so a malformed slot cannot ride through.
277
627
  const cachedFacts = parseCachedFacts(parsed.cachedFacts);
278
- parsed = cachedFacts === undefined
279
- ? stripCachedFacts(parsed)
280
- : { ...parsed, cachedFacts };
628
+ parsed = cachedFacts === undefined ? stripCachedFacts(parsed) : { ...parsed, cachedFacts };
281
629
  // Normalize the OPTIONAL scope-subtree slot (ADR-0027 / Task 2.2): an absent, empty, or
282
630
  // malformed slot is scrubbed to ABSENT (→ `principalFromClaims` falls back to the self-node,
283
631
  // non-widening); a well-formed non-empty array is the validated cached subtree. Never
284
632
  // fabricated — rebuild the object so a junk slot cannot ride through into a Principal.
285
633
  const scopeSubtreeNodeIds = parseScopeSubtree(parsed.scopeSubtreeNodeIds);
286
- parsed = scopeSubtreeNodeIds === undefined
287
- ? stripScopeSubtree(parsed)
288
- : { ...parsed, scopeSubtreeNodeIds };
634
+ parsed =
635
+ scopeSubtreeNodeIds === undefined
636
+ ? stripScopeSubtree(parsed)
637
+ : { ...parsed, scopeSubtreeNodeIds };
638
+ // The subtree's version stamp (Phase 1C) rides only beside a validated subtree; a junk or
639
+ // orphaned stamp is dropped (→ treated as unknown → one re-derive at the next reload).
640
+ if (scopeSubtreeNodeIds === undefined ||
641
+ !isSubtreeVersion(parsed.scopeSubtreeVersion)) {
642
+ parsed = stripSubtreeVersion(parsed);
643
+ }
289
644
  // SESSION-LIVENESS GATE: the per-token binding alone is not enough — a logout
290
645
  // revokes the session but cannot reach every outstanding access-token key. So we
291
646
  // ALSO require the session-claims entry (which `unbindSession` deletes on logout)
@@ -376,9 +731,25 @@ class SessionTokenBinder {
376
731
  }
377
732
  }
378
733
  /**
379
- * Drop the session-claims entry on logout/revoke, so a rotate after logout cannot
380
- * re-mint a binding (defense-in-depth: the session is already revoked, but this
381
- * removes the claims cushion too). The per-access-token bindings expire by TTL.
734
+ * The THROWING variant of {@link unbindSession} (Phase 3 review P1): the same keys, but a
735
+ * Redis error PROPAGATES. An administrative revoke (an account disable through the session
736
+ * sweeper) must report a failed unbind so the admin can retry, instead of leaving the
737
+ * session's live access tokens valid until their TTL. Idempotent (absent keys are fine).
738
+ */
739
+ async unbindSessionStrict(sessionId) {
740
+ await this.redis.del(sessClaimsKey(sessionId));
741
+ const current = await this.redis.get(sessAtKey(sessionId));
742
+ if (current !== null && current !== '')
743
+ await this.redis.del(atKey(current));
744
+ await this.redis.del(sessAtKey(sessionId));
745
+ }
746
+ /**
747
+ * Revoke a session in Redis (logout, password reset, refresh reuse-kill, admin revoke):
748
+ * drop the session-claims entry — which the {@link resolve} liveness gate requires, so
749
+ * EVERY access token of the session is denied on its next request — and also the
750
+ * session's current access-token binding and pointer. A grace-period token from an
751
+ * earlier rotation is covered by the liveness gate and expires within the grace.
752
+ * Best-effort per key; TTLs clean up regardless ({@link unbindSessionStrict} throws instead).
382
753
  */
383
754
  async unbindSession(sessionId) {
384
755
  try {
@@ -387,6 +758,15 @@ class SessionTokenBinder {
387
758
  catch {
388
759
  // best-effort; TTL cleans up regardless.
389
760
  }
761
+ try {
762
+ const current = await this.redis.get(sessAtKey(sessionId));
763
+ if (current !== null && current !== '')
764
+ await this.redis.del(atKey(current));
765
+ await this.redis.del(sessAtKey(sessionId));
766
+ }
767
+ catch {
768
+ // best-effort; the liveness gate already denies every token of this session.
769
+ }
390
770
  }
391
771
  }
392
772
  exports.SessionTokenBinder = SessionTokenBinder;
@@ -1 +1 @@
1
- {"version":3,"file":"encrypted-file-secrets.d.ts","sourceRoot":"","sources":["../../src/secrets/encrypted-file-secrets.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AASjF,qBAAa,4BAA6B,YAAW,eAAe;;IAKlE;;;;;OAKG;gBACS,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG;IAMhD,OAAO,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;IAS1C,KAAK,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI3D,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;CA+BxD"}
1
+ {"version":3,"file":"encrypted-file-secrets.d.ts","sourceRoot":"","sources":["../../src/secrets/encrypted-file-secrets.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAYjF,qBAAa,4BAA6B,YAAW,eAAe;;IAKlE;;;;;OAKG;gBACS,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG;IAMhD,OAAO,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;IAS1C,KAAK,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI3D,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;CAmCxD"}
@@ -84,9 +84,15 @@ class EncryptedFileSecretsProvider {
84
84
  try {
85
85
  (0, node_fs_1.chmodSync)(this.#file, 0o600);
86
86
  }
87
- catch { /* best-effort on platforms without chmod */ }
87
+ catch {
88
+ /* best-effort on platforms without chmod */
89
+ }
88
90
  }
89
91
  }
90
92
  exports.EncryptedFileSecretsProvider = EncryptedFileSecretsProvider;
91
- function toB64(b) { return Buffer.from(b).toString('base64'); }
92
- function fromB64(s) { return new Uint8Array(Buffer.from(s, 'base64')); }
93
+ function toB64(b) {
94
+ return Buffer.from(b).toString('base64');
95
+ }
96
+ function fromB64(s) {
97
+ return new Uint8Array(Buffer.from(s, 'base64'));
98
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `HotRevokingSessionRecordStore` — a decorator on `SHIELD_SESSION_RECORD_STORE`
3
+ * (security hardening M-A).
4
+ *
5
+ * WHY: core's `SessionAuthority` revokes through the durable store only — the refresh
6
+ * reuse-kill (`revokeLineage`) and the absolute-cap / logout path (`revokeSession`) mark
7
+ * MariaDB rows revoked, but the guard's per-request path reads Redis alone (invariant #12)
8
+ * and never consults MariaDB. Without this decorator a stolen-and-replayed refresh token
9
+ * kills the lineage durably while the live access token keeps working until its TTL.
10
+ *
11
+ * WHAT: after a durable revoke SUCCEEDS, unbind the affected sessions in Redis
12
+ * (`SessionTokenBinder.unbindSession` drops the session-claims liveness entry, so every
13
+ * access token of the session is denied on its next request). Durable first; a durable
14
+ * failure propagates and nothing is unbound. The Redis step is best-effort: a failure there
15
+ * never fails the durable revoke, and the exposure is bounded by the access TTL (#8).
16
+ *
17
+ * No core change: the port is decorated, not extended. The lineage → session lookup is a
18
+ * guard-side read on the durable adapter ({@link LineageSessionLookup}).
19
+ */
20
+ import type { CreateSessionInput, InsertRefreshInput, RefreshTokenRecord, SessionRecord, SessionRecordStore } from '@astram/core';
21
+ /** The Redis side of a revoke (the token binder implements it). */
22
+ export interface SessionHotRevoker {
23
+ unbindSession(sessionId: string): Promise<void>;
24
+ }
25
+ /** Resolve the sessions a refresh lineage belongs to (a lifecycle-time durable read). */
26
+ export interface LineageSessionLookup {
27
+ sessionIdsForLineage(lineageId: string): Promise<string[]>;
28
+ }
29
+ export declare class HotRevokingSessionRecordStore implements SessionRecordStore {
30
+ private readonly inner;
31
+ private readonly hot;
32
+ private readonly lineages;
33
+ constructor(inner: SessionRecordStore, hot: SessionHotRevoker, lineages: LineageSessionLookup);
34
+ createSession(input: CreateSessionInput): Promise<SessionRecord>;
35
+ insertRefreshRecord(input: InsertRefreshInput): Promise<RefreshTokenRecord>;
36
+ findRefreshByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
37
+ findSessionById(sessionId: string): Promise<SessionRecord | null>;
38
+ markRotatedIfActive(refreshId: string): Promise<boolean>;
39
+ markRevoked(refreshId: string): Promise<void>;
40
+ /** Durable lineage kill, then unbind every session of the lineage in Redis. */
41
+ revokeLineage(lineageId: string): Promise<void>;
42
+ /** Durable session revoke, then unbind it in Redis. */
43
+ revokeSession(sessionId: string): Promise<void>;
44
+ private unbind;
45
+ }
46
+ //# sourceMappingURL=hot-revoking-session-record-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hot-revoking-session-record-store.d.ts","sourceRoot":"","sources":["../../src/session/hot-revoking-session-record-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EACV,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,kBAAkB,EACnB,MAAM,cAAc,CAAC;AAEtB,mEAAmE;AACnE,MAAM,WAAW,iBAAiB;IAChC,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED,yFAAyF;AACzF,MAAM,WAAW,oBAAoB;IACnC,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC5D;AAED,qBAAa,6BAA8B,YAAW,kBAAkB;IAEpE,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAFR,KAAK,EAAE,kBAAkB,EACzB,GAAG,EAAE,iBAAiB,EACtB,QAAQ,EAAE,oBAAoB;IAGjD,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC;IAIhE,mBAAmB,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAI3E,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAIxE,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAIjE,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIxD,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI7C,+EAA+E;IACzE,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWrD,uDAAuD;IACjD,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAKvC,MAAM;CAOrB"}