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.
- package/README.md +64 -0
- package/build/adonis_peers.d.ts +14 -0
- package/build/adonis_peers.js +56 -0
- package/build/cli.d.ts +2 -0
- package/build/cli.js +111 -0
- package/build/commands/add.d.ts +18 -0
- package/build/commands/add.js +246 -0
- package/build/commands/check.d.ts +4 -0
- package/build/commands/check.js +159 -0
- package/build/commands/diff.d.ts +2 -0
- package/build/commands/diff.js +74 -0
- package/build/commands/init.d.ts +6 -0
- package/build/commands/init.js +124 -0
- package/build/commands/list.d.ts +2 -0
- package/build/commands/list.js +34 -0
- package/build/config_merge.d.ts +10 -0
- package/build/config_merge.js +71 -0
- package/build/constants_merge.d.ts +4 -0
- package/build/constants_merge.js +85 -0
- package/build/copy.d.ts +8 -0
- package/build/copy.js +33 -0
- package/build/host.d.ts +15 -0
- package/build/host.js +85 -0
- package/build/registry/index.d.ts +26 -0
- package/build/registry/index.js +116 -0
- package/build/registry/plan.d.ts +53 -0
- package/build/registry/plan.js +159 -0
- package/build/schema.d.ts +136 -0
- package/build/schema.js +58 -0
- package/build/types.d.ts +21 -0
- package/build/types.js +1 -0
- package/build/wire.d.ts +16 -0
- package/build/wire.js +162 -0
- package/package.json +51 -0
- package/registry/README.md +86 -0
- package/registry/account/contracts/avatar_storage.ts +7 -0
- package/registry/account/contracts/index.ts +7 -0
- package/registry/account/contracts/types.ts +23 -0
- package/registry/account/events.ts +14 -0
- package/registry/account/module.json +31 -0
- package/registry/account/options.ts +11 -0
- package/registry/account/service.ts +155 -0
- package/registry/account/stubs/adapters/account.ts +21 -0
- package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
- package/registry/account/stubs/controllers/account_controller.ts +190 -0
- package/registry/account/stubs/routes/account.ts +53 -0
- package/registry/account/stubs/validators/account.ts +37 -0
- package/registry/account/tests/account_service.spec.ts +132 -0
- package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
- package/registry/api/envelope.ts +66 -0
- package/registry/api/exception.ts +38 -0
- package/registry/api/exception_handler.ts +54 -0
- package/registry/api/module.json +27 -0
- package/registry/api/provider.ts +129 -0
- package/registry/api/service.ts +88 -0
- package/registry/api/tests/emit_safe.spec.ts +107 -0
- package/registry/api/tests/respond.spec.ts +40 -0
- package/registry/auth/contracts/index.ts +18 -0
- package/registry/auth/contracts/password_reset_store.ts +21 -0
- package/registry/auth/contracts/session_manager.ts +19 -0
- package/registry/auth/contracts/types.ts +32 -0
- package/registry/auth/events.ts +21 -0
- package/registry/auth/module.json +45 -0
- package/registry/auth/options.ts +23 -0
- package/registry/auth/service.ts +150 -0
- package/registry/auth/stubs/adapters/auth.ts +21 -0
- package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
- package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
- package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
- package/registry/auth/stubs/adapters/map_user.ts +19 -0
- package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
- package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
- package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
- package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
- package/registry/auth/stubs/models/password_reset.ts +29 -0
- package/registry/auth/stubs/models/user.ts +99 -0
- package/registry/auth/stubs/providers/vine_provider.ts +43 -0
- package/registry/auth/stubs/routes/auth.ts +36 -0
- package/registry/auth/stubs/start/limiter.ts +17 -0
- package/registry/auth/stubs/validators/auth.ts +48 -0
- package/registry/auth/stubs/validators/rules/exists.ts +21 -0
- package/registry/auth/stubs/validators/rules/unique.ts +21 -0
- package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
- package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
- package/registry/auth/stubs/validators/vine.d.ts +10 -0
- package/registry/auth/tests/auth_service.spec.ts +145 -0
- package/registry/auth/tests/auth_update_password.spec.ts +99 -0
- package/registry/auth/tests/fakes/index.ts +3 -0
- package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
- package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
- package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
- package/registry/constants/exceptions.ts +64 -0
- package/registry/constants/index.ts +12 -0
- package/registry/constants/responseCodes.ts +87 -0
- package/registry/contracts/index.ts +5 -0
- package/registry/contracts/user.ts +78 -0
- package/registry/creem/contracts/creem_client.ts +34 -0
- package/registry/creem/contracts/index.ts +8 -0
- package/registry/creem/contracts/types.ts +30 -0
- package/registry/creem/events.ts +9 -0
- package/registry/creem/module.json +28 -0
- package/registry/creem/service.ts +87 -0
- package/registry/creem/stubs/adapters/creem.ts +23 -0
- package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
- package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
- package/registry/notification/contracts/app_notification_store.ts +8 -0
- package/registry/notification/contracts/index.ts +9 -0
- package/registry/notification/contracts/mail_transport.ts +7 -0
- package/registry/notification/contracts/types.ts +32 -0
- package/registry/notification/module.json +23 -0
- package/registry/notification/options.ts +11 -0
- package/registry/notification/service.ts +103 -0
- package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
- package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
- package/registry/notification/stubs/adapters/notification.ts +36 -0
- package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
- package/registry/notification/stubs/models/app_notification.ts +45 -0
- package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
- package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
- package/registry/notification/tests/notification_service.spec.ts +63 -0
- package/registry/registry.json +4 -0
- package/registry/subscription/contracts/index.ts +9 -0
- package/registry/subscription/contracts/subscription_store.ts +15 -0
- package/registry/subscription/contracts/types.ts +39 -0
- package/registry/subscription/events.ts +13 -0
- package/registry/subscription/module.json +31 -0
- package/registry/subscription/options.ts +14 -0
- package/registry/subscription/service.ts +140 -0
- package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
- package/registry/subscription/stubs/adapters/subscription.ts +21 -0
- package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
- package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
- package/registry/subscription/stubs/models/subscription.ts +45 -0
- package/registry/subscription/tests/billing_service.spec.ts +189 -0
- package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
- package/registry/types/api.ts +41 -0
- package/registry/types/constants.ts +7 -0
- package/registry/types/index.ts +6 -0
- package/registry/types/models.ts +32 -0
- 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
|
+
}
|