adonia 0.1.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 (140) hide show
  1. package/README.md +64 -0
  2. package/build/adonis_peers.d.ts +14 -0
  3. package/build/adonis_peers.js +56 -0
  4. package/build/cli.d.ts +2 -0
  5. package/build/cli.js +111 -0
  6. package/build/commands/add.d.ts +18 -0
  7. package/build/commands/add.js +246 -0
  8. package/build/commands/check.d.ts +4 -0
  9. package/build/commands/check.js +159 -0
  10. package/build/commands/diff.d.ts +2 -0
  11. package/build/commands/diff.js +74 -0
  12. package/build/commands/init.d.ts +6 -0
  13. package/build/commands/init.js +124 -0
  14. package/build/commands/list.d.ts +2 -0
  15. package/build/commands/list.js +34 -0
  16. package/build/config_merge.d.ts +10 -0
  17. package/build/config_merge.js +71 -0
  18. package/build/constants_merge.d.ts +4 -0
  19. package/build/constants_merge.js +85 -0
  20. package/build/copy.d.ts +8 -0
  21. package/build/copy.js +33 -0
  22. package/build/host.d.ts +15 -0
  23. package/build/host.js +85 -0
  24. package/build/registry/index.d.ts +26 -0
  25. package/build/registry/index.js +116 -0
  26. package/build/registry/plan.d.ts +53 -0
  27. package/build/registry/plan.js +159 -0
  28. package/build/schema.d.ts +136 -0
  29. package/build/schema.js +58 -0
  30. package/build/types.d.ts +21 -0
  31. package/build/types.js +1 -0
  32. package/build/wire.d.ts +16 -0
  33. package/build/wire.js +162 -0
  34. package/package.json +51 -0
  35. package/registry/README.md +86 -0
  36. package/registry/account/contracts/avatar_storage.ts +7 -0
  37. package/registry/account/contracts/index.ts +7 -0
  38. package/registry/account/contracts/types.ts +23 -0
  39. package/registry/account/events.ts +14 -0
  40. package/registry/account/module.json +31 -0
  41. package/registry/account/options.ts +11 -0
  42. package/registry/account/service.ts +155 -0
  43. package/registry/account/stubs/adapters/account.ts +21 -0
  44. package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
  45. package/registry/account/stubs/controllers/account_controller.ts +190 -0
  46. package/registry/account/stubs/routes/account.ts +53 -0
  47. package/registry/account/stubs/validators/account.ts +37 -0
  48. package/registry/account/tests/account_service.spec.ts +132 -0
  49. package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
  50. package/registry/api/envelope.ts +66 -0
  51. package/registry/api/exception.ts +38 -0
  52. package/registry/api/exception_handler.ts +54 -0
  53. package/registry/api/module.json +27 -0
  54. package/registry/api/provider.ts +129 -0
  55. package/registry/api/service.ts +88 -0
  56. package/registry/api/tests/emit_safe.spec.ts +107 -0
  57. package/registry/api/tests/respond.spec.ts +40 -0
  58. package/registry/auth/contracts/index.ts +18 -0
  59. package/registry/auth/contracts/password_reset_store.ts +21 -0
  60. package/registry/auth/contracts/session_manager.ts +19 -0
  61. package/registry/auth/contracts/types.ts +32 -0
  62. package/registry/auth/events.ts +21 -0
  63. package/registry/auth/module.json +45 -0
  64. package/registry/auth/options.ts +23 -0
  65. package/registry/auth/service.ts +150 -0
  66. package/registry/auth/stubs/adapters/auth.ts +21 -0
  67. package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
  68. package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
  69. package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
  70. package/registry/auth/stubs/adapters/map_user.ts +19 -0
  71. package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
  72. package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
  73. package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
  74. package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
  75. package/registry/auth/stubs/models/password_reset.ts +29 -0
  76. package/registry/auth/stubs/models/user.ts +99 -0
  77. package/registry/auth/stubs/providers/vine_provider.ts +43 -0
  78. package/registry/auth/stubs/routes/auth.ts +36 -0
  79. package/registry/auth/stubs/start/limiter.ts +17 -0
  80. package/registry/auth/stubs/validators/auth.ts +48 -0
  81. package/registry/auth/stubs/validators/rules/exists.ts +21 -0
  82. package/registry/auth/stubs/validators/rules/unique.ts +21 -0
  83. package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
  84. package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
  85. package/registry/auth/stubs/validators/vine.d.ts +10 -0
  86. package/registry/auth/tests/auth_service.spec.ts +145 -0
  87. package/registry/auth/tests/auth_update_password.spec.ts +99 -0
  88. package/registry/auth/tests/fakes/index.ts +3 -0
  89. package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
  90. package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
  91. package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
  92. package/registry/constants/exceptions.ts +64 -0
  93. package/registry/constants/index.ts +12 -0
  94. package/registry/constants/responseCodes.ts +87 -0
  95. package/registry/contracts/index.ts +5 -0
  96. package/registry/contracts/user.ts +78 -0
  97. package/registry/creem/contracts/creem_client.ts +34 -0
  98. package/registry/creem/contracts/index.ts +8 -0
  99. package/registry/creem/contracts/types.ts +30 -0
  100. package/registry/creem/events.ts +9 -0
  101. package/registry/creem/module.json +28 -0
  102. package/registry/creem/service.ts +87 -0
  103. package/registry/creem/stubs/adapters/creem.ts +23 -0
  104. package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
  105. package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
  106. package/registry/notification/contracts/app_notification_store.ts +8 -0
  107. package/registry/notification/contracts/index.ts +9 -0
  108. package/registry/notification/contracts/mail_transport.ts +7 -0
  109. package/registry/notification/contracts/types.ts +32 -0
  110. package/registry/notification/module.json +23 -0
  111. package/registry/notification/options.ts +11 -0
  112. package/registry/notification/service.ts +103 -0
  113. package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
  114. package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
  115. package/registry/notification/stubs/adapters/notification.ts +36 -0
  116. package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
  117. package/registry/notification/stubs/models/app_notification.ts +45 -0
  118. package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
  119. package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
  120. package/registry/notification/tests/notification_service.spec.ts +63 -0
  121. package/registry/registry.json +4 -0
  122. package/registry/subscription/contracts/index.ts +9 -0
  123. package/registry/subscription/contracts/subscription_store.ts +15 -0
  124. package/registry/subscription/contracts/types.ts +39 -0
  125. package/registry/subscription/events.ts +13 -0
  126. package/registry/subscription/module.json +31 -0
  127. package/registry/subscription/options.ts +14 -0
  128. package/registry/subscription/service.ts +140 -0
  129. package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
  130. package/registry/subscription/stubs/adapters/subscription.ts +21 -0
  131. package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
  132. package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
  133. package/registry/subscription/stubs/models/subscription.ts +45 -0
  134. package/registry/subscription/tests/billing_service.spec.ts +189 -0
  135. package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
  136. package/registry/types/api.ts +41 -0
  137. package/registry/types/constants.ts +7 -0
  138. package/registry/types/index.ts +6 -0
  139. package/registry/types/models.ts +32 -0
  140. package/schema/adonia.schema.json +39 -0
@@ -0,0 +1,99 @@
1
+ import { DateTime } from 'luxon'
2
+ import hash from '@adonisjs/core/services/hash'
3
+ import { compose } from '@adonisjs/core/helpers'
4
+ import { BaseModel, column, computed } from '@adonisjs/lucid/orm'
5
+ import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
6
+ import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
7
+ import { ModelsTypes } from '#modules/types'
8
+ import encryption from '@adonisjs/core/services/encryption'
9
+ import env from '#start/env'
10
+ import { defaultAuthOptions } from '../../options.ts'
11
+
12
+ interface IUserModel extends ModelsTypes.IUserModel {}
13
+
14
+ const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
15
+ uids: ['email'],
16
+ passwordColumnName: 'password',
17
+ })
18
+
19
+ export default class User
20
+ extends compose(BaseModel, AuthFinder)
21
+ implements ModelsTypes.ModularModel<IUserModel>
22
+ {
23
+ static accessTokens = DbAccessTokensProvider.forModel(User)
24
+
25
+ nameSpace = 'user'
26
+
27
+ @column({ isPrimary: true, serializeAs: null })
28
+ declare id: number
29
+
30
+ @column()
31
+ declare authMethod: 'github' | 'google' | 'email'
32
+
33
+ @column()
34
+ declare email: string
35
+
36
+ @column()
37
+ declare pendingEmail: string | null
38
+
39
+ @column()
40
+ declare firstName: string
41
+
42
+ @column()
43
+ declare lastName: string
44
+
45
+ @column({ serializeAs: null })
46
+ declare avatarKey: string | null
47
+
48
+ @column({ serializeAs: null })
49
+ declare password: string | null
50
+
51
+ @column({ serializeAs: null })
52
+ declare emailVerificationToken: string | null
53
+
54
+ @column()
55
+ declare creemCustomerId: string | null
56
+
57
+ @column()
58
+ declare emailMarketing: boolean
59
+
60
+ @column()
61
+ declare emailProductUpdates: boolean
62
+
63
+ @column()
64
+ declare emailSecurityAlerts: boolean
65
+
66
+ @column()
67
+ declare emailWeeklyDigest: boolean
68
+
69
+ @computed()
70
+ get name() {
71
+ return this.firstName + ' ' + this.lastName
72
+ }
73
+
74
+ @column.dateTime({ autoCreate: true })
75
+ declare createdAt: DateTime
76
+
77
+ @column.dateTime({ autoCreate: true, autoUpdate: true })
78
+ declare updatedAt: DateTime | null
79
+
80
+ @column.dateTime()
81
+ declare emailVerifiedAt: DateTime | null
82
+
83
+ @computed()
84
+ get avatar() {
85
+ return this.avatarKey ? `${env.get('AVATAR_URL')}/${this.avatarKey}` : ''
86
+ }
87
+
88
+ verifyEmail() {
89
+ this.emailVerifiedAt = DateTime.now()
90
+ this.emailVerificationToken = null
91
+ }
92
+
93
+ generateVerificationToken() {
94
+ this.emailVerificationToken = encryption.encrypt(
95
+ this.email,
96
+ defaultAuthOptions.verificationTtl
97
+ )
98
+ }
99
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Registers Vine macros used by auth validators: unique, exists,
3
+ * validToken, validPassword.
4
+ *
5
+ * Copy into providers/ and register in adonisrc.ts providers, after
6
+ * `@adonisjs/core/providers/vinejs_provider`:
7
+ *
8
+ * () => import('#providers/vine_provider'),
9
+ */
10
+ import type { ApplicationService } from '@adonisjs/core/types'
11
+ import { VineString } from '@vinejs/vine'
12
+ import { uniqueRule } from '#validators/rules/unique'
13
+ import { existsRule } from '#validators/rules/exists'
14
+ import { validTokenRule } from '#validators/rules/valid_token'
15
+ import { validPasswordRule } from '#validators/rules/valid_password'
16
+
17
+ export default class VineMacrosProvider {
18
+ constructor(protected app: ApplicationService) {}
19
+
20
+ boot() {
21
+ VineString.macro(
22
+ 'unique',
23
+ function (this: VineString, options: { table: string; column: string }) {
24
+ return this.use(uniqueRule(options))
25
+ }
26
+ )
27
+ VineString.macro(
28
+ 'exists',
29
+ function (this: VineString, options: { table: string; column: string }) {
30
+ return this.use(existsRule(options))
31
+ }
32
+ )
33
+ VineString.macro(
34
+ 'validToken',
35
+ function (this: VineString, options: { table: string; column: string }) {
36
+ return this.use(validTokenRule(options))
37
+ }
38
+ )
39
+ VineString.macro('validPassword', function (this: VineString) {
40
+ return this.use(validPasswordRule())
41
+ })
42
+ }
43
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Example auth routes. Requires controllers (and typically validators)
3
+ * installed via `adonia add auth --with-controllers --with-validators`
4
+ * or `--with-stubs`.
5
+ *
6
+ * Copy Vine provider (`providers/vine_provider.ts`) and register it in
7
+ * adonisrc.ts. Logout needs named auth middleware from start/kernel.ts.
8
+ *
9
+ * Sensitive routes should use authLimiter (5/min). Install @adonisjs/limiter,
10
+ * copy stubs/start/limiter.ts, then uncomment `.use(authLimiter)` below.
11
+ */
12
+ import router from '@adonisjs/core/services/router'
13
+ // import { middleware } from '#start/kernel'
14
+ // import { authLimiter } from '#start/limiter'
15
+
16
+ const AuthController = () => import('#controllers/auth_controller')
17
+
18
+ router
19
+ .group(() => {
20
+ router.post('register', [AuthController, 'register'])
21
+ // .use(authLimiter)
22
+ router.post('login', [AuthController, 'login'])
23
+ // .use(authLimiter)
24
+ router.post('logout', [AuthController, 'logout'])
25
+ // .use(middleware.auth())
26
+ router.post('activate', [AuthController, 'activate'])
27
+ router.post('activate/request', [AuthController, 'sendAccountActivationEmail'])
28
+ // .use(authLimiter)
29
+ router.post('password/forgot', [AuthController, 'requestPasswordReset'])
30
+ // .use(authLimiter)
31
+ router.post('password/validate', [AuthController, 'validatePasswordReset'])
32
+ router.post('password/reset', [AuthController, 'updatePassword'])
33
+ // .use(authLimiter)
34
+ })
35
+ .prefix('/api/v1/auth')
36
+ .as('adonia.auth')
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Host example rate limiters.
3
+ *
4
+ * Peer: @adonisjs/limiter (not installed by `adonia add`).
5
+ * After `node ace add @adonisjs/limiter`, uncomment `.use(authLimiter)`
6
+ * on sensitive auth routes and `.use(accountLimiter)` on the account
7
+ * group in start/routes/account.ts.
8
+ */
9
+ import limiter from '@adonisjs/limiter/services/main'
10
+
11
+ export const authLimiter = limiter.define('auth', () => {
12
+ return limiter.allowRequests(5).every('1 minute')
13
+ })
14
+
15
+ export const accountLimiter = limiter.define('account', () => {
16
+ return limiter.allowRequests(60).every('1 minute')
17
+ })
@@ -0,0 +1,48 @@
1
+ import vine from '@vinejs/vine'
2
+
3
+ export const registerValidator = vine.compile(
4
+ vine.object({
5
+ email: vine.string().email().normalizeEmail().unique({ table: 'users', column: 'email' }),
6
+ password: vine.string().validPassword().confirmed(),
7
+ firstName: vine.string().trim().maxLength(80).optional(),
8
+ lastName: vine.string().trim().maxLength(80).optional(),
9
+ })
10
+ )
11
+
12
+ export const loginValidator = vine.compile(
13
+ vine.object({
14
+ email: vine.string().email().normalizeEmail(),
15
+ password: vine.string(),
16
+ })
17
+ )
18
+
19
+ export const activateValidator = vine.compile(
20
+ vine.object({
21
+ token: vine.string(),
22
+ })
23
+ )
24
+
25
+ export const resendActivationValidator = vine.compile(
26
+ vine.object({
27
+ email: vine.string().email().normalizeEmail().exists({ table: 'users', column: 'email' }),
28
+ })
29
+ )
30
+
31
+ export const requestPasswordResetValidator = vine.compile(
32
+ vine.object({
33
+ email: vine.string().email().normalizeEmail().exists({ table: 'users', column: 'email' }),
34
+ })
35
+ )
36
+
37
+ export const validatePasswordResetValidator = vine.compile(
38
+ vine.object({
39
+ token: vine.string().validToken({ table: 'password_resets', column: 'token' }),
40
+ })
41
+ )
42
+
43
+ export const updatePasswordValidator = vine.compile(
44
+ vine.object({
45
+ token: vine.string().validToken({ table: 'password_resets', column: 'token' }),
46
+ password: vine.string().validPassword().confirmed(),
47
+ })
48
+ )
@@ -0,0 +1,21 @@
1
+ import vine from '@vinejs/vine'
2
+ import type { FieldContext } from '@vinejs/vine/types'
3
+
4
+ type Options = {
5
+ table: string
6
+ column: string
7
+ }
8
+
9
+ async function exists(value: unknown, options: Options, field: FieldContext) {
10
+ if (typeof value !== 'string') {
11
+ return
12
+ }
13
+
14
+ const db = (await import('@adonisjs/lucid/services/db')).default
15
+ const row = await db.from(options.table).where(options.column, value).first()
16
+ if (!row) {
17
+ field.report('The selected {{ field }} is invalid', 'exists', field)
18
+ }
19
+ }
20
+
21
+ export const existsRule = vine.createRule(exists)
@@ -0,0 +1,21 @@
1
+ import vine from '@vinejs/vine'
2
+ import type { FieldContext } from '@vinejs/vine/types'
3
+
4
+ type Options = {
5
+ table: string
6
+ column: string
7
+ }
8
+
9
+ async function unique(value: unknown, options: Options, field: FieldContext) {
10
+ if (typeof value !== 'string') {
11
+ return
12
+ }
13
+
14
+ const db = (await import('@adonisjs/lucid/services/db')).default
15
+ const row = await db.from(options.table).where(options.column, value).first()
16
+ if (row) {
17
+ field.report('The {{ field }} has already been taken', 'unique', field)
18
+ }
19
+ }
20
+
21
+ export const uniqueRule = vine.createRule(unique)
@@ -0,0 +1,20 @@
1
+ import vine from '@vinejs/vine'
2
+ import type { FieldContext } from '@vinejs/vine/types'
3
+
4
+ function validPassword(value: unknown, _options: undefined, field: FieldContext) {
5
+ if (typeof value !== 'string') {
6
+ return
7
+ }
8
+
9
+ const ok =
10
+ value.length >= 8 && /[A-Z]/.test(value) && /[a-z]/.test(value) && /[0-9]/.test(value)
11
+ if (!ok) {
12
+ field.report(
13
+ 'The {{ field }} must be at least 8 characters and contain uppercase, lowercase, and a number',
14
+ 'validPassword',
15
+ field
16
+ )
17
+ }
18
+ }
19
+
20
+ export const validPasswordRule = vine.createRule(validPassword)
@@ -0,0 +1,27 @@
1
+ import vine from '@vinejs/vine'
2
+ import type { FieldContext } from '@vinejs/vine/types'
3
+
4
+ type Options = {
5
+ table: string
6
+ column: string
7
+ }
8
+
9
+ async function validToken(value: unknown, options: Options, field: FieldContext) {
10
+ if (typeof value !== 'string') {
11
+ return
12
+ }
13
+
14
+ const db = (await import('@adonisjs/lucid/services/db')).default
15
+ const row = await db.from(options.table).where(options.column, value).first()
16
+ if (!row) {
17
+ field.report('The {{ field }} is invalid', 'validToken', field)
18
+ return
19
+ }
20
+
21
+ const expiresAt = (row as { expires_at?: string | Date | null }).expires_at
22
+ if (expiresAt && new Date(expiresAt).getTime() < Date.now()) {
23
+ field.report('The {{ field }} has expired', 'validToken', field)
24
+ }
25
+ }
26
+
27
+ export const validTokenRule = vine.createRule(validToken)
@@ -0,0 +1,10 @@
1
+ declare module '@vinejs/vine' {
2
+ interface VineString {
3
+ unique(options: { table: string; column: string }): this
4
+ exists(options: { table: string; column: string }): this
5
+ validToken(options: { table: string; column: string }): this
6
+ validPassword(): this
7
+ }
8
+ }
9
+
10
+ export {}
@@ -0,0 +1,145 @@
1
+ import { test } from '@japa/runner'
2
+ import assert from 'node:assert/strict'
3
+ import encryption from '@adonisjs/core/services/encryption'
4
+ import { AuthService } from '#modules/auth/service'
5
+ import { MemoryUserStore } from '#modules/auth/tests/fakes/memory_user_store'
6
+ import { MemoryPasswordResetStore } from '#modules/auth/tests/fakes/memory_password_reset_store'
7
+ import { MemorySessionManager } from '#modules/auth/tests/fakes/memory_session_manager'
8
+
9
+ function makeAuth() {
10
+ const users = new MemoryUserStore()
11
+ const passwordResets = new MemoryPasswordResetStore(users)
12
+ const service = new AuthService(users, passwordResets)
13
+ const emissions: Array<{ event: string; payload: any }> = []
14
+ ;(service as any).emitSafe = async (event: string, payload: any) => {
15
+ emissions.push({ event, payload })
16
+ }
17
+ return { service, users, passwordResets, emissions }
18
+ }
19
+
20
+ test.group('AuthService - register and activate', () => {
21
+ test('register issues a verification token', async () => {
22
+ const { service, users } = makeAuth()
23
+ const user = await service.registerUser({
24
+ email: 'a@test.com',
25
+ password: 'secret',
26
+ firstName: 'A',
27
+ })
28
+ assert.ok(user.emailVerificationToken)
29
+ assert.equal(users.users.get(user.id)?.emailVerificationToken, user.emailVerificationToken)
30
+ })
31
+
32
+ test('activate with a valid token verifies the user', async () => {
33
+ const { service } = makeAuth()
34
+ const registered = await service.registerUser({
35
+ email: 'a@test.com',
36
+ password: 'secret',
37
+ })
38
+ const token = registered.emailVerificationToken!
39
+ const verified = await service.activateUserAccount(token)
40
+ assert.ok(verified.emailVerifiedAt)
41
+ assert.equal(verified.emailVerificationToken, null)
42
+ })
43
+
44
+ test('activate with an expired token throws E_INVALID_TOKEN', async () => {
45
+ const { service, users } = makeAuth()
46
+ const registered = await service.registerUser({
47
+ email: 'a@test.com',
48
+ password: 'secret',
49
+ })
50
+ const expired = encryption.encrypt(registered.email, '-1 hours')
51
+ const stored = users.users.get(registered.id)!
52
+ stored.emailVerificationToken = expired
53
+ users.users.set(stored.id, stored)
54
+
55
+ await assert.rejects(
56
+ () => service.activateUserAccount(expired),
57
+ (err: any) => {
58
+ assert.equal(err.code, 'E_INVALID_TOKEN')
59
+ return true
60
+ }
61
+ )
62
+ })
63
+ })
64
+
65
+ test.group('AuthService - loginByEmail', () => {
66
+ test('login for an unverified user throws and has no side effects', async () => {
67
+ const { service, users, emissions } = makeAuth()
68
+ const registered = await service.registerUser({
69
+ email: 'a@test.com',
70
+ password: 'secret',
71
+ })
72
+ const savesBefore = users.saves.length
73
+ emissions.length = 0
74
+
75
+ const session = new MemorySessionManager()
76
+ await assert.rejects(
77
+ () => service.loginByEmail({ email: 'a@test.com', password: 'secret' }, session),
78
+ (err: any) => {
79
+ assert.equal(err.code, 'E_ACCOUNT_UNVERIFIED')
80
+ return true
81
+ }
82
+ )
83
+
84
+ assert.equal(users.saves.length, savesBefore)
85
+ assert.equal(emissions.length, 0)
86
+ assert.equal(session.logins.length, 0)
87
+ assert.equal(registered.emailVerifiedAt, null)
88
+ })
89
+
90
+ test('login returns user and session result', async () => {
91
+ const { service } = makeAuth()
92
+ const registered = await service.registerUser({
93
+ email: 'a@test.com',
94
+ password: 'secret',
95
+ })
96
+ await service.activateUserAccount(registered.emailVerificationToken!)
97
+
98
+ const session = new MemorySessionManager()
99
+ session.result = { token: 'tok', expiresAt: new Date('2030-01-01') }
100
+ const result = await service.loginByEmail({ email: 'a@test.com', password: 'secret' }, session)
101
+
102
+ assert.equal(result.user.email, 'a@test.com')
103
+ assert.equal(result.session.token, 'tok')
104
+ })
105
+ })
106
+
107
+ test.group('AuthService - password reset', () => {
108
+ test('request creates one row and deletes older rows', async () => {
109
+ const { service, users, passwordResets } = makeAuth()
110
+ const user = await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
111
+ await service.requestPasswordReset('a@test.com')
112
+ await service.requestPasswordReset('a@test.com')
113
+ assert.equal(passwordResets.rows.size, 1)
114
+ assert.equal([...passwordResets.rows.values()][0].userId, user.id)
115
+ assert.equal(passwordResets.createCalls.length, 2)
116
+ })
117
+
118
+ test('validate rotates the token', async () => {
119
+ const { service, users, passwordResets } = makeAuth()
120
+ await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
121
+ await service.requestPasswordReset('a@test.com')
122
+ const original = [...passwordResets.byToken.keys()][0]
123
+ const refreshed = await service.validatePasswordResetToken(original)
124
+ assert.notEqual(refreshed.token, original)
125
+ assert.equal(await passwordResets.findByToken(original), null)
126
+ })
127
+
128
+ test('update succeeds once and the token is dead afterward', async () => {
129
+ const { service, users, passwordResets } = makeAuth()
130
+ await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
131
+ await service.requestPasswordReset('a@test.com')
132
+ const token = [...passwordResets.byToken.keys()][0]
133
+
134
+ await service.updatePassword({ token, password: 'new-pass' })
135
+ assert.equal(await users.verifyPassword(1, 'new-pass'), true)
136
+
137
+ await assert.rejects(
138
+ () => service.updatePassword({ token, password: 'another' }),
139
+ (err: any) => {
140
+ assert.equal(err.code, 'E_INVALID_TOKEN')
141
+ return true
142
+ }
143
+ )
144
+ })
145
+ })
@@ -0,0 +1,99 @@
1
+ import { test } from '@japa/runner'
2
+ import assert from 'node:assert/strict'
3
+ import { AuthService } from '#modules/auth/service'
4
+ import type { User, UserStore } from '#modules/contracts/index'
5
+ import type {
6
+ AuthPasswordReset,
7
+ PasswordResetStore,
8
+ TokenDuration,
9
+ } from '#modules/auth/contracts/index'
10
+
11
+ function makeUser(overrides: Partial<User> = {}): User {
12
+ return {
13
+ id: 1,
14
+ email: 'user@test.com',
15
+ pendingEmail: null,
16
+ authMethod: 'email',
17
+ firstName: 'Test',
18
+ lastName: 'User',
19
+ avatarKey: null,
20
+ emailVerifiedAt: new Date(),
21
+ emailVerificationToken: null,
22
+ ...overrides,
23
+ }
24
+ }
25
+
26
+ function makeUserStore(overrides: Partial<UserStore> = {}): UserStore {
27
+ return {
28
+ createEmailUser: async () => makeUser(),
29
+ findOrFail: async () => makeUser(),
30
+ findByEmail: async () => null,
31
+ findByEmailOrFail: async () => makeUser(),
32
+ findByVerificationToken: async () => null,
33
+ verifyCredentials: async () => makeUser(),
34
+ verifyPassword: async () => true,
35
+ save: async (u) => u,
36
+ updatePassword: async (u) => u,
37
+ delete: async () => {},
38
+ ...overrides,
39
+ }
40
+ }
41
+
42
+ function makePasswordResetStore(seed?: AuthPasswordReset): PasswordResetStore {
43
+ const tokens = new Map<string, AuthPasswordReset>()
44
+ if (seed) {
45
+ tokens.set(seed.token, seed)
46
+ }
47
+
48
+ return {
49
+ async createForUser(userId: number, _duration: TokenDuration) {
50
+ const row: AuthPasswordReset = {
51
+ id: 1,
52
+ userId,
53
+ token: 'fresh',
54
+ isExpired: false,
55
+ }
56
+ tokens.set(row.token, row)
57
+ return row
58
+ },
59
+ async findByToken(token: string) {
60
+ return tokens.get(token) ?? null
61
+ },
62
+ async deleteForUser(userId: number) {
63
+ for (const [key, row] of tokens) {
64
+ if (row.userId === userId) {
65
+ tokens.delete(key)
66
+ }
67
+ }
68
+ },
69
+ async refreshToken(reset: AuthPasswordReset, _duration: TokenDuration) {
70
+ return reset
71
+ },
72
+ async findUserByReset(reset: AuthPasswordReset) {
73
+ return makeUser({ id: reset.userId })
74
+ },
75
+ }
76
+ }
77
+
78
+ test.group('AuthService - updatePassword token reuse', () => {
79
+ test('second updatePassword with same token throws E_INVALID_TOKEN', async () => {
80
+ const reset: AuthPasswordReset = {
81
+ id: 1,
82
+ userId: 1,
83
+ token: 'reuse-me',
84
+ isExpired: false,
85
+ }
86
+ const passwordResets = makePasswordResetStore(reset)
87
+ const service = new AuthService(makeUserStore(), passwordResets)
88
+
89
+ await service.updatePassword({ token: 'reuse-me', password: 'new-pass-1' })
90
+
91
+ await assert.rejects(
92
+ () => service.updatePassword({ token: 'reuse-me', password: 'new-pass-2' }),
93
+ (err: any) => {
94
+ assert.equal(err.code, 'E_INVALID_TOKEN')
95
+ return true
96
+ }
97
+ )
98
+ })
99
+ })
@@ -0,0 +1,3 @@
1
+ export { MemoryUserStore } from './memory_user_store.ts'
2
+ export { MemoryPasswordResetStore } from './memory_password_reset_store.ts'
3
+ export { MemorySessionManager } from './memory_session_manager.ts'
@@ -0,0 +1,71 @@
1
+ import type { User } from '#modules/contracts/index'
2
+ import type {
3
+ AuthPasswordReset,
4
+ PasswordResetStore,
5
+ TokenDuration,
6
+ } from '#modules/auth/contracts/index'
7
+ import type { MemoryUserStore } from './memory_user_store.ts'
8
+
9
+ /**
10
+ * In-memory PasswordResetStore. createForUser deletes prior rows for the user.
11
+ */
12
+ export class MemoryPasswordResetStore implements PasswordResetStore {
13
+ rows = new Map<number, AuthPasswordReset>()
14
+ byToken = new Map<string, AuthPasswordReset>()
15
+ nextId = 1
16
+ createCalls: number[] = []
17
+
18
+ constructor(private users?: MemoryUserStore) {}
19
+
20
+ async createForUser(userId: number, _duration: TokenDuration): Promise<AuthPasswordReset> {
21
+ this.createCalls.push(userId)
22
+ await this.deleteForUser(userId)
23
+ const row: AuthPasswordReset = {
24
+ id: this.nextId++,
25
+ userId,
26
+ token: `reset-${userId}-${this.nextId}`,
27
+ isExpired: false,
28
+ }
29
+ this.rows.set(row.id, row)
30
+ this.byToken.set(row.token, row)
31
+ return { ...row }
32
+ }
33
+
34
+ async findByToken(token: string): Promise<AuthPasswordReset | null> {
35
+ const row = this.byToken.get(token)
36
+ return row ? { ...row } : null
37
+ }
38
+
39
+ async deleteForUser(userId: number): Promise<void> {
40
+ for (const [id, row] of this.rows) {
41
+ if (row.userId === userId) {
42
+ this.rows.delete(id)
43
+ this.byToken.delete(row.token)
44
+ }
45
+ }
46
+ }
47
+
48
+ async refreshToken(
49
+ reset: AuthPasswordReset,
50
+ _duration: TokenDuration
51
+ ): Promise<AuthPasswordReset> {
52
+ const existing = this.rows.get(reset.id)
53
+ if (!existing) throw new Error('reset not found')
54
+ this.byToken.delete(existing.token)
55
+ const refreshed: AuthPasswordReset = {
56
+ ...existing,
57
+ token: `refreshed-${existing.id}`,
58
+ isExpired: false,
59
+ }
60
+ this.rows.set(refreshed.id, refreshed)
61
+ this.byToken.set(refreshed.token, refreshed)
62
+ return { ...refreshed }
63
+ }
64
+
65
+ async findUserByReset(reset: AuthPasswordReset): Promise<User> {
66
+ if (!this.users) {
67
+ throw new Error('MemoryPasswordResetStore requires a MemoryUserStore for findUserByReset')
68
+ }
69
+ return this.users.findOrFail(reset.userId)
70
+ }
71
+ }