nestjs-ddd-cli 3.2.2 → 3.3.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 (210) hide show
  1. package/README.md +118 -9
  2. package/ddd.schema.json +11 -1
  3. package/dist/commands/doctor-enhanced.d.ts +1 -0
  4. package/dist/commands/doctor-enhanced.js +94 -79
  5. package/dist/commands/doctor-enhanced.js.map +1 -1
  6. package/dist/commands/doctor.d.ts +9 -0
  7. package/dist/commands/doctor.js +68 -21
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/generate-all.js +54 -28
  10. package/dist/commands/generate-all.js.map +1 -1
  11. package/dist/commands/generate-controller.d.ts +1 -0
  12. package/dist/commands/generate-controller.js +71 -0
  13. package/dist/commands/generate-controller.js.map +1 -0
  14. package/dist/commands/generate-domain-service.d.ts +1 -1
  15. package/dist/commands/generate-domain-service.js +6 -4
  16. package/dist/commands/generate-domain-service.js.map +1 -1
  17. package/dist/commands/generate-dto.d.ts +4 -0
  18. package/dist/commands/generate-dto.js +162 -0
  19. package/dist/commands/generate-dto.js.map +1 -0
  20. package/dist/commands/generate-entity.js +7 -2
  21. package/dist/commands/generate-entity.js.map +1 -1
  22. package/dist/commands/generate-event.js +10 -4
  23. package/dist/commands/generate-event.js.map +1 -1
  24. package/dist/commands/generate-migration-deployment.d.ts +15 -0
  25. package/dist/commands/generate-migration-deployment.js +326 -0
  26. package/dist/commands/generate-migration-deployment.js.map +1 -0
  27. package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
  28. package/dist/commands/generate-migration-deployment.spec.js +337 -0
  29. package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
  30. package/dist/commands/generate-module.js +4 -2
  31. package/dist/commands/generate-module.js.map +1 -1
  32. package/dist/commands/generate-query.js +23 -4
  33. package/dist/commands/generate-query.js.map +1 -1
  34. package/dist/commands/generate-repository.d.ts +1 -1
  35. package/dist/commands/generate-repository.js +17 -34
  36. package/dist/commands/generate-repository.js.map +1 -1
  37. package/dist/commands/generate-service.js +19 -4
  38. package/dist/commands/generate-service.js.map +1 -1
  39. package/dist/commands/generate-usecase.js +12 -31
  40. package/dist/commands/generate-usecase.js.map +1 -1
  41. package/dist/commands/generate-value-object.js +3 -14
  42. package/dist/commands/generate-value-object.js.map +1 -1
  43. package/dist/commands/init-project.d.ts +3 -0
  44. package/dist/commands/init-project.js +19 -9
  45. package/dist/commands/init-project.js.map +1 -1
  46. package/dist/commands/interactive-scaffold.js +10 -0
  47. package/dist/commands/interactive-scaffold.js.map +1 -1
  48. package/dist/commands/migration.d.ts +3 -0
  49. package/dist/commands/migration.js +83 -48
  50. package/dist/commands/migration.js.map +1 -1
  51. package/dist/commands/recipe.d.ts +2 -0
  52. package/dist/commands/recipe.js +229 -85
  53. package/dist/commands/recipe.js.map +1 -1
  54. package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
  55. package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
  56. package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
  57. package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
  58. package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
  59. package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
  60. package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
  61. package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
  62. package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
  63. package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
  64. package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
  65. package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
  66. package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
  67. package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
  68. package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
  69. package/dist/commands/recipes/index.d.ts +11 -0
  70. package/dist/commands/recipes/index.js +11 -0
  71. package/dist/commands/recipes/index.js.map +1 -1
  72. package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
  73. package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
  74. package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
  75. package/dist/commands/recipes/joi-env.recipe.js +1 -1
  76. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  77. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  79. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  80. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  82. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  83. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  85. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  86. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  88. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  89. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  91. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  92. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  94. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  95. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  96. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  97. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  98. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  100. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  101. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  103. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  104. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  106. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  107. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  109. package/dist/index.js +77 -9
  110. package/dist/index.js.map +1 -1
  111. package/dist/templates/ai-context/CLAUDE.md.hbs +39 -3
  112. package/dist/templates/ai-context/conventions.md.hbs +13 -8
  113. package/dist/templates/command/create-command.hbs +8 -3
  114. package/dist/templates/command/delete-command.hbs +1 -1
  115. package/dist/templates/command/update-command.hbs +1 -1
  116. package/dist/templates/controller/application-controller.hbs +7 -0
  117. package/dist/templates/controller/controller.hbs +6 -0
  118. package/dist/templates/dto/create-dto.hbs +17 -5
  119. package/dist/templates/dto/filter-dto.hbs +1 -1
  120. package/dist/templates/dto/response-dto.hbs +1 -1
  121. package/dist/templates/entity/entity.hbs +4 -0
  122. package/dist/templates/mapper/mapper.hbs +5 -1
  123. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  124. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  125. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  126. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  127. package/dist/templates/query/get-all.query.hbs +10 -2
  128. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  129. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  130. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  131. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  132. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  133. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  134. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  135. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  136. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  137. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  138. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  139. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  140. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  141. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  142. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  146. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  147. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  153. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  154. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  155. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  156. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  157. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  158. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  160. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  161. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  162. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  163. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  164. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  165. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  166. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  167. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  168. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  169. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  170. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  179. package/dist/templates/repository/repository.hbs +42 -4
  180. package/dist/templates/resolver/graphql-input.hbs +23 -6
  181. package/dist/templates/resolver/resolver.hbs +4 -0
  182. package/dist/templates/service/domain-service.hbs +2 -2
  183. package/dist/templates/test/controller.spec.hbs +5 -1
  184. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  185. package/dist/templates/test/repository.spec.hbs +40 -1
  186. package/dist/templates/test/usecase.spec.hbs +23 -4
  187. package/dist/templates/usecase/application-usecase.hbs +17 -0
  188. package/dist/templates/usecase/create-usecase.hbs +34 -3
  189. package/dist/templates/usecase/update-usecase.hbs +15 -1
  190. package/dist/utils/config.utils.d.ts +2 -0
  191. package/dist/utils/config.utils.js +14 -2
  192. package/dist/utils/config.utils.js.map +1 -1
  193. package/dist/utils/dependency.utils.d.ts +16 -2
  194. package/dist/utils/dependency.utils.js +100 -12
  195. package/dist/utils/dependency.utils.js.map +1 -1
  196. package/dist/utils/field.utils.d.ts +6 -0
  197. package/dist/utils/field.utils.js +50 -8
  198. package/dist/utils/field.utils.js.map +1 -1
  199. package/dist/utils/field.utils.spec.d.ts +1 -0
  200. package/dist/utils/field.utils.spec.js +38 -0
  201. package/dist/utils/field.utils.spec.js.map +1 -0
  202. package/dist/utils/file.utils.d.ts +23 -1
  203. package/dist/utils/file.utils.js +37 -1
  204. package/dist/utils/file.utils.js.map +1 -1
  205. package/dist/utils/naming.utils.js +5 -4
  206. package/dist/utils/naming.utils.js.map +1 -1
  207. package/dist/utils/static-recipe.utils.d.ts +4 -0
  208. package/dist/utils/static-recipe.utils.js +148 -0
  209. package/dist/utils/static-recipe.utils.js.map +1 -0
  210. package/package.json +12 -5
@@ -0,0 +1,549 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ const HANDLE = /^[A-Za-z0-9_-]{43}$/;
4
+ const SESSION_HANDLE = /^v3\.([A-Za-z0-9_-]{43})$/;
5
+ export const SESSION_SECONDS = 3300;
6
+ export const LOGIN_SECONDS = 600;
7
+ const GENERATION_SECONDS = 31_536_000;
8
+ // Conservative 25-hour retirement default; validate against the provider refresh cutoff.
9
+ export const TOKEN_RETENTION_SECONDS = 90_000;
10
+ export type OpaqueSessionSettings = Readonly<{
11
+ sessionSeconds: number;
12
+ loginSeconds: number;
13
+ generationSeconds: number;
14
+ tokenRetentionSeconds: number;
15
+ maxProviderRefreshSeconds: number;
16
+ maxProviderAccessSeconds: number;
17
+ leaseSeconds: number;
18
+ retrySeconds: number;
19
+ }>;
20
+ function sessionSettings(input: Partial<OpaqueSessionSettings>): OpaqueSessionSettings {
21
+ const settings = Object.freeze({
22
+ sessionSeconds: SESSION_SECONDS,
23
+ loginSeconds: LOGIN_SECONDS,
24
+ generationSeconds: GENERATION_SECONDS,
25
+ tokenRetentionSeconds: TOKEN_RETENTION_SECONDS,
26
+ maxProviderRefreshSeconds: 86400,
27
+ maxProviderAccessSeconds: 3600,
28
+ leaseSeconds: 30,
29
+ retrySeconds: 60,
30
+ ...input,
31
+ });
32
+ const maximum: OpaqueSessionSettings = {
33
+ sessionSeconds: 86400,
34
+ loginSeconds: 900,
35
+ generationSeconds: 31536000,
36
+ tokenRetentionSeconds: 7776000,
37
+ maxProviderRefreshSeconds: 7775999,
38
+ maxProviderAccessSeconds: 86400,
39
+ leaseSeconds: 300,
40
+ retrySeconds: 3600,
41
+ };
42
+ if (
43
+ Object.entries(settings).some(
44
+ ([name, value]) =>
45
+ !Number.isSafeInteger(value) ||
46
+ value < 1 ||
47
+ value > maximum[name as keyof OpaqueSessionSettings],
48
+ ) ||
49
+ settings.tokenRetentionSeconds <= settings.maxProviderRefreshSeconds ||
50
+ settings.tokenRetentionSeconds <= settings.maxProviderAccessSeconds ||
51
+ settings.sessionSeconds > settings.maxProviderAccessSeconds ||
52
+ settings.leaseSeconds >= settings.tokenRetentionSeconds
53
+ ) {
54
+ throw new Error('Invalid BFF session duration configuration');
55
+ }
56
+ return settings;
57
+ }
58
+ export type RetirementTokens = { accessToken?: string; refreshToken?: string; expiresAt: number };
59
+ export type RevocationClaim = { ticket: string; lease: string; tokens: RetirementTokens };
60
+
61
+ export type TokenPair = {
62
+ accessToken: string;
63
+ refreshToken?: string;
64
+ expiresAt: number;
65
+ };
66
+ export type PendingLogin = {
67
+ browser: string;
68
+ generation: string;
69
+ state: string;
70
+ verifier: string;
71
+ nonce: string;
72
+ next: string;
73
+ startedAt: number;
74
+ };
75
+ export type SessionCommands = {
76
+ eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
77
+ };
78
+
79
+ const BEGIN = `
80
+ local generation = redis.call('GET', KEYS[1])
81
+ if not generation then
82
+ generation = ARGV[1]
83
+ redis.call('SET', KEYS[1], generation, 'EX', ARGV[2])
84
+ end
85
+ if ARGV[5] == 'fresh' then
86
+ local oldkey = redis.call('GET', KEYS[3])
87
+ local oldraw = oldkey and redis.call('GET', oldkey)
88
+ if oldraw then
89
+ redis.call('SET', ARGV[9] .. ARGV[6], oldraw, 'EX', ARGV[8])
90
+ redis.call('ZADD', KEYS[4], ARGV[7], ARGV[6])
91
+ redis.call('DEL', oldkey)
92
+ end
93
+ redis.call('DEL', KEYS[3])
94
+ generation = ARGV[1]
95
+ redis.call('SET', KEYS[1], generation, 'EX', ARGV[2])
96
+ end
97
+ if generation ~= ARGV[1] then return false end
98
+ if not redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4], 'NX') then return false end
99
+ return generation`;
100
+ const TAKE = `
101
+ local raw = redis.call('GET', KEYS[1])
102
+ if not raw then return false end
103
+ local ok, pending = pcall(cjson.decode, raw)
104
+ if not ok then redis.call('DEL', KEYS[1]); return false end
105
+ if pending.browser ~= ARGV[1] or pending.generation ~= ARGV[2] or pending.state ~= ARGV[3] then return false end
106
+ redis.call('DEL', KEYS[1])
107
+ if redis.call('GET', KEYS[2]) ~= pending.generation then return false end
108
+ if not redis.call('SET', KEYS[3], raw, 'EX', ARGV[4], 'NX') then return false end
109
+ return raw`;
110
+ const COMPLETE = `
111
+ if redis.call('GET', KEYS[3]) ~= ARGV[4] then return false end
112
+ if redis.call('GET', KEYS[1]) ~= ARGV[1] then return false end
113
+ if ARGV[9] ~= '' then
114
+ if redis.call('EXISTS', KEYS[7]) == 1 then return false end
115
+ local staged = redis.call('GET', KEYS[6])
116
+ if not staged then return false end
117
+ local ok, prior = pcall(cjson.decode, staged)
118
+ local newok, candidate = pcall(cjson.decode, ARGV[2])
119
+ if not ok or not newok or prior.tokens.accessToken ~= candidate.tokens.accessToken or prior.tokens.refreshToken ~= candidate.tokens.refreshToken then return false end
120
+ end
121
+ if redis.call('EXISTS', KEYS[2]) == 1 then return false end
122
+ local oldkey = redis.call('GET', KEYS[4])
123
+ local oldraw = oldkey and redis.call('GET', oldkey)
124
+ if oldraw then
125
+ redis.call('SET', ARGV[8] .. ARGV[7], oldraw, 'EX', ARGV[6])
126
+ redis.call('ZADD', KEYS[5], ARGV[5], ARGV[7])
127
+ redis.call('DEL', oldkey)
128
+ end
129
+ redis.call('DEL', KEYS[3])
130
+ redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[6])
131
+ redis.call('SET', KEYS[4], KEYS[2], 'EX', ARGV[6])
132
+ if ARGV[9] ~= '' then
133
+ redis.call('DEL', KEYS[6])
134
+ redis.call('ZREM', KEYS[5], ARGV[9])
135
+ end
136
+ return 'OK'`;
137
+ const READ = `
138
+ local raw = redis.call('GET', KEYS[1])
139
+ if not raw then return false end
140
+ local ok, session = pcall(cjson.decode, raw)
141
+ if not ok then redis.call('DEL', KEYS[1]); return false end
142
+ if session.generation ~= ARGV[1] then return false end
143
+ if redis.call('GET', ARGV[2] .. session.browser) ~= session.generation then return false end
144
+ return raw`;
145
+ const LOGOUT = `
146
+ redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
147
+ local current = redis.call('GET', KEYS[4])
148
+ local sessionkey = current or KEYS[2]
149
+ local raw = redis.call('GET', sessionkey)
150
+ local pendingRaw = redis.call('GET', KEYS[3])
151
+ if pendingRaw then
152
+ local pendingOk, pending = pcall(cjson.decode, pendingRaw)
153
+ if pendingOk and pending.browser == ARGV[3] then redis.call('DEL', KEYS[3]) end
154
+ end
155
+ if not raw then return false end
156
+ local ok, session = pcall(cjson.decode, raw)
157
+ if not ok or session.browser ~= ARGV[3] then return false end
158
+ redis.call('SET', ARGV[7] .. ARGV[4], raw, 'EX', ARGV[6])
159
+ redis.call('ZADD', KEYS[5], ARGV[5], ARGV[4])
160
+ redis.call('DEL', sessionkey, KEYS[4])
161
+ return raw`;
162
+ const ENQUEUE = `
163
+ if not redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then return false end
164
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[4])
165
+ return 'OK'`;
166
+ const CLAIM = `
167
+ local candidates
168
+ if ARGV[5] ~= '' then candidates = {ARGV[5]}
169
+ else candidates = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 3) end
170
+ for _, ticket in ipairs(candidates) do
171
+ if redis.call('ZSCORE', KEYS[1], ticket) then
172
+ local raw = redis.call('GET', ARGV[3] .. ticket)
173
+ if not raw then redis.call('ZREM', KEYS[1], ticket)
174
+ elseif redis.call('SET', ARGV[4] .. ticket, ARGV[2], 'EX', ARGV[6], 'NX') then
175
+ redis.call('SET', ARGV[7] .. ticket, '1', 'EX', ARGV[8])
176
+ redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[6]), ticket)
177
+ return {ticket, raw}
178
+ end
179
+ end
180
+ end
181
+ return false`;
182
+ const SETTLE = `
183
+ if redis.call('GET', KEYS[3]) ~= ARGV[1] then return false end
184
+ if ARGV[3] == 'complete' then
185
+ redis.call('DEL', KEYS[2], KEYS[3], KEYS[4])
186
+ redis.call('ZREM', KEYS[1], ARGV[2])
187
+ else
188
+ redis.call('DEL', KEYS[3])
189
+ redis.call('ZADD', KEYS[1], ARGV[4], ARGV[2])
190
+ end
191
+ return 'OK'`;
192
+
193
+ function handle(): string {
194
+ return randomBytes(32).toString('base64url');
195
+ }
196
+ function hash(value: string): string {
197
+ return createHash('sha256').update(value).digest('hex');
198
+ }
199
+ function record(value: unknown): Record<string, unknown> | null {
200
+ if (typeof value !== 'string' || value.length > 40_000) return null;
201
+ try {
202
+ const parsed: unknown = JSON.parse(value);
203
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
204
+ ? (parsed as Record<string, unknown>)
205
+ : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+ function validTokens(value: unknown, now = 0): value is TokenPair {
211
+ if (!value || typeof value !== 'object') return false;
212
+ const tokens = value as TokenPair;
213
+ return (
214
+ typeof tokens.accessToken === 'string' &&
215
+ tokens.accessToken.length > 0 &&
216
+ tokens.accessToken.length <= 16_384 &&
217
+ !/[^\x21-\x7e]/.test(tokens.accessToken) &&
218
+ (tokens.refreshToken === undefined ||
219
+ (typeof tokens.refreshToken === 'string' &&
220
+ tokens.refreshToken.length > 0 &&
221
+ tokens.refreshToken.length <= 16_384 &&
222
+ !/[^\x21-\x7e]/.test(tokens.refreshToken))) &&
223
+ Number.isSafeInteger(tokens.expiresAt) &&
224
+ tokens.expiresAt > now
225
+ );
226
+ }
227
+ function validRetirement(value: unknown): value is RetirementTokens {
228
+ if (!value || typeof value !== 'object') return false;
229
+ const tokens = value as RetirementTokens;
230
+ const token = (item: unknown) =>
231
+ item === undefined ||
232
+ (typeof item === 'string' &&
233
+ item.length > 0 &&
234
+ item.length <= 16_384 &&
235
+ !/[^\x21-\x7e]/.test(item));
236
+ return (
237
+ token(tokens.accessToken) &&
238
+ token(tokens.refreshToken) &&
239
+ (tokens.accessToken !== undefined || tokens.refreshToken !== undefined) &&
240
+ Number.isSafeInteger(tokens.expiresAt) &&
241
+ tokens.expiresAt > 0
242
+ );
243
+ }
244
+
245
+ /** All tokens and pending PKCE secrets stay in the server store. No local fallback. */
246
+ export class OpaqueSessionStore {
247
+ private readonly prefix: string;
248
+ private readonly settings: Readonly<OpaqueSessionSettings>;
249
+ constructor(
250
+ private readonly commands: SessionCommands,
251
+ clientId: string,
252
+ private readonly now: () => number = () => Math.floor(Date.now() / 1000),
253
+ settings: Partial<OpaqueSessionSettings> = {},
254
+ ) {
255
+ this.settings = sessionSettings(settings);
256
+ if (!clientId.trim() || clientId.length > 256) throw new Error('Invalid BFF client namespace');
257
+ // A stable client hash also keeps all Lua keys in one Redis Cluster slot.
258
+ this.prefix = `oidc:bff:{${hash(clientId)}}:`;
259
+ }
260
+ private key(kind: string, id: string): string {
261
+ return `${this.prefix}${kind}:${hash(id)}`;
262
+ }
263
+ private queueKey(ticket: string): string {
264
+ return `${this.prefix}revocation:${ticket}`;
265
+ }
266
+
267
+ async beginLogin(input: {
268
+ browser?: string;
269
+ generation?: string;
270
+ next: string;
271
+ fresh?: boolean;
272
+ }): Promise<{
273
+ browser: string;
274
+ generation: string;
275
+ pendingHandle: string;
276
+ pending: PendingLogin;
277
+ } | null> {
278
+ if (
279
+ !input.next.startsWith('/') ||
280
+ input.next.startsWith('//') ||
281
+ input.next.includes('\\') ||
282
+ /[\x00-\x1f\x7f]/.test(input.next) ||
283
+ input.next.length > 2048
284
+ )
285
+ throw new Error('Invalid return path');
286
+ const browser = input.browser && HANDLE.test(input.browser) ? input.browser : handle();
287
+ const generation =
288
+ !input.fresh && input.generation && HANDLE.test(input.generation)
289
+ ? input.generation
290
+ : handle();
291
+ const pendingHandle = handle();
292
+ const pending: PendingLogin = {
293
+ browser: hash(browser),
294
+ generation,
295
+ state: handle(),
296
+ verifier: handle(),
297
+ nonce: handle(),
298
+ next: input.next,
299
+ startedAt: this.now(),
300
+ };
301
+ const result = await this.commands.eval(BEGIN, {
302
+ keys: [
303
+ this.key('browser', browser),
304
+ this.key('pending', pendingHandle),
305
+ this.key('current', browser),
306
+ `${this.prefix}revocations`,
307
+ ],
308
+ arguments: [
309
+ generation,
310
+ String(this.settings.generationSeconds),
311
+ JSON.stringify(pending),
312
+ String(this.settings.loginSeconds),
313
+ input.fresh ? 'fresh' : 'retain',
314
+ handle(),
315
+ String(this.now()),
316
+ String(this.settings.tokenRetentionSeconds),
317
+ `${this.prefix}revocation:`,
318
+ ],
319
+ });
320
+ return result === generation ? { browser, generation, pendingHandle, pending } : null;
321
+ }
322
+
323
+ async takeLogin(
324
+ pendingHandle: string | undefined,
325
+ browser: string | undefined,
326
+ generation: string | undefined,
327
+ state: string | null,
328
+ ): Promise<PendingLogin | null> {
329
+ if (
330
+ !pendingHandle ||
331
+ !HANDLE.test(pendingHandle) ||
332
+ !browser ||
333
+ !HANDLE.test(browser) ||
334
+ !generation ||
335
+ !HANDLE.test(generation) ||
336
+ !state ||
337
+ !HANDLE.test(state)
338
+ )
339
+ return null;
340
+ const parsed = record(
341
+ await this.commands.eval(TAKE, {
342
+ keys: [
343
+ this.key('pending', pendingHandle),
344
+ this.key('browser', browser),
345
+ this.key('claim', state),
346
+ ],
347
+ arguments: [hash(browser), generation, state, String(this.settings.loginSeconds)],
348
+ }),
349
+ );
350
+ if (
351
+ !parsed ||
352
+ parsed['browser'] !== hash(browser) ||
353
+ parsed['generation'] !== generation ||
354
+ parsed['state'] !== state ||
355
+ typeof parsed['verifier'] !== 'string' ||
356
+ !HANDLE.test(parsed['verifier']) ||
357
+ typeof parsed['nonce'] !== 'string' ||
358
+ !HANDLE.test(parsed['nonce']) ||
359
+ typeof parsed['next'] !== 'string' ||
360
+ typeof parsed['startedAt'] !== 'number' ||
361
+ parsed['startedAt'] > this.now() ||
362
+ this.now() - parsed['startedAt'] >= this.settings.loginSeconds
363
+ )
364
+ return null;
365
+ return parsed as PendingLogin;
366
+ }
367
+
368
+ async completeLogin(
369
+ pending: PendingLogin,
370
+ tokens: TokenPair,
371
+ stagedTicket?: string,
372
+ ): Promise<string | null> {
373
+ const now = this.now();
374
+ if (
375
+ !validTokens(tokens, now) ||
376
+ tokens.expiresAt > now + this.settings.maxProviderAccessSeconds ||
377
+ !/^[a-f0-9]{64}$/.test(pending.browser) ||
378
+ !HANDLE.test(pending.generation) ||
379
+ !HANDLE.test(pending.state) ||
380
+ !Number.isSafeInteger(pending.startedAt) ||
381
+ pending.startedAt > now ||
382
+ now - pending.startedAt >= this.settings.loginSeconds ||
383
+ (stagedTicket !== undefined && !HANDLE.test(stagedTicket))
384
+ )
385
+ return null;
386
+ const sid = `v3.${handle()}`;
387
+ const result = await this.commands.eval(COMPLETE, {
388
+ keys: [
389
+ `${this.prefix}browser:${pending.browser}`,
390
+ this.key('session', sid),
391
+ this.key('claim', pending.state),
392
+ `${this.prefix}current:${pending.browser}`,
393
+ `${this.prefix}revocations`,
394
+ this.queueKey(stagedTicket ?? 'invalid'),
395
+ `${this.prefix}retiring:${stagedTicket ?? 'invalid'}`,
396
+ ],
397
+ arguments: [
398
+ pending.generation,
399
+ JSON.stringify({
400
+ browser: pending.browser,
401
+ generation: pending.generation,
402
+ activeUntil: Math.min(now + this.settings.sessionSeconds, tokens.expiresAt),
403
+ tokens,
404
+ }),
405
+ String(Math.min(this.settings.sessionSeconds, tokens.expiresAt - now)),
406
+ JSON.stringify(pending),
407
+ String(now),
408
+ String(this.settings.tokenRetentionSeconds),
409
+ handle(),
410
+ `${this.prefix}revocation:`,
411
+ stagedTicket ?? '',
412
+ ],
413
+ });
414
+ return result === 'OK' ? sid : null;
415
+ }
416
+
417
+ async read(sid: string | undefined, generation: string | undefined): Promise<TokenPair | null> {
418
+ if (!sid || !SESSION_HANDLE.test(sid) || !generation || !HANDLE.test(generation)) return null;
419
+ const parsed = record(
420
+ await this.commands.eval(READ, {
421
+ keys: [this.key('session', sid)],
422
+ arguments: [generation, `${this.prefix}browser:`],
423
+ }),
424
+ );
425
+ return parsed &&
426
+ typeof parsed['activeUntil'] === 'number' &&
427
+ parsed['activeUntil'] > this.now() &&
428
+ validTokens(parsed['tokens'], this.now())
429
+ ? parsed['tokens']
430
+ : null;
431
+ }
432
+
433
+ async logout(
434
+ browser: string | undefined,
435
+ sid?: string,
436
+ pendingHandle?: string,
437
+ ): Promise<{
438
+ generation: string;
439
+ tokens: TokenPair | null;
440
+ revocationTicket: string | null;
441
+ } | null> {
442
+ if (!browser || !HANDLE.test(browser)) return null;
443
+ const generation = handle();
444
+ const ticket = handle();
445
+ const parsed = record(
446
+ await this.commands.eval(LOGOUT, {
447
+ keys: [
448
+ this.key('browser', browser),
449
+ this.key('session', sid && SESSION_HANDLE.test(sid) ? sid : 'invalid'),
450
+ this.key(
451
+ 'pending',
452
+ pendingHandle && HANDLE.test(pendingHandle) ? pendingHandle : 'invalid',
453
+ ),
454
+ this.key('current', browser),
455
+ `${this.prefix}revocations`,
456
+ ],
457
+ arguments: [
458
+ generation,
459
+ String(this.settings.generationSeconds),
460
+ hash(browser),
461
+ ticket,
462
+ String(this.now()),
463
+ String(this.settings.tokenRetentionSeconds),
464
+ `${this.prefix}revocation:`,
465
+ ],
466
+ }),
467
+ );
468
+ const tokens = parsed && validTokens(parsed['tokens']) ? parsed['tokens'] : null;
469
+ return { generation, tokens, revocationTicket: tokens ? ticket : null };
470
+ }
471
+
472
+ /** Stage immediately after token minting; session commit transfers custody atomically. */
473
+ async queueRevocation(tokens: RetirementTokens, delaySeconds = 0): Promise<string> {
474
+ if (
475
+ !validRetirement(tokens) ||
476
+ !Number.isSafeInteger(delaySeconds) ||
477
+ delaySeconds < 0 ||
478
+ delaySeconds > this.settings.loginSeconds
479
+ ) {
480
+ throw new Error('Invalid token retirement record');
481
+ }
482
+ const ticket = handle();
483
+ const result = await this.commands.eval(ENQUEUE, {
484
+ keys: [this.queueKey(ticket), `${this.prefix}revocations`],
485
+ arguments: [
486
+ JSON.stringify({ tokens }),
487
+ String(this.settings.tokenRetentionSeconds),
488
+ String(this.now() + delaySeconds),
489
+ ticket,
490
+ ],
491
+ });
492
+ if (result !== 'OK') throw new Error('BFF token retirement is unavailable');
493
+ return ticket;
494
+ }
495
+
496
+ async claimRevocation(ticket?: string): Promise<RevocationClaim | null> {
497
+ if (ticket !== undefined && !HANDLE.test(ticket)) return null;
498
+ const lease = handle();
499
+ const result = await this.commands.eval(CLAIM, {
500
+ keys: [`${this.prefix}revocations`],
501
+ arguments: [
502
+ String(this.now()),
503
+ lease,
504
+ `${this.prefix}revocation:`,
505
+ `${this.prefix}revocation-lease:`,
506
+ ticket ?? '',
507
+ String(this.settings.leaseSeconds),
508
+ `${this.prefix}retiring:`,
509
+ String(this.settings.tokenRetentionSeconds),
510
+ ],
511
+ });
512
+ if (!Array.isArray(result) || typeof result[0] !== 'string' || !HANDLE.test(result[0]))
513
+ return null;
514
+ const parsed = record(result[1]);
515
+ if (!parsed || !validRetirement(parsed['tokens']))
516
+ throw new Error('Invalid BFF token retirement record');
517
+ return { ticket: result[0], lease, tokens: parsed['tokens'] };
518
+ }
519
+
520
+ async revocationPending(ticket: string): Promise<boolean> {
521
+ if (!HANDLE.test(ticket)) return false;
522
+ return (
523
+ (await this.commands.eval("return redis.call('EXISTS', KEYS[1])", {
524
+ keys: [this.queueKey(ticket)],
525
+ arguments: [],
526
+ })) === 1
527
+ );
528
+ }
529
+
530
+ async settleRevocation(claim: RevocationClaim, complete: boolean): Promise<boolean> {
531
+ if (!HANDLE.test(claim.ticket) || !HANDLE.test(claim.lease)) return false;
532
+ return (
533
+ (await this.commands.eval(SETTLE, {
534
+ keys: [
535
+ `${this.prefix}revocations`,
536
+ this.queueKey(claim.ticket),
537
+ `${this.prefix}revocation-lease:${claim.ticket}`,
538
+ `${this.prefix}retiring:${claim.ticket}`,
539
+ ],
540
+ arguments: [
541
+ claim.lease,
542
+ claim.ticket,
543
+ complete ? 'complete' : 'retry',
544
+ String(this.now() + this.settings.retrySeconds),
545
+ ],
546
+ })) === 'OK'
547
+ );
548
+ }
549
+ }
@@ -0,0 +1,158 @@
1
+ import { expect, test } from 'bun:test';
2
+ import {
3
+ createValkeySessionCommands,
4
+ SessionStorageUnavailableError,
5
+ type SessionConnection,
6
+ } from './valkey-session-store';
7
+
8
+ function connection(overrides: Partial<SessionConnection> = {}): SessionConnection {
9
+ return {
10
+ isOpen: true,
11
+ isReady: true,
12
+ on: () => {},
13
+ connect: () => Promise.resolve(),
14
+ destroy: () => {},
15
+ withAbortSignal: () => ({ eval: () => Promise.resolve('synthetic-result') }),
16
+ ...overrides,
17
+ };
18
+ }
19
+ const config = { url: 'redis://synthetic.private.test:6379', trustedInternalPlaintext: true };
20
+ test('shares one single-flight connection, executes bounded commands, and cannot reopen after close', async () => {
21
+ let created = 0,
22
+ destroyed = 0;
23
+ const commands = createValkeySessionCommands(config, {
24
+ createConnection: () => {
25
+ created++;
26
+ return connection({
27
+ destroy: () => {
28
+ destroyed++;
29
+ },
30
+ });
31
+ },
32
+ });
33
+ expect(
34
+ await Promise.all(
35
+ Array.from({ length: 4 }, () => commands.eval('return 1', { keys: [], arguments: [] })),
36
+ ),
37
+ ).toEqual(Array(4).fill('synthetic-result'));
38
+ expect(created).toBe(1);
39
+ commands.close();
40
+ expect(destroyed).toBe(1);
41
+ await expect(commands.eval('return 1', { keys: [], arguments: [] })).rejects.toBeInstanceOf(
42
+ SessionStorageUnavailableError,
43
+ );
44
+ });
45
+ test('requires TLS unless the owner explicitly enables trusted internal plaintext', () => {
46
+ for (const url of [
47
+ 'redis://private.test:6379',
48
+ 'https://foreign.test',
49
+ 'rediss://private.test/0?password=secret',
50
+ 'rediss://private.test/not-a-db',
51
+ ]) {
52
+ expect(() => createValkeySessionCommands({ url })).toThrow('configuration');
53
+ }
54
+ expect(() => createValkeySessionCommands({ url: 'rediss://private.test:6379/0' })).not.toThrow();
55
+ });
56
+ test('bounds ignored-abort connection and command work, without exposing raw secret-bearing failures', async () => {
57
+ const slowConnect = createValkeySessionCommands(
58
+ { ...config, commandTimeoutMs: 20 },
59
+ {
60
+ createConnection: () => connection({ connect: () => new Promise(() => {}) }),
61
+ },
62
+ );
63
+ await expect(slowConnect.eval('return 1', { keys: [], arguments: [] })).rejects.toThrow(
64
+ 'BFF session storage is unavailable',
65
+ );
66
+ slowConnect.close();
67
+ const slowCommand = createValkeySessionCommands(
68
+ { ...config, commandTimeoutMs: 20 },
69
+ {
70
+ createConnection: () =>
71
+ connection({ withAbortSignal: () => ({ eval: () => new Promise(() => {}) }) }),
72
+ },
73
+ );
74
+ await expect(slowCommand.eval('return 1', { keys: [], arguments: [] })).rejects.toThrow(
75
+ 'BFF session storage is unavailable',
76
+ );
77
+ slowCommand.close();
78
+ const failure = createValkeySessionCommands(config, {
79
+ createConnection: () =>
80
+ connection({ connect: () => Promise.reject(new Error('secret redis://credential')) }),
81
+ });
82
+ await expect(failure.eval('return 1', { keys: [], arguments: [] })).rejects.toThrow(
83
+ 'BFF session storage is unavailable',
84
+ );
85
+ failure.close();
86
+ });
87
+ test('never executes a command after its connecting deadline already expired', async () => {
88
+ let release: (() => void) | undefined,
89
+ executions = 0;
90
+ const commands = createValkeySessionCommands(
91
+ { ...config, commandTimeoutMs: 20 },
92
+ {
93
+ createConnection: () =>
94
+ connection({
95
+ connect: () =>
96
+ new Promise<void>((resolve) => {
97
+ release = resolve;
98
+ }),
99
+ withAbortSignal: () => ({
100
+ eval: () => {
101
+ executions++;
102
+ return Promise.resolve(null);
103
+ },
104
+ }),
105
+ }),
106
+ },
107
+ );
108
+ await expect(commands.eval('return 1', { keys: [], arguments: [] })).rejects.toBeInstanceOf(
109
+ SessionStorageUnavailableError,
110
+ );
111
+ release?.();
112
+ await Promise.resolve();
113
+ await Promise.resolve();
114
+ expect(executions).toBe(0);
115
+ commands.close();
116
+ });
117
+ test('rejects overlarge command input before connecting', async () => {
118
+ let creates = 0;
119
+ const commands = createValkeySessionCommands(config, {
120
+ createConnection: () => {
121
+ creates++;
122
+ return connection();
123
+ },
124
+ });
125
+ await expect(
126
+ commands.eval('x'.repeat(16385), { keys: [], arguments: [] }),
127
+ ).rejects.toBeInstanceOf(SessionStorageUnavailableError);
128
+ expect(creates).toBe(0);
129
+ commands.close();
130
+ });
131
+
132
+ test('snapshots caller-owned command arrays before awaiting connection', async () => {
133
+ let release: (() => void) | undefined;
134
+ let seen: { keys: string[]; arguments: string[] } | undefined;
135
+ const commands = createValkeySessionCommands(config, {
136
+ createConnection: () =>
137
+ connection({
138
+ connect: () =>
139
+ new Promise<void>((resolve) => {
140
+ release = resolve;
141
+ }),
142
+ withAbortSignal: () => ({
143
+ eval: (_script, command) => {
144
+ seen = command;
145
+ return Promise.resolve(1);
146
+ },
147
+ }),
148
+ }),
149
+ });
150
+ const input = { keys: ['original-key'], arguments: ['original-argument'] };
151
+ const pending = commands.eval('return 1', input);
152
+ input.keys[0] = 'changed-key';
153
+ input.arguments[0] = 'changed-argument';
154
+ release?.();
155
+ await pending;
156
+ expect(seen).toEqual({ keys: ['original-key'], arguments: ['original-argument'] });
157
+ commands.close();
158
+ });