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,190 @@
1
+ /**
2
+ * Example account controller. Copy into app/controllers.
3
+ *
4
+ * Copy Vine provider (`providers/vine_provider.ts`) and register it in
5
+ * adonisrc.ts so unique/validPassword macros load (same provider as auth).
6
+ * Authenticated routes need named auth middleware from start/kernel.ts.
7
+ * Inbox methods need `#models/app_notification` from
8
+ * `adonia add notification --with-models`.
9
+ */
10
+ import type { HttpContext } from '@adonisjs/core/http'
11
+ import { DateTime } from 'luxon'
12
+ import { createAccountService } from '#adapters/account'
13
+ import { responseCodes } from '#constants/responseCodes'
14
+ import User from '#models/user'
15
+ import AppNotification from '#models/app_notification'
16
+ import {
17
+ updateProfileValidator,
18
+ replaceEmailValidator,
19
+ replacePasswordValidator,
20
+ confirmEmailChangeValidator,
21
+ updateNotificationsValidator,
22
+ } from '../validators/account.ts'
23
+
24
+ const account = createAccountService()
25
+
26
+ export default class AccountController {
27
+ async getAuthenticatedUser(ctx: HttpContext) {
28
+ const user = ctx.auth.getUserOrFail()
29
+ return ctx.response.ok(
30
+ await ctx.respond({
31
+ success: true,
32
+ message: responseCodes.USER_SHOW.code,
33
+ data: user,
34
+ })
35
+ )
36
+ }
37
+
38
+ async updateProfile(ctx: HttpContext) {
39
+ const user = ctx.auth.getUserOrFail()
40
+ const data = await ctx.request.validateUsing(updateProfileValidator)
41
+ const updated = await account.updateProfile(user.id, data)
42
+ return ctx.response.ok(
43
+ await ctx.respond({
44
+ success: true,
45
+ message: responseCodes.PROFILE_UPDATED.code,
46
+ data: updated,
47
+ })
48
+ )
49
+ }
50
+
51
+ async replaceEmail(ctx: HttpContext) {
52
+ const user = ctx.auth.getUserOrFail()
53
+ const data = await ctx.request.validateUsing(replaceEmailValidator)
54
+ const updated = await account.replaceEmail(user.id, data)
55
+ return ctx.response.ok(
56
+ await ctx.respond({
57
+ success: true,
58
+ message: responseCodes.EMAIL_VERIFICATION_SENT.code,
59
+ data: updated,
60
+ })
61
+ )
62
+ }
63
+
64
+ async confirmEmailChange(ctx: HttpContext) {
65
+ const { token } = await ctx.request.validateUsing(confirmEmailChangeValidator)
66
+ const user = await account.confirmEmailChange(token)
67
+ return ctx.response.ok(
68
+ await ctx.respond({
69
+ success: true,
70
+ message: responseCodes.EMAIL_CHANGE_CONFIRMED.code,
71
+ data: user,
72
+ })
73
+ )
74
+ }
75
+
76
+ async replacePassword(ctx: HttpContext) {
77
+ const user = ctx.auth.getUserOrFail()
78
+ const data = await ctx.request.validateUsing(replacePasswordValidator)
79
+ await account.replacePassword(user.id, data)
80
+ return ctx.response.ok(
81
+ await ctx.respond({
82
+ success: true,
83
+ message: responseCodes.PASSWORD_UPDATED.code,
84
+ data: null,
85
+ })
86
+ )
87
+ }
88
+
89
+ async deleteAccount(ctx: HttpContext) {
90
+ const user = ctx.auth.getUserOrFail()
91
+ await account.deleteAccount(user.id)
92
+ return ctx.response.ok(
93
+ await ctx.respond({
94
+ success: true,
95
+ message: responseCodes.ACCOUNT_DELETED.code,
96
+ data: null,
97
+ })
98
+ )
99
+ }
100
+
101
+ async getNotifications(ctx: HttpContext) {
102
+ const authUser = ctx.auth.getUserOrFail()
103
+ const user = await User.findOrFail(authUser.id)
104
+ return ctx.response.ok(
105
+ await ctx.respond({
106
+ success: true,
107
+ message: responseCodes.NOTIFICATIONS_FETCHED.code,
108
+ data: {
109
+ emailMarketing: user.emailMarketing,
110
+ emailProductUpdates: user.emailProductUpdates,
111
+ emailSecurityAlerts: user.emailSecurityAlerts,
112
+ emailWeeklyDigest: user.emailWeeklyDigest,
113
+ },
114
+ })
115
+ )
116
+ }
117
+
118
+ async updateNotifications(ctx: HttpContext) {
119
+ const authUser = ctx.auth.getUserOrFail()
120
+ const data = await ctx.request.validateUsing(updateNotificationsValidator)
121
+ const user = await User.findOrFail(authUser.id)
122
+ if (data.emailMarketing !== undefined) user.emailMarketing = data.emailMarketing
123
+ if (data.emailProductUpdates !== undefined) {
124
+ user.emailProductUpdates = data.emailProductUpdates
125
+ }
126
+ if (data.emailSecurityAlerts !== undefined) {
127
+ user.emailSecurityAlerts = data.emailSecurityAlerts
128
+ }
129
+ if (data.emailWeeklyDigest !== undefined) user.emailWeeklyDigest = data.emailWeeklyDigest
130
+ await user.save()
131
+ return ctx.response.ok(
132
+ await ctx.respond({
133
+ success: true,
134
+ message: responseCodes.NOTIFICATIONS_UPDATED.code,
135
+ data: {
136
+ emailMarketing: user.emailMarketing,
137
+ emailProductUpdates: user.emailProductUpdates,
138
+ emailSecurityAlerts: user.emailSecurityAlerts,
139
+ emailWeeklyDigest: user.emailWeeklyDigest,
140
+ },
141
+ })
142
+ )
143
+ }
144
+
145
+ async getInboxNotifications(ctx: HttpContext) {
146
+ const user = ctx.auth.getUserOrFail()
147
+ const rows = await AppNotification.query()
148
+ .where('userId', user.id)
149
+ .orderBy('createdAt', 'desc')
150
+ return ctx.response.ok(
151
+ await ctx.respond({
152
+ success: true,
153
+ message: responseCodes.INBOX_FETCHED.code,
154
+ data: rows,
155
+ })
156
+ )
157
+ }
158
+
159
+ async markAllNotificationsRead(ctx: HttpContext) {
160
+ const user = ctx.auth.getUserOrFail()
161
+ await AppNotification.query()
162
+ .where('userId', user.id)
163
+ .whereNull('readAt')
164
+ .update({ readAt: DateTime.now().toSQL() })
165
+ return ctx.response.ok(
166
+ await ctx.respond({
167
+ success: true,
168
+ message: responseCodes.INBOX_MARKED_ALL_READ.code,
169
+ data: null,
170
+ })
171
+ )
172
+ }
173
+
174
+ async markNotificationRead(ctx: HttpContext) {
175
+ const user = ctx.auth.getUserOrFail()
176
+ const notification = await AppNotification.query()
177
+ .where('id', ctx.params.id)
178
+ .where('userId', user.id)
179
+ .firstOrFail()
180
+ notification.readAt = DateTime.now()
181
+ await notification.save()
182
+ return ctx.response.ok(
183
+ await ctx.respond({
184
+ success: true,
185
+ message: responseCodes.INBOX_MARKED_READ.code,
186
+ data: notification,
187
+ })
188
+ )
189
+ }
190
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Example account routes. Requires controllers (and typically validators)
3
+ * installed via `adonia add account --with-controllers --with-validators`
4
+ * or `--with-stubs`.
5
+ *
6
+ * Copy Vine provider (`providers/vine_provider.ts`) and register it in
7
+ * adonisrc.ts. Authenticated routes need named auth middleware from
8
+ * start/kernel.ts. POST email/confirm is public (token in the body).
9
+ * Inbox routes need `adonia add notification --with-models`.
10
+ *
11
+ * Account routes should use accountLimiter (60/min). Install
12
+ * @adonisjs/limiter, copy stubs/start/limiter.ts, then uncomment
13
+ * `.use(accountLimiter)` below.
14
+ */
15
+ import router from '@adonisjs/core/services/router'
16
+ // import { middleware } from '#start/kernel'
17
+ // import { accountLimiter } from '#start/limiter'
18
+
19
+ const AccountController = () => import('#controllers/account_controller')
20
+
21
+ router
22
+ .group(() => {
23
+ router.get('user', [AccountController, 'getAuthenticatedUser'])
24
+ // .use(middleware.auth())
25
+ })
26
+ .prefix('/api/v1')
27
+ .as('adonia.user')
28
+
29
+ router
30
+ .group(() => {
31
+ router.patch('profile', [AccountController, 'updateProfile'])
32
+ // .use(middleware.auth())
33
+ router.post('email', [AccountController, 'replaceEmail'])
34
+ // .use(middleware.auth())
35
+ router.post('email/confirm', [AccountController, 'confirmEmailChange'])
36
+ router.post('password', [AccountController, 'replacePassword'])
37
+ // .use(middleware.auth())
38
+ router.get('notifications', [AccountController, 'getNotifications'])
39
+ // .use(middleware.auth())
40
+ router.put('notifications', [AccountController, 'updateNotifications'])
41
+ // .use(middleware.auth())
42
+ router.delete('/', [AccountController, 'deleteAccount'])
43
+ // .use(middleware.auth())
44
+ router.get('inbox', [AccountController, 'getInboxNotifications'])
45
+ // .use(middleware.auth())
46
+ router.post('inbox/read-all', [AccountController, 'markAllNotificationsRead'])
47
+ // .use(middleware.auth())
48
+ router.patch('inbox/:id', [AccountController, 'markNotificationRead'])
49
+ // .use(middleware.auth())
50
+ })
51
+ .prefix('/api/v1/account')
52
+ .as('adonia.account')
53
+ // .use(accountLimiter)
@@ -0,0 +1,37 @@
1
+ import vine from '@vinejs/vine'
2
+
3
+ export const updateProfileValidator = vine.compile(
4
+ vine.object({
5
+ firstName: vine.string().trim().maxLength(80).optional(),
6
+ lastName: vine.string().trim().maxLength(80).optional(),
7
+ avatarKey: vine.string().nullable().optional(),
8
+ })
9
+ )
10
+
11
+ export const replaceEmailValidator = vine.compile(
12
+ vine.object({
13
+ email: vine.string().email().normalizeEmail().unique({ table: 'users', column: 'email' }),
14
+ })
15
+ )
16
+
17
+ export const confirmEmailChangeValidator = vine.compile(
18
+ vine.object({
19
+ token: vine.string(),
20
+ })
21
+ )
22
+
23
+ export const replacePasswordValidator = vine.compile(
24
+ vine.object({
25
+ currentPassword: vine.string(),
26
+ newPassword: vine.string().validPassword().confirmed(),
27
+ })
28
+ )
29
+
30
+ export const updateNotificationsValidator = vine.compile(
31
+ vine.object({
32
+ emailMarketing: vine.boolean().optional(),
33
+ emailProductUpdates: vine.boolean().optional(),
34
+ emailSecurityAlerts: vine.boolean().optional(),
35
+ emailWeeklyDigest: vine.boolean().optional(),
36
+ })
37
+ )
@@ -0,0 +1,132 @@
1
+ import { test } from '@japa/runner'
2
+ import assert from 'node:assert/strict'
3
+ import encryption from '@adonisjs/core/services/encryption'
4
+ import { defaultAccountOptions } from '#modules/account/options'
5
+ import { AccountService } from '#modules/account/service'
6
+ import { AuthService } from '#modules/auth/service'
7
+ import { MemoryUserStore } from '#modules/auth/tests/fakes/memory_user_store'
8
+ import { MemoryPasswordResetStore } from '#modules/auth/tests/fakes/memory_password_reset_store'
9
+ import { MemorySessionManager } from '#modules/auth/tests/fakes/memory_session_manager'
10
+ import { MemoryAvatarStorage } from '#modules/account/tests/fakes/memory_avatar_storage'
11
+
12
+ function makeAccount(users = new MemoryUserStore()) {
13
+ const avatars = new MemoryAvatarStorage()
14
+ const service = new AccountService(users, avatars)
15
+ const emissions: Array<{ event: string; payload: any }> = []
16
+ ;(service as any).emitSafe = async (event: string, payload: any) => {
17
+ emissions.push({ event, payload })
18
+ }
19
+ return { service, users, avatars, emissions }
20
+ }
21
+
22
+ test.group('AccountService - email change', () => {
23
+ test('after replaceEmail, login with the old address still succeeds', async () => {
24
+ const users = new MemoryUserStore()
25
+ const { service } = makeAccount(users)
26
+ const auth = new AuthService(users, new MemoryPasswordResetStore(users))
27
+ ;(auth as any).emitSafe = async () => {}
28
+
29
+ let user = await users.createEmailUser({ email: 'old@test.com', password: 'secret' })
30
+ user = await users.save({
31
+ ...user,
32
+ emailVerifiedAt: new Date(),
33
+ emailVerificationToken: null,
34
+ })
35
+
36
+ await service.replaceEmail(user.id, { email: 'new@test.com' })
37
+ const stored = await users.findOrFail(user.id)
38
+ assert.equal(stored.email, 'old@test.com')
39
+ assert.equal(stored.pendingEmail, 'new@test.com')
40
+
41
+ const session = new MemorySessionManager()
42
+ const result = await auth.loginByEmail(
43
+ { email: 'old@test.com', password: 'secret' },
44
+ session
45
+ )
46
+ assert.equal(result.user.email, 'old@test.com')
47
+ })
48
+
49
+ test('confirmEmailChange with mismatched token throws and leaves emails unchanged', async () => {
50
+ const { service, users } = makeAccount()
51
+ let user = await users.createEmailUser({ email: 'old@test.com', password: 'secret' })
52
+ user = await users.save({ ...user, emailVerifiedAt: new Date() })
53
+ await service.replaceEmail(user.id, { email: 'new@test.com' })
54
+
55
+ await assert.rejects(
56
+ () =>
57
+ service.confirmEmailChange(
58
+ encryption.encrypt('wrong@test.com', defaultAccountOptions.emailChangeTtl)
59
+ ),
60
+ (err: any) => {
61
+ assert.equal(err.code, 'E_INVALID_TOKEN')
62
+ return true
63
+ }
64
+ )
65
+
66
+ const stored = await users.findOrFail(user.id)
67
+ assert.equal(stored.email, 'old@test.com')
68
+ assert.equal(stored.pendingEmail, 'new@test.com')
69
+ })
70
+
71
+ test('confirmEmailChange rejects if another user owns pendingEmail', async () => {
72
+ const { service, users } = makeAccount()
73
+
74
+ let user = await users.createEmailUser({ email: 'old@test.com', password: 'secret' })
75
+ user = await users.save({ ...user, emailVerifiedAt: new Date() })
76
+ const updated = await service.replaceEmail(user.id, { email: 'soon-taken@test.com' })
77
+
78
+ // Race: address claimed after the change was requested
79
+ await users.createEmailUser({ email: 'soon-taken@test.com', password: 'x' })
80
+
81
+ await assert.rejects(
82
+ () => service.confirmEmailChange(updated.emailVerificationToken!),
83
+ (err: any) => {
84
+ assert.equal(err.code, 'E_EMAIL_EXISTS')
85
+ return true
86
+ }
87
+ )
88
+ })
89
+
90
+ test('confirmEmailChange swaps pendingEmail into email', async () => {
91
+ const { service, users, emissions } = makeAccount()
92
+ let user = await users.createEmailUser({ email: 'old@test.com', password: 'secret' })
93
+ user = await users.save({ ...user, emailVerifiedAt: new Date() })
94
+ const pending = await service.replaceEmail(user.id, { email: 'new@test.com' })
95
+ const confirmed = await service.confirmEmailChange(pending.emailVerificationToken!)
96
+
97
+ assert.equal(confirmed.email, 'new@test.com')
98
+ assert.equal(confirmed.pendingEmail, null)
99
+ assert.ok(confirmed.emailVerifiedAt)
100
+ assert.ok(emissions.some((e) => e.event === 'Account:ConfirmEmailChange'))
101
+ })
102
+ })
103
+
104
+ test.group('AccountService - password and delete', () => {
105
+ test('replacePassword with wrong current password throws E_INVALID_PASSWORD', async () => {
106
+ const { service, users } = makeAccount()
107
+ const user = await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
108
+
109
+ await assert.rejects(
110
+ () =>
111
+ service.replacePassword(user.id, {
112
+ currentPassword: 'wrong',
113
+ newPassword: 'new-secret',
114
+ }),
115
+ (err: any) => {
116
+ assert.equal(err.code, 'E_INVALID_PASSWORD')
117
+ return true
118
+ }
119
+ )
120
+ })
121
+
122
+ test('deleteAccount removes the avatar and the user', async () => {
123
+ const { service, users, avatars } = makeAccount()
124
+ let user = await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
125
+ user = await users.save({ ...user, avatarKey: 'avatars/1.png' })
126
+
127
+ await service.deleteAccount(user.id)
128
+
129
+ assert.deepEqual(avatars.deleted, ['avatars/1.png'])
130
+ assert.equal(users.users.has(user.id), false)
131
+ })
132
+ })
@@ -0,0 +1,12 @@
1
+ import type { AvatarStorage } from '#modules/account/contracts/index'
2
+
3
+ /**
4
+ * In-memory AvatarStorage that records deleted keys.
5
+ */
6
+ export class MemoryAvatarStorage implements AvatarStorage {
7
+ deleted: string[] = []
8
+
9
+ async delete(key: string): Promise<void> {
10
+ this.deleted.push(key)
11
+ }
12
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Runtime helpers for the shared API envelope.
3
+ * Types live in modules/types/api.ts.
4
+ */
5
+ import type {
6
+ ApiErrorBody,
7
+ ApiResponse,
8
+ ApiResult,
9
+ PaginatedResponse,
10
+ } from '#modules/types/api'
11
+
12
+ /**
13
+ * Build the shared failure envelope used by ApiService and the exception handler.
14
+ */
15
+ export function prepareError(message: string, status?: number): ApiErrorBody {
16
+ return {
17
+ success: false,
18
+ message,
19
+ ...(status !== undefined && { status }),
20
+ }
21
+ }
22
+
23
+ export function isApiError(
24
+ result: unknown
25
+ ): result is Extract<ApiResult<never>, { success: false }> {
26
+ return (
27
+ typeof result === 'object' &&
28
+ result !== null &&
29
+ 'success' in result &&
30
+ (result as { success: unknown }).success === false &&
31
+ 'message' in result
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Paginated envelopes carry `meta`. Checked before isApiResponse so a
37
+ * PaginatedResponse is not treated as a plain ApiResponse.
38
+ */
39
+ export function isPaginatedResponse(result: unknown): result is PaginatedResponse<unknown> {
40
+ return (
41
+ typeof result === 'object' &&
42
+ result !== null &&
43
+ 'success' in result &&
44
+ (result as { success: unknown }).success === true &&
45
+ 'data' in result &&
46
+ 'message' in result &&
47
+ 'meta' in result &&
48
+ typeof (result as { meta: unknown }).meta === 'object' &&
49
+ (result as { meta: unknown }).meta !== null &&
50
+ 'total' in (result as { meta: object }).meta &&
51
+ 'page' in (result as { meta: object }).meta &&
52
+ 'pageSize' in (result as { meta: object }).meta &&
53
+ 'totalPages' in (result as { meta: object }).meta
54
+ )
55
+ }
56
+
57
+ export function isApiResponse(result: unknown): result is ApiResponse<unknown> {
58
+ return (
59
+ typeof result === 'object' &&
60
+ result !== null &&
61
+ 'success' in result &&
62
+ (result as { success: unknown }).success === true &&
63
+ 'data' in result &&
64
+ 'message' in result
65
+ )
66
+ }
@@ -0,0 +1,38 @@
1
+ import { Exception } from '@adonisjs/core/exceptions'
2
+ import type { StatusCodeEntry } from '#modules/types/constants'
3
+
4
+ type ApiExceptionOptions = {
5
+ status?: number
6
+ code?: string
7
+ message?: string
8
+ cause?: unknown
9
+ }
10
+
11
+ /**
12
+ * Domain exception carrier. No custom handle() — the API exception handler
13
+ * formats every error (framework + ApiException) through one JSON envelope.
14
+ *
15
+ * @example
16
+ * throw new ApiException('E_INVALID_TOKEN', { status: 400 })
17
+ * throw ApiException.from(exceptions.INVALID_TOKEN)
18
+ */
19
+ export default class ApiException extends Exception {
20
+ constructor(code: string, options: ApiExceptionOptions = {}) {
21
+ super(options.message ?? code, {
22
+ status: options.status ?? 400,
23
+ code,
24
+ cause: options.cause,
25
+ })
26
+ }
27
+
28
+ /**
29
+ * Build from a modules/constants exceptions entry.
30
+ */
31
+ static from(entry: StatusCodeEntry, cause?: unknown) {
32
+ return new ApiException(entry.code, {
33
+ status: entry.status,
34
+ code: entry.code,
35
+ cause,
36
+ })
37
+ }
38
+ }
@@ -0,0 +1,54 @@
1
+ import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
2
+ import { prepareError } from '#modules/api/envelope'
3
+
4
+ type HttpError = Parameters<ExceptionHandler['renderErrorAsJSON']>[0]
5
+
6
+ /**
7
+ * Global API exception handler.
8
+ *
9
+ * Overrides JSON renderers so Adonis package errors, validation errors, and
10
+ * AppException all share one envelope — no per-package instanceof branches.
11
+ */
12
+ export default class HttpExceptionHandler extends ExceptionHandler {
13
+ /**
14
+ * Youch debug payloads are unused for this API (force_json middleware).
15
+ * Always emit the envelope so clients get a stable shape in every environment.
16
+ */
17
+ protected debug = false
18
+
19
+ /**
20
+ * Client errors are expected; skip noisy logs for common statuses.
21
+ */
22
+ protected ignoreStatuses = [400, 401, 403, 404, 422]
23
+
24
+ async handle(error: unknown, ctx: HttpContext) {
25
+ return super.handle(error, ctx)
26
+ }
27
+
28
+ async report(error: unknown, ctx: HttpContext) {
29
+ return super.report(error, ctx)
30
+ }
31
+
32
+ /**
33
+ * All non-validation errors that accept JSON land here
34
+ * (auth, limiter, route not found, AppException, unknown, …).
35
+ */
36
+ async renderErrorAsJSON(error: HttpError, ctx: HttpContext): Promise<void> {
37
+ const message = error.code ?? error.message
38
+ ctx.response.status(error.status).json(prepareError(message, error.status))
39
+ }
40
+
41
+ /**
42
+ * Vine validation is the one structured special case (field messages[]).
43
+ */
44
+ async renderValidationErrorAsJSON(error: HttpError, ctx: HttpContext): Promise<void> {
45
+ const messages = error.messages as Array<{ message?: string }> | undefined
46
+ const firstMessage = messages?.[0]?.message
47
+ const message = firstMessage ?? error.code ?? error.message
48
+
49
+ ctx.response.status(error.status).json({
50
+ ...prepareError(message, error.status),
51
+ errors: error.messages,
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "api",
3
+ "description": "Shared ApiService, envelopes, exception handler, and HttpContext.respond provider",
4
+ "files": [
5
+ "service.ts",
6
+ "envelope.ts",
7
+ "exception.ts",
8
+ "exception_handler.ts",
9
+ "provider.ts",
10
+ "module.json"
11
+ ],
12
+ "stubs": [],
13
+ "dependencies": {},
14
+ "registryDependencies": [
15
+ "types",
16
+ "constants",
17
+ "contracts"
18
+ ],
19
+ "peerModels": [],
20
+ "configKeys": [],
21
+ "env": [],
22
+ "events": [],
23
+ "hostFacingExceptions": [],
24
+ "hostTests": [
25
+ "tests/integration/"
26
+ ]
27
+ }