@shipfox/api-auth 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +116 -0
  5. package/LICENSE +21 -0
  6. package/README.md +336 -0
  7. package/dist/config.d.ts +22 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +95 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/auth.d.ts +108 -0
  12. package/dist/core/auth.d.ts.map +1 -0
  13. package/dist/core/auth.js +476 -0
  14. package/dist/core/auth.js.map +1 -0
  15. package/dist/core/entities/email-verification.d.ts +9 -0
  16. package/dist/core/entities/email-verification.d.ts.map +1 -0
  17. package/dist/core/entities/email-verification.js +3 -0
  18. package/dist/core/entities/email-verification.js.map +1 -0
  19. package/dist/core/entities/password-reset.d.ts +9 -0
  20. package/dist/core/entities/password-reset.d.ts.map +1 -0
  21. package/dist/core/entities/password-reset.js +3 -0
  22. package/dist/core/entities/password-reset.js.map +1 -0
  23. package/dist/core/entities/refresh-token.d.ts +12 -0
  24. package/dist/core/entities/refresh-token.d.ts.map +1 -0
  25. package/dist/core/entities/refresh-token.js +3 -0
  26. package/dist/core/entities/refresh-token.js.map +1 -0
  27. package/dist/core/entities/user.d.ts +12 -0
  28. package/dist/core/entities/user.d.ts.map +1 -0
  29. package/dist/core/entities/user.js +3 -0
  30. package/dist/core/entities/user.js.map +1 -0
  31. package/dist/core/errors.d.ts +54 -0
  32. package/dist/core/errors.d.ts.map +1 -0
  33. package/dist/core/errors.js +106 -0
  34. package/dist/core/errors.js.map +1 -0
  35. package/dist/core/job-lease-token.d.ts +12 -0
  36. package/dist/core/job-lease-token.d.ts.map +1 -0
  37. package/dist/core/job-lease-token.js +58 -0
  38. package/dist/core/job-lease-token.js.map +1 -0
  39. package/dist/core/jwt.d.ts +37 -0
  40. package/dist/core/jwt.d.ts.map +1 -0
  41. package/dist/core/jwt.js +46 -0
  42. package/dist/core/jwt.js.map +1 -0
  43. package/dist/core/password.d.ts +10 -0
  44. package/dist/core/password.d.ts.map +1 -0
  45. package/dist/core/password.js +13 -0
  46. package/dist/core/password.js.map +1 -0
  47. package/dist/core/rate-limit.d.ts +34 -0
  48. package/dist/core/rate-limit.d.ts.map +1 -0
  49. package/dist/core/rate-limit.js +76 -0
  50. package/dist/core/rate-limit.js.map +1 -0
  51. package/dist/core/runner-session-token.d.ts +6 -0
  52. package/dist/core/runner-session-token.d.ts.map +1 -0
  53. package/dist/core/runner-session-token.js +37 -0
  54. package/dist/core/runner-session-token.js.map +1 -0
  55. package/dist/db/db.d.ts +1723 -0
  56. package/dist/db/db.d.ts.map +1 -0
  57. package/dist/db/db.js +28 -0
  58. package/dist/db/db.js.map +1 -0
  59. package/dist/db/email-verifications.d.ts +39 -0
  60. package/dist/db/email-verifications.d.ts.map +1 -0
  61. package/dist/db/email-verifications.js +95 -0
  62. package/dist/db/email-verifications.js.map +1 -0
  63. package/dist/db/migrations.d.ts +2 -0
  64. package/dist/db/migrations.d.ts.map +1 -0
  65. package/dist/db/migrations.js +5 -0
  66. package/dist/db/migrations.js.map +1 -0
  67. package/dist/db/password-resets.d.ts +23 -0
  68. package/dist/db/password-resets.d.ts.map +1 -0
  69. package/dist/db/password-resets.js +37 -0
  70. package/dist/db/password-resets.js.map +1 -0
  71. package/dist/db/rate-limits.d.ts +18 -0
  72. package/dist/db/rate-limits.d.ts.map +1 -0
  73. package/dist/db/rate-limits.js +44 -0
  74. package/dist/db/rate-limits.js.map +1 -0
  75. package/dist/db/refresh-tokens.d.ts +44 -0
  76. package/dist/db/refresh-tokens.d.ts.map +1 -0
  77. package/dist/db/refresh-tokens.js +71 -0
  78. package/dist/db/refresh-tokens.js.map +1 -0
  79. package/dist/db/schema/common.d.ts +2 -0
  80. package/dist/db/schema/common.d.ts.map +1 -0
  81. package/dist/db/schema/common.js +4 -0
  82. package/dist/db/schema/common.js.map +1 -0
  83. package/dist/db/schema/email-verifications.d.ts +114 -0
  84. package/dist/db/schema/email-verifications.d.ts.map +1 -0
  85. package/dist/db/schema/email-verifications.js +35 -0
  86. package/dist/db/schema/email-verifications.js.map +1 -0
  87. package/dist/db/schema/outbox.d.ts +195 -0
  88. package/dist/db/schema/outbox.d.ts.map +1 -0
  89. package/dist/db/schema/outbox.js +5 -0
  90. package/dist/db/schema/outbox.js.map +1 -0
  91. package/dist/db/schema/password-resets.d.ts +114 -0
  92. package/dist/db/schema/password-resets.d.ts.map +1 -0
  93. package/dist/db/schema/password-resets.js +35 -0
  94. package/dist/db/schema/password-resets.js.map +1 -0
  95. package/dist/db/schema/rate-limits.d.ts +145 -0
  96. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  97. package/dist/db/schema/rate-limits.js +25 -0
  98. package/dist/db/schema/rate-limits.js.map +1 -0
  99. package/dist/db/schema/refresh-tokens.d.ts +165 -0
  100. package/dist/db/schema/refresh-tokens.d.ts.map +1 -0
  101. package/dist/db/schema/refresh-tokens.js +47 -0
  102. package/dist/db/schema/refresh-tokens.js.map +1 -0
  103. package/dist/db/schema/users.d.ts +149 -0
  104. package/dist/db/schema/users.d.ts.map +1 -0
  105. package/dist/db/schema/users.js +40 -0
  106. package/dist/db/schema/users.js.map +1 -0
  107. package/dist/db/users.d.ts +28 -0
  108. package/dist/db/users.d.ts.map +1 -0
  109. package/dist/db/users.js +83 -0
  110. package/dist/db/users.js.map +1 -0
  111. package/dist/index.d.ts +14 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +52 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/metrics/index.d.ts +2 -0
  116. package/dist/metrics/index.d.ts.map +1 -0
  117. package/dist/metrics/index.js +3 -0
  118. package/dist/metrics/index.js.map +1 -0
  119. package/dist/metrics/instance.d.ts +16 -0
  120. package/dist/metrics/instance.d.ts.map +1 -0
  121. package/dist/metrics/instance.js +52 -0
  122. package/dist/metrics/instance.js.map +1 -0
  123. package/dist/presentation/auth/bearer-token-auth.d.ts +15 -0
  124. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -0
  125. package/dist/presentation/auth/bearer-token-auth.js +31 -0
  126. package/dist/presentation/auth/bearer-token-auth.js.map +1 -0
  127. package/dist/presentation/auth/jwt-auth.d.ts +10 -0
  128. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -0
  129. package/dist/presentation/auth/jwt-auth.js +31 -0
  130. package/dist/presentation/auth/jwt-auth.js.map +1 -0
  131. package/dist/presentation/auth/lease-token-auth.d.ts +15 -0
  132. package/dist/presentation/auth/lease-token-auth.d.ts.map +1 -0
  133. package/dist/presentation/auth/lease-token-auth.js +27 -0
  134. package/dist/presentation/auth/lease-token-auth.js.map +1 -0
  135. package/dist/presentation/auth/refresh-cookie.d.ts +7 -0
  136. package/dist/presentation/auth/refresh-cookie.d.ts.map +1 -0
  137. package/dist/presentation/auth/refresh-cookie.js +29 -0
  138. package/dist/presentation/auth/refresh-cookie.js.map +1 -0
  139. package/dist/presentation/auth/runner-session-auth.d.ts +3 -0
  140. package/dist/presentation/auth/runner-session-auth.d.ts.map +1 -0
  141. package/dist/presentation/auth/runner-session-auth.js +16 -0
  142. package/dist/presentation/auth/runner-session-auth.js.map +1 -0
  143. package/dist/presentation/dto/user.d.ts +8 -0
  144. package/dist/presentation/dto/user.d.ts.map +1 -0
  145. package/dist/presentation/dto/user.js +19 -0
  146. package/dist/presentation/dto/user.js.map +1 -0
  147. package/dist/presentation/e2eRoutes/create-session.d.ts +2 -0
  148. package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -0
  149. package/dist/presentation/e2eRoutes/create-session.js +49 -0
  150. package/dist/presentation/e2eRoutes/create-session.js.map +1 -0
  151. package/dist/presentation/e2eRoutes/create-user.d.ts +2 -0
  152. package/dist/presentation/e2eRoutes/create-user.d.ts.map +1 -0
  153. package/dist/presentation/e2eRoutes/create-user.js +42 -0
  154. package/dist/presentation/e2eRoutes/create-user.js.map +1 -0
  155. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  156. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  157. package/dist/presentation/e2eRoutes/index.js +15 -0
  158. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  159. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -0
  160. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -0
  161. package/dist/presentation/routes/email-verification/verify-email-confirm.js +35 -0
  162. package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -0
  163. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts +2 -0
  164. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -0
  165. package/dist/presentation/routes/email-verification/verify-email-resend.js +27 -0
  166. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -0
  167. package/dist/presentation/routes/index.d.ts +3 -0
  168. package/dist/presentation/routes/index.d.ts.map +1 -0
  169. package/dist/presentation/routes/index.js +31 -0
  170. package/dist/presentation/routes/index.js.map +1 -0
  171. package/dist/presentation/routes/password/change-password.d.ts +2 -0
  172. package/dist/presentation/routes/password/change-password.d.ts.map +1 -0
  173. package/dist/presentation/routes/password/change-password.js +50 -0
  174. package/dist/presentation/routes/password/change-password.js.map +1 -0
  175. package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -0
  176. package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -0
  177. package/dist/presentation/routes/password/password-reset-confirm.js +36 -0
  178. package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -0
  179. package/dist/presentation/routes/password/password-reset-request.d.ts +2 -0
  180. package/dist/presentation/routes/password/password-reset-request.d.ts.map +1 -0
  181. package/dist/presentation/routes/password/password-reset-request.js +26 -0
  182. package/dist/presentation/routes/password/password-reset-request.js.map +1 -0
  183. package/dist/presentation/routes/rate-limit.d.ts +10 -0
  184. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  185. package/dist/presentation/routes/rate-limit.js +101 -0
  186. package/dist/presentation/routes/rate-limit.js.map +1 -0
  187. package/dist/presentation/routes/registration/signup.d.ts +2 -0
  188. package/dist/presentation/routes/registration/signup.d.ts.map +1 -0
  189. package/dist/presentation/routes/registration/signup.js +82 -0
  190. package/dist/presentation/routes/registration/signup.js.map +1 -0
  191. package/dist/presentation/routes/session/login.d.ts +2 -0
  192. package/dist/presentation/routes/session/login.d.ts.map +1 -0
  193. package/dist/presentation/routes/session/login.js +48 -0
  194. package/dist/presentation/routes/session/login.js.map +1 -0
  195. package/dist/presentation/routes/session/logout.d.ts +2 -0
  196. package/dist/presentation/routes/session/logout.d.ts.map +1 -0
  197. package/dist/presentation/routes/session/logout.js +25 -0
  198. package/dist/presentation/routes/session/logout.js.map +1 -0
  199. package/dist/presentation/routes/session/me.d.ts +2 -0
  200. package/dist/presentation/routes/session/me.d.ts.map +1 -0
  201. package/dist/presentation/routes/session/me.js +42 -0
  202. package/dist/presentation/routes/session/me.js.map +1 -0
  203. package/dist/presentation/routes/session/refresh.d.ts +2 -0
  204. package/dist/presentation/routes/session/refresh.d.ts.map +1 -0
  205. package/dist/presentation/routes/session/refresh.js +49 -0
  206. package/dist/presentation/routes/session/refresh.js.map +1 -0
  207. package/dist/presentation/subscribers/index.d.ts +3 -0
  208. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  209. package/dist/presentation/subscribers/index.js +4 -0
  210. package/dist/presentation/subscribers/index.js.map +1 -0
  211. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +3 -0
  212. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +1 -0
  213. package/dist/presentation/subscribers/on-email-verification-send-requested.js +13 -0
  214. package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +1 -0
  215. package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts +3 -0
  216. package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts.map +1 -0
  217. package/dist/presentation/subscribers/on-password-reset-send-requested.js +14 -0
  218. package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -0
  219. package/dist/tsconfig.test.tsbuildinfo +1 -0
  220. package/drizzle/0000_initial.sql +81 -0
  221. package/drizzle/meta/0000_snapshot.json +643 -0
  222. package/drizzle/meta/_journal.json +13 -0
  223. package/drizzle.config.ts +7 -0
  224. package/package.json +88 -0
  225. package/src/config.ts +88 -0
  226. package/src/core/auth.test.ts +675 -0
  227. package/src/core/auth.ts +560 -0
  228. package/src/core/entities/email-verification.ts +8 -0
  229. package/src/core/entities/password-reset.ts +8 -0
  230. package/src/core/entities/refresh-token.ts +11 -0
  231. package/src/core/entities/user.ts +12 -0
  232. package/src/core/errors.ts +123 -0
  233. package/src/core/job-lease-token.test.ts +201 -0
  234. package/src/core/job-lease-token.ts +83 -0
  235. package/src/core/jwt.test.ts +180 -0
  236. package/src/core/jwt.ts +66 -0
  237. package/src/core/password.test.ts +28 -0
  238. package/src/core/password.ts +21 -0
  239. package/src/core/rate-limit.test.ts +462 -0
  240. package/src/core/rate-limit.ts +130 -0
  241. package/src/core/runner-session-token.test.ts +126 -0
  242. package/src/core/runner-session-token.ts +49 -0
  243. package/src/db/db.ts +28 -0
  244. package/src/db/email-verifications.test.ts +61 -0
  245. package/src/db/email-verifications.ts +160 -0
  246. package/src/db/migrations.ts +4 -0
  247. package/src/db/password-resets.test.ts +61 -0
  248. package/src/db/password-resets.ts +69 -0
  249. package/src/db/rate-limits.ts +69 -0
  250. package/src/db/refresh-tokens.test.ts +164 -0
  251. package/src/db/refresh-tokens.ts +149 -0
  252. package/src/db/schema/common.ts +3 -0
  253. package/src/db/schema/email-verifications.ts +37 -0
  254. package/src/db/schema/outbox.ts +4 -0
  255. package/src/db/schema/password-resets.ts +37 -0
  256. package/src/db/schema/rate-limits.ts +27 -0
  257. package/src/db/schema/refresh-tokens.ts +43 -0
  258. package/src/db/schema/users.ts +37 -0
  259. package/src/db/users.test.ts +75 -0
  260. package/src/db/users.ts +121 -0
  261. package/src/index.test.ts +39 -0
  262. package/src/index.ts +69 -0
  263. package/src/metrics/index.ts +13 -0
  264. package/src/metrics/instance.test.ts +69 -0
  265. package/src/metrics/instance.ts +77 -0
  266. package/src/presentation/auth/bearer-token-auth.test.ts +99 -0
  267. package/src/presentation/auth/bearer-token-auth.ts +51 -0
  268. package/src/presentation/auth/jwt-auth.test.ts +136 -0
  269. package/src/presentation/auth/jwt-auth.ts +39 -0
  270. package/src/presentation/auth/lease-token-auth.test.ts +56 -0
  271. package/src/presentation/auth/lease-token-auth.ts +25 -0
  272. package/src/presentation/auth/refresh-cookie.test.ts +39 -0
  273. package/src/presentation/auth/refresh-cookie.ts +34 -0
  274. package/src/presentation/auth/runner-session-auth.test.ts +54 -0
  275. package/src/presentation/auth/runner-session-auth.ts +13 -0
  276. package/src/presentation/dto/user.ts +21 -0
  277. package/src/presentation/e2eRoutes/create-session.ts +39 -0
  278. package/src/presentation/e2eRoutes/create-user.ts +38 -0
  279. package/src/presentation/e2eRoutes/index.test.ts +102 -0
  280. package/src/presentation/e2eRoutes/index.ts +10 -0
  281. package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +66 -0
  282. package/src/presentation/routes/email-verification/verify-email-confirm.ts +37 -0
  283. package/src/presentation/routes/email-verification/verify-email-resend.test.ts +100 -0
  284. package/src/presentation/routes/email-verification/verify-email-resend.ts +26 -0
  285. package/src/presentation/routes/index.ts +29 -0
  286. package/src/presentation/routes/password/change-password.test.ts +106 -0
  287. package/src/presentation/routes/password/change-password.ts +45 -0
  288. package/src/presentation/routes/password/password-reset-confirm.test.ts +63 -0
  289. package/src/presentation/routes/password/password-reset-confirm.ts +35 -0
  290. package/src/presentation/routes/password/password-reset-request.test.ts +56 -0
  291. package/src/presentation/routes/password/password-reset-request.ts +25 -0
  292. package/src/presentation/routes/rate-limit.test.ts +341 -0
  293. package/src/presentation/routes/rate-limit.ts +119 -0
  294. package/src/presentation/routes/registration/signup.test.ts +284 -0
  295. package/src/presentation/routes/registration/signup.ts +81 -0
  296. package/src/presentation/routes/session/login.test.ts +77 -0
  297. package/src/presentation/routes/session/login.ts +50 -0
  298. package/src/presentation/routes/session/logout.test.ts +55 -0
  299. package/src/presentation/routes/session/logout.ts +23 -0
  300. package/src/presentation/routes/session/me.test.ts +56 -0
  301. package/src/presentation/routes/session/me.ts +35 -0
  302. package/src/presentation/routes/session/refresh.test.ts +89 -0
  303. package/src/presentation/routes/session/refresh.ts +54 -0
  304. package/src/presentation/subscribers/email-send-requested.test.ts +77 -0
  305. package/src/presentation/subscribers/index.ts +2 -0
  306. package/src/presentation/subscribers/on-email-verification-send-requested.ts +10 -0
  307. package/src/presentation/subscribers/on-password-reset-send-requested.ts +13 -0
  308. package/test/env.ts +10 -0
  309. package/test/factories/index.ts +1 -0
  310. package/test/factories/user.ts +36 -0
  311. package/test/globalSetup.ts +18 -0
  312. package/test/index.ts +1 -0
  313. package/test/routes.ts +275 -0
  314. package/test/setup.ts +17 -0
  315. package/tsconfig.build.json +9 -0
  316. package/tsconfig.build.tsbuildinfo +1 -0
  317. package/tsconfig.json +4 -0
  318. package/tsconfig.test.json +8 -0
  319. package/vitest.config.ts +11 -0
@@ -0,0 +1,49 @@
1
+ import {
2
+ RUNNER_SESSION_TOKEN_AUDIENCE,
3
+ type RunnerSessionTokenClaims,
4
+ runnerSessionTokenClaimsSchema,
5
+ } from '@shipfox/api-auth-dto';
6
+ import {signHs256, verifyHs256} from '@shipfox/node-jwt';
7
+ import {config} from '#config.js';
8
+ import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
9
+
10
+ // `aud`, `iat` and `exp` are set by the codec (jose); callers supply the business ids only.
11
+ export type IssueRunnerSessionTokenParams = Omit<RunnerSessionTokenClaims, 'aud' | 'iat' | 'exp'>;
12
+
13
+ export async function issueRunnerSessionToken(
14
+ claims: IssueRunnerSessionTokenParams,
15
+ ): Promise<string> {
16
+ const token = await signHs256({
17
+ payload: {
18
+ runnerSessionId: claims.runnerSessionId,
19
+ workspaceId: claims.workspaceId,
20
+ scope: claims.scope,
21
+ labels: claims.labels,
22
+ maxClaims: claims.maxClaims,
23
+ },
24
+ secret: config.AUTH_RUNNER_SESSION_TOKEN_SECRET,
25
+ expiresIn: config.AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN,
26
+ audience: RUNNER_SESSION_TOKEN_AUDIENCE,
27
+ });
28
+ recordTokenIssued('runner_session');
29
+ return token;
30
+ }
31
+
32
+ /** Returns the claims on success, or `null` for any invalid input — never throws. */
33
+ export async function verifyRunnerSessionToken(
34
+ token: string,
35
+ ): Promise<RunnerSessionTokenClaims | null> {
36
+ try {
37
+ const claims = await verifyHs256({
38
+ token,
39
+ secret: config.AUTH_RUNNER_SESSION_TOKEN_SECRET,
40
+ schema: runnerSessionTokenClaimsSchema,
41
+ audience: RUNNER_SESSION_TOKEN_AUDIENCE,
42
+ });
43
+ recordTokenVerified('runner_session', 'ok');
44
+ return claims;
45
+ } catch {
46
+ recordTokenVerified('runner_session', 'rejected');
47
+ return null;
48
+ }
49
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,28 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {emailVerifications} from './schema/email-verifications.js';
4
+ import {authOutbox} from './schema/outbox.js';
5
+ import {passwordResets} from './schema/password-resets.js';
6
+ import {authRateLimits} from './schema/rate-limits.js';
7
+ import {refreshTokens} from './schema/refresh-tokens.js';
8
+ import {users} from './schema/users.js';
9
+
10
+ export const schema = {
11
+ users,
12
+ passwordResets,
13
+ refreshTokens,
14
+ emailVerifications,
15
+ authOutbox,
16
+ authRateLimits,
17
+ };
18
+
19
+ let _db: NodePgDatabase<typeof schema> | undefined;
20
+
21
+ export function db() {
22
+ if (!_db) _db = drizzle(pgClient(), {schema});
23
+ return _db;
24
+ }
25
+
26
+ export function closeDb(): void {
27
+ _db = undefined;
28
+ }
@@ -0,0 +1,61 @@
1
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {consumeEmailVerification, createEmailVerification} from './email-verifications.js';
3
+ import {createUser} from './users.js';
4
+
5
+ function emailFor(suffix: string): string {
6
+ return `${suffix}-${crypto.randomUUID()}@example.com`;
7
+ }
8
+
9
+ describe('email-verifications db', () => {
10
+ test('creates a verification and consumes it once', async () => {
11
+ const user = await createUser({email: emailFor('ev1'), hashedPassword: 'h'});
12
+ const raw = `verification-${crypto.randomUUID()}`;
13
+ const verification = await createEmailVerification({
14
+ userId: user.id,
15
+ hashedToken: hashOpaqueToken(raw),
16
+ expiresAt: new Date(Date.now() + 60_000),
17
+ skipEmail: true,
18
+ });
19
+
20
+ const first = await consumeEmailVerification({hashedToken: verification.hashedToken});
21
+ const second = await consumeEmailVerification({hashedToken: verification.hashedToken});
22
+
23
+ expect(first?.id).toBe(verification.id);
24
+ expect(second).toBeUndefined();
25
+ });
26
+
27
+ test('rejects expired verification', async () => {
28
+ const user = await createUser({email: emailFor('ev2'), hashedPassword: 'h'});
29
+ const verification = await createEmailVerification({
30
+ userId: user.id,
31
+ hashedToken: hashOpaqueToken(`expired-v-${crypto.randomUUID()}`),
32
+ expiresAt: new Date(Date.now() - 60_000),
33
+ skipEmail: true,
34
+ });
35
+
36
+ const consumed = await consumeEmailVerification({hashedToken: verification.hashedToken});
37
+
38
+ expect(consumed).toBeUndefined();
39
+ });
40
+
41
+ test('creating a new verification invalidates prior unused for the same user', async () => {
42
+ const user = await createUser({email: emailFor('ev3'), hashedPassword: 'h'});
43
+ const first = await createEmailVerification({
44
+ userId: user.id,
45
+ hashedToken: hashOpaqueToken(`first-v-${crypto.randomUUID()}`),
46
+ expiresAt: new Date(Date.now() + 60_000),
47
+ skipEmail: true,
48
+ });
49
+
50
+ await createEmailVerification({
51
+ userId: user.id,
52
+ hashedToken: hashOpaqueToken(`second-v-${crypto.randomUUID()}`),
53
+ expiresAt: new Date(Date.now() + 60_000),
54
+ skipEmail: true,
55
+ });
56
+
57
+ const consumeFirst = await consumeEmailVerification({hashedToken: first.hashedToken});
58
+
59
+ expect(consumeFirst).toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,160 @@
1
+ import {AUTH_EMAIL_VERIFICATION_SEND_REQUESTED, type AuthEventMap} from '@shipfox/api-auth-dto';
2
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
3
+ import {and, desc, eq, gt, isNull, sql} from 'drizzle-orm';
4
+ import type {EmailVerification} from '#core/entities/email-verification.js';
5
+ import type {User} from '#core/entities/user.js';
6
+ import {db} from './db.js';
7
+ import {emailVerifications, toEmailVerification} from './schema/email-verifications.js';
8
+ import {authOutbox} from './schema/outbox.js';
9
+ import {toUser, users} from './schema/users.js';
10
+
11
+ interface CreateEmailVerificationBaseParams {
12
+ userId: string;
13
+ hashedToken: string;
14
+ expiresAt: Date;
15
+ }
16
+
17
+ export type CreateEmailVerificationParams = CreateEmailVerificationBaseParams &
18
+ (
19
+ | {sendEmail: {email: string; verifyLink: string}; skipEmail?: never}
20
+ | {sendEmail?: never; skipEmail: true}
21
+ );
22
+
23
+ export async function createEmailVerification(
24
+ params: CreateEmailVerificationParams,
25
+ ): Promise<EmailVerification> {
26
+ return await db().transaction(async (tx) => {
27
+ await tx
28
+ .update(emailVerifications)
29
+ .set({usedAt: sql`now()`})
30
+ .where(and(eq(emailVerifications.userId, params.userId), isNull(emailVerifications.usedAt)));
31
+
32
+ const rows = await tx
33
+ .insert(emailVerifications)
34
+ .values({
35
+ userId: params.userId,
36
+ hashedToken: params.hashedToken,
37
+ expiresAt: params.expiresAt,
38
+ })
39
+ .returning();
40
+
41
+ const row = rows[0];
42
+ if (!row) throw new Error('Insert returned no rows');
43
+ if (params.sendEmail) {
44
+ await writeOutboxEvent<AuthEventMap>(tx, authOutbox, {
45
+ type: AUTH_EMAIL_VERIFICATION_SEND_REQUESTED,
46
+ payload: params.sendEmail,
47
+ });
48
+ }
49
+ return toEmailVerification(row);
50
+ });
51
+ }
52
+
53
+ export interface CreateResendEmailVerificationParams {
54
+ email: string;
55
+ hashedToken: string;
56
+ expiresAt: Date;
57
+ cooldownSeconds: number;
58
+ sendEmail: {verifyLink: string};
59
+ now?: Date | undefined;
60
+ }
61
+
62
+ export interface CreateResendEmailVerificationResult {
63
+ user?: User | undefined;
64
+ verification?: EmailVerification | undefined;
65
+ nextResendAvailableAt: Date;
66
+ }
67
+
68
+ function secondsAfter(date: Date, seconds: number): Date {
69
+ return new Date(date.getTime() + seconds * 1000);
70
+ }
71
+
72
+ export async function createResendEmailVerification(
73
+ params: CreateResendEmailVerificationParams,
74
+ ): Promise<CreateResendEmailVerificationResult> {
75
+ const now = params.now ?? new Date();
76
+ const genericNextResendAvailableAt = secondsAfter(now, params.cooldownSeconds);
77
+
78
+ return await db().transaction(async (tx) => {
79
+ const userRows = await tx
80
+ .select()
81
+ .from(users)
82
+ .where(eq(users.email, params.email))
83
+ .limit(1)
84
+ .for('update');
85
+ const userRow = userRows[0];
86
+ if (!userRow) {
87
+ return {nextResendAvailableAt: genericNextResendAvailableAt};
88
+ }
89
+
90
+ const user = toUser(userRow);
91
+ if (user.status !== 'active' || user.emailVerifiedAt !== null) {
92
+ return {nextResendAvailableAt: genericNextResendAvailableAt};
93
+ }
94
+
95
+ const latestRows = await tx
96
+ .select()
97
+ .from(emailVerifications)
98
+ .where(and(eq(emailVerifications.userId, user.id), isNull(emailVerifications.usedAt)))
99
+ .orderBy(desc(emailVerifications.createdAt))
100
+ .limit(1);
101
+ const latest = latestRows[0] ? toEmailVerification(latestRows[0]) : undefined;
102
+ if (latest && secondsAfter(latest.createdAt, params.cooldownSeconds) > now) {
103
+ // Return the same public retry estimate as no-op cases so the endpoint does
104
+ // not expose existing account state or prior verification timing.
105
+ return {user, nextResendAvailableAt: genericNextResendAvailableAt};
106
+ }
107
+
108
+ await tx
109
+ .update(emailVerifications)
110
+ .set({usedAt: sql`now()`})
111
+ .where(and(eq(emailVerifications.userId, user.id), isNull(emailVerifications.usedAt)));
112
+
113
+ const rows = await tx
114
+ .insert(emailVerifications)
115
+ .values({
116
+ userId: user.id,
117
+ hashedToken: params.hashedToken,
118
+ expiresAt: params.expiresAt,
119
+ })
120
+ .returning();
121
+
122
+ const row = rows[0];
123
+ if (!row) throw new Error('Insert returned no rows');
124
+ const verification = toEmailVerification(row);
125
+ if (params.sendEmail) {
126
+ await writeOutboxEvent<AuthEventMap>(tx, authOutbox, {
127
+ type: AUTH_EMAIL_VERIFICATION_SEND_REQUESTED,
128
+ payload: {
129
+ email: user.email,
130
+ verifyLink: params.sendEmail.verifyLink,
131
+ },
132
+ });
133
+ }
134
+ return {
135
+ user,
136
+ verification,
137
+ nextResendAvailableAt: secondsAfter(verification.createdAt, params.cooldownSeconds),
138
+ };
139
+ });
140
+ }
141
+
142
+ export async function consumeEmailVerification(params: {
143
+ hashedToken: string;
144
+ }): Promise<EmailVerification | undefined> {
145
+ const rows = await db()
146
+ .update(emailVerifications)
147
+ .set({usedAt: sql`now()`})
148
+ .where(
149
+ and(
150
+ eq(emailVerifications.hashedToken, params.hashedToken),
151
+ isNull(emailVerifications.usedAt),
152
+ gt(emailVerifications.expiresAt, sql`now()`),
153
+ ),
154
+ )
155
+ .returning();
156
+
157
+ const row = rows[0];
158
+ if (!row) return undefined;
159
+ return toEmailVerification(row);
160
+ }
@@ -0,0 +1,4 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,61 @@
1
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {consumePasswordReset, createPasswordReset} from './password-resets.js';
3
+ import {createUser} from './users.js';
4
+
5
+ function emailFor(suffix: string): string {
6
+ return `${suffix}-${crypto.randomUUID()}@example.com`;
7
+ }
8
+
9
+ describe('password-resets db', () => {
10
+ test('creates a reset and consumes it once', async () => {
11
+ const user = await createUser({email: emailFor('pr1'), hashedPassword: 'h'});
12
+ const raw = `test-token-${crypto.randomUUID()}`;
13
+ const reset = await createPasswordReset({
14
+ userId: user.id,
15
+ hashedToken: hashOpaqueToken(raw),
16
+ expiresAt: new Date(Date.now() + 60_000),
17
+ skipEmail: true,
18
+ });
19
+
20
+ const first = await consumePasswordReset({hashedToken: reset.hashedToken});
21
+ const second = await consumePasswordReset({hashedToken: reset.hashedToken});
22
+
23
+ expect(first?.id).toBe(reset.id);
24
+ expect(second).toBeUndefined();
25
+ });
26
+
27
+ test('rejects expired token', async () => {
28
+ const user = await createUser({email: emailFor('pr2'), hashedPassword: 'h'});
29
+ const reset = await createPasswordReset({
30
+ userId: user.id,
31
+ hashedToken: hashOpaqueToken(`expired-${crypto.randomUUID()}`),
32
+ expiresAt: new Date(Date.now() - 60_000),
33
+ skipEmail: true,
34
+ });
35
+
36
+ const consumed = await consumePasswordReset({hashedToken: reset.hashedToken});
37
+
38
+ expect(consumed).toBeUndefined();
39
+ });
40
+
41
+ test('creating a new reset invalidates prior unused resets for the same user', async () => {
42
+ const user = await createUser({email: emailFor('pr3'), hashedPassword: 'h'});
43
+ const first = await createPasswordReset({
44
+ userId: user.id,
45
+ hashedToken: hashOpaqueToken(`first-${crypto.randomUUID()}`),
46
+ expiresAt: new Date(Date.now() + 60_000),
47
+ skipEmail: true,
48
+ });
49
+
50
+ await createPasswordReset({
51
+ userId: user.id,
52
+ hashedToken: hashOpaqueToken(`second-${crypto.randomUUID()}`),
53
+ expiresAt: new Date(Date.now() + 60_000),
54
+ skipEmail: true,
55
+ });
56
+
57
+ const consumeFirst = await consumePasswordReset({hashedToken: first.hashedToken});
58
+
59
+ expect(consumeFirst).toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,69 @@
1
+ import {AUTH_PASSWORD_RESET_SEND_REQUESTED, type AuthEventMap} from '@shipfox/api-auth-dto';
2
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
3
+ import {and, eq, gt, isNull, sql} from 'drizzle-orm';
4
+ import type {PasswordReset} from '#core/entities/password-reset.js';
5
+ import {db} from './db.js';
6
+ import {authOutbox} from './schema/outbox.js';
7
+ import {passwordResets, toPasswordReset} from './schema/password-resets.js';
8
+
9
+ interface CreatePasswordResetBaseParams {
10
+ userId: string;
11
+ hashedToken: string;
12
+ expiresAt: Date;
13
+ }
14
+
15
+ export type CreatePasswordResetParams = CreatePasswordResetBaseParams &
16
+ (
17
+ | {sendEmail: {email: string; resetLink: string; expiresInHours: number}; skipEmail?: never}
18
+ | {sendEmail?: never; skipEmail: true}
19
+ );
20
+
21
+ export async function createPasswordReset(
22
+ params: CreatePasswordResetParams,
23
+ ): Promise<PasswordReset> {
24
+ return await db().transaction(async (tx) => {
25
+ await tx
26
+ .update(passwordResets)
27
+ .set({usedAt: sql`now()`})
28
+ .where(and(eq(passwordResets.userId, params.userId), isNull(passwordResets.usedAt)));
29
+
30
+ const rows = await tx
31
+ .insert(passwordResets)
32
+ .values({
33
+ userId: params.userId,
34
+ hashedToken: params.hashedToken,
35
+ expiresAt: params.expiresAt,
36
+ })
37
+ .returning();
38
+
39
+ const row = rows[0];
40
+ if (!row) throw new Error('Insert returned no rows');
41
+ if (params.sendEmail) {
42
+ await writeOutboxEvent<AuthEventMap>(tx, authOutbox, {
43
+ type: AUTH_PASSWORD_RESET_SEND_REQUESTED,
44
+ payload: params.sendEmail,
45
+ });
46
+ }
47
+ return toPasswordReset(row);
48
+ });
49
+ }
50
+
51
+ export async function consumePasswordReset(params: {
52
+ hashedToken: string;
53
+ }): Promise<PasswordReset | undefined> {
54
+ const rows = await db()
55
+ .update(passwordResets)
56
+ .set({usedAt: sql`now()`})
57
+ .where(
58
+ and(
59
+ eq(passwordResets.hashedToken, params.hashedToken),
60
+ isNull(passwordResets.usedAt),
61
+ gt(passwordResets.expiresAt, sql`now()`),
62
+ ),
63
+ )
64
+ .returning();
65
+
66
+ const row = rows[0];
67
+ if (!row) return undefined;
68
+ return toPasswordReset(row);
69
+ }
@@ -0,0 +1,69 @@
1
+ import {lt, sql} from 'drizzle-orm';
2
+ import {db} from './db.js';
3
+ import {authRateLimits} from './schema/rate-limits.js';
4
+
5
+ export interface ConsumeAuthRateLimitParams {
6
+ action: string;
7
+ scope: string;
8
+ identifierHmac: string;
9
+ windowStart: Date;
10
+ expiresAt: Date;
11
+ timeoutMs: number;
12
+ }
13
+
14
+ export interface ConsumeAuthRateLimitResult {
15
+ count: number;
16
+ expiresAt: Date;
17
+ }
18
+
19
+ export async function consumeAuthRateLimit(
20
+ params: ConsumeAuthRateLimitParams,
21
+ ): Promise<ConsumeAuthRateLimitResult> {
22
+ return await db().transaction(async (tx) => {
23
+ await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
24
+
25
+ const rows = await tx
26
+ .insert(authRateLimits)
27
+ .values({
28
+ action: params.action,
29
+ scope: params.scope,
30
+ identifierHmac: params.identifierHmac,
31
+ windowStart: params.windowStart,
32
+ count: 1,
33
+ expiresAt: params.expiresAt,
34
+ })
35
+ .onConflictDoUpdate({
36
+ target: [
37
+ authRateLimits.action,
38
+ authRateLimits.scope,
39
+ authRateLimits.identifierHmac,
40
+ authRateLimits.windowStart,
41
+ ],
42
+ set: {
43
+ count: sql`${authRateLimits.count} + 1`,
44
+ updatedAt: sql`now()`,
45
+ },
46
+ })
47
+ .returning({count: authRateLimits.count, expiresAt: authRateLimits.expiresAt});
48
+
49
+ const row = rows[0];
50
+ if (!row) throw new Error('Rate limit upsert returned no rows');
51
+ return row;
52
+ });
53
+ }
54
+
55
+ let nextPruneAt = 0;
56
+
57
+ export async function pruneExpiredAuthRateLimits(
58
+ params: {now?: Date | undefined; minIntervalMs?: number | undefined} = {},
59
+ ): Promise<number | undefined> {
60
+ const now = params.now ?? new Date();
61
+ const minIntervalMs = params.minIntervalMs ?? 60_000;
62
+ if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
63
+
64
+ nextPruneAt = now.getTime() + minIntervalMs;
65
+
66
+ const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
67
+
68
+ return result.rowCount ?? 0;
69
+ }
@@ -0,0 +1,164 @@
1
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {
3
+ createRefreshToken,
4
+ findActiveRefreshTokenByHash,
5
+ findRefreshTokenByHash,
6
+ revokeRefreshTokenByHash,
7
+ revokeRefreshTokensForUser,
8
+ rotateRefreshToken,
9
+ } from './refresh-tokens.js';
10
+ import {createUser} from './users.js';
11
+
12
+ function emailFor(suffix: string): string {
13
+ return `${suffix}-${crypto.randomUUID()}@example.com`;
14
+ }
15
+
16
+ describe('refresh-tokens db', () => {
17
+ test('creates and finds an active refresh token', async () => {
18
+ const user = await createUser({email: emailFor('rt-create'), hashedPassword: 'h'});
19
+ const hashedToken = hashOpaqueToken(`refresh-${crypto.randomUUID()}`);
20
+
21
+ const created = await createRefreshToken({
22
+ userId: user.id,
23
+ hashedToken,
24
+ expiresAt: new Date(Date.now() + 60_000),
25
+ });
26
+ const found = await findActiveRefreshTokenByHash({hashedToken});
27
+
28
+ expect(created.userId).toBe(user.id);
29
+ expect(found?.id).toBe(created.id);
30
+ });
31
+
32
+ test('rejects expired refresh tokens', async () => {
33
+ const user = await createUser({email: emailFor('rt-exp'), hashedPassword: 'h'});
34
+ const hashedToken = hashOpaqueToken(`expired-${crypto.randomUUID()}`);
35
+ await createRefreshToken({
36
+ userId: user.id,
37
+ hashedToken,
38
+ expiresAt: new Date(Date.now() - 60_000),
39
+ });
40
+
41
+ const found = await findActiveRefreshTokenByHash({hashedToken});
42
+
43
+ expect(found).toBeUndefined();
44
+ });
45
+
46
+ test('rotates a token once, creates the successor, and only the first caller wins', async () => {
47
+ const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
48
+ const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
49
+ const nextHashedToken = hashOpaqueToken(`next-${crypto.randomUUID()}`);
50
+ const created = await createRefreshToken({
51
+ userId: user.id,
52
+ hashedToken: currentHashedToken,
53
+ expiresAt: new Date(Date.now() + 60_000),
54
+ });
55
+
56
+ const won = await rotateRefreshToken({
57
+ id: created.id,
58
+ currentHashedToken,
59
+ nextHashedToken,
60
+ expiresAt: new Date(Date.now() + 120_000),
61
+ });
62
+ const lost = await rotateRefreshToken({
63
+ id: created.id,
64
+ currentHashedToken,
65
+ nextHashedToken: hashOpaqueToken(`lost-${crypto.randomUUID()}`),
66
+ expiresAt: new Date(Date.now() + 120_000),
67
+ });
68
+ const successor = await findActiveRefreshTokenByHash({hashedToken: nextHashedToken});
69
+
70
+ expect(won?.rotatedAt).toBeInstanceOf(Date);
71
+ expect(lost).toBeUndefined();
72
+ expect(successor?.hashedToken).toBe(nextHashedToken);
73
+ });
74
+
75
+ test('rolls back the rotation when successor creation fails', async () => {
76
+ const user = await createUser({email: emailFor('rt-rotate-rollback'), hashedPassword: 'h'});
77
+ const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
78
+ const duplicateHashedToken = hashOpaqueToken(`duplicate-${crypto.randomUUID()}`);
79
+ const created = await createRefreshToken({
80
+ userId: user.id,
81
+ hashedToken: currentHashedToken,
82
+ expiresAt: new Date(Date.now() + 60_000),
83
+ });
84
+ await createRefreshToken({
85
+ userId: user.id,
86
+ hashedToken: duplicateHashedToken,
87
+ expiresAt: new Date(Date.now() + 60_000),
88
+ });
89
+
90
+ const failed = rotateRefreshToken({
91
+ id: created.id,
92
+ currentHashedToken,
93
+ nextHashedToken: duplicateHashedToken,
94
+ expiresAt: new Date(Date.now() + 120_000),
95
+ });
96
+
97
+ await expect(failed).rejects.toThrow();
98
+ const active = await findActiveRefreshTokenByHash({hashedToken: currentHashedToken});
99
+ expect(active?.id).toBe(created.id);
100
+ expect(active?.rotatedAt).toBeNull();
101
+ });
102
+
103
+ test('rotated tokens drop out of the active lookup but stay findable for the grace window', async () => {
104
+ const user = await createUser({email: emailFor('rt-rotated-find'), hashedPassword: 'h'});
105
+ const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
106
+ const nextHashedToken = hashOpaqueToken(`next-${crypto.randomUUID()}`);
107
+ const created = await createRefreshToken({
108
+ userId: user.id,
109
+ hashedToken: currentHashedToken,
110
+ expiresAt: new Date(Date.now() + 60_000),
111
+ });
112
+ await rotateRefreshToken({
113
+ id: created.id,
114
+ currentHashedToken,
115
+ nextHashedToken,
116
+ expiresAt: new Date(Date.now() + 120_000),
117
+ });
118
+
119
+ const active = await findActiveRefreshTokenByHash({hashedToken: currentHashedToken});
120
+ const found = await findRefreshTokenByHash({hashedToken: currentHashedToken});
121
+
122
+ expect(active).toBeUndefined();
123
+ expect(found?.id).toBe(created.id);
124
+ expect(found?.rotatedAt).toBeInstanceOf(Date);
125
+ });
126
+
127
+ test('revokes a single refresh token by hash', async () => {
128
+ const user = await createUser({email: emailFor('rt-revoke'), hashedPassword: 'h'});
129
+ const hashedToken = hashOpaqueToken(`revoke-${crypto.randomUUID()}`);
130
+ await createRefreshToken({
131
+ userId: user.id,
132
+ hashedToken,
133
+ expiresAt: new Date(Date.now() + 60_000),
134
+ });
135
+
136
+ await revokeRefreshTokenByHash({hashedToken});
137
+ const found = await findActiveRefreshTokenByHash({hashedToken});
138
+
139
+ expect(found).toBeUndefined();
140
+ });
141
+
142
+ test('revokes all user refresh tokens except the selected session', async () => {
143
+ const user = await createUser({email: emailFor('rt-revoke-user'), hashedPassword: 'h'});
144
+ const keepHash = hashOpaqueToken(`keep-${crypto.randomUUID()}`);
145
+ const revokeHash = hashOpaqueToken(`revoke-${crypto.randomUUID()}`);
146
+ const keep = await createRefreshToken({
147
+ userId: user.id,
148
+ hashedToken: keepHash,
149
+ expiresAt: new Date(Date.now() + 60_000),
150
+ });
151
+ await createRefreshToken({
152
+ userId: user.id,
153
+ hashedToken: revokeHash,
154
+ expiresAt: new Date(Date.now() + 60_000),
155
+ });
156
+
157
+ await revokeRefreshTokensForUser({userId: user.id, exceptRefreshTokenId: keep.id});
158
+ const kept = await findActiveRefreshTokenByHash({hashedToken: keepHash});
159
+ const revoked = await findActiveRefreshTokenByHash({hashedToken: revokeHash});
160
+
161
+ expect(kept?.id).toBe(keep.id);
162
+ expect(revoked).toBeUndefined();
163
+ });
164
+ });