ioredis-toolkit 0.0.10 → 0.5.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 (267) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +1 -1
  3. package/README.md +68 -1613
  4. package/dist/cache/cache.d.ts +30 -0
  5. package/dist/cache/cache.d.ts.map +1 -0
  6. package/dist/cache/cache.js +59 -0
  7. package/dist/cache/cache.js.map +1 -0
  8. package/dist/cache/config.d.ts +12 -0
  9. package/dist/cache/config.d.ts.map +1 -0
  10. package/dist/cache/config.js +13 -0
  11. package/dist/cache/config.js.map +1 -0
  12. package/dist/cache/types.d.ts +32 -0
  13. package/dist/cache/types.d.ts.map +1 -0
  14. package/dist/cache/types.js +5 -0
  15. package/dist/cache/types.js.map +1 -0
  16. package/dist/index.d.ts +43 -51
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +32 -44
  19. package/dist/index.js.map +1 -0
  20. package/dist/lock/config.d.ts +12 -0
  21. package/dist/lock/config.d.ts.map +1 -0
  22. package/dist/lock/config.js +8 -0
  23. package/dist/lock/config.js.map +1 -0
  24. package/dist/lock/lock.d.ts +20 -0
  25. package/dist/lock/lock.d.ts.map +1 -0
  26. package/dist/lock/lock.js +44 -0
  27. package/dist/lock/lock.js.map +1 -0
  28. package/dist/lock/types.d.ts +19 -0
  29. package/dist/lock/types.d.ts.map +1 -0
  30. package/dist/lock/types.js +2 -0
  31. package/dist/lock/types.js.map +1 -0
  32. package/dist/modules-config.d.ts +3 -0
  33. package/dist/modules-config.d.ts.map +1 -0
  34. package/dist/modules-config.js +2 -0
  35. package/dist/modules-config.js.map +1 -0
  36. package/dist/pubsub/config.d.ts +11 -0
  37. package/dist/pubsub/config.d.ts.map +1 -0
  38. package/dist/pubsub/config.js +6 -0
  39. package/dist/pubsub/config.js.map +1 -0
  40. package/dist/pubsub/pubsub.d.ts +20 -0
  41. package/dist/pubsub/pubsub.d.ts.map +1 -0
  42. package/dist/pubsub/pubsub.js +54 -0
  43. package/dist/pubsub/pubsub.js.map +1 -0
  44. package/dist/pubsub/types.d.ts +24 -0
  45. package/dist/pubsub/types.d.ts.map +1 -0
  46. package/dist/pubsub/types.js +2 -0
  47. package/dist/pubsub/types.js.map +1 -0
  48. package/dist/rate-limit/config.d.ts +12 -0
  49. package/dist/rate-limit/config.d.ts.map +1 -0
  50. package/dist/rate-limit/config.js +6 -0
  51. package/dist/rate-limit/config.js.map +1 -0
  52. package/dist/rate-limit/rate-limiter.d.ts +18 -0
  53. package/dist/rate-limit/rate-limiter.d.ts.map +1 -0
  54. package/dist/rate-limit/rate-limiter.js +37 -0
  55. package/dist/rate-limit/rate-limiter.js.map +1 -0
  56. package/dist/rate-limit/types.d.ts +27 -0
  57. package/dist/rate-limit/types.d.ts.map +1 -0
  58. package/dist/rate-limit/types.js +2 -0
  59. package/dist/rate-limit/types.js.map +1 -0
  60. package/dist/redis/client-facade.d.ts +77 -0
  61. package/dist/redis/client-facade.d.ts.map +1 -0
  62. package/dist/redis/client-facade.js +102 -0
  63. package/dist/redis/client-facade.js.map +1 -0
  64. package/dist/redis/client.d.ts +10 -0
  65. package/dist/redis/client.d.ts.map +1 -0
  66. package/dist/redis/client.js +29 -0
  67. package/dist/redis/client.js.map +1 -0
  68. package/dist/redis/cluster.d.ts +7 -0
  69. package/dist/redis/cluster.d.ts.map +1 -0
  70. package/dist/redis/cluster.js +47 -0
  71. package/dist/redis/cluster.js.map +1 -0
  72. package/dist/redis/config.d.ts +39 -0
  73. package/dist/redis/config.d.ts.map +1 -0
  74. package/dist/redis/config.js +52 -0
  75. package/dist/redis/config.js.map +1 -0
  76. package/dist/redis/errors.d.ts +5 -0
  77. package/dist/redis/errors.d.ts.map +1 -0
  78. package/dist/redis/errors.js +5 -0
  79. package/dist/redis/errors.js.map +1 -0
  80. package/dist/redis/types.d.ts +135 -0
  81. package/dist/redis/types.d.ts.map +1 -0
  82. package/dist/redis/types.js +2 -0
  83. package/dist/redis/types.js.map +1 -0
  84. package/dist/redis/wrapper.d.ts +88 -0
  85. package/dist/redis/wrapper.d.ts.map +1 -0
  86. package/dist/redis/wrapper.js +206 -0
  87. package/dist/redis/wrapper.js.map +1 -0
  88. package/dist/session/config.d.ts +47 -0
  89. package/dist/session/config.d.ts.map +1 -0
  90. package/dist/session/config.js +101 -0
  91. package/dist/session/config.js.map +1 -0
  92. package/dist/session/cookie.d.ts +16 -0
  93. package/dist/session/cookie.d.ts.map +1 -0
  94. package/dist/session/cookie.js +28 -0
  95. package/dist/session/cookie.js.map +1 -0
  96. package/dist/session/errors.d.ts +56 -0
  97. package/dist/session/errors.d.ts.map +1 -0
  98. package/dist/session/errors.js +58 -0
  99. package/dist/session/errors.js.map +1 -0
  100. package/dist/session/factory.d.ts +21 -0
  101. package/dist/session/factory.d.ts.map +1 -0
  102. package/dist/session/factory.js +30 -0
  103. package/dist/session/factory.js.map +1 -0
  104. package/dist/session/health.d.ts +12 -0
  105. package/dist/session/health.d.ts.map +1 -0
  106. package/dist/session/health.js +23 -0
  107. package/dist/session/health.js.map +1 -0
  108. package/dist/session/keys.d.ts +23 -0
  109. package/dist/session/keys.d.ts.map +1 -0
  110. package/dist/session/keys.js +27 -0
  111. package/dist/session/keys.js.map +1 -0
  112. package/dist/session/manager.d.ts +34 -0
  113. package/dist/session/manager.d.ts.map +1 -0
  114. package/dist/session/manager.js +31 -0
  115. package/dist/session/manager.js.map +1 -0
  116. package/dist/session/metrics.d.ts +11 -0
  117. package/dist/session/metrics.d.ts.map +1 -0
  118. package/dist/session/metrics.js +10 -0
  119. package/dist/session/metrics.js.map +1 -0
  120. package/dist/session/repository.d.ts +49 -0
  121. package/dist/session/repository.d.ts.map +1 -0
  122. package/dist/session/repository.js +203 -0
  123. package/dist/session/repository.js.map +1 -0
  124. package/dist/session/revocation.d.ts +22 -0
  125. package/dist/session/revocation.d.ts.map +1 -0
  126. package/dist/session/revocation.js +41 -0
  127. package/dist/session/revocation.js.map +1 -0
  128. package/dist/session/script-sources.d.ts +11 -0
  129. package/dist/session/script-sources.d.ts.map +1 -0
  130. package/dist/session/script-sources.js +140 -0
  131. package/dist/session/script-sources.js.map +1 -0
  132. package/dist/session/scripts.d.ts +15 -0
  133. package/dist/session/scripts.d.ts.map +1 -0
  134. package/dist/session/scripts.js +41 -0
  135. package/dist/session/scripts.js.map +1 -0
  136. package/dist/session/serializer.d.ts +12 -0
  137. package/dist/session/serializer.d.ts.map +1 -0
  138. package/dist/session/serializer.js +77 -0
  139. package/dist/session/serializer.js.map +1 -0
  140. package/dist/session/service.d.ts +48 -0
  141. package/dist/session/service.d.ts.map +1 -0
  142. package/dist/session/service.js +235 -0
  143. package/dist/session/service.js.map +1 -0
  144. package/dist/session/token.d.ts +16 -0
  145. package/dist/session/token.d.ts.map +1 -0
  146. package/dist/session/token.js +32 -0
  147. package/dist/session/token.js.map +1 -0
  148. package/dist/session/types.d.ts +134 -0
  149. package/dist/session/types.d.ts.map +1 -0
  150. package/dist/session/types.js +2 -0
  151. package/dist/session/types.js.map +1 -0
  152. package/dist/streams/config.d.ts +12 -0
  153. package/dist/streams/config.d.ts.map +1 -0
  154. package/dist/streams/config.js +6 -0
  155. package/dist/streams/config.js.map +1 -0
  156. package/dist/streams/streams.d.ts +24 -0
  157. package/dist/streams/streams.d.ts.map +1 -0
  158. package/dist/streams/streams.js +55 -0
  159. package/dist/streams/streams.js.map +1 -0
  160. package/dist/streams/types.d.ts +32 -0
  161. package/dist/streams/types.d.ts.map +1 -0
  162. package/dist/streams/types.js +2 -0
  163. package/dist/streams/types.js.map +1 -0
  164. package/docs/ACCEPTANCE-REPORT.md +70 -0
  165. package/docs/ARCHITECTURE.md +61 -0
  166. package/docs/CAPACITY.md +33 -0
  167. package/docs/DEPLOYMENT.md +22 -0
  168. package/docs/README-API.md +15 -0
  169. package/docs/STATE-MACHINE.md +38 -0
  170. package/docs/TESTING.md +37 -0
  171. package/docs/THREAT-MODEL.md +23 -0
  172. package/docs/TYPE-SAFETY.md +34 -0
  173. package/docs/modules/cache/README.md +7 -0
  174. package/docs/modules/cache/usage.md +156 -0
  175. package/docs/modules/lock/README.md +7 -0
  176. package/docs/modules/lock/usage.md +105 -0
  177. package/docs/modules/pubsub/README.md +7 -0
  178. package/docs/modules/pubsub/usage.md +106 -0
  179. package/docs/modules/rate-limit/README.md +7 -0
  180. package/docs/modules/rate-limit/usage.md +100 -0
  181. package/docs/modules/sessions/README.md +7 -0
  182. package/docs/modules/sessions/usage.md +262 -0
  183. package/docs/modules/streams/README.md +7 -0
  184. package/docs/modules/streams/usage.md +141 -0
  185. package/package.json +50 -60
  186. package/src/scripts/cleanup-index.lua +4 -0
  187. package/src/scripts/conditional-update.lua +21 -0
  188. package/src/scripts/consume-session.lua +21 -0
  189. package/src/scripts/create-session.lua +28 -0
  190. package/src/scripts/delete.lua +2 -0
  191. package/src/scripts/destroy-user.lua +13 -0
  192. package/src/scripts/enforce-limit.lua +17 -0
  193. package/src/scripts/revoke-session.lua +13 -0
  194. package/src/scripts/rotate.lua +24 -0
  195. package/src/scripts/touch-session.lua +28 -0
  196. package/src/scripts/update-session.lua +18 -0
  197. package/dist/cache.d.ts +0 -797
  198. package/dist/cache.js +0 -1115
  199. package/dist/client.d.ts +0 -287
  200. package/dist/client.js +0 -1113
  201. package/dist/cluster-slot.d.ts +0 -4
  202. package/dist/cluster-slot.js +0 -31
  203. package/dist/cluster.d.ts +0 -79
  204. package/dist/cluster.js +0 -156
  205. package/dist/errors.d.ts +0 -30
  206. package/dist/errors.js +0 -63
  207. package/dist/health.d.ts +0 -180
  208. package/dist/health.js +0 -239
  209. package/dist/lock.d.ts +0 -233
  210. package/dist/lock.js +0 -440
  211. package/dist/logger.d.ts +0 -12
  212. package/dist/logger.js +0 -40
  213. package/dist/pubsub.d.ts +0 -423
  214. package/dist/pubsub.js +0 -537
  215. package/dist/ratelimiter.d.ts +0 -441
  216. package/dist/ratelimiter.js +0 -539
  217. package/dist/session/index.d.ts +0 -23
  218. package/dist/session/index.js +0 -16
  219. package/dist/session/revocation-store.d.ts +0 -176
  220. package/dist/session/revocation-store.js +0 -318
  221. package/dist/session/scripts/cleanup-index.lua +0 -21
  222. package/dist/session/scripts/conditional-update-encrypted.lua +0 -60
  223. package/dist/session/scripts/conditional-update.lua +0 -63
  224. package/dist/session/scripts/create.lua +0 -83
  225. package/dist/session/scripts/delete-by-user.lua +0 -29
  226. package/dist/session/scripts/delete.lua +0 -15
  227. package/dist/session/scripts/enforce-limit.lua +0 -38
  228. package/dist/session/scripts/revoke.lua +0 -61
  229. package/dist/session/scripts/rotate-encrypted.lua +0 -149
  230. package/dist/session/scripts/rotate.lua +0 -167
  231. package/dist/session/scripts/touch-encrypted.lua +0 -89
  232. package/dist/session/scripts/touch.lua +0 -72
  233. package/dist/session/scripts/validate.lua +0 -90
  234. package/dist/session/session-circuit-breaker.d.ts +0 -42
  235. package/dist/session/session-circuit-breaker.js +0 -129
  236. package/dist/session/session-config.d.ts +0 -355
  237. package/dist/session/session-config.js +0 -171
  238. package/dist/session/session-cookie.d.ts +0 -72
  239. package/dist/session/session-cookie.js +0 -101
  240. package/dist/session/session-encryption.d.ts +0 -87
  241. package/dist/session/session-encryption.js +0 -139
  242. package/dist/session/session-errors.d.ts +0 -85
  243. package/dist/session/session-errors.js +0 -145
  244. package/dist/session/session-health.d.ts +0 -38
  245. package/dist/session/session-health.js +0 -60
  246. package/dist/session/session-keys.d.ts +0 -64
  247. package/dist/session/session-keys.js +0 -128
  248. package/dist/session/session-manager.d.ts +0 -73
  249. package/dist/session/session-manager.js +0 -94
  250. package/dist/session/session-metrics.d.ts +0 -41
  251. package/dist/session/session-metrics.js +0 -135
  252. package/dist/session/session-repository.d.ts +0 -184
  253. package/dist/session/session-repository.js +0 -763
  254. package/dist/session/session-scripts.d.ts +0 -36
  255. package/dist/session/session-scripts.js +0 -130
  256. package/dist/session/session-serializer.d.ts +0 -42
  257. package/dist/session/session-serializer.js +0 -267
  258. package/dist/session/session-service.d.ts +0 -123
  259. package/dist/session/session-service.js +0 -670
  260. package/dist/session/session-token.d.ts +0 -38
  261. package/dist/session/session-token.js +0 -86
  262. package/dist/session/session-types.d.ts +0 -281
  263. package/dist/session/session-types.js +0 -16
  264. package/dist/types.d.ts +0 -924
  265. package/dist/types.js +0 -151
  266. package/dist/utils/deepmerge.d.ts +0 -9
  267. package/dist/utils/deepmerge.js +0 -61
@@ -1,83 +0,0 @@
1
- -- create.lua (version 1)
2
- -- Atomically creates a session record, registers it in the user index
3
- -- (ZSET, score = microsecond-resolution Redis server time, used purely
4
- -- for eviction ordering - see below), optionally claims an idempotency
5
- -- key, and enforces maxSessionsPerUser with bounded oldest-first eviction.
6
- --
7
- -- KEYS[1] = session record key
8
- -- KEYS[2] = user session index key (ZSET)
9
- -- KEYS[3] = idempotency claim key (only passed when idempotency is enabled)
10
- --
11
- -- ARGV[1] = serialized session envelope (plain v1 or encrypted v2)
12
- -- ARGV[2] = jti
13
- -- ARGV[3] = createdAt (Unix seconds)
14
- -- ARGV[4] = session TTL (absoluteExpiresAt - now, clamped >= 1)
15
- -- ARGV[5] = maxSessionsPerUser (0 disables)
16
- -- ARGV[6] = session key prefix for this user (same hash tag => same slot)
17
- -- ARGV[7] = max evictions per call (bounded Lua work)
18
- -- ARGV[8] = idempotency claim TTL ('' when idempotency is disabled)
19
- --
20
- -- Returns:
21
- -- {1, {evictedJtis...}} success (evicted list may be empty)
22
- -- {3, jti} idempotent replay: a previous attempt created
23
- -- the session; jti identifies it
24
- -- -5 jti collision: a session with this id exists
25
- --
26
- -- All keys share the {userId} hash tag, so this script is atomic on a
27
- -- single Cluster slot. No cross-slot keys are touched.
28
- local key = KEYS[1]
29
- local index = KEYS[2]
30
-
31
- -- Idempotent replay wins over collision detection: a retry with the same
32
- -- idempotencyKey must return the original session, even though the session
33
- -- record already exists.
34
- if ARGV[8] ~= '' then
35
- local claimKey = KEYS[3]
36
- local claimJti = redis.call('GET', claimKey)
37
- if claimJti then
38
- return { 3, claimJti }
39
- end
40
- redis.call('SET', claimKey, ARGV[2], 'EX', tonumber(ARGV[8]))
41
- end
42
-
43
- if redis.call('EXISTS', key) == 1 then
44
- return -5
45
- end
46
-
47
- redis.call('SET', key, ARGV[1], 'EX', tonumber(ARGV[4]))
48
-
49
- -- The user index score is used purely for oldest-first eviction ordering
50
- -- (never as the record's createdAt, which is app-stamped seconds inside the
51
- -- envelope). Redis breaks equal ZSET scores by lexicographic member order,
52
- -- not insertion order, so scoring by second-granularity createdAt would
53
- -- make eviction pick an arbitrary (jti-lexicographic) session rather than
54
- -- the actual oldest one whenever a user creates more than one session in
55
- -- the same wall-clock second - a realistic case under normal traffic, not
56
- -- just a burst edge case. Redis is single-threaded, so two scripts touching
57
- -- the same user slot never observe the same TIME() reading in practice;
58
- -- microsecond-resolution server time therefore gives a strictly monotonic,
59
- -- clock-skew-free ordering key for this user's index.
60
- local t = redis.call('TIME')
61
- local orderScore = tonumber(t[1]) + (tonumber(t[2]) / 1000000)
62
- redis.call('ZADD', index, orderScore, ARGV[2])
63
-
64
- local evicted = {}
65
- local limit = tonumber(ARGV[5])
66
-
67
- if limit and limit > 0 then
68
- local card = redis.call('ZCARD', index)
69
- if card > limit then
70
- local excess = card - limit
71
- local n = math.min(excess, tonumber(ARGV[7]))
72
- local members = redis.call('ZRANGE', index, 0, n - 1)
73
- local prefix = ARGV[6]
74
-
75
- for _, member in ipairs(members) do
76
- redis.call('DEL', prefix .. member)
77
- redis.call('ZREM', index, member)
78
- table.insert(evicted, member)
79
- end
80
- end
81
- end
82
-
83
- return { 1, evicted }
@@ -1,29 +0,0 @@
1
- -- delete-by-user.lua (version 2)
2
- -- Deletes a bounded batch of a user's sessions and removes them from the
3
- -- user index, in one same-slot script. The caller chunks the user's jtis
4
- -- into batches (limits.maxBatchSize), so a user with an arbitrarily large
5
- -- session count never produces one giant script.
6
- --
7
- -- KEYS[1] = user session index key
8
- -- KEYS[2..] = session record keys (same slot as KEYS[1])
9
- --
10
- -- ARGV[1..] = matching jtis (index i pairs with KEYS[i + 1])
11
- --
12
- -- Returns: the jtis whose records were actually deleted. The index key is
13
- -- deleted at the end (the whole-user operation is complete when the caller
14
- -- has processed every batch).
15
- local deleted = {}
16
-
17
- for i = 2, #KEYS do
18
- local jti = ARGV[i - 1]
19
-
20
- if redis.call('DEL', KEYS[i]) == 1 then
21
- table.insert(deleted, jti)
22
- end
23
-
24
- redis.call('ZREM', KEYS[1], jti)
25
- end
26
-
27
- redis.call('DEL', KEYS[1])
28
-
29
- return deleted
@@ -1,15 +0,0 @@
1
- -- delete.lua (version 1)
2
- -- Physically deletes a session record and removes it from the user index.
3
- -- Idempotent: deleting a missing session returns 0 and is not an error.
4
- --
5
- -- KEYS[1] = session record key
6
- -- KEYS[2] = user session index key
7
- --
8
- -- ARGV[1] = jti
9
- --
10
- -- Returns: 1 if the record existed, 0 otherwise.
11
- local existed = redis.call('DEL', KEYS[1])
12
-
13
- redis.call('ZREM', KEYS[2], ARGV[1])
14
-
15
- return existed
@@ -1,38 +0,0 @@
1
- -- enforce-limit.lua (version 1)
2
- -- Standalone max-session enforcement (admin/repair paths). Evicts the
3
- -- oldest excess sessions from a user's index using ZSET ordering and
4
- -- bounded work (never loads the whole index).
5
- --
6
- -- KEYS[1] = user session index key
7
- --
8
- -- ARGV[1] = maxSessionsPerUser (0 disables)
9
- -- ARGV[2] = max evictions per call (bounded Lua work; callers loop to
10
- -- converge when the excess exceeds this bound)
11
- -- ARGV[3] = session key prefix for this user (same hash tag => same slot)
12
- --
13
- -- Returns: { evictedCount, {evictedJtis...} }
14
- local card = redis.call('ZCARD', KEYS[1])
15
- local limit = tonumber(ARGV[1])
16
-
17
- if limit <= 0 then
18
- return { 0, {} }
19
- end
20
-
21
- local excess = card - limit
22
-
23
- if excess <= 0 then
24
- return { 0, {} }
25
- end
26
-
27
- local n = math.min(excess, tonumber(ARGV[2]))
28
- local members = redis.call('ZRANGE', KEYS[1], 0, n - 1)
29
- local prefix = ARGV[3]
30
- local evicted = {}
31
-
32
- for _, member in ipairs(members) do
33
- redis.call('DEL', prefix .. member)
34
- redis.call('ZREM', KEYS[1], member)
35
- table.insert(evicted, member)
36
- end
37
-
38
- return { #evicted, evicted }
@@ -1,61 +0,0 @@
1
- -- revoke.lua (version 1)
2
- -- Logically revokes a session: status -> 'revoked' with a bounded tombstone
3
- -- TTL (remaining absolute lifetime). Revoked sessions never authenticate,
4
- -- even if a stale copy of the record survives somewhere. Idempotent: a
5
- -- second revoke returns 2.
6
- --
7
- -- KEYS[1] = session record key
8
- --
9
- -- ARGV[1] = new serialized envelope for encrypted (v2) sessions,
10
- -- '' for plain (v1) sessions (the script builds it itself)
11
- -- ARGV[2] = tombstone TTL (remaining absolute lifetime, clamped >= 1)
12
- --
13
- -- Returns:
14
- -- 1 revoked
15
- -- 2 already revoked (idempotent)
16
- -- 0 not found
17
- -- 3 unknown envelope (neither v1 nor v2)
18
- local raw = redis.call('GET', KEYS[1])
19
-
20
- if not raw then
21
- return 0
22
- end
23
-
24
- local env = cjson.decode(raw)
25
- local ttl = tonumber(ARGV[2])
26
-
27
- if env.v == 1 then
28
- local s = env.s
29
-
30
- if s.status == 'revoked' then
31
- return 2
32
- end
33
-
34
- s.status = 'revoked'
35
- redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
36
- return 1
37
- end
38
-
39
- if env.v == 2 then
40
- if env.st == 'revoked' then
41
- return 2
42
- end
43
-
44
- if ARGV[1] == '' then
45
- return 4
46
- end
47
-
48
- local newEnv = cjson.decode(ARGV[1])
49
- newEnv.st = 'revoked'
50
- newEnv.ver = env.ver
51
- newEnv.la = env.la
52
- newEnv.idle = env.idle
53
- newEnv.exp = env.exp
54
- newEnv.rn = env.rn
55
- newEnv.rj = env.rj
56
-
57
- redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', ttl)
58
- return 1
59
- end
60
-
61
- return 3
@@ -1,149 +0,0 @@
1
- -- rotate-encrypted.lua (version 1) - encrypted (v2) envelopes
2
- -- Atomic, single-use session rotation for encrypted sessions.
3
- --
4
- -- The ciphertext cannot be decoded inside Lua, so the app decrypts the
5
- -- old record, builds the consumed + successor payloads (re-encrypted with
6
- -- the current key) and passes them as ARGV. The script is the only
7
- -- authority for the state transition, decided from the plaintext header
8
- -- mirrors (st/ver/exp/rn/rj/fam) and Redis server time; the header mirrors
9
- -- of the written payloads are overwritten from the checked values so a
10
- -- stale app payload can never misrepresent state.
11
- --
12
- -- Token-family reuse detection: mirrors rotate.lua (see that file for the
13
- -- full rationale). The plaintext `fam` header mirror lets this script make
14
- -- the same family-head decision the plain path makes, even though it can
15
- -- never see or alter the ciphertext itself: the app is responsible for
16
- -- baking the correct familyId into both the ciphertext and the `fam`
17
- -- mirror before calling this script (see SessionRepository.rotate), and
18
- -- this script cross-checks/forces the mirror the same way it already
19
- -- forces st/ver/la/idle/exp/rn/rj - if the app ever got it wrong, the next
20
- -- read's assertHeaderMatches() catches the ciphertext/header disagreement
21
- -- and fails closed rather than silently establishing the wrong lineage.
22
- --
23
- -- KEYS[1] = old session record key
24
- -- KEYS[2] = successor session record key
25
- -- KEYS[3] = user session index key
26
- --
27
- -- ARGV[1] = consumed serialized envelope (app-built, re-encrypted)
28
- -- ARGV[2] = successor serialized envelope (app-built, re-encrypted)
29
- -- ARGV[3] = successor jti
30
- -- ARGV[4] = expected version of the old session ('' = no check)
31
- -- ARGV[5] = rotation nonce hash ('' = none)
32
- -- ARGV[6] = retain consumed tombstone (1/0)
33
- -- ARGV[7] = old jti
34
- -- ARGV[8] = successor TTL (clamped >= 1)
35
- -- ARGV[9] = consumed tombstone TTL (clamped >= 1)
36
- -- ARGV[10] = session key prefix for this user (same hash tag => same slot)
37
- -- ARGV[11] = family-head key prefix for this user (same hash tag)
38
- -- ARGV[12] = revoke the family head on genuine replay (1/0)
39
- --
40
- -- Returns: same codes as rotate.lua (1/2/0/-1/-2/-3/-4/5/{-6,familyId,headJti}),
41
- -- plus 6 when the envelope is plain (caller must use the plain path).
42
- local raw = redis.call('GET', KEYS[1])
43
-
44
- if not raw then
45
- return 0
46
- end
47
-
48
- local env = cjson.decode(raw)
49
-
50
- if env.v ~= 2 then
51
- return 6
52
- end
53
-
54
- local t = redis.call('TIME')
55
- local now = tonumber(t[1])
56
-
57
- -- Immutable identity field: unchanged across every rotation of this
58
- -- lineage. Legacy pre-migration records lacking it adopt their own jti as
59
- -- the familyId from this point forward (self-healing) - see rotate.lua.
60
- local familyId = env.fam
61
- if familyId == nil or familyId == cjson.null then
62
- familyId = ARGV[7]
63
- end
64
- local familyHeadKey = ARGV[11] .. familyId
65
-
66
- -- Retry-safe replay detection BEFORE rejecting consumed records.
67
- -- The rotation nonce uniquely identifies the rotation: when the consumed
68
- -- record carries the same nonce hash, this is a retry of an already-applied
69
- -- rotation. The stored rotatedTo jti is returned (the retry's own freshly
70
- -- generated successor jti is discarded - it can never match).
71
- if env.st == 'consumed' then
72
- if ARGV[5] ~= '' and env.rn == ARGV[5] then
73
- return { 2, env.rj }
74
- end
75
-
76
- -- Genuine reuse of an already-rotated-away token.
77
- if ARGV[12] == '1' then
78
- local headJti = redis.call('GET', familyHeadKey)
79
- if headJti then
80
- redis.call('DEL', ARGV[10] .. headJti)
81
- redis.call('ZREM', KEYS[3], headJti)
82
- end
83
- redis.call('DEL', familyHeadKey)
84
- return { -6, familyId, headJti or '' }
85
- end
86
-
87
- return -1
88
- end
89
-
90
- if env.st ~= 'active' then
91
- return -1
92
- end
93
-
94
- if tonumber(env.exp) <= now then
95
- redis.call('DEL', KEYS[1])
96
- redis.call('ZREM', KEYS[3], ARGV[7])
97
- return -2
98
- end
99
-
100
- if ARGV[4] ~= '' and tostring(env.ver) ~= ARGV[4] then
101
- return -3
102
- end
103
-
104
- if redis.call('EXISTS', KEYS[2]) == 1 then
105
- return -4
106
- end
107
-
108
- local consumedEnv = cjson.decode(ARGV[1])
109
- consumedEnv.st = 'consumed'
110
- consumedEnv.ver = env.ver
111
- consumedEnv.la = env.la
112
- consumedEnv.idle = env.idle
113
- consumedEnv.exp = env.exp
114
- consumedEnv.rn = cjson.null
115
- consumedEnv.rj = ARGV[3]
116
- consumedEnv.fam = familyId
117
-
118
- if ARGV[5] ~= '' then
119
- consumedEnv.rn = ARGV[5]
120
- end
121
-
122
- local nextEnv = cjson.decode(ARGV[2])
123
- nextEnv.st = 'active'
124
- nextEnv.ver = 1
125
- nextEnv.la = now
126
- if nextEnv.idle ~= cjson.null then
127
- nextEnv.idle = math.min(tonumber(nextEnv.idle), tonumber(nextEnv.exp))
128
- end
129
- nextEnv.rn = cjson.null
130
- nextEnv.rj = cjson.null
131
- nextEnv.fam = familyId
132
-
133
- local nextTtl = math.max(1, tonumber(ARGV[8]))
134
-
135
- if ARGV[6] == '1' then
136
- redis.call('SET', KEYS[1], cjson.encode(consumedEnv), 'EX', math.max(1, tonumber(ARGV[9])))
137
- else
138
- redis.call('DEL', KEYS[1])
139
- end
140
-
141
- redis.call('SET', KEYS[2], cjson.encode(nextEnv), 'EX', nextTtl)
142
- redis.call('ZREM', KEYS[3], ARGV[7])
143
- -- Microsecond-resolution ordering score - see create.lua for why
144
- -- second-granularity scores break oldest-first eviction ordering.
145
- redis.call('ZADD', KEYS[3], tonumber(t[1]) + (tonumber(t[2]) / 1000000), ARGV[3])
146
- -- Advance the family head to the new generation.
147
- redis.call('SET', familyHeadKey, ARGV[3], 'EX', nextTtl)
148
-
149
- return { 1, ARGV[3] }
@@ -1,167 +0,0 @@
1
- -- rotate.lua (version 1) - plain (v1) envelopes
2
- -- Atomic, single-use session rotation.
3
- --
4
- -- The old session is consumed (status=consumed, consumedAt, rotatedTo,
5
- -- rotationNonceHash) and the successor is created, all in one same-slot
6
- -- script. No preliminary GET can make this safe; the script is the only
7
- -- authority. Timestamps are stamped from Redis server time.
8
- --
9
- -- Retry-safe idempotency: when the caller retries a rotation that already
10
- -- succeeded (response lost), the consumed record retains rotationNonceHash
11
- -- and rotatedTo. If both match the retry, the script returns {2, jti} and
12
- -- the caller returns the existing successor instead of a replay error.
13
- --
14
- -- Token-family reuse detection: every session carries a stable familyId
15
- -- (the first generation's own jti), unchanged across rotations. A
16
- -- {userId}-scoped "family-head" pointer key tracks the jti of the family's
17
- -- current active generation, updated atomically on every successful
18
- -- rotation. When a CONSUMED predecessor is replayed for real (not a
19
- -- same-nonce retry), that is a strong signal the old token was stolen: if
20
- -- ARGV[9] enables it, the script atomically revokes whatever the family
21
- -- head currently points to (the live session an attacker could otherwise
22
- -- keep using) and clears the head pointer, so the entire lineage dies
23
- -- rather than just rejecting the one replayed request. The head pointer is
24
- -- purely a defensive correlation key: per I7 it is never consulted by
25
- -- validate() and can never itself grant authentication.
26
- --
27
- -- KEYS[1] = old session record key
28
- -- KEYS[2] = successor session record key
29
- -- KEYS[3] = user session index key
30
- --
31
- -- ARGV[1] = successor serialized envelope (app-built static fields)
32
- -- ARGV[2] = successor jti
33
- -- ARGV[3] = expected version of the old session ('' = no check)
34
- -- ARGV[4] = rotation nonce hash ('' = none)
35
- -- ARGV[5] = retain consumed tombstone (1/0)
36
- -- ARGV[6] = old jti
37
- -- ARGV[7] = session key prefix for this user (same hash tag => same slot)
38
- -- ARGV[8] = family-head key prefix for this user (same hash tag)
39
- -- ARGV[9] = revoke the family head on genuine replay (1/0)
40
- --
41
- -- Returns:
42
- -- {1, successorJti} success
43
- -- {2, successorJti} idempotent replay of a rotation with the same nonce
44
- -- 0 old session not found
45
- -- -1 already consumed / revoked (replay; family not
46
- -- touched - either ARGV[9] is off, or the status
47
- -- was already 'revoked' rather than 'consumed')
48
- -- {-6, familyId, headJti} genuine replay of a consumed predecessor: the
49
- -- family head (headJti, or "" if none was set)
50
- -- was revoked and the family head pointer cleared
51
- -- -2 old session expired (record removed)
52
- -- -3 version conflict
53
- -- -4 successor jti collision
54
- -- 5 envelope is encrypted (use the encrypted path)
55
- local raw = redis.call('GET', KEYS[1])
56
-
57
- if not raw then
58
- return 0
59
- end
60
-
61
- local env = cjson.decode(raw)
62
-
63
- if env.v ~= 1 then
64
- return 5
65
- end
66
-
67
- local s = env.s
68
- local t = redis.call('TIME')
69
- local now = tonumber(t[1])
70
-
71
- -- Immutable identity field: unchanged across every rotation of this
72
- -- lineage. Legacy pre-migration records lacking it adopt their own jti as
73
- -- the familyId from this point forward (self-healing).
74
- local familyId = s.familyId
75
- if familyId == nil or familyId == cjson.null then
76
- familyId = s.jti
77
- end
78
- local familyHeadKey = ARGV[8] .. familyId
79
-
80
- -- Retry-safe replay detection BEFORE rejecting consumed records.
81
- -- The rotation nonce uniquely identifies the rotation: when the consumed
82
- -- record carries the same nonce hash, this is a retry of an already-applied
83
- -- rotation. The stored rotatedTo jti is returned (the retry's own freshly
84
- -- generated successor jti is discarded - it can never match).
85
- if s.status == 'consumed' then
86
- if ARGV[4] ~= '' and s.rotationNonceHash == ARGV[4] then
87
- return { 2, s.rotatedTo }
88
- end
89
-
90
- -- Genuine reuse of an already-rotated-away token.
91
- if ARGV[9] == '1' then
92
- local headJti = redis.call('GET', familyHeadKey)
93
- if headJti then
94
- redis.call('DEL', ARGV[7] .. headJti)
95
- redis.call('ZREM', KEYS[3], headJti)
96
- end
97
- redis.call('DEL', familyHeadKey)
98
- return { -6, familyId, headJti or '' }
99
- end
100
-
101
- return -1
102
- end
103
-
104
- if s.status ~= 'active' then
105
- return -1
106
- end
107
-
108
- if tonumber(s.absoluteExpiresAt) <= now then
109
- redis.call('DEL', KEYS[1])
110
- redis.call('ZREM', KEYS[3], ARGV[6])
111
- return -2
112
- end
113
-
114
- if ARGV[3] ~= '' and tostring(s.version) ~= ARGV[3] then
115
- return -3
116
- end
117
-
118
- -- Successor collision: cryptographically improbable, but never overwrite.
119
- if redis.call('EXISTS', KEYS[2]) == 1 then
120
- return -4
121
- end
122
-
123
- -- Build the successor record; server time is authoritative for ordering.
124
- local nextEnv = cjson.decode(ARGV[1])
125
- local ns = nextEnv.s
126
-
127
- ns.createdAt = now
128
- ns.lastAccessedAt = now
129
- ns.rotatedFrom = s.jti
130
- ns.familyId = familyId
131
- ns.version = 1
132
-
133
- if ns.idleExpiresAt and ns.idleExpiresAt ~= cjson.null then
134
- ns.idleExpiresAt = math.min(tonumber(ns.idleExpiresAt), tonumber(ns.absoluteExpiresAt))
135
- end
136
-
137
- local nextTtl = math.max(1, tonumber(ns.absoluteExpiresAt) - now)
138
-
139
- -- Consume the old session.
140
- s.status = 'consumed'
141
- s.consumedAt = now
142
- s.rotatedTo = ARGV[2]
143
-
144
- if ARGV[4] ~= '' then
145
- s.rotationNonceHash = ARGV[4]
146
- else
147
- s.rotationNonceHash = cjson.null
148
- end
149
-
150
- local oldTtl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
151
-
152
- if ARGV[5] == '1' then
153
- -- Short-lived consumed tombstone for replay detection (bounded TTL).
154
- redis.call('SET', KEYS[1], cjson.encode(env), 'EX', oldTtl)
155
- else
156
- redis.call('DEL', KEYS[1])
157
- end
158
-
159
- redis.call('SET', KEYS[2], cjson.encode(nextEnv), 'EX', nextTtl)
160
- redis.call('ZREM', KEYS[3], ARGV[6])
161
- -- Microsecond-resolution ordering score - see create.lua for why
162
- -- second-granularity scores break oldest-first eviction ordering.
163
- redis.call('ZADD', KEYS[3], tonumber(t[1]) + (tonumber(t[2]) / 1000000), ARGV[2])
164
- -- Advance the family head to the new generation.
165
- redis.call('SET', familyHeadKey, ARGV[2], 'EX', nextTtl)
166
-
167
- return { 1, ARGV[2] }
@@ -1,89 +0,0 @@
1
- -- touch-encrypted.lua (version 1) - encrypted (v2) envelopes
2
- -- Throttled, monotonic activity refresh for encrypted sessions.
3
- --
4
- -- The ciphertext cannot be decoded inside Lua, so the app re-encrypts the
5
- -- record (with its own clock) and passes the new envelope as ARGV[4]. The
6
- -- script validates state via the plaintext header mirrors and enforces
7
- -- monotonicity: a stale request can never move lastAccessedAt backwards.
8
- --
9
- -- KEYS[1] = session record key
10
- --
11
- -- ARGV[1] = touchInterval (seconds)
12
- -- ARGV[2] = idleTimeout (seconds, '' when disabled)
13
- -- ARGV[3] = force (1 = ignore throttle)
14
- -- ARGV[4] = new serialized envelope (re-encrypted with the current key)
15
- -- ARGV[5] = new lastAccessedAt (app clock, seconds)
16
- -- ARGV[6] = new idleExpiresAt (app clock, seconds, '' when disabled)
17
- -- ARGV[7] = new TTL (absoluteExpiresAt - now, clamped >= 1)
18
- --
19
- -- Returns:
20
- -- 1 touched (write performed)
21
- -- 2 skipped: inside touchInterval
22
- -- 3 skipped: request older than recorded activity
23
- -- 0 not found
24
- -- -1 consumed or revoked
25
- -- -2 absolute expiry passed; record deleted
26
- -- -3 idle expired; NOT resurrected
27
- -- 4 envelope is plain (caller must use the plain touch path)
28
- -- 5 stale request: ARGV[5] older than recorded activity
29
- local raw = redis.call('GET', KEYS[1])
30
-
31
- if not raw then
32
- return 0
33
- end
34
-
35
- local env = cjson.decode(raw)
36
-
37
- if env.v ~= 2 then
38
- return 4
39
- end
40
-
41
- if env.st ~= 'active' then
42
- return -1
43
- end
44
-
45
- local now = tonumber(redis.call('TIME')[1])
46
-
47
- if tonumber(env.exp) <= now then
48
- redis.call('DEL', KEYS[1])
49
- return -2
50
- end
51
-
52
- if env.idle ~= cjson.null and tonumber(env.idle) <= now then
53
- return -3
54
- end
55
-
56
- local interval = tonumber(ARGV[1])
57
-
58
- if tonumber(ARGV[3]) ~= 1 and now - tonumber(env.la) < interval then
59
- return 2
60
- end
61
-
62
- -- Monotonic guard: a stale app request must never regress activity.
63
- -- Equal-second requests are allowed (the app clock is second-granular and
64
- -- cannot advance within the same second); only strictly older writes are
65
- -- rejected.
66
- local newLa = tonumber(ARGV[5])
67
-
68
- if newLa < tonumber(env.la) then
69
- return 5
70
- end
71
-
72
- local newEnv = cjson.decode(ARGV[4])
73
-
74
- -- Keep the plaintext header mirrors in sync with the checked values.
75
- newEnv.st = env.st
76
- newEnv.la = newLa
77
- if ARGV[6] ~= '' then
78
- newEnv.idle = tonumber(ARGV[6])
79
- else
80
- newEnv.idle = cjson.null
81
- end
82
- newEnv.ver = env.ver
83
- newEnv.exp = env.exp
84
- newEnv.rn = env.rn
85
- newEnv.rj = env.rj
86
-
87
- redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', tonumber(ARGV[7]))
88
-
89
- return 1