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
package/build/wire.js ADDED
@@ -0,0 +1,162 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ const REQUIRED_IMPORTS = {
4
+ '#modules/*': './modules/*.js',
5
+ '#modules/types': './modules/types/index.js',
6
+ '#modules/contracts': './modules/contracts/index.js',
7
+ '#constants': './modules/constants/index.js',
8
+ '#constants/*': './modules/constants/*.js',
9
+ '#adapters/*': './app/adapters/*.js',
10
+ };
11
+ const EMPTY_MODULES_CONFIG = `const modulesConfig: Record<string, { description: string; emits: string[] }> = {}
12
+
13
+ export default modulesConfig
14
+ `;
15
+ const EXCEPTION_HANDLER_REEXPORT = `export { default } from '#modules/api/exception_handler'
16
+ `;
17
+ export function printWireChecklist() {
18
+ console.log(`
19
+ Host wiring checklist (run \`init --wire\` to apply automatically):
20
+ 1. Merge #modules/*, #modules/types, #modules/contracts,
21
+ #constants, #constants/*, #adapters/* into package.json "imports".
22
+ 2. Append () => import('#modules/api/provider') to adonisrc.ts providers.
23
+ 3. Set server.errorHandler(() => import('#modules/api/exception_handler')) in start/kernel.ts.
24
+ 4. Create app/exceptions/handler.ts re-export of #modules/api/exception_handler.
25
+ 5. Create config/modules.ts with an empty Record if missing.
26
+ `);
27
+ }
28
+ export function applyWire(cwd, dryRun = false) {
29
+ const actions = [];
30
+ actions.push(...mergePackageImports(cwd, dryRun));
31
+ actions.push(...ensureProvider(cwd, dryRun));
32
+ actions.push(...ensureErrorHandler(cwd, dryRun));
33
+ actions.push(...ensureExceptionHandlerFile(cwd, dryRun));
34
+ actions.push(...ensureModulesConfig(cwd, dryRun));
35
+ return actions;
36
+ }
37
+ function mergePackageImports(cwd, dryRun) {
38
+ const pkgPath = join(cwd, 'package.json');
39
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
40
+ pkg.imports ??= {};
41
+ const added = [];
42
+ for (const [key, value] of Object.entries(REQUIRED_IMPORTS)) {
43
+ if (pkg.imports[key] === value)
44
+ continue;
45
+ if (!(key in pkg.imports)) {
46
+ pkg.imports[key] = value;
47
+ added.push(key);
48
+ }
49
+ else if (pkg.imports[key] !== value) {
50
+ // leave host override; only fill missing
51
+ continue;
52
+ }
53
+ }
54
+ if (added.length && !dryRun) {
55
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
56
+ }
57
+ return added.map((k) => `imports: ${k}`);
58
+ }
59
+ function ensureProvider(cwd, dryRun) {
60
+ const path = join(cwd, 'adonisrc.ts');
61
+ if (!existsSync(path))
62
+ return [];
63
+ let content = readFileSync(path, 'utf8');
64
+ if (content.includes('#modules/api/provider'))
65
+ return [];
66
+ const providerLine = ` () => import('#modules/api/provider'),`;
67
+ if (/providers:\s*\[/.test(content)) {
68
+ content = content.replace(/providers:\s*\[/, (m) => `${m}\n${providerLine}`);
69
+ }
70
+ else {
71
+ content += `\nexport const providers = [\n${providerLine}\n]\n`;
72
+ }
73
+ if (!dryRun)
74
+ writeFileSync(path, content);
75
+ return ['adonisrc.ts providers'];
76
+ }
77
+ function ensureErrorHandler(cwd, dryRun) {
78
+ const path = join(cwd, 'start/kernel.ts');
79
+ if (!existsSync(path)) {
80
+ if (!dryRun) {
81
+ mkdirSync(dirname(path), { recursive: true });
82
+ writeFileSync(path, `import server from '@adonisjs/core/services/server'\n\nserver.errorHandler(() => import('#modules/api/exception_handler'))\n`);
83
+ }
84
+ return ['start/kernel.ts (created)'];
85
+ }
86
+ const original = readFileSync(path, 'utf8');
87
+ if (original.includes('#modules/api/exception_handler'))
88
+ return [];
89
+ // Decide before appending the errorHandler line — that line contains
90
+ // `server.` and would otherwise hide a missing import.
91
+ const needsServerImport = !original.includes("from '@adonisjs/core/services/server'") &&
92
+ !original.includes('server.');
93
+ let content = original + `\nserver.errorHandler(() => import('#modules/api/exception_handler'))\n`;
94
+ if (needsServerImport) {
95
+ content = `import server from '@adonisjs/core/services/server'\n${content}`;
96
+ }
97
+ if (!dryRun)
98
+ writeFileSync(path, content);
99
+ return ['start/kernel.ts errorHandler'];
100
+ }
101
+ function ensureExceptionHandlerFile(cwd, dryRun) {
102
+ const path = join(cwd, 'app/exceptions/handler.ts');
103
+ if (existsSync(path)) {
104
+ const content = readFileSync(path, 'utf8');
105
+ const isDefault = content.includes('@adonisjs/core/exceptions') ||
106
+ content.trim().length < 40 ||
107
+ /export default class HttpExceptionHandler/.test(content);
108
+ if (!isDefault && content.includes('#modules/api/exception_handler'))
109
+ return [];
110
+ if (!isDefault)
111
+ return [];
112
+ }
113
+ if (!dryRun) {
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ writeFileSync(path, EXCEPTION_HANDLER_REEXPORT);
116
+ }
117
+ return ['app/exceptions/handler.ts'];
118
+ }
119
+ function ensureModulesConfig(cwd, dryRun) {
120
+ const path = join(cwd, 'config/modules.ts');
121
+ if (existsSync(path))
122
+ return [];
123
+ if (!dryRun) {
124
+ mkdirSync(dirname(path), { recursive: true });
125
+ writeFileSync(path, EMPTY_MODULES_CONFIG);
126
+ }
127
+ return ['config/modules.ts'];
128
+ }
129
+ export { REQUIRED_IMPORTS, EMPTY_MODULES_CONFIG };
130
+ /**
131
+ * Append a side-effect import of `start/routes/<module>.ts` into
132
+ * `start/routes.ts`. Idempotent. Does not create the module route file.
133
+ */
134
+ export function wireModuleRoute(cwd, moduleName, dryRun = false) {
135
+ const routeFile = join(cwd, 'start/routes', `${moduleName}.ts`);
136
+ if (!existsSync(routeFile)) {
137
+ return {
138
+ ok: false,
139
+ error: `Cannot --wire-routes for "${moduleName}": missing start/routes/${moduleName}.ts. ` +
140
+ `Pass --with-routes first, or create the file.`,
141
+ };
142
+ }
143
+ const routesPath = join(cwd, 'start/routes.ts');
144
+ const importRelative = `./routes/${moduleName}.js`;
145
+ const importLine = `import '${importRelative}'`;
146
+ let content = existsSync(routesPath) ? readFileSync(routesPath, 'utf8') : '';
147
+ if (content.includes(`'${importRelative}'`) ||
148
+ content.includes(`"${importRelative}"`) ||
149
+ content.includes(`'#start/routes/${moduleName}'`) ||
150
+ content.includes(`"#start/routes/${moduleName}"`)) {
151
+ return { ok: true, actions: [] };
152
+ }
153
+ content = content.length ? `${content.trimEnd()}\n${importLine}\n` : `${importLine}\n`;
154
+ if (!dryRun) {
155
+ mkdirSync(dirname(routesPath), { recursive: true });
156
+ writeFileSync(routesPath, content);
157
+ }
158
+ return {
159
+ ok: true,
160
+ actions: [`start/routes.ts → ${importRelative}`],
161
+ };
162
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "adonia",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Adonia modules for AdonisJS \u2014 CLI and bundled registry",
7
+ "bin": {
8
+ "adonia": "./build/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": "./build/cli.js",
12
+ "./schema/adonia.schema.json": "./schema/adonia.schema.json"
13
+ },
14
+ "files": [
15
+ "build",
16
+ "schema",
17
+ "registry"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "dev": "tsc --watch",
25
+ "check-types": "tsc --noEmit",
26
+ "sync-registry": "node --import tsx scripts/sync-registry.ts",
27
+ "prepack": "npm run sync-registry && npm run build",
28
+ "test": "npm run sync-registry && npm run build && node --import tsx --test tests/*.test.ts",
29
+ "adonia": "node build/cli.js"
30
+ },
31
+ "dependencies": {
32
+ "commander": "^13.1.0",
33
+ "fast-glob": "^3.3.3",
34
+ "zod": "^3.24.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.13.10",
38
+ "tsx": "^4.19.3",
39
+ "typescript": "^5.9.2"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/britzdylan/adonia.git",
44
+ "directory": "packages/modules"
45
+ },
46
+ "homepage": "https://github.com/britzdylan/adonia#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/britzdylan/adonia/issues"
49
+ },
50
+ "license": "MIT"
51
+ }
@@ -0,0 +1,86 @@
1
+ # Modules package (authoring fixture)
2
+
3
+ This directory is the **authoring source** for Adonia modules. It lives
4
+ inside a fixture Adonis app used to design and test slices. Hosts do
5
+ **not** copy from here — they run:
6
+
7
+ ```bash
8
+ npx adonia@latest init --wire
9
+ npx adonia@latest add auth
10
+ npx adonia@latest add auth --with-adapters
11
+ npx adonia@latest add auth --with-stubs
12
+ npx adonia@latest add auth --with-routes --wire-routes
13
+ # or full kit:
14
+ npx adonia@latest init --scaffold
15
+ ```
16
+
17
+ Local authors iterating on a module can point the CLI at this tree:
18
+
19
+ ```bash
20
+ npx adonia add auth --registry .
21
+ # or from the monorepo root:
22
+ node packages/modules/build/cli.js add auth \
23
+ --registry apps/adonis-api-stater/modules
24
+ ```
25
+
26
+ `npm run sync-registry -w adonia` copies this tree into
27
+ `packages/modules/registry/` for the npm tarball.
28
+
29
+ ## Layout
30
+
31
+ | Path | Role |
32
+ |------|------|
33
+ | `types/`, `constants/`, `contracts/` | Shared core (always copy with `api`) |
34
+ | `api/` | Envelopes, `ApiService`, `ctx.respond`, exception handler |
35
+ | `auth/`, `account/`, `notification/`, `creem/`, `subscription/` | Feature modules |
36
+
37
+ Import path: `#modules/<name>/…` (for example `#modules/auth/service`).
38
+
39
+ Public names are listed in `registry.json`.
40
+
41
+ ## `module.json` schema
42
+
43
+ Each feature module (and `api`) ships a manifest:
44
+
45
+ - **files** — domain slice copy set (relative to the module folder).
46
+ - **stubs** — host files under `stubs/` (`models`, `migrations`,
47
+ `controllers`, `validators`, `adapters`, `providers`, `start`,
48
+ `routes`). Opt-in via CLI `--with-*` flags; plain `add` does not copy
49
+ them. `--with-adapters` copies Lucid/SDK adapters to `app/adapters/`.
50
+ `--with-controllers` and `--with-stubs` imply adapters. `--with-validators`
51
+ also copies `providers/`; `--with-routes` and `--with-stubs` also copy
52
+ `start/` (limiter example). Adonis kits (`@adonisjs/mail`,
53
+ `@adonisjs/drive`, `@adonisjs/limiter`) are not stubbed with no-ops.
54
+ `add` and `check` warn when a copied stub needs a kit that is missing
55
+ from `package.json` and print `node ace add @adonisjs/<pkg>`.
56
+ - **dependencies** — npm packages this module imports.
57
+ - **registryDependencies** — other modules that must already be present.
58
+ - **peerModels** — `#models/*` aliases Lucid adapters expect.
59
+ - **configKeys** — keys under `config/modules.ts`.
60
+ - **env** — environment variables the host must provide.
61
+ - **events** — names to list in `config/modules.ts` `emits`.
62
+ - **hostFacingExceptions** — codes kept for middleware/hosts (see
63
+ `constants/exceptions.ts`).
64
+ - **hostTests** — integration specs that stay in the host `tests/` tree.
65
+
66
+ ## Install checklist (manual / without CLI)
67
+
68
+ Prefer `npx adonia`. If installing by hand:
69
+
70
+ 1. Aliases: `#modules/*`, `#modules/types`, `#constants`, `#adapters/*`.
71
+ 2. Copy `api` + `types` + `constants` + `contracts`.
72
+ 3. Register `#modules/api/provider` and `#modules/api/exception_handler`.
73
+ 4. Copy feature modules in `registryDependencies` order.
74
+ 5. Optionally copy stubs (`--with-stubs` / `--with-adapters` /
75
+ `--with-routes`) into `app/`, `app/adapters/`, `database/`,
76
+ `providers/`, `start/`, and `start/routes/`. Register
77
+ `providers/vine_provider.ts` in `adonisrc.ts`. If a copied stub
78
+ imports `@adonisjs/mail`, `@adonisjs/drive`, or `@adonisjs/limiter`,
79
+ run `node ace add` for that package (the CLI prints the command).
80
+ 6. Fill `config/modules.ts` from each module's `events` list.
81
+
82
+ ## Authoring a new module
83
+
84
+ See [Adding a feature module](../../../docs/architecture/adding-modules.md)
85
+ for the service API, contracts, events, stubs, and `module.json`
86
+ checklist.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Optional avatar file cleanup. Host wires Drive / S3 / local disk.
3
+ * AccountService never imports Adonis Drive or MultipartFile.
4
+ */
5
+ export interface AvatarStorage {
6
+ delete(key: string): Promise<void>
7
+ }
@@ -0,0 +1,7 @@
1
+ export type {
2
+ UpdateProfileInput,
3
+ ReplaceEmailInput,
4
+ ReplacePasswordInput,
5
+ } from './types.ts'
6
+
7
+ export type { AvatarStorage } from './avatar_storage.ts'
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Account module input types.
3
+ * User persistence uses shared #modules/contracts UserStore / User.
4
+ */
5
+
6
+ export type UpdateProfileInput = {
7
+ firstName?: string
8
+ lastName?: string
9
+ /**
10
+ * New avatar storage key after the host uploaded the file.
11
+ * Pass null to clear. Omit to leave unchanged.
12
+ */
13
+ avatarKey?: string | null
14
+ }
15
+
16
+ export type ReplaceEmailInput = {
17
+ email: string
18
+ }
19
+
20
+ export type ReplacePasswordInput = {
21
+ currentPassword: string
22
+ newPassword: string
23
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Account module events. Import this file so the EventsList augmentation loads.
3
+ */
4
+ import type { User } from '#modules/contracts/index'
5
+
6
+ declare module '@adonisjs/core/types' {
7
+ interface EventsList {
8
+ 'Account:UpdateUserProfile': User
9
+ 'Account:UpdateUserEmail': { user: User; newEmail: string }
10
+ 'Account:ConfirmEmailChange': User
11
+ 'Account:UpdateUserPassword': User
12
+ 'Account:DeleteAccount': { userId: number }
13
+ }
14
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "account",
3
+ "description": "Authenticated profile, email change, password change, account deletion",
4
+ "files": [
5
+ "service.ts",
6
+ "events.ts",
7
+ "options.ts",
8
+ "contracts/**",
9
+ "module.json"
10
+ ],
11
+ "stubs": [
12
+ "stubs/controllers/account_controller.ts",
13
+ "stubs/validators/account.ts",
14
+ "stubs/adapters/**/*",
15
+ "stubs/routes/account.ts"
16
+ ],
17
+ "dependencies": {},
18
+ "registryDependencies": ["api", "auth"],
19
+ "peerModels": ["#models/user"],
20
+ "configKeys": ["modules.account"],
21
+ "env": ["APP_KEY"],
22
+ "events": [
23
+ "Account:UpdateUserProfile",
24
+ "Account:UpdateUserEmail",
25
+ "Account:ConfirmEmailChange",
26
+ "Account:UpdateUserPassword",
27
+ "Account:DeleteAccount"
28
+ ],
29
+ "hostFacingExceptions": ["PAYLOAD_TOO_LARGE"],
30
+ "hostTests": ["tests/integration/lucid_adapters.spec.ts"]
31
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Host-editable defaults for AccountService.
3
+ */
4
+ export type AccountOptions = {
5
+ /** Duration passed to encryption.encrypt for pending-email tokens. */
6
+ emailChangeTtl: string
7
+ }
8
+
9
+ export const defaultAccountOptions: AccountOptions = {
10
+ emailChangeTtl: '2 Hours',
11
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * AccountService
3
+ *
4
+ * Authenticated account actions: profile, email, password, delete.
5
+ * Returns DTOs / void; controllers build HTTP envelopes.
6
+ */
7
+ import encryption from '@adonisjs/core/services/encryption'
8
+ import ApiService from '#modules/api/service'
9
+ import ApiException from '#modules/api/exception'
10
+ import { exceptions } from '#constants/exceptions'
11
+ import type { User, UserStore } from '#modules/contracts/index'
12
+ import type {
13
+ AvatarStorage,
14
+ ReplaceEmailInput,
15
+ ReplacePasswordInput,
16
+ UpdateProfileInput,
17
+ } from '#modules/account/contracts/index'
18
+ import { defaultAccountOptions, type AccountOptions } from '#modules/account/options'
19
+ import '#modules/account/events'
20
+
21
+ const noopAvatars: AvatarStorage = {
22
+ async delete() {},
23
+ }
24
+
25
+ export class AccountService extends ApiService {
26
+ namespace = 'account'
27
+
28
+ constructor(
29
+ private users: UserStore,
30
+ private avatars: AvatarStorage = noopAvatars,
31
+ private options: AccountOptions = defaultAccountOptions
32
+ ) {
33
+ super()
34
+ }
35
+
36
+ private invalidTokenException() {
37
+ return ApiException.from(exceptions.INVALID_TOKEN)
38
+ }
39
+
40
+ private tokensMatch(incoming: string, stored: string): boolean {
41
+ const storedToken = encryption.decrypt(stored)
42
+ return encryption.decrypt(incoming) === storedToken && storedToken != null
43
+ }
44
+
45
+ private issuePendingEmailToken(user: User, pendingEmail: string): User {
46
+ return {
47
+ ...user,
48
+ pendingEmail,
49
+ emailVerificationToken: encryption.encrypt(pendingEmail, this.options.emailChangeTtl),
50
+ }
51
+ }
52
+
53
+ public async updateProfile(userId: number, data: UpdateProfileInput): Promise<User> {
54
+ const user = await this.users.findOrFail(userId)
55
+ const previousAvatarKey = user.avatarKey
56
+
57
+ if (data.firstName !== undefined) {
58
+ user.firstName = data.firstName
59
+ }
60
+ if (data.lastName !== undefined) {
61
+ user.lastName = data.lastName
62
+ }
63
+ if (data.avatarKey !== undefined) {
64
+ user.avatarKey = data.avatarKey
65
+ }
66
+
67
+ const saved = await this.users.save(user)
68
+
69
+ if (
70
+ data.avatarKey !== undefined &&
71
+ previousAvatarKey &&
72
+ previousAvatarKey !== data.avatarKey
73
+ ) {
74
+ await this.avatars.delete(previousAvatarKey)
75
+ }
76
+
77
+ this.emitSafe('Account:UpdateUserProfile', saved)
78
+ return saved
79
+ }
80
+
81
+ /**
82
+ * Stages a new email on pendingEmail. Current email stays valid until
83
+ * confirmEmailChange succeeds.
84
+ */
85
+ public async replaceEmail(userId: number, data: ReplaceEmailInput): Promise<User> {
86
+ const user = await this.users.findOrFail(userId)
87
+
88
+ if (user.email === data.email) {
89
+ throw ApiException.from(exceptions.SAME_EMAIL)
90
+ }
91
+
92
+ const existing = await this.users.findByEmail(data.email)
93
+ if (existing) {
94
+ throw ApiException.from(exceptions.EMAIL_EXISTS)
95
+ }
96
+
97
+ const updated = await this.users.save(this.issuePendingEmailToken(user, data.email))
98
+
99
+ this.emitSafe('Account:UpdateUserEmail', {
100
+ user: updated,
101
+ newEmail: data.email,
102
+ })
103
+
104
+ return updated
105
+ }
106
+
107
+ /**
108
+ * Confirms a pending email change using the verification token.
109
+ */
110
+ public async confirmEmailChange(token: string): Promise<User> {
111
+ const user = await this.users.findByVerificationToken(token)
112
+ if (!user || !user.pendingEmail) {
113
+ throw this.invalidTokenException()
114
+ }
115
+ if (!user.emailVerificationToken || !this.tokensMatch(token, user.emailVerificationToken)) {
116
+ throw this.invalidTokenException()
117
+ }
118
+
119
+ const taken = await this.users.findByEmail(user.pendingEmail)
120
+ if (taken && taken.id !== user.id) {
121
+ throw ApiException.from(exceptions.EMAIL_EXISTS)
122
+ }
123
+
124
+ const confirmed = await this.users.save({
125
+ ...user,
126
+ email: user.pendingEmail,
127
+ pendingEmail: null,
128
+ emailVerifiedAt: new Date(),
129
+ emailVerificationToken: null,
130
+ })
131
+
132
+ this.emitSafe('Account:ConfirmEmailChange', confirmed)
133
+ return confirmed
134
+ }
135
+
136
+ public async replacePassword(userId: number, data: ReplacePasswordInput): Promise<void> {
137
+ const user = await this.users.findOrFail(userId)
138
+ const valid = await this.users.verifyPassword(userId, data.currentPassword)
139
+ if (!valid) {
140
+ throw ApiException.from(exceptions.INVALID_PASSWORD)
141
+ }
142
+
143
+ await this.users.updatePassword(user, data.newPassword)
144
+ this.emitSafe('Account:UpdateUserPassword', user)
145
+ }
146
+
147
+ public async deleteAccount(userId: number): Promise<void> {
148
+ const user = await this.users.findOrFail(userId)
149
+ if (user.avatarKey) {
150
+ await this.avatars.delete(user.avatarKey)
151
+ }
152
+ await this.users.delete(userId)
153
+ this.emitSafe('Account:DeleteAccount', { userId })
154
+ }
155
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Host Lucid factory for account. Copied to app/adapters/account.ts.
3
+ * Import as `#adapters/account`.
4
+ *
5
+ * Reuses `#adapters/lucid_user_store` from the auth adapter stubs.
6
+ * Avatar cleanup uses Drive (`node ace add @adonisjs/drive`).
7
+ */
8
+ import { AccountService } from '#modules/account/service'
9
+ import { LucidUserStore } from '#adapters/lucid_user_store'
10
+ import { DriveAvatarStorage } from './drive_avatar_storage.ts'
11
+ import { defaultAccountOptions, type AccountOptions } from '#modules/account/options'
12
+ import type { AvatarStorage } from '#modules/account/contracts/index'
13
+
14
+ export { DriveAvatarStorage } from './drive_avatar_storage.ts'
15
+
16
+ export function createAccountService(
17
+ avatars: AvatarStorage = new DriveAvatarStorage(),
18
+ options: AccountOptions = defaultAccountOptions
19
+ ) {
20
+ return new AccountService(new LucidUserStore(), avatars, options)
21
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Drive-backed AvatarStorage. Copied to app/adapters/drive_avatar_storage.ts.
3
+ * Requires `@adonisjs/drive` (`node ace add @adonisjs/drive`).
4
+ */
5
+ import drive from '@adonisjs/drive/services/main'
6
+ import type { AvatarStorage } from '#modules/account/contracts/index'
7
+
8
+ export class DriveAvatarStorage implements AvatarStorage {
9
+ constructor(private diskName?: string) {}
10
+
11
+ async delete(key: string): Promise<void> {
12
+ const disk = this.diskName ? drive.use(this.diskName) : drive.use()
13
+ await disk.delete(key)
14
+ }
15
+ }