From 16361f25b3540e3e39b1cbc97e6a8797546a523e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:34:27 +0400 Subject: [PATCH 01/89] chore(auth): add bouncer, limiter and jose dependencies This branch needs three packages that the scaffold never installed. Bouncer and limiter are the authorization and rate limiting libraries the README names explicitly for this branch, and neither ships with the base @adonisjs/auth or core packages. A JWT signing library is needed as well, since the installed @adonisjs/auth only ships session, basic auth and access tokens guards, no JWT guard exists in the package itself, so the custom guard this branch writes needs a real implementation to sign and verify tokens against. jose was chosen over jsonwebtoken for that last piece since it is pure JavaScript with no native bindings, ships native ESM and TypeScript types, and is the library the Node ecosystem has largely converged on for JWT and JOSE operations going forward. --- package-lock.json | 76 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ 2 files changed, 79 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9a92682..260ced4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "@adonisjs/auth": "^10.1.0", + "@adonisjs/bouncer": "^4.0.1", "@adonisjs/core": "^7.4.0", "@adonisjs/cors": "^3.0.0", + "@adonisjs/limiter": "^3.0.1", "@adonisjs/lucid": "^22.4.2", "@adonisjs/session": "^8.1.0", "@adonisjs/shield": "^9.0.0", @@ -19,6 +21,7 @@ "@tuyau/core": "^1.2.2", "@vinejs/vine": "^4.4.0", "better-sqlite3": "^13.0.3", + "jose": "^6.2.10", "luxon": "^3.7.2", "pg": "^8.23.0", "reflect-metadata": "^0.2.2" @@ -197,6 +200,35 @@ "@adonisjs/http-server": "^8.0.0-next.17 || ^8.0.0 || ^9.0.0" } }, + "node_modules/@adonisjs/bouncer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adonisjs/bouncer/-/bouncer-4.0.1.tgz", + "integrity": "sha512-YTVlr2apjEHZIDgs09zl2dAMse5xBOCWQ+iY5qYxsZwMRAZcNHaOMjhD6LGjxXKyyM0tFbypgshAWiKuFbI1MA==", + "license": "MIT", + "dependencies": { + "@poppinss/utils": "^7.0.1" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@adonisjs/assembler": "^8.0.0-next.26 || ^8.0.0", + "@adonisjs/core": "^7.0.0-next.16 || ^7.0.0", + "@adonisjs/i18n": "^3.0.0-next.2 || ^3.0.0", + "edge.js": "^6.4.0" + }, + "peerDependenciesMeta": { + "@adonisjs/assembler": { + "optional": true + }, + "@adonisjs/i18n": { + "optional": true + }, + "edge.js": { + "optional": true + } + } + }, "node_modules/@adonisjs/config": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-6.1.0.tgz", @@ -502,6 +534,35 @@ "@adonisjs/fold": "^11.0.0-next.2" } }, + "node_modules/@adonisjs/limiter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@adonisjs/limiter/-/limiter-3.0.1.tgz", + "integrity": "sha512-U7drt/FPlB5PV84dnxQQSFGk75fDxgEHSkfT/eGwx/+Z4re7XlkVU4oros/VGefD72JTgXp7Kz97tS1XW7eEXA==", + "dependencies": { + "rate-limiter-flexible": "^9.1.1" + }, + "peerDependencies": { + "@adonisjs/assembler": "^8.0.0-next.9 || ^8.0.0", + "@adonisjs/core": "^7.0.0-next.1 || ^7.0.0", + "@adonisjs/i18n": "^3.0.0-next.0 || ^3.0.0", + "@adonisjs/lucid": "^22.0.0-next.0 || ^22.0.0", + "@adonisjs/redis": "^10.0.0-next.1 || ^10.0.0" + }, + "peerDependenciesMeta": { + "@adonisjs/assembler": { + "optional": true + }, + "@adonisjs/i18n": { + "optional": true + }, + "@adonisjs/lucid": { + "optional": true + }, + "@adonisjs/redis": { + "optional": true + } + } + }, "node_modules/@adonisjs/logger": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@adonisjs/logger/-/logger-7.1.1.tgz", @@ -4649,6 +4710,15 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -5959,6 +6029,12 @@ "node": ">= 0.8" } }, + "node_modules/rate-limiter-flexible": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-9.1.1.tgz", + "integrity": "sha512-imxFjzPCmvDLMe7d2tsgiSQvs5EI2fI9SNymmslAfOqznZhsZ+PqbIjIYKpuSbd3pKovR1aMG47qfCLIO/adVg==", + "license": "ISC" + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", diff --git a/package.json b/package.json index f04437f..95beb75 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,10 @@ }, "dependencies": { "@adonisjs/auth": "^10.1.0", + "@adonisjs/bouncer": "^4.0.1", "@adonisjs/core": "^7.4.0", "@adonisjs/cors": "^3.0.0", + "@adonisjs/limiter": "^3.0.1", "@adonisjs/lucid": "^22.4.2", "@adonisjs/session": "^8.1.0", "@adonisjs/shield": "^9.0.0", @@ -68,6 +70,7 @@ "@tuyau/core": "^1.2.2", "@vinejs/vine": "^4.4.0", "better-sqlite3": "^13.0.3", + "jose": "^6.2.10", "luxon": "^3.7.2", "pg": "^8.23.0", "reflect-metadata": "^0.2.2" From 5ddb82ade2c4816a5c8cf7880a0a034308104604 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:15 +0400 Subject: [PATCH 02/89] chore(auth): register the bouncer and limiter providers node ace configure @adonisjs/bouncer and node ace configure @adonisjs/limiter both need their provider registered in adonisrc.ts before the IoC container can resolve ctx.bouncer or the limiter service anywhere else in the app, the same wiring every other package in this file already goes through. The bouncer commands entry and the indexPolicies hook are added for the same reason, so that ace can discover policy classes and expose the make:policy generator. Running the packages' own configure command rather than hand writing this file avoids guessing at an API shape from memory, which is exactly the kind of mistake that broke typecheck on the previous branch. --- adonisrc.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/adonisrc.ts b/adonisrc.ts index a96390d..df65430 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -1,6 +1,7 @@ import { indexEntities } from '@adonisjs/core' import { defineConfig } from '@adonisjs/core/app' import { generateRegistry } from '@tuyau/core/hooks' +import { indexPolicies } from '@adonisjs/bouncer' export default defineConfig({ /* @@ -28,6 +29,7 @@ export default defineConfig({ () => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands'), () => import('@adonisjs/session/commands'), + () => import('@adonisjs/bouncer/commands'), ], /* @@ -53,6 +55,8 @@ export default defineConfig({ () => import('@adonisjs/cors/cors_provider'), () => import('@adonisjs/auth/auth_provider'), () => import('#providers/api_provider'), + () => import('@adonisjs/bouncer/bouncer_provider'), + () => import('@adonisjs/limiter/limiter_provider'), ], /* @@ -111,6 +115,7 @@ export default defineConfig({ transformers: { enabled: true }, }), generateRegistry(), + indexPolicies(), ], }, }) From 4467bc3797457422ec46d216c51779a4286159c6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:15 +0400 Subject: [PATCH 03/89] chore(auth): register the bouncer initialization middleware ctx.bouncer only exists on the request context once InitializeBouncerMiddleware has run, the same pattern initialize_auth_middleware already follows for ctx.auth. It is placed after silent_auth_middleware in the router stack since bouncer needs to read the resolved user off ctx.auth when it builds the Bouncer instance for the request, and that resolution has to happen first. --- start/kernel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/start/kernel.ts b/start/kernel.ts index 05c358a..5ec73e6 100644 --- a/start/kernel.ts +++ b/start/kernel.ts @@ -56,6 +56,7 @@ router.use([ () => import('@adonisjs/shield/shield_middleware'), () => import('@adonisjs/auth/initialize_auth_middleware'), () => import('#middleware/silent_auth_middleware'), + () => import('#middleware/initialize_bouncer_middleware'), ]) /** From a3fe08f0286e3aec9385720327e986dee25e1c8a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:24 +0400 Subject: [PATCH 04/89] feat(auth): add the bouncer initialization middleware This middleware builds a Bouncer instance for each request, resolved from ctx.auth.user, and attaches it to the context as ctx.bouncer so downstream middleware, controllers and policies can call bouncer.authorize or bouncer.allows without constructing their own instance. It also augments the HttpContext type so ctx.bouncer is typed against the abilities and policies actually registered in the app, rather than an untyped any. Generated by the package's own configure command rather than written by hand, since this class needs to match the exact Bouncer constructor signature of the installed version. --- .../initialize_bouncer_middleware.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/middleware/initialize_bouncer_middleware.ts diff --git a/app/middleware/initialize_bouncer_middleware.ts b/app/middleware/initialize_bouncer_middleware.ts new file mode 100644 index 0000000..448a2b6 --- /dev/null +++ b/app/middleware/initialize_bouncer_middleware.ts @@ -0,0 +1,43 @@ +import * as abilities from '#abilities/main' +import { policies } from '#generated/policies' + +import { Bouncer } from '@adonisjs/bouncer' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * Init bouncer middleware is used to create a bouncer instance + * during an HTTP request + */ +export default class InitializeBouncerMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + /** + * Create bouncer instance for the ongoing HTTP request. + * We will pull the user from the HTTP context. + */ + ctx.bouncer = new Bouncer( + () => ctx.auth.user || null, + abilities, + policies + ).setContainerResolver(ctx.containerResolver) + + /** + * Share bouncer helpers with Edge templates. + */ + if ('view' in ctx) { + ctx.view.share(ctx.bouncer.edgeHelpers) + } + + return next() + } +} + +declare module '@adonisjs/core/http' { + export interface HttpContext { + bouncer: Bouncer< + Exclude, + typeof abilities, + typeof policies + > + } +} From 59b9996b8ca48e7e33952115070b63c1c0212a5d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:24 +0400 Subject: [PATCH 05/89] chore(auth): add the bouncer abilities entry point Abilities registered here can be referenced by name from a policy or from Edge templates, and the file needs to exist before any policy class can import from it. The placeholder editUser ability that ships with the package's own scaffold is left in place for now rather than deleted, since this branch does not yet define resource specific abilities, those belong to the categories, products, customers and orders branches once their own authorization rules are known. --- app/abilities/main.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 app/abilities/main.ts diff --git a/app/abilities/main.ts b/app/abilities/main.ts new file mode 100644 index 0000000..9c4fc0d --- /dev/null +++ b/app/abilities/main.ts @@ -0,0 +1,23 @@ +/* +|-------------------------------------------------------------------------- +| Bouncer abilities +|-------------------------------------------------------------------------- +| +| You may export multiple abilities from this file and pre-register them +| when creating the Bouncer instance. +| +| Pre-registered policies and abilities can be referenced as a string by their +| name. Also they are must if want to perform authorization inside Edge +| templates. +| +*/ + +import { Bouncer } from '@adonisjs/bouncer' + +/** + * Delete the following ability to start from + * scratch + */ +export const editUser = Bouncer.ability(() => { + return true +}) From 0a0be485d239bbe8da29786de2348c5e23893291 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:36 +0400 Subject: [PATCH 06/89] chore(auth): configure the rate limiter database store LIMITER_STORE was already declared in the typed env schema back in feature/core-architecture, set to database in every env file, but nothing had actually configured the limiter package against that setting until now. The database store persists rate limit counters in the rate_limits table this branch also adds, which is the right choice for this project since it already runs a shared PostgreSQL instance in every environment and avoids introducing Redis as a new piece of infrastructure just for throttling a handful of auth routes. A memory store is kept alongside it since the limiter package ships it by default and it costs nothing to keep as a documented alternative, useful for a future test suite that wants throttling behavior without touching the database. --- config/limiter.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 config/limiter.ts diff --git a/config/limiter.ts b/config/limiter.ts new file mode 100644 index 0000000..3395812 --- /dev/null +++ b/config/limiter.ts @@ -0,0 +1,27 @@ +import env from '#start/env' +import { defineConfig, stores } from '@adonisjs/limiter' + +const limiterConfig = defineConfig({ + default: env.get('LIMITER_STORE'), + stores: { + /** + * Database store to save rate limiting data inside a + * MYSQL or PostgreSQL database. + */ + database: stores.database({ + tableName: 'rate_limits', + }), + + /** + * Memory store could be used during + * testing + */ + memory: stores.memory({}), + }, +}) + +export default limiterConfig + +declare module '@adonisjs/limiter/types' { + export interface LimitersList extends InferLimiters {} +} From 73948d21d3fb9b8a65c2a759b1e721c5beddab48 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:36 +0400 Subject: [PATCH 07/89] feat(auth): define the auth rate limiter This is the named throttle middleware the register, login and forgot-password routes apply later in this branch, matching the THROTTLE_AUTH_MAX and THROTTLE_AUTH_WINDOW values already present in the env schema. The limit itself is wired directly to those env values here rather than hardcoded, so tuning the throttle in a given environment only ever requires an env change, never a code change. --- start/limiter.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 start/limiter.ts diff --git a/start/limiter.ts b/start/limiter.ts new file mode 100644 index 0000000..bc84b7d --- /dev/null +++ b/start/limiter.ts @@ -0,0 +1,16 @@ +/* +|-------------------------------------------------------------------------- +| Define HTTP limiters +|-------------------------------------------------------------------------- +| +| The "limiter.define" method creates an HTTP middleware to apply rate +| limits on a route or a group of routes. Feel free to define as many +| throttle middleware as needed. +| +*/ + +import limiter from '@adonisjs/limiter/services/main' + +export const throttle = limiter.define('global', () => { + return limiter.allowRequests(10).every('1 minute') +}) From 842220f9fb849d9e298970ba7aa1be384a94cebe Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:36:37 +0400 Subject: [PATCH 08/89] feat(auth): add the rate limits migration The limiter package's database store needs a table to persist request counters and their expiry against a key, generated directly by the package's own configure command so its column names and types match exactly what the store implementation expects to read and write. --- .../1787564106653_create_rate_limits_table.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 database/migrations/1787564106653_create_rate_limits_table.ts diff --git a/database/migrations/1787564106653_create_rate_limits_table.ts b/database/migrations/1787564106653_create_rate_limits_table.ts new file mode 100644 index 0000000..3baffd1 --- /dev/null +++ b/database/migrations/1787564106653_create_rate_limits_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'rate_limits' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.string('key', 255).notNullable().primary() + table.integer('points', 9).notNullable().defaultTo(0) + table.bigint('expire').unsigned() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} From ca6240e95a7d797cc59edf60b6ed02a4f664514d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:37:21 +0400 Subject: [PATCH 09/89] fix(auth): wire the auth throttle to THROTTLE_AUTH_MAX and THROTTLE_AUTH_WINDOW The previous commit on this file claimed the throttle was already wired to the env driven auth limits, but it had actually only committed the package's generated stub unchanged, a global limiter hardcoded to ten requests per minute under the name global. This commit corrects that: the exported throttle is renamed to authThrottle and reads its request count and window straight from the typed env service instead of a literal, matching what register, login and forgot-password are supposed to apply later in this branch. Caught while reviewing the previous commit's own message against the file it actually produced, before any route wired the throttle in and made the mismatch harder to notice. --- start/limiter.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/start/limiter.ts b/start/limiter.ts index bc84b7d..23fecc2 100644 --- a/start/limiter.ts +++ b/start/limiter.ts @@ -9,8 +9,14 @@ | */ +import env from '#start/env' import limiter from '@adonisjs/limiter/services/main' -export const throttle = limiter.define('global', () => { - return limiter.allowRequests(10).every('1 minute') +/** + * Applied to register, login and forgot-password in start/routes.ts. + * Limits are read from THROTTLE_AUTH_MAX and THROTTLE_AUTH_WINDOW so an + * environment can tighten or relax them without a code change. + */ +export const authThrottle = limiter.define('auth', () => { + return limiter.allowRequests(env.get('THROTTLE_AUTH_MAX')).every(env.get('THROTTLE_AUTH_WINDOW')) }) From 26d8c039ae1b0865b20ffcaa38e28d568883110b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:44:47 +0400 Subject: [PATCH 10/89] chore(auth): add the argon2 hashing dependency HASH_DRIVER has been set to argon in every env file since feature/core-architecture, and the README's tech stack table names Argon2 explicitly as the password hashing algorithm for this project, but the argon2 npm package itself, the native binding the hash service's argon driver wraps, was never installed. Without it, config/hash.ts could only ever resolve to the scrypt driver the --kit=api scaffold shipped by default, regardless of what HASH_DRIVER said. The package ships prebuilt binaries for linux, darwin, freebsd and win32 across common architectures, verified directly by hashing and verifying a password with it before wiring it into config/hash.ts, so no local compilation step is required in most environments including this one. --- package-lock.json | 57 ++++++++++++++++++++++++++++++++++++++++++----- package.json | 2 ++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 260ced4..23cec5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@japa/api-client": "^3.2.1", "@tuyau/core": "^1.2.2", "@vinejs/vine": "^4.4.0", + "argon2": "^0.45.1", "better-sqlite3": "^13.0.3", "jose": "^6.2.10", "luxon": "^3.7.2", @@ -1013,6 +1014,12 @@ "node": ">=0.1.90" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -2527,6 +2534,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3099,11 +3122,27 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4692,7 +4731,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/jest-diff": { @@ -5265,6 +5303,17 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -5554,7 +5603,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6365,7 +6413,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6378,7 +6425,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7164,7 +7210,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 95beb75..bb0a825 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@japa/api-client": "^3.2.1", "@tuyau/core": "^1.2.2", "@vinejs/vine": "^4.4.0", + "argon2": "^0.45.1", "better-sqlite3": "^13.0.3", "jose": "^6.2.10", "luxon": "^3.7.2", @@ -83,6 +84,7 @@ }, "allowScripts": { "@swc/core": true, + "argon2": true, "better-sqlite3": true, "esbuild": true, "fsevents": true From 3f5419d8b214ccbb91560a17481e2e046c1a4421 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:44:59 +0400 Subject: [PATCH 11/89] feat(auth): configure argon2 as the default hasher The hash config inherited from the --kit=api scaffold hardcoded scrypt as the only hasher and never read HASH_DRIVER at all, so setting that variable in any env file had no effect. This branch introduces password based login through AuthService, so the actual hasher the project uses stops being a detail and starts being something the README specifies directly, Argon2, the algorithm OWASP currently recommends for password storage. The default is now read from the typed env service instead of a literal, and the argon entry uses the id variant with a 64 MiB memory cost, three iterations and four way parallelism, values chosen to sit well above argon2's own minimum recommended memory cost without making login latency noticeable on ordinary hardware. Bcrypt is kept configured as a documented fallback since HASH_DRIVER's schema in start/env.ts already allows it, rather than deleting a working driver the project might still want later. --- config/hash.ts | 71 +++++++++++++++----------------------------------- 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/config/hash.ts b/config/hash.ts index a54c086..566d381 100644 --- a/config/hash.ts +++ b/config/hash.ts @@ -1,65 +1,36 @@ +import env from '#start/env' import { defineConfig, drivers } from '@adonisjs/core/hash' /** * Hashing configuration. * - * This starter uses Node.js scrypt under the hood. - * Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback + * The default hasher is read from HASH_DRIVER so an environment can switch + * between argon and bcrypt without a code change, matching the enum already + * declared in start/env.ts. Argon2 is the recommended choice and what every + * documented env file in this project sets HASH_DRIVER to; bcrypt is kept + * configured as a documented fallback rather than removed outright. */ const hashConfig = defineConfig({ - /** - * Default hasher used by the application. - */ - default: 'scrypt', + default: env.get('HASH_DRIVER'), list: { /** - * Scrypt is memory-hard, which makes brute-force attacks more expensive. + * Argon2id, the variant OWASP recommends when side channel attacks are + * a concern alongside GPU cracking, which fits a JWT backed API that + * only ever hashes on register, activation and password reset. */ - scrypt: drivers.scrypt({ - /** - * Work factor (Node alias: N / cost). - * Higher values increase security and CPU+memory usage. - * - * Tuning guideline: - * - Start with 16384. - * - Increase gradually (for example 32768) and benchmark login/signup latency. - * - Keep values practical for your slowest production machine. - * - * Node constraint: value must be a power of two greater than 1. - */ - cost: 16384, - - /** - * Block size (Node alias: r / blockSize). - * Increases memory and CPU linearly. - * - * Tuning guideline: - * - Keep 8 unless you have a measured reason to change it. - * - Raise only with benchmark data, because memory usage grows quickly. - */ - blockSize: 8, - - /** - * Parallelization (Node alias: p / parallelization). - * Controls how many independent computations are performed. - * - * Tuning guideline: - * - Keep 1 for most applications. - * - Increase only after load testing if your infrastructure benefits from it. - */ - parallelization: 1, + argon: drivers.argon2({ + variant: 'id', + memory: 65536, + iterations: 3, + parallelism: 4, + }), - /** - * Maximum memory limit in bytes (Node alias: maxmem / maxMemory). - * Hashing throws if the estimated memory usage is above this limit. - * Node documents the check as approximately: 128 * N * r > maxmem. - * - * Tuning guideline: - * - Keep this aligned with your cost/blockSize choices. - * - Increase carefully on memory-constrained environments. - */ - maxMemory: 33554432, + /** + * Bcrypt, kept as a documented alternative. + */ + bcrypt: drivers.bcrypt({ + rounds: 10, }), }, }) From 2411b154cea6e9348d6f72aee2e6018e6c127c16 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:44:59 +0400 Subject: [PATCH 12/89] fix(auth): hardcode the limiter default store to database The generated config read its default store straight from env.get('LIMITER_STORE'), but that variable is typed as 'database' | 'redis' in start/env.ts, matching the README's documented env reference table, while only a database store is actually configured below. Running npm run typecheck against the file as generated failed since 'redis' is not assignable to the narrower type the configured stores record actually allows. The default is hardcoded to 'database' instead, the same way config/database.ts hardcodes its own connection rather than deriving it from a variable that has no wired up alternative. Redis stays undocumented as an option here until the project actually provisions a Redis instance, at which point both the store and this default would need to be added together. --- config/limiter.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/config/limiter.ts b/config/limiter.ts index 3395812..bb1f0a3 100644 --- a/config/limiter.ts +++ b/config/limiter.ts @@ -1,8 +1,13 @@ -import env from '#start/env' import { defineConfig, stores } from '@adonisjs/limiter' +// NOTE: LIMITER_STORE is typed as 'database' | 'redis' in start/env.ts, +// matching the README's documented env reference, but only the database +// store is wired up below since this project has no Redis instance. The +// default is hardcoded to 'database' rather than read from env, the same +// way config/database.ts hardcodes its pg connection instead of deriving +// it from a variable with no matching configured alternative. const limiterConfig = defineConfig({ - default: env.get('LIMITER_STORE'), + default: 'database', stores: { /** * Database store to save rate limiting data inside a From dc3ee04af769659941d63312f22a28bd719298f3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:45:09 +0400 Subject: [PATCH 13/89] fix(auth): remove the Edge view branch from the bouncer middleware The generated middleware included a guarded block sharing bouncer helpers with Edge templates whenever ctx.view was present. This project has no Edge or view provider registered anywhere, it is an API only service per the README's own architecture, so ctx.view never exists on the HttpContext type here and the guard could not narrow it to anything usable, which failed npm run typecheck with ctx.view being of type unknown. Removing the branch rather than typing around it keeps the middleware honest about what this project actually is. If a view layer is ever added later, this is the file where that sharing call would come back. --- app/middleware/initialize_bouncer_middleware.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/middleware/initialize_bouncer_middleware.ts b/app/middleware/initialize_bouncer_middleware.ts index 448a2b6..6ab6d4c 100644 --- a/app/middleware/initialize_bouncer_middleware.ts +++ b/app/middleware/initialize_bouncer_middleware.ts @@ -21,13 +21,6 @@ export default class InitializeBouncerMiddleware { policies ).setContainerResolver(ctx.containerResolver) - /** - * Share bouncer helpers with Edge templates. - */ - if ('view' in ctx) { - ctx.view.share(ctx.bouncer.edgeHelpers) - } - return next() } } From 3e13896abcd2f3d3d97529e0650e6a51cba0e641 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:45:09 +0400 Subject: [PATCH 14/89] chore(codegen): regenerate the policies registry InitializeBouncerMiddleware imports #generated/policies, but that file only starts existing once node ace codegen runs after the bouncer package's indexPolicies hook is registered in adonisrc.ts. Without it, npm run typecheck failed with a missing module error on that import. The registry is currently empty since this branch does not define any policy classes yet, categories, products, customers and orders each own their own policy in their respective feature branches. --- .adonisjs/client/manifest.d.ts | 1 + .adonisjs/server/policies.ts | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 .adonisjs/server/policies.ts diff --git a/.adonisjs/client/manifest.d.ts b/.adonisjs/client/manifest.d.ts index 9fc3356..b1c625c 100644 --- a/.adonisjs/client/manifest.d.ts +++ b/.adonisjs/client/manifest.d.ts @@ -6,4 +6,5 @@ /// /// /// +/// /// diff --git a/.adonisjs/server/policies.ts b/.adonisjs/server/policies.ts new file mode 100644 index 0000000..a2d4a6c --- /dev/null +++ b/.adonisjs/server/policies.ts @@ -0,0 +1,3 @@ +export const policies = { +} + From 861fe0c4e7a367857c6ba2575914a1296e004ef2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:46:50 +0400 Subject: [PATCH 15/89] chore(database): regenerate the schema snapshot with rate_limits Running the rate_limits migration against the live test database regenerated this file automatically, the same way it did in feature/data-modeling. It now includes the RateLimitSchema class alongside the twelve tables from the core and auth schemas. --- database/schema.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/database/schema.ts b/database/schema.ts index 76665a1..ee8acf2 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -125,6 +125,17 @@ export class ProductSchema extends BaseModel { declare unitPrice: number } +export class RateLimitSchema extends BaseModel { + static $columns = ['expire', 'key', 'points'] as const + $columns = RateLimitSchema.$columns + @column() + declare expire: bigint | number | null + @column({ isPrimary: true }) + declare key: string + @column() + declare points: number +} + export class RolePermissionSchema extends BaseModel { static $columns = ['permissionId', 'roleId'] as const $columns = RolePermissionSchema.$columns From 084dd797cb4f40f49139f59edbe707e02b9178ac Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:50:51 +0400 Subject: [PATCH 16/89] chore(auth): register the app/auth import alias The custom JWT guard needs to live somewhere, and app/auth is where that guard and any future auth specific helpers belong, distinct from app/middleware and app/services which already have their own aliases. Without this entry in the imports map, config/auth.ts could not resolve #auth/jwt_guard through Node's subpath imports, the same mechanism every other first party import in this project already relies on instead of relative paths. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index bb0a825..32fae6c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "imports": { "#controllers/*": "./app/controllers/*.js", + "#auth/*": "./app/auth/*.js", "#helpers/*": "./app/helpers/*.js", "#exceptions/*": "./app/exceptions/*.js", "#models/*": "./app/models/*.js", From cd97d7f8818f7077f71c3976f00c4d09e43be0a2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:50:52 +0400 Subject: [PATCH 17/89] feat(auth): add a custom JWT guard The installed @adonisjs/auth version ships session, basic auth and access tokens guards, no JWT guard exists in the package itself, yet the README's own code samples call ctx.auth.use('jwt') and read a payload property off it, meaning the project expects a guard registered under that name inside the normal auth guard system rather than a bespoke authentication mechanism bolted on separately. JwtGuard implements the package's own GuardContract interface, the same contract SessionGuard and AccessTokensGuard satisfy, so the rest of the app can treat it identically to a built in guard: authenticate, check, getUserOrFail and authenticateAsClient for Japa's api client test helpers. It is deliberately narrow in scope, verifying the token's signature and expiry with jose and resolving the subject claim to a real User is all it does. It does not check the jti blacklist or the user's enabled and accountLocked flags, since the README assigns that check to AuthMiddleware specifically, layered on top of authenticate() rather than duplicated inside the guard. JWT_EXPIRY is documented as either a bare number of seconds or a time span like 7d, and jose's own parser only accepts the latter form for strings, so a purely numeric value is converted to a number before being handed to setExpirationTime. Signing is exposed as a standalone signJwt function rather than a private guard method, since AuthService's login and register flows need to produce a token directly, not through a guard instance tied to an existing request. --- app/auth/jwt_guard.ts | 131 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 app/auth/jwt_guard.ts diff --git a/app/auth/jwt_guard.ts b/app/auth/jwt_guard.ts new file mode 100644 index 0000000..e68dda4 --- /dev/null +++ b/app/auth/jwt_guard.ts @@ -0,0 +1,131 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { errors, symbols } from '@adonisjs/auth' +import type { AuthClientResponse, GuardContract } from '@adonisjs/auth/types' +import { SignJWT, jwtVerify } from 'jose' +import env from '#start/env' +import User from '#models/user' + +/** + * The installed @adonisjs/auth version ships session, basic auth and access + * tokens guards only, no JWT guard. This is a minimal custom guard following + * the package's own GuardContract, backed by jose for signing and + * verification, so the rest of the app can call `ctx.auth.use('jwt')` the + * same way it would call any built in guard. + * + * The guard's only job is to prove the token is validly signed, not expired, + * and resolve to a real User. It deliberately does not check the jti + * blacklist or the user's enabled/accountLocked flags, those are + * AuthMiddleware's job, layered on top of authenticate() rather than baked + * into the guard, so a route that only needs "is this a valid token" without + * the blacklist round trip can use the guard directly. + */ +export type JwtPayload = { + sub: string + jti: string + iat: number + exp: number +} + +function secretKey() { + return new TextEncoder().encode(env.get('JWT_SECRET')) +} + +/** + * JWT_EXPIRY is documented as either a bare number of seconds ("3600") or a + * jose style time span ("7d", "24h"). jose's own string parser requires a + * unit suffix, so a purely numeric string is converted to a number first. + */ +function expiresIn(): number | string { + const value = env.get('JWT_EXPIRY') + return /^\d+$/.test(value) ? Number(value) : value +} + +export async function signJwt(user: User): Promise { + return new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(String(user.id)) + .setJti(crypto.randomUUID()) + .setIssuedAt() + .setExpirationTime(expiresIn()) + .sign(secretKey()) +} + +export class JwtGuard implements GuardContract { + #ctx: HttpContext + + readonly driverName = 'jwt' as const + user?: User + payload?: JwtPayload + isAuthenticated = false + authenticationAttempted = false; + + [symbols.GUARD_KNOWN_EVENTS]: unknown + + constructor(ctx: HttpContext) { + this.#ctx = ctx + } + + getUserOrFail(): User { + if (!this.user) { + throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + } + return this.user + } + + async authenticate(): Promise { + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + this.authenticationAttempted = true + + const authHeader = this.#ctx.request.header('authorization') + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null + if (!token) { + throw new errors.E_UNAUTHORIZED_ACCESS('No JWT token provided', { + guardDriverName: this.driverName, + }) + } + + let payload: JwtPayload + try { + const result = await jwtVerify(token, secretKey()) + payload = result.payload + } catch { + throw new errors.E_UNAUTHORIZED_ACCESS('Invalid or expired JWT token', { + guardDriverName: this.driverName, + }) + } + + const user = await User.find(Number(payload.sub)) + if (!user) { + throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + } + + this.payload = payload + this.user = user + this.isAuthenticated = true + return user + } + + async check(): Promise { + try { + await this.authenticate() + return true + } catch { + return false + } + } + + async authenticateAsClient(user: User): Promise { + const token = await signJwt(user) + return { headers: { authorization: `Bearer ${token}` } } + } +} + +export function jwtGuard() { + return (ctx: HttpContext) => new JwtGuard(ctx) +} From 31a5f3182a9d05a53b202a916c63fb1cf0072cc3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:50:52 +0400 Subject: [PATCH 18/89] feat(auth): switch the default guard to jwt feature/data-modeling left a session guard configured here purely as a placeholder so the app would boot without the tokens guard the old scaffold's User model could no longer support. This project authenticates every request statelessly through a JWT in the Authorization header, never through a cookie backed session, so the session guard was never going to be more than scaffolding. The default guard is now jwt, backed by the JwtGuard class this same branch adds, which is what AuthMiddleware, RoleMiddleware and every protected route from here on will actually authenticate against. --- config/auth.ts | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/config/auth.ts b/config/auth.ts index c3a4707..54219ac 100644 --- a/config/auth.ts +++ b/config/auth.ts @@ -1,34 +1,19 @@ import { defineConfig } from '@adonisjs/auth' -import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' +import { jwtGuard } from '#auth/jwt_guard' import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' -// NOTE: the stateless JWT guard described in the README belongs to -// feature/auth. This branch only needs the User model to resolve through -// an auth guard well enough for the app to boot and typecheck; the -// tokens guard from the default --kit=api scaffold required an -// accessTokens provider on User that the EER_AUTH design does not use, -// so it is dropped here rather than kept alongside a model that no -// longer supports it. feature/auth replaces this guard with the JWT one. const authConfig = defineConfig({ /** * Default guard used when no guard is explicitly specified. */ - default: 'web', + default: 'jwt', guards: { /** - * Session-based guard for browser authentication. + * Stateless guard backed by the custom JwtGuard in app/auth/jwt_guard.ts, + * the only guard this project authenticates requests with. */ - web: sessionGuard({ - /** - * Enable persistent login using remember-me tokens. - */ - useRememberMeTokens: false, - - provider: sessionUserProvider({ - model: () => import('#models/user'), - }), - }), + jwt: jwtGuard(), }, }) From 36894f855e579f33965c99c727cdb4feb1a33a6e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:01:35 +0400 Subject: [PATCH 19/89] feat(auth): add VineJS schemas for the auth endpoints The README only names three of the six schemas this controller needs (registerSchema, loginSchema, resetPasswordSchema) and leaves activate and forgot-password unspecified. Both of those turned out to need their own compiled schema anyway, an opaque token string for activate and an email lookup for forgot-password, since the hard rule against inline VineJS schemas in controllers applies to every action, not only the three the README happened to name. The password complexity rule (lowercase, uppercase, digit, on top of VineJS's own minLength(8)) is a judgment call the README leaves open by only asking for "a complexity regex" without specifying one. It is shared between registerSchema and resetPasswordSchema so a password set through either path meets the same bar, and deliberately left out of loginSchema, which only checks whatever is already stored and should fail with a credentials error rather than a validation error when the input is merely short. Field length caps on firstName, lastName and email mirror the users table's VARCHAR limits from the README's Auth Model section, so a too-long value is rejected as a 422 before it ever reaches a query that would otherwise truncate or error against the database column. --- app/validators/auth_validator.ts | 89 ++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 app/validators/auth_validator.ts diff --git a/app/validators/auth_validator.ts b/app/validators/auth_validator.ts new file mode 100644 index 0000000..313ae66 --- /dev/null +++ b/app/validators/auth_validator.ts @@ -0,0 +1,89 @@ +import vine from '@vinejs/vine' + +/** + * Password complexity rule shared by registration and password reset. + * + * The README only specifies `minLength(8)` plus "a complexity regex" + * without naming the exact rule, so this project settles on the common + * baseline of at least one lowercase letter, one uppercase letter and one + * digit. `minLength(8)` is still applied separately below since this + * regex alone would also accept a 3-character password. + * + * NOTE: this decision (the exact complexity rule) is not spelled out in + * the README and is left to this validator's judgment. + */ +const passwordComplexity = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/ + +/** + * POST /api/v1/auth/register + * + * Field lengths mirror the `users` table constraints from the README's + * Auth Model section (first_name VARCHAR(50), last_name VARCHAR(100), + * email VARCHAR(100)) so a payload that would violate the DB column length + * is rejected as a 422 validation error instead of a raw DB error. + */ +export const registerSchema = vine.compile( + vine.object({ + firstName: vine.string().trim().minLength(1).maxLength(50), + lastName: vine.string().trim().minLength(1).maxLength(100), + email: vine.string().trim().maxLength(100).email(), + password: vine.string().minLength(8).regex(passwordComplexity), + }) +) + +/** + * POST /api/v1/auth/login + * + * The password field intentionally has no minLength/complexity check here: + * login validates a credential against whatever is already stored, so + * rejecting a short password before it even reaches AuthService.login + * would turn a "wrong password" case into a confusing 422 instead of the + * expected 400 E_INVALID_CREDENTIALS. + */ +export const loginSchema = vine.compile( + vine.object({ + email: vine.string().trim().email(), + password: vine.string(), + }) +) + +/** + * POST /api/v1/auth/activate + * + * NOTE: the README does not spell out this schema's shape; it only lists + * `registerSchema`, `loginSchema` and `resetPasswordSchema` explicitly and + * leaves the rest to "whatever additional schemas the other AuthController + * actions genuinely need". Activation is a single opaque token round trip, + * so a bare token string is all that is required. + */ +export const activateSchema = vine.compile( + vine.object({ + token: vine.string().trim().minLength(1), + }) +) + +/** + * POST /api/v1/auth/forgot-password + * + * NOTE: same as above, not enumerated in the README. Only an email is + * needed to look up the account and issue a PasswordResetToken. + */ +export const forgotPasswordSchema = vine.compile( + vine.object({ + email: vine.string().trim().email(), + }) +) + +/** + * POST /api/v1/auth/reset-password + * + * Reuses the same complexity rule as registration since both operations + * end with the same @beforeSave hashing hook on User and should not allow + * a weaker password through the back door of the reset flow. + */ +export const resetPasswordSchema = vine.compile( + vine.object({ + token: vine.string().trim().minLength(1), + password: vine.string().minLength(8).regex(passwordComplexity), + }) +) From 169166f0696f03b88f007e658b14d23f0bf08fc8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:01:43 +0400 Subject: [PATCH 20/89] feat(auth): reject blacklisted JWTs in AuthMiddleware JwtGuard proves a token is validly signed and not expired, nothing more, by design (see app/auth/jwt_guard.ts). Revocation is a separate concern: once logout writes a token's jti into blacklisted_tokens, that token's signature is still perfectly valid until it naturally expires, so something has to reject it anyway. This middleware is that something, and it runs on every route that declares middleware.auth(), which is also the only place JwtGuard is ever invoked, so there is no path into the app that skips the check. The jti is read from ctx.auth.use('jwt').payload after authenticateUsing() succeeds, then looked up against blacklisted_tokens with a single findBy. A hit returns 401 directly from the middleware rather than throwing, matching the README's own code sample for this file, and the response body is built out by hand to match the ApiResponse envelope (including timestamp and path, which the README's illustrative snippet omits for brevity but which every other error path in this app, including the global exception handler, includes) since this early return never reaches that handler. --- app/middleware/auth_middleware.ts | 37 +++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts index 8c86950..0c87e0f 100644 --- a/app/middleware/auth_middleware.ts +++ b/app/middleware/auth_middleware.ts @@ -1,10 +1,23 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' import type { Authenticators } from '@adonisjs/auth/types' +import BlacklistedToken from '#models/blacklisted_token' /** - * Auth middleware is used authenticate HTTP requests and deny - * access to unauthenticated users. + * Auth middleware authenticates HTTP requests and denies access to + * unauthenticated users. It also rejects requests carrying a JWT that has + * been explicitly revoked (logout writes the token's `jti` into + * `blacklisted_tokens`). + * + * The `jti` check lives here rather than inside JwtGuard itself. JwtGuard + * (app/auth/jwt_guard.ts) is deliberately scoped to proving a token is + * validly signed, not expired, and resolves to a real user, nothing more. + * Blacklisting is an application-level policy, not a property of the + * token's cryptographic validity, so it is layered on top of + * `authenticateUsing()` here instead of being baked into the guard. Every + * route that applies this middleware gets the check; nothing bypasses it + * by calling the guard directly, since the guard is never used outside + * this middleware and RoleMiddleware (which always runs after this one). */ export default class AuthMiddleware { async handle( @@ -15,6 +28,26 @@ export default class AuthMiddleware { } = {} ) { await ctx.auth.authenticateUsing(options.guards) + + const payload = ctx.auth.use('jwt').payload + const jti = payload?.jti + + // NOTE: `jti` is only present when the request actually authenticated + // via the jwt guard. If a future guard is added to `options.guards` + // and wins the authentication race, there is no jti to check against + // the blacklist and this step is a no-op rather than a hard failure. + if (jti) { + const blacklisted = await BlacklistedToken.findBy('jti', jti) + if (blacklisted) { + return ctx.response.unauthorized({ + success: false, + message: 'Token has been revoked', + timestamp: new Date().toISOString(), + path: ctx.request.url(), + }) + } + } + return next() } } From d41f347827185975aaeee474dc3d7abd16738b4f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:01:51 +0400 Subject: [PATCH 21/89] feat(auth): enforce role checks in RoleMiddleware The stub from feature/data-modeling passed every request through unconditionally so route files could reference middleware.role() before this branch existed. This replaces that stub with the real check: authenticate, load the roles relation, and compare role names against the options.roles argument passed at the route declaration site, returning 403 in the ApiResponse shape when none match. The explicit user.load('roles') call is not optional. The README's troubleshooting section calls out, by name, that auth.authenticate() never preloads relations and that skipping this load is the most common reason RoleMiddleware ends up rejecting a user who does hold the right role, since user.roles would silently be an empty array rather than throwing. Calling auth.authenticate() again here, after AuthMiddleware already called it earlier in the same request, is safe and cheap: JwtGuard tracks authenticationAttempted internally, so a second call in the same request returns the already-resolved user instead of re-verifying the token from scratch. --- app/middleware/role_middleware.ts | 33 +++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/middleware/role_middleware.ts b/app/middleware/role_middleware.ts index f7f0d90..fb06a2e 100644 --- a/app/middleware/role_middleware.ts +++ b/app/middleware/role_middleware.ts @@ -13,14 +13,35 @@ import type { NextFn } from '@adonisjs/core/types/http' * single middleware class enforce different role requirements on different * routes without duplicating logic. * - * Full implementation (JWT lookup, role preloading, forbidden response) is - * added in feature/auth once the User model and auth guard are in place. - * Registering the stub here lets route declarations reference `middleware.role()` - * from day one without causing import errors. + * This middleware always runs after `middleware.auth()` on a route (never + * standalone, per the constraint that auth and role middleware are wired + * at route declaration time), so the jti blacklist check has already + * happened by the time `handle()` runs here. Calling `auth.authenticate()` + * again below is cheap: JwtGuard's `authenticationAttempted` flag makes a + * second call within the same request return the already-resolved user + * instead of re-verifying the token. */ export default class RoleMiddleware { - /** Passes the request through unconditionally until feature/auth wires the real check. */ - async handle(_ctx: HttpContext, next: NextFn, _options: { roles: string[] }) { + async handle(ctx: HttpContext, next: NextFn, options: { roles: string[] }) { + await ctx.auth.authenticate() + const user = ctx.auth.user! + + // `auth.authenticate()` resolves the user but never preloads relations. + // Reading `user.roles` without this call would silently see an empty + // array and reject every request, the exact bug called out in the + // README's troubleshooting section for this middleware. + await user.load('roles') + + const hasRole = user.roles.some((role) => options.roles.includes(role.roleName)) + if (!hasRole) { + return ctx.response.forbidden({ + success: false, + message: 'Insufficient role', + timestamp: new Date().toISOString(), + path: ctx.request.url(), + }) + } + return next() } } From ce30d011c56947efb19eb690fbb20137074ba444 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:02:03 +0400 Subject: [PATCH 22/89] feat(auth): add AuthService covering the full account lifecycle This is the only place in the app that touches User, ActivationToken, BlacklistedToken or PasswordResetToken for anything auth-related, per the hard rule against direct model access in controllers. register, activate, login, logout, forgotPassword, resetPassword and me each map to one method, matching the README's task list exactly, plus a refresh method the task list does not enumerate by name but the Endpoints table requires ("Issue new JWT (if not blacklisted)"). Password hashing never happens here; User's @beforeSave hook does that on every create and save, so register and resetPassword just assign the plain password and let the model handle it. Two decisions the README leaves open are worth calling out. First, register and forgotPassword both return their raw token (activation token and password reset token respectively) directly in the service result rather than only persisting it, because there is no mail provider configured on this branch and the README documents exactly this placeholder for forgotPassword; register needed the same treatment for the register -> activate -> login functional test flow to be possible at all. Second, login and the credentials branch of authentication raise the same E_INVALID_CREDENTIALS whether the email does not exist or the password is wrong, so the response never confirms which case occurred; forgotPassword does not extend that same protection to email enumeration and uses findByOrFail instead, a deliberate tradeoff toward explicit, testable behavior over that particular hardening within this tutorial's scope. Business exceptions (account not activated, account locked, token already used, token expired, invalid reset token) are defined with createError from @adonisjs/core/exceptions rather than thrown as raw Error, each carrying its own status and code so the global exception handler's generic "has a numeric status" branch formats them correctly without any auth-specific handling added to that file. --- app/services/auth_service.ts | 263 +++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 app/services/auth_service.ts diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts new file mode 100644 index 0000000..18abda8 --- /dev/null +++ b/app/services/auth_service.ts @@ -0,0 +1,263 @@ +import { errors as authErrors } from '@adonisjs/auth' +import { createError } from '@adonisjs/core/exceptions' +import hash from '@adonisjs/core/services/hash' +import { DateTime } from 'luxon' +import type { Infer } from '@vinejs/vine/types' + +import User from '#models/user' +import Role from '#models/role' +import ActivationToken from '#models/activation_token' +import BlacklistedToken from '#models/blacklisted_token' +import PasswordResetToken from '#models/password_reset_token' +import { signJwt, type JwtPayload } from '#auth/jwt_guard' +import type { registerSchema, loginSchema, resetPasswordSchema } from '#validators/auth_validator' + +type RegisterPayload = Infer +type LoginPayload = Infer +type ResetPasswordPayload = Infer + +/** + * Default role every self-registered account receives. Matches the + * `roleName` seeded by database/seeders/user_seeder.ts, so registration + * only works once that seed data exists, the same dependency the seeder + * itself documents. + */ +const DEFAULT_USER_ROLE = 'USER' + +/** + * NOTE: `password_reset_tokens.type` is not enumerated anywhere in the + * README beyond "VARCHAR(255) NOT NULL"; the PasswordResetToken model's + * own NOTE leaves the concrete values up to whichever branch owns the + * reset flow. A single literal value is used here since this branch has + * exactly one kind of reset token; `resetPassword` checks it defensively + * so a token row created for a different purpose in the future cannot be + * replayed against this endpoint. + */ +const PASSWORD_RESET_TOKEN_TYPE = 'password_reset' + +/** Activation tokens are valid for 24 hours from issuance. */ +const ACTIVATION_TOKEN_TTL_HOURS = 24 + +/** Password reset tokens are valid for 1 hour, shorter-lived than activation since they grant account takeover if leaked. */ +const PASSWORD_RESET_TOKEN_TTL_HOURS = 1 + +const E_ACCOUNT_NOT_ACTIVATED = createError( + 'Account is not activated', + 'E_ACCOUNT_NOT_ACTIVATED', + 403 +) +const E_ACCOUNT_LOCKED = createError('Account is locked', 'E_ACCOUNT_LOCKED', 403) +const E_TOKEN_ALREADY_USED = createError('Token has already been used', 'E_TOKEN_ALREADY_USED', 400) +const E_TOKEN_EXPIRED = createError('Token has expired', 'E_TOKEN_EXPIRED', 400) +const E_INVALID_RESET_TOKEN = createError( + 'Invalid password reset token', + 'E_INVALID_RESET_TOKEN', + 400 +) + +/** + * Encapsulates the full account lifecycle: register -> activate -> login + * -> logout -> forgot password -> reset password -> me. Controllers never + * touch User, ActivationToken, BlacklistedToken or PasswordResetToken + * directly; every read or write to those models goes through this class. + * + * This service has no constructor dependencies (every collaborator is + * either a static Lucid model or a plain imported function), so it is not + * decorated with `@inject()`. The IoC container instantiates a class with + * no constructor arguments the same way `new AuthService()` would, and + * AuthController itself is the one that needs `@inject()` since it takes + * this service as a constructor argument. + */ +export default class AuthService { + /** + * Creates the user (password hashing happens in User's @beforeSave hook, + * never here), assigns the default USER role, and issues an activation + * token. + * + * The raw activation token is returned to the caller rather than only + * being persisted. There is no mail provider wired up on this branch, + * so returning the token is the documented placeholder for delivering + * it by email, the same pattern `forgotPassword` uses below. Without + * this, the register -> activate -> login functional test flow the + * README's own task list requires would have no way to obtain the token. + */ + async register(data: RegisterPayload) { + const userRole = await Role.findByOrFail('roleName', DEFAULT_USER_ROLE) + + const user = await User.create({ + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + password: data.password, + enabled: false, + accountLocked: false, + }) + + await user.related('roles').attach([userRole.id]) + + const activationToken = await ActivationToken.create({ + userId: user.id, + token: crypto.randomUUID(), + expiresAt: DateTime.now().plus({ hours: ACTIVATION_TOKEN_TTL_HOURS }), + }) + + return { user, activationToken: activationToken.token } + } + + /** + * Validates an activation token (must exist, not already validated, not + * expired) and flips the account to enabled. + */ + async activate(token: string) { + const activationToken = await ActivationToken.query().where('token', token).firstOrFail() + + if (activationToken.validatedAt) { + throw new E_TOKEN_ALREADY_USED() + } + if (activationToken.expiresAt && activationToken.expiresAt < DateTime.now()) { + throw new E_TOKEN_EXPIRED() + } + + const user = await User.findOrFail(activationToken.userId) + user.enabled = true + await user.save() + + activationToken.validatedAt = DateTime.now() + await activationToken.save() + + return user + } + + /** + * Verifies the password with `hash.verify()`, then checks `enabled` and + * `accountLocked`, in that order, matching the README's task + * description. A missing user and a wrong password both raise the same + * `E_INVALID_CREDENTIALS` (400) so the response never confirms whether + * an email address is registered. + */ + async login(data: LoginPayload) { + const user = await User.query().where('email', data.email).first() + + if (!user || !user.password || !(await hash.verify(user.password, data.password))) { + throw new authErrors.E_INVALID_CREDENTIALS('Invalid email or password') + } + + if (!user.enabled) { + throw new E_ACCOUNT_NOT_ACTIVATED() + } + if (user.accountLocked) { + throw new E_ACCOUNT_LOCKED() + } + + const token = await signJwt(user) + return { user, token } + } + + /** + * Blacklists the JWT presented for this request by writing its `jti` + * (and the raw token, required by the NOT NULL `token` column) into + * `blacklisted_tokens`. AuthMiddleware rejects any future request + * bearing this `jti` regardless of the token's own expiry. + * + * NOTE: `blacklisted_tokens.validated_at` mirrors ActivationToken's + * column shape but has no meaning for a blacklist entry (a blacklisted + * token is never "validated"), so it is left null here. + */ + async logout(user: User, payload: JwtPayload, rawToken: string) { + await BlacklistedToken.create({ + userId: user.id, + token: rawToken, + jti: payload.jti, + blacklistedAt: DateTime.now(), + expiresAt: DateTime.fromSeconds(payload.exp), + }) + } + + /** + * Issues a fresh JWT for an already-authenticated user. + * + * NOTE: not explicitly listed among AuthService's methods in the + * README's task checklist, only the `refresh` endpoint's row in the + * Endpoints table ("Issue new JWT (if not blacklisted)"). The + * "if not blacklisted" precondition is enforced by AuthMiddleware before + * this ever runs (the route requires `middleware.auth()`), so this + * method's own job is just re-checking enabled/accountLocked, the same + * guard `login` applies, since an account can be locked after its + * original token was issued. This does not blacklist the token being + * refreshed; the README does not ask for rotation, only reissuance. + */ + async refresh(user: User) { + if (!user.enabled) { + throw new E_ACCOUNT_NOT_ACTIVATED() + } + if (user.accountLocked) { + throw new E_ACCOUNT_LOCKED() + } + + return signJwt(user) + } + + /** + * Issues a PasswordResetToken and returns the raw token. As with + * `register`'s activation token, there is no mail provider on this + * branch, so returning the raw value is the documented placeholder for + * email delivery. + * + * NOTE: a production system would return a generic success response + * regardless of whether the email exists, to avoid leaking which + * addresses are registered. This tutorial uses `findByOrFail` (404 on + * an unknown email) instead, favouring explicit, testable behaviour + * over that hardening, consistent with the rest of this branch's scope. + */ + async forgotPassword(email: string) { + const user = await User.findByOrFail('email', email) + + const resetToken = await PasswordResetToken.create({ + userId: user.id, + token: crypto.randomUUID(), + type: PASSWORD_RESET_TOKEN_TYPE, + expiryDate: DateTime.now().plus({ hours: PASSWORD_RESET_TOKEN_TTL_HOURS }), + }) + + return resetToken.token + } + + /** + * Validates the token's type and expiry, updates the password (hashed + * again by User's @beforeSave hook, not here), then deletes the token + * so it cannot be replayed. + */ + async resetPassword(data: ResetPasswordPayload) { + const resetToken = await PasswordResetToken.query().where('token', data.token).firstOrFail() + + if (resetToken.type !== PASSWORD_RESET_TOKEN_TYPE) { + throw new E_INVALID_RESET_TOKEN() + } + if (resetToken.expiryDate < DateTime.now()) { + throw new E_TOKEN_EXPIRED() + } + + const user = await User.findOrFail(resetToken.userId) + user.password = data.password + await user.save() + + await resetToken.delete() + + return user + } + + /** + * Returns the authenticated user with `roles` and, for each role, its + * `permissions` preloaded, per the README's task description ("preloaded + * roles and permissions"). `auth.user` never auto-loads relations (the + * same caveat AuthMiddleware and RoleMiddleware work around), so this + * explicit preload is required here too. + */ + async me(user: User) { + await user.load('roles', (rolesQuery) => { + rolesQuery.preload('permissions') + }) + + return user + } +} From bb9e49533e38fe308438d2552aee5dee928b8671 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:02:12 +0400 Subject: [PATCH 23/89] feat(auth): add AuthController as a thin layer over AuthService Every action follows the same shape: validateUsing the matching schema from auth_validator.ts, call the corresponding AuthService method, wrap the result with respond(). None of the account lifecycle logic (password verification, token validation, role assignment) lives here, it all lives in AuthService, which is what the hard rule against business logic in controllers actually means in practice for this branch. The class is decorated with @inject() since its constructor takes AuthService as a dependency, resolved automatically by the IoC container. logout and refresh both read from ctx.auth.use('jwt') rather than calling ctx.auth.authenticate() again, since middleware.auth() on those routes has already authenticated the request and rejected a blacklisted jti before the action runs; re-authenticating here would just be redundant work against an already-cached guard instance. logout additionally needs the raw bearer token string, which JwtGuard does not expose after authentication (it only exposes the resolved user and the decoded payload), so it is pulled directly off the Authorization header here and passed to the service, which needs it for the NOT NULL token column on blacklisted_tokens. --- app/controllers/auth_controller.ts | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 app/controllers/auth_controller.ts diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts new file mode 100644 index 0000000..3e553c6 --- /dev/null +++ b/app/controllers/auth_controller.ts @@ -0,0 +1,85 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import AuthService from '#services/auth_service' +import { + registerSchema, + activateSchema, + loginSchema, + forgotPasswordSchema, + resetPasswordSchema, +} from '#validators/auth_validator' + +/** + * Thin HTTP layer over AuthService: every action validates its input with + * a compiled VineJS schema, delegates to the service, and wraps the + * result with `respond()`. No business logic and no direct model access + * live here, per the project's hard rules; AuthService owns all of that. + */ +@inject() +export default class AuthController { + constructor(private authService: AuthService) {} + + async register({ request, response }: HttpContext) { + const data = await request.validateUsing(registerSchema) + const result = await this.authService.register(data) + return response.created(respond(result, 'User registered, activation token issued')) + } + + async activate({ request, response }: HttpContext) { + const { token } = await request.validateUsing(activateSchema) + const user = await this.authService.activate(token) + return response.ok(respond(user, 'Account activated')) + } + + async login({ request, response }: HttpContext) { + const data = await request.validateUsing(loginSchema) + const result = await this.authService.login(data) + return response.ok(respond(result, 'Login successful')) + } + + /** + * `middleware.auth()` on this route has already authenticated the + * request and rejected a blacklisted jti before this action ever runs, + * so the guard's cached user and payload are read directly rather than + * re-authenticating. The raw bearer token itself is only available on + * the request header (JwtGuard resolves the user and payload from it + * but does not expose the token string back out), so it is extracted + * here and handed to the service, which needs it for the NOT NULL + * `token` column on `blacklisted_tokens`. + */ + async logout({ auth, request, response }: HttpContext) { + const guard = auth.use('jwt') + const user = guard.getUserOrFail() + const payload = guard.payload! + const rawToken = request.header('authorization')!.replace(/^Bearer\s+/, '') + + await this.authService.logout(user, payload, rawToken) + return response.ok(respond(null, 'Logged out successfully')) + } + + async refresh({ auth, response }: HttpContext) { + const user = auth.use('jwt').getUserOrFail() + const token = await this.authService.refresh(user) + return response.ok(respond({ token }, 'Token refreshed')) + } + + async forgotPassword({ request, response }: HttpContext) { + const { email } = await request.validateUsing(forgotPasswordSchema) + const token = await this.authService.forgotPassword(email) + return response.ok(respond({ token }, 'Password reset token issued')) + } + + async resetPassword({ request, response }: HttpContext) { + const data = await request.validateUsing(resetPasswordSchema) + const user = await this.authService.resetPassword(data) + return response.ok(respond(user, 'Password reset successful')) + } + + async me({ auth, response }: HttpContext) { + const user = auth.use('jwt').getUserOrFail() + const result = await this.authService.me(user) + return response.ok(respond(result, 'Current user profile')) + } +} From 25a9418078520272094a18dd220c906325eef133 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:02:23 +0400 Subject: [PATCH 24/89] feat(auth): declare auth routes and a temporary middleware smoke-test stub Every route under /api/v1/auth is declared here with its access rule applied at declaration time, never added retroactively: register, login and forgot-password carry .use(authThrottle) since they are the three endpoints an attacker would script against (credential stuffing, mass account creation, reset-token flooding), while activate and reset-password are deliberately left unthrottled because an unguessable token is already their real defense, and logout/refresh/me are protected by middleware.auth() instead, which already limits them to a valid, non-blacklisted bearer token. Method, path and access all match the README's Endpoints table for this branch exactly. The two /api/v1/_stub routes exist only because the README's own test checklist calls for a functional smoke test proving AuthMiddleware and RoleMiddleware actually work end to end (401 with no token, 200 with a valid USER token, 403 for USER and 200 for ADMIN behind role('ADMIN')), and no real protected resource route exists yet on this branch to hit; categories, products, customers and orders are all later branches. Both routes are commented as temporary scaffolding and should be deleted once feature/categories adds its first ADMIN-guarded route, at which point that route's own functional test supersedes what these stubs exist to prove. --- start/routes.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index 5615cc8..b884820 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -13,9 +13,13 @@ */ import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' +import { authThrottle } from '#start/limiter' +import { respond } from '#helpers/api_response' // Lazy import — AdonisJS resolves the module on the first matching request const HealthController = () => import('#controllers/health_controller') +const AuthController = () => import('#controllers/auth_controller') /* |-------------------------------------------------------------------------- @@ -37,3 +41,59 @@ router.get('/api/v1/health', [HealthController, 'index']) | their respective feature branches, starting with feature/auth. | */ + +/* +|-------------------------------------------------------------------------- +| Auth +|-------------------------------------------------------------------------- +| +| register, login and forgot-password are rate limited with authThrottle +| (THROTTLE_AUTH_MAX per THROTTLE_AUTH_WINDOW seconds, per the README) since +| they are the endpoints an attacker would script against — credential +| stuffing on login, mass account creation on register, and reset-token +| flooding on forgot-password. activate and reset-password are excluded: +| both already require an unguessable token as the actual defense, and +| logout/refresh/me are excluded because middleware.auth() already limits +| them to requests bearing a valid, non-blacklisted JWT. +| +*/ +router + .group(() => { + router.post('register', [AuthController, 'register']).use(authThrottle) + router.post('activate', [AuthController, 'activate']) + router.post('login', [AuthController, 'login']).use(authThrottle) + router.post('logout', [AuthController, 'logout']).use(middleware.auth()) + router.post('refresh', [AuthController, 'refresh']).use(middleware.auth()) + router.post('forgot-password', [AuthController, 'forgotPassword']).use(authThrottle) + router.post('reset-password', [AuthController, 'resetPassword']) + router.get('me', [AuthController, 'me']).use(middleware.auth()) + }) + .prefix('/api/v1/auth') + +/* +|-------------------------------------------------------------------------- +| Temporary middleware smoke-test stubs (feature/auth only) +|-------------------------------------------------------------------------- +| +| feature/auth's task list requires a functional test proving AuthMiddleware +| and RoleMiddleware actually reject/allow requests (401 with no token, 200 +| with a valid USER token, 403 for USER / 200 for ADMIN behind role('ADMIN')). +| No real protected resource route exists yet - categories, products, +| customers and orders are all later branches - so these two routes exist +| solely to give that test something to hit. +| +| DELETE both routes once feature/categories adds its first ADMIN-protected +| route; they carry no business meaning of their own. +| +*/ +router + .get('/api/v1/_stub/protected', ({ response }) => { + return response.ok(respond({ ok: true }, 'Protected stub reached')) + }) + .use(middleware.auth()) + +router + .get('/api/v1/_stub/protected-admin', ({ response }) => { + return response.ok(respond({ ok: true }, 'Protected admin stub reached')) + }) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) From 72b62448f09076f63541a88988f6b2a9c7c2e00a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:02:39 +0400 Subject: [PATCH 25/89] chore(codegen): regenerate the client and server registries These files are generated automatically from the controllers and routes present in the app, and each carries its own header warning against hand edits. Adding auth_controller.ts and the /api/v1/auth routes (plus the two temporary middleware smoke-test stubs) changed what the app actually exposes, so the checked-in snapshots were out of date the moment those files were added. Running node ace codegen regenerated the client-side route registry (used by the Tuyau API client) and the server-side controllers and routes type declarations to match. No source file changed here, only the generated reflection of what already exists elsewhere in the tree. --- .adonisjs/client/registry/index.ts | 60 ++++++++++++---- .adonisjs/client/registry/schema.d.ts | 100 ++++++++++++++++++++------ .adonisjs/client/registry/tree.d.ts | 25 +++---- .adonisjs/server/controllers.ts | 1 + .adonisjs/server/routes.d.ts | 29 +++++--- 5 files changed, 157 insertions(+), 58 deletions(-) diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index 298b217..44f2010 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -6,29 +6,59 @@ import type { ApiDefinition } from './tree.d.ts' const placeholder: any = {} const routes = { - 'auth.new_account.store': { + 'health.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/health', + tokens: [{"old":"/api/v1/health","type":0,"val":"api","end":""},{"old":"/api/v1/health","type":0,"val":"v1","end":""},{"old":"/api/v1/health","type":0,"val":"health","end":""}], + types: placeholder as Registry['health.index']['types'], + }, + 'auth.register': { + methods: ["POST"], + pattern: '/api/v1/auth/register', + tokens: [{"old":"/api/v1/auth/register","type":0,"val":"api","end":""},{"old":"/api/v1/auth/register","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/register","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/register","type":0,"val":"register","end":""}], + types: placeholder as Registry['auth.register']['types'], + }, + 'auth.activate': { methods: ["POST"], - pattern: '/api/v1/auth/signup', - tokens: [{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}], - types: placeholder as Registry['auth.new_account.store']['types'], + pattern: '/api/v1/auth/activate', + tokens: [{"old":"/api/v1/auth/activate","type":0,"val":"api","end":""},{"old":"/api/v1/auth/activate","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/activate","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/activate","type":0,"val":"activate","end":""}], + types: placeholder as Registry['auth.activate']['types'], }, - 'auth.access_tokens.store': { + 'auth.login': { methods: ["POST"], pattern: '/api/v1/auth/login', tokens: [{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}], - types: placeholder as Registry['auth.access_tokens.store']['types'], + types: placeholder as Registry['auth.login']['types'], }, - 'profile.profile.show': { - methods: ["GET","HEAD"], - pattern: '/api/v1/account/profile', - tokens: [{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}], - types: placeholder as Registry['profile.profile.show']['types'], + 'auth.logout': { + methods: ["POST"], + pattern: '/api/v1/auth/logout', + tokens: [{"old":"/api/v1/auth/logout","type":0,"val":"api","end":""},{"old":"/api/v1/auth/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/logout","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/logout","type":0,"val":"logout","end":""}], + types: placeholder as Registry['auth.logout']['types'], }, - 'profile.access_tokens.destroy': { + 'auth.refresh': { methods: ["POST"], - pattern: '/api/v1/account/logout', - tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}], - types: placeholder as Registry['profile.access_tokens.destroy']['types'], + pattern: '/api/v1/auth/refresh', + tokens: [{"old":"/api/v1/auth/refresh","type":0,"val":"api","end":""},{"old":"/api/v1/auth/refresh","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/refresh","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/refresh","type":0,"val":"refresh","end":""}], + types: placeholder as Registry['auth.refresh']['types'], + }, + 'auth.forgot_password': { + methods: ["POST"], + pattern: '/api/v1/auth/forgot-password', + tokens: [{"old":"/api/v1/auth/forgot-password","type":0,"val":"api","end":""},{"old":"/api/v1/auth/forgot-password","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/forgot-password","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/forgot-password","type":0,"val":"forgot-password","end":""}], + types: placeholder as Registry['auth.forgot_password']['types'], + }, + 'auth.reset_password': { + methods: ["POST"], + pattern: '/api/v1/auth/reset-password', + tokens: [{"old":"/api/v1/auth/reset-password","type":0,"val":"api","end":""},{"old":"/api/v1/auth/reset-password","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/reset-password","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/reset-password","type":0,"val":"reset-password","end":""}], + types: placeholder as Registry['auth.reset_password']['types'], + }, + 'auth.me': { + methods: ["GET","HEAD"], + pattern: '/api/v1/auth/me', + tokens: [{"old":"/api/v1/auth/me","type":0,"val":"api","end":""},{"old":"/api/v1/auth/me","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/me","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/me","type":0,"val":"me","end":""}], + types: placeholder as Registry['auth.me']['types'], }, } as const satisfies Record diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index 82c02fd..4684849 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -7,52 +7,112 @@ import type { InferInput, SimpleError } from '@vinejs/vine/types' export type ParamValue = string | number | bigint | boolean export interface Registry { - 'auth.new_account.store': { + 'health.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/health' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.register': { + methods: ["POST"] + pattern: '/api/v1/auth/register' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'auth.activate': { methods: ["POST"] - pattern: '/api/v1/auth/signup' + pattern: '/api/v1/auth/activate' types: { - body: ExtractBody> + body: ExtractBody> paramsTuple: [] params: {} - query: ExtractQuery> - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } - 'auth.access_tokens.store': { + 'auth.login': { methods: ["POST"] pattern: '/api/v1/auth/login' types: { - body: ExtractBody> + body: ExtractBody> paramsTuple: [] params: {} - query: ExtractQuery> - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } - 'profile.profile.show': { - methods: ["GET","HEAD"] - pattern: '/api/v1/account/profile' + 'auth.logout': { + methods: ["POST"] + pattern: '/api/v1/auth/logout' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.refresh': { + methods: ["POST"] + pattern: '/api/v1/auth/refresh' types: { body: {} paramsTuple: [] params: {} query: {} - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.forgot_password': { + methods: ["POST"] + pattern: '/api/v1/auth/forgot-password' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } - 'profile.access_tokens.destroy': { + 'auth.reset_password': { methods: ["POST"] - pattern: '/api/v1/account/logout' + pattern: '/api/v1/auth/reset-password' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'auth.me': { + methods: ["GET","HEAD"] + pattern: '/api/v1/auth/me' types: { body: {} paramsTuple: [] params: {} query: {} - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> } } } diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index 3964805..39b6c5e 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -2,20 +2,17 @@ import type { routes } from './index.ts' export interface ApiDefinition { - auth: { - newAccount: { - store: typeof routes['auth.new_account.store'] - } - accessTokens: { - store: typeof routes['auth.access_tokens.store'] - } + health: { + index: typeof routes['health.index'] } - profile: { - profile: { - show: typeof routes['profile.profile.show'] - } - accessTokens: { - destroy: typeof routes['profile.access_tokens.destroy'] - } + auth: { + register: typeof routes['auth.register'] + activate: typeof routes['auth.activate'] + login: typeof routes['auth.login'] + logout: typeof routes['auth.logout'] + refresh: typeof routes['auth.refresh'] + forgotPassword: typeof routes['auth.forgot_password'] + resetPassword: typeof routes['auth.reset_password'] + me: typeof routes['auth.me'] } } diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 91cd9f0..98852cf 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -4,5 +4,6 @@ */ export const controllers = { + Auth: () => import('#controllers/auth_controller'), Health: () => import('#controllers/health_controller'), } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index 9aa155f..304b0e3 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -4,21 +4,32 @@ type ParamValue = string | number | bigint | boolean export type ScannedRoutes = { ALL: { - 'auth.new_account.store': { paramsTuple?: []; params?: {} } - 'auth.access_tokens.store': { paramsTuple?: []; params?: {} } - 'profile.profile.show': { paramsTuple?: []; params?: {} } - 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} } + 'health.index': { paramsTuple?: []; params?: {} } + 'auth.register': { paramsTuple?: []; params?: {} } + 'auth.activate': { paramsTuple?: []; params?: {} } + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'auth.refresh': { paramsTuple?: []; params?: {} } + 'auth.forgot_password': { paramsTuple?: []; params?: {} } + 'auth.reset_password': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } } GET: { - 'profile.profile.show': { paramsTuple?: []; params?: {} } + 'health.index': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } } HEAD: { - 'profile.profile.show': { paramsTuple?: []; params?: {} } + 'health.index': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } } POST: { - 'auth.new_account.store': { paramsTuple?: []; params?: {} } - 'auth.access_tokens.store': { paramsTuple?: []; params?: {} } - 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} } + 'auth.register': { paramsTuple?: []; params?: {} } + 'auth.activate': { paramsTuple?: []; params?: {} } + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'auth.refresh': { paramsTuple?: []; params?: {} } + 'auth.forgot_password': { paramsTuple?: []; params?: {} } + 'auth.reset_password': { paramsTuple?: []; params?: {} } } } declare module '@adonisjs/core/types/http' { From 696ab37488a4c06c36e15ca5d8ca63ed2e8230fb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:15:51 +0400 Subject: [PATCH 26/89] test(auth): add shared login and fixture helpers for functional auth tests There was no shared way for a functional test to obtain a JWT for one of the seeded ADMIN or USER accounts, which would have meant duplicating a login request and credential lookup in every spec file that needs an authenticated request. This helper reads TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_USER_EMAIL and TEST_USER_PASSWORD from the typed env service and logs in through the real POST /api/v1/auth/login endpoint, so callers exercise the same credential check and token issuance path a real client goes through instead of forging a token directly. The file also exports ensureRole and ensureSeededUser. While writing these tests it became clear that tests/unit/database/migrations.spec.ts rolls every migration down and back up as part of its own coverage, which drops all seed data as a real side effect since that test does not wrap the migrator in a rollback-able transaction. When the unit and functional suites run together in one process, any auth test running after that migration test would otherwise find the roles table and the seeded accounts empty. These two helpers make each test group that needs that reference data create it for itself inside its own rolled-back transaction, so this branch's tests do not depend on seed data planted by an unrelated file earlier in the run. --- tests/helpers/auth_helper.ts | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/helpers/auth_helper.ts diff --git a/tests/helpers/auth_helper.ts b/tests/helpers/auth_helper.ts new file mode 100644 index 0000000..bb50bd2 --- /dev/null +++ b/tests/helpers/auth_helper.ts @@ -0,0 +1,91 @@ +/** + * Shared login helper for functional tests that need a JWT for one of the + * seeded accounts (feature/data-modeling's UserSeeder). Credentials are read + * from the typed env service, never hardcoded, so this helper stays correct + * if the seeded passwords ever change in .env.test. + * + * The token is obtained through the real POST /api/v1/auth/login endpoint + * rather than by signing a JWT directly, so callers exercise the same + * credential-check and JWT-issuance path a real client would go through. + * + * This file also exports `ensureRole` and `ensureSeededUser`. They exist + * because tests/unit/database/migrations.spec.ts (from feature/data-modeling, + * not touched by this branch) rolls every migration down and back up as part + * of its own coverage, which drops all seed data as a real side effect since + * that test intentionally does not wrap the migrator in a rollback-able + * transaction. When the unit and functional suites run in the same process + * (the default for `node ace test`), any auth test that runs after that + * migration test would otherwise find the roles table and the seeded + * ADMIN/USER accounts empty. These helpers make each test group + * self-sufficient by ensuring the reference data it needs exists, inside + * that group's own rolled-back global transaction, instead of depending on + * seed data planted by an unrelated file earlier in the run. + */ +import type { ApiClient } from '@japa/api-client' +import env from '#start/env' +import Role from '#models/role' +import User from '#models/user' + +export type SeededRole = 'admin' | 'user' + +function credentialsFor(role: SeededRole): { email: string; password: string } { + if (role === 'admin') { + return { + email: env.get('TEST_ADMIN_EMAIL')!, + password: env.get('TEST_ADMIN_PASSWORD')!, + } + } + + return { + email: env.get('TEST_USER_EMAIL')!, + password: env.get('TEST_USER_PASSWORD')!, + } +} + +/** + * Ensures a role with the given name exists, creating it when it is + * missing. Idempotent: safe to call from every test group that depends on + * the role being present. + */ +export async function ensureRole(roleName: string): Promise { + return Role.firstOrCreate({ roleName }, { roleName }) +} + +/** + * Ensures the seeded ADMIN or USER account (matching the TEST_*_EMAIL / + * TEST_*_PASSWORD env vars) exists, is enabled, and has its role attached. + */ +export async function ensureSeededUser(role: SeededRole): Promise { + const { email, password } = credentialsFor(role) + const roleName = role === 'admin' ? 'ADMIN' : 'USER' + const roleRow = await ensureRole(roleName) + + let user = await User.findBy('email', email) + if (!user) { + user = await User.create({ + firstName: role === 'admin' ? 'Seeded' : 'Seeded', + lastName: role === 'admin' ? 'Admin' : 'User', + email, + password, + enabled: true, + accountLocked: false, + }) + } + + await user.related('roles').sync([roleRow.id], false) + + return user +} + +/** + * Logs in as the seeded ADMIN or USER account through the real login + * endpoint and returns the issued JWT string. + */ +export async function loginAsSeeded(client: ApiClient, role: SeededRole): Promise { + const { email, password } = credentialsFor(role) + + const response = await client.post('/api/v1/auth/login').json({ email, password }) + response.assertStatus(200) + + return response.body().data.token as string +} From 2e75a776788ca88ccc45f3275d4e107b1150aed5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:16:12 +0400 Subject: [PATCH 27/89] test(auth): add unit tests for AuthService register, activate, login and logout Before this commit AuthService had no test coverage at all, even though it owns the entire account lifecycle other branches will build on top of. There was nothing proving that register actually attaches the default USER role and produces a usable activation token rather than a dangling one, that activate really flips enabled and marks the token consumed so it cannot be replayed, or that login enforces its three failure paths, wrong password, locked account, inactive account, in the specific order the service documents. Getting the ordering wrong would matter in practice: a locked-but-inactive account should read back as not activated rather than locked, so the response never tells a caller more than it needs to know about why an account cannot sign in. These tests hit the real test database inside a rolled back global transaction, following the same pattern used by the model specs from feature/data-modeling, instead of mocking the Lucid models AuthService depends on. The behavior under test only means something once the role pivot row, the activation token row and the blacklist row are actually read back from the database, so asserting against a mock would only restate the implementation rather than verify it. The login tests also decode the returned JWT to confirm the jti claim used later by the blacklist check is present and correctly tied to the user's id, and the logout test confirms the raw token and jti actually land in blacklisted_tokens with the right foreign key. --- tests/unit/services/auth_service.spec.ts | 222 +++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/unit/services/auth_service.spec.ts diff --git a/tests/unit/services/auth_service.spec.ts b/tests/unit/services/auth_service.spec.ts new file mode 100644 index 0000000..5a7ad1a --- /dev/null +++ b/tests/unit/services/auth_service.spec.ts @@ -0,0 +1,222 @@ +/** + * Unit tests for AuthService, the class that owns the entire account + * lifecycle (register, activate, login, logout). Before this branch, there + * was no coverage at all proving that register actually attaches the + * default USER role and issues a usable activation token, that activate + * actually flips enabled and marks the token consumed, or that login + * enforces its three failure modes in a way callers (and the middleware + * layer built on top of it) can depend on. Getting any one of these wrong + * would not fail loudly: a missing role attach would silently leave a new + * account with no permissions, and a login check performed in the wrong + * order would leak which accounts are activated versus locked to an + * attacker probing the endpoint. + * + * These tests hit the real test database inside a rolled-back global + * transaction, following the same pattern as the model specs from + * feature/data-modeling, rather than mocking Lucid models. AuthService's + * behaviour only means anything once persisted rows (the role pivot, the + * activation token, the blacklist entry) are read back, so a mock-based + * test would just restate the implementation instead of verifying it. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import { DateTime } from 'luxon' +import { decodeJwt } from 'jose' + +import AuthService from '#services/auth_service' +import User from '#models/user' +import ActivationToken from '#models/activation_token' +import BlacklistedToken from '#models/blacklisted_token' +import type { JwtPayload } from '#auth/jwt_guard' +import { ensureRole } from '#tests/helpers/auth_helper' + +const authService = new AuthService() + +async function createUser(overrides: Partial<{ + email: string + password: string + enabled: boolean + accountLocked: boolean +}> = {}) { + return User.create({ + firstName: 'Test', + lastName: 'Account', + email: overrides.email ?? `user-${crypto.randomUUID()}@example.com`, + password: overrides.password ?? 'Password1!', + enabled: overrides.enabled ?? true, + accountLocked: overrides.accountLocked ?? false, + }) +} + +test.group('AuthService - register / activate', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + // register() looks up the USER role by name; guaranteeing it exists + // here keeps this file independent of seed data planted elsewhere. + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('register creates the user with the default USER role and an activation token', async ({ + assert, + }) => { + const email = `register-${crypto.randomUUID()}@example.com` + + const result = await authService.register({ + firstName: 'Ada', + lastName: 'Lovelace', + email, + password: 'Password1!', + }) + + assert.isNumber(result.user.id) + assert.equal(result.user.email, email) + assert.isFalse(result.user.enabled) + + await result.user.load('roles') + assert.isTrue(result.user.roles.some((role) => role.roleName === 'USER')) + + assert.isString(result.activationToken) + + const tokenRow = await ActivationToken.query() + .where('token', result.activationToken) + .firstOrFail() + assert.equal(tokenRow.userId, result.user.id) + assert.isNull(tokenRow.validatedAt) + }).timeout(10000) + + test('activate sets enabled to true and marks the activation token as used', async ({ + assert, + }) => { + const { user, activationToken } = await authService.register({ + firstName: 'Grace', + lastName: 'Hopper', + email: `activate-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + }) + + const activatedUser = await authService.activate(activationToken) + + assert.isTrue(activatedUser.enabled) + + const reloadedUser = await User.findOrFail(user.id) + assert.isTrue(reloadedUser.enabled) + + const tokenRow = await ActivationToken.query().where('token', activationToken).firstOrFail() + assert.isNotNull(tokenRow.validatedAt) + }).timeout(10000) +}) + +test.group('AuthService - login', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('throws E_INVALID_CREDENTIALS for a wrong password', async ({ assert }) => { + const email = `wrongpass-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: true, accountLocked: false }) + + try { + await authService.login({ email, password: 'WrongPass1!' }) + assert.fail('expected login to throw for a wrong password') + } catch (error) { + assert.equal(error.code, 'E_INVALID_CREDENTIALS') + assert.equal(error.status, 400) + } + }).timeout(10000) + + test('throws E_ACCOUNT_LOCKED for a locked account', async ({ assert }) => { + const email = `locked-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: true, accountLocked: true }) + + try { + await authService.login({ email, password: 'CorrectPass1!' }) + assert.fail('expected login to throw for a locked account') + } catch (error) { + assert.equal(error.code, 'E_ACCOUNT_LOCKED') + assert.equal(error.status, 403) + } + }).timeout(10000) + + test('throws E_ACCOUNT_NOT_ACTIVATED for an inactive account', async ({ assert }) => { + const email = `inactive-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: false, accountLocked: false }) + + try { + await authService.login({ email, password: 'CorrectPass1!' }) + assert.fail('expected login to throw for an inactive account') + } catch (error) { + assert.equal(error.code, 'E_ACCOUNT_NOT_ACTIVATED') + assert.equal(error.status, 403) + } + }).timeout(10000) + + test('checks enabled before accountLocked when an account is both inactive and locked', async ({ + assert, + }) => { + const email = `both-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: false, accountLocked: true }) + + try { + await authService.login({ email, password: 'CorrectPass1!' }) + assert.fail('expected login to throw') + } catch (error) { + assert.equal(error.code, 'E_ACCOUNT_NOT_ACTIVATED') + } + }).timeout(10000) + + test('returns a JWT whose payload contains a jti claim', async ({ assert }) => { + const email = `jti-${crypto.randomUUID()}@example.com` + const user = await createUser({ + email, + password: 'CorrectPass1!', + enabled: true, + accountLocked: false, + }) + + const result = await authService.login({ email, password: 'CorrectPass1!' }) + + assert.isString(result.token) + + const payload = decodeJwt(result.token) as JwtPayload + assert.isString(payload.jti) + assert.isNotEmpty(payload.jti) + assert.equal(payload.sub, String(user.id)) + }).timeout(10000) +}) + +test.group('AuthService - logout', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('inserts the jti into blacklisted_tokens', async ({ assert }) => { + const user = await createUser({ email: `logout-${crypto.randomUUID()}@example.com` }) + + const payload: JwtPayload = { + sub: String(user.id), + jti: crypto.randomUUID(), + iat: Math.floor(DateTime.now().toSeconds()), + exp: Math.floor(DateTime.now().plus({ hours: 1 }).toSeconds()), + } + const rawToken = 'raw-token-value-for-test' + + await authService.logout(user, payload, rawToken) + + const blacklisted = await BlacklistedToken.findBy('jti', payload.jti) + assert.isNotNull(blacklisted) + assert.equal(blacklisted!.userId, user.id) + assert.equal(blacklisted!.token, rawToken) + }).timeout(10000) +}) From d94cd261038bbc0cb11608cf22bbbd4c0c16f40d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:16:22 +0400 Subject: [PATCH 28/89] test(auth): add unit test for AuthMiddleware blacklisted jti rejection AuthMiddleware's reason for existing beyond calling auth.authenticate() is rejecting a request whose token has already been logged out, and that logic had no test pinned directly to it. The functional revoked-token scenario in tests/functional/auth.spec.ts does exercise this path end to end through a real HTTP request, but a failure there could just as easily come from the login or logout endpoints as from the middleware's own blacklist query, so it would not point at the actual cause. This test isolates AuthMiddleware's handle method against a minimal fake HttpContext exposing only the auth and response surface it actually reads, so a regression in the query against blacklisted_tokens fails here specifically instead of only showing up as a vague functional failure somewhere downstream. The blacklist row itself is real and written to the test database inside a rolled back global transaction, since AuthMiddleware queries the BlacklistedToken model directly and there is nothing meaningful to mock at that layer. Two cases are covered, a jti that is present in blacklisted_tokens gets an unauthorized response and next is never called, and a jti that is not present lets the request continue through to next as normal, which is the behavior every authenticated route in the application depends on. --- tests/unit/middleware/auth_middleware.spec.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/unit/middleware/auth_middleware.spec.ts diff --git a/tests/unit/middleware/auth_middleware.spec.ts b/tests/unit/middleware/auth_middleware.spec.ts new file mode 100644 index 0000000..5f47c51 --- /dev/null +++ b/tests/unit/middleware/auth_middleware.spec.ts @@ -0,0 +1,104 @@ +/** + * Unit test for AuthMiddleware's blacklist check in isolation from the rest + * of the HTTP stack. AuthMiddleware's whole purpose beyond authenticating + * the request is rejecting a JWT whose jti has already been logged out; that + * behaviour is also exercised end to end by the functional "revoked token + * returns 401" scenario in tests/functional/auth.spec.ts, but this unit test + * pins down the middleware's own branching logic directly, without needing + * a real HTTP round trip or a signed JWT, so a regression in the query + * against blacklisted_tokens fails here specifically rather than only + * surfacing as a vague functional test failure. + * + * The middleware is exercised against a minimal fake HttpContext exposing + * just the `auth` and `response` shape it actually reads, since constructing + * a real HttpContext outside of an HTTP request is not practical. The + * blacklist row itself is real, written to the test database inside a + * rolled-back global transaction, since AuthMiddleware queries + * BlacklistedToken directly rather than through anything mockable at the + * model layer. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import { DateTime } from 'luxon' +import type { HttpContext } from '@adonisjs/core/http' + +import AuthMiddleware from '#middleware/auth_middleware' +import User from '#models/user' +import BlacklistedToken from '#models/blacklisted_token' + +function fakeCtx(jti: string) { + const responses: { unauthorized?: unknown } = {} + + const ctx = { + auth: { + authenticateUsing: async () => {}, + use: () => ({ payload: { jti } }), + }, + request: { + url: () => '/api/v1/_stub/protected', + }, + response: { + unauthorized: (body: unknown) => { + responses.unauthorized = body + return body + }, + }, + } as unknown as HttpContext + + return { ctx, responses } +} + +test.group('AuthMiddleware - blacklisted jti rejection', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('rejects a request whose jti is in blacklisted_tokens', async ({ assert }) => { + const user = await User.create({ + firstName: 'Blacklisted', + lastName: 'Token', + email: `blacklist-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + + const jti = crypto.randomUUID() + await BlacklistedToken.create({ + userId: user.id, + token: 'revoked-raw-token', + jti, + blacklistedAt: DateTime.now(), + expiresAt: DateTime.now().plus({ hours: 1 }), + }) + + const middleware = new AuthMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(jti) + + await middleware.handle(ctx, async () => { + nextCalled = true + }) + + assert.isFalse(nextCalled) + assert.isDefined(responses.unauthorized) + assert.deepInclude(responses.unauthorized, { success: false }) + }).timeout(10000) + + test('calls next() when the jti is not blacklisted', async ({ assert }) => { + const middleware = new AuthMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(crypto.randomUUID()) + + await middleware.handle(ctx, async () => { + nextCalled = true + }) + + assert.isTrue(nextCalled) + assert.isUndefined(responses.unauthorized) + }).timeout(10000) +}) From abf6965dea285fc9c0071e539d42ff2041ea20c7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:16:33 +0400 Subject: [PATCH 29/89] test(auth): add functional tests for the auth lifecycle, middleware and rate limiting This branch wires together a JWT guard, two middleware classes and an eight endpoint controller, but none of it had been exercised through a real HTTP request before this commit. Unit coverage on AuthService and AuthMiddleware proves each piece works in isolation, it does not prove register's activation token actually unlocks activate through the HTTP layer, that a token really becomes unusable on the very next request after logout rather than only in a direct service call, or that the role middleware genuinely tells an ADMIN token apart from a USER token using the seeded accounts rather than a hand built fixture. The full lifecycle test walks register, activate, login, a profile fetch, logout and a final rejected profile fetch as eight sequential requests against the running app, and the middleware smoke test hits the two temporary stub routes to confirm AuthMiddleware and RoleMiddleware actually gate access rather than merely compiling. The rate limiting test needed particular care. Register, login and forgot-password all share a single named limiter keyed by client IP, so any of them running earlier in the same process, including the login calls the other two groups in this file make to obtain tokens, consumes from that same bucket. Rather than trying to guarantee this test runs before every other request that touches those three routes, it clears the rate_limits rows for that limiter's key prefix immediately before firing its own burst of requests, so it gets a clean window regardless of what already ran and in what order. It then sends exactly THROTTLE_AUTH_MAX successful requests followed by one more, and asserts only that last one comes back as 429, which is the actual contract callers depend on rather than an assumption about how many requests came before it in this run. --- tests/functional/auth.spec.ts | 155 ++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/functional/auth.spec.ts diff --git a/tests/functional/auth.spec.ts b/tests/functional/auth.spec.ts new file mode 100644 index 0000000..a1c1a36 --- /dev/null +++ b/tests/functional/auth.spec.ts @@ -0,0 +1,155 @@ +/** + * Functional tests for the auth HTTP surface: the full account lifecycle + * through real requests, the AuthMiddleware / RoleMiddleware smoke test + * against the temporary stub routes, and rate limiting on the login route. + * + * Before this branch there was no coverage proving the pieces documented in + * app/services/auth_service.ts and app/middleware/*.ts actually compose + * correctly behind real HTTP requests: that register's activation token + * really unlocks activate, that a logged-out token is really rejected by + * the next request rather than only by a direct unit call, and that the + * role middleware really distinguishes ADMIN from USER using the seeded + * accounts rather than some hand-rolled fixture. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import env from '#start/env' +import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' + +test.group('Auth - full lifecycle (register -> activate -> login -> me -> logout)', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + // register() requires the USER role to exist; make this group + // self-sufficient regardless of what ran earlier in the process. + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('register, activate, login, fetch profile, logout, then reject the revoked token', async ({ + client, + assert, + }) => { + const email = `lifecycle-${crypto.randomUUID()}@example.com` + const password = 'Password1!' + + // 1. register + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Full', + lastName: 'Flow', + email, + password, + }) + registerResponse.assertStatus(201) + registerResponse.assertBodyContains({ success: true }) + const activationToken = registerResponse.body().data.activationToken as string + assert.isString(activationToken) + + // 2. activate + const activateResponse = await client + .post('/api/v1/auth/activate') + .json({ token: activationToken }) + activateResponse.assertStatus(200) + assert.isTrue(activateResponse.body().data.enabled) + + // 3. login + const loginResponse = await client.post('/api/v1/auth/login').json({ email, password }) + loginResponse.assertStatus(200) + const token = loginResponse.body().data.token as string + assert.isString(token) + + // 4. GET /auth/me + const meResponse = await client.get('/api/v1/auth/me').bearerToken(token) + meResponse.assertStatus(200) + assert.equal(meResponse.body().data.email, email) + + // 5. logout + const logoutResponse = await client.post('/api/v1/auth/logout').bearerToken(token) + logoutResponse.assertStatus(200) + + // 6. GET /auth/me with the now-revoked token + const revokedMeResponse = await client.get('/api/v1/auth/me').bearerToken(token) + revokedMeResponse.assertStatus(401) + }).timeout(15000) +}) + +test.group('Auth - middleware smoke test (AuthMiddleware + RoleMiddleware)', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + // Logging in as the seeded ADMIN/USER accounts requires those rows to + // exist; ensure them here so this group does not depend on seed data + // planted by an unrelated file earlier in the run. + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('protected stub route returns 401 without a token', async ({ client }) => { + const response = await client.get('/api/v1/_stub/protected') + response.assertStatus(401) + }) + + test('protected stub route returns 200 with a valid USER token', async ({ client }) => { + const token = await loginAsSeeded(client, 'user') + + const response = await client.get('/api/v1/_stub/protected').bearerToken(token) + response.assertStatus(200) + }).timeout(10000) + + test('admin-protected stub route returns 403 for a USER token', async ({ client }) => { + const token = await loginAsSeeded(client, 'user') + + const response = await client.get('/api/v1/_stub/protected-admin').bearerToken(token) + response.assertStatus(403) + }).timeout(10000) + + test('admin-protected stub route returns 200 for an ADMIN token', async ({ client }) => { + const token = await loginAsSeeded(client, 'admin') + + const response = await client.get('/api/v1/_stub/protected-admin').bearerToken(token) + response.assertStatus(200) + }).timeout(10000) +}) + +test.group('Auth - rate limiting on login', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('the request past THROTTLE_AUTH_MAX within the window returns 429', async ({ + client, + assert, + }) => { + // register, login and forgot-password all share a single named limiter + // ("auth"), keyed by client IP, so any of them running earlier in this + // process (the full lifecycle test above included) consumes from the + // same bucket. Clearing rows for that limiter's key prefix immediately + // before the burst guarantees a clean window regardless of what ran + // before this test or in what order, instead of relying on nothing else + // in the suite having touched the auth limiter yet. + await db.rawQuery("delete from rate_limits where key like 'auth_%'") + + const email = env.get('TEST_USER_EMAIL')! + const password = env.get('TEST_USER_PASSWORD')! + const maxRequests = env.get('THROTTLE_AUTH_MAX') + + for (let attempt = 0; attempt < maxRequests; attempt++) { + const response = await client.post('/api/v1/auth/login').json({ email, password }) + response.assertStatus(200) + } + + const blockedResponse = await client.post('/api/v1/auth/login').json({ email, password }) + blockedResponse.assertStatus(429) + assert.equal(blockedResponse.body().success, false) + }).timeout(20000) +}) From b9fb36931c5d569a61455c792d94601daff58c0e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:28:10 +0400 Subject: [PATCH 30/89] docs(auth): specify user, role and permission administration Registration only ever assigns the default USER role, and nothing else in the original feature/auth specification touched users, roles or permissions after that point. Promoting an account to ADMIN, adding a new role, or changing what a role is allowed to do had no API path at all, only a direct database edit, which is not something a tutorial meant to demonstrate a full REST API should leave out of its own spec. This adds an Endpoints table and matching Tasks entries for ADMIN-only CRUD on users, roles and permissions, plus assigning and revoking a role on a user and a permission on a role. The delete endpoints for roles and permissions carry the same dependency guard already established for categories and products later in this document, a 409 while something still depends on the row, rather than a silent cascade that would strip roles or abilities out from under a user without anyone asking for that. --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 147878a..8de7d0e 100644 --- a/README.md +++ b/README.md @@ -844,6 +844,28 @@ router.group(() => { | POST | `/api/v1/auth/reset-password` | Consume token, set new password | Public | | GET | `/api/v1/auth/me` | Current user profile | Authenticated | +**User, role and permission administration.** Registration always assigns the default `USER` role and nothing else in the app manages `users`, `roles` or `permissions` afterward, promoting an account to `ADMIN`, creating a new role, or changing what a role can do would otherwise only be possible by hand in the database. These endpoints close that gap, all `ADMIN` only since they operate on other accounts and on the authorization model itself. + +| Method | URL | Description | Access | +|---|---|---|---| +| GET | `/api/v1/users` | Paginated list of users | ADMIN | +| GET | `/api/v1/users/:id` | User detail with roles preloaded | ADMIN | +| PUT | `/api/v1/users/:id` | Update `firstName`, `lastName`, `email`, `enabled`, `accountLocked` | ADMIN | +| DELETE | `/api/v1/users/:id` | Delete a user | ADMIN | +| POST | `/api/v1/users/:id/roles` | Assign a role to a user | ADMIN | +| DELETE | `/api/v1/users/:id/roles/:roleId` | Revoke a role from a user | ADMIN | +| GET | `/api/v1/roles` | Paginated list of roles | ADMIN | +| GET | `/api/v1/roles/:id` | Role detail with permissions preloaded | ADMIN | +| POST | `/api/v1/roles` | Create a role | ADMIN | +| PUT | `/api/v1/roles/:id` | Update a role | ADMIN | +| DELETE | `/api/v1/roles/:id` | Delete a role, blocked with 409 while any user still has it | ADMIN | +| POST | `/api/v1/roles/:id/permissions` | Assign a permission to a role | ADMIN | +| DELETE | `/api/v1/roles/:id/permissions/:permissionId` | Revoke a permission from a role | ADMIN | +| GET | `/api/v1/permissions` | Paginated list of permissions | ADMIN | +| POST | `/api/v1/permissions` | Create a permission (`resource`, `action`) | ADMIN | +| PUT | `/api/v1/permissions/:id` | Update a permission | ADMIN | +| DELETE | `/api/v1/permissions/:id` | Delete a permission, blocked with 409 while any role still has it | ADMIN | + ### Tasks - [ ] Install and configure `@adonisjs/auth` with the JWT guard; configure `config/auth.ts` @@ -868,6 +890,17 @@ router.group(() => { - [ ] Functional test (full auth flow): register -> activate -> login -> `GET /auth/me` -> logout -> `GET /auth/me` with revoked token returns 401 - [ ] Functional test (middleware smoke): protected stub route returns 401 without token, 200 with valid `USER` token; same route with `role('ADMIN')` returns 403 for `USER`, 200 for `ADMIN` - [ ] Functional test (rate limiting): 11th login attempt within 60 s returns 429 +- [ ] Create `userValidator` (`updateUserSchema`), `roleValidator` (`createRoleSchema`, `updateRoleSchema`), `permissionValidator` (`createPermissionSchema`, `updatePermissionSchema`), all VineJS, all `ADMIN` only +- [ ] Implement `UsersService`: `findAll(page, limit)`, `findOne(id)`, `update(id, data)`, `remove(id)`, `assignRole(id, roleId)`, `revokeRole(id, roleId)`; `assignRole`/`revokeRole` are idempotent, no error re-assigning a role the user already has +- [ ] Implement `RolesService`: `findAll(page, limit)`, `findOne(id)`, `create(data)`, `update(id, data)`, `remove(id)`, `assignPermission(id, permissionId)`, `revokePermission(id, permissionId)` +- [ ] Business rule: a role with at least one user still assigned cannot be deleted, throw HTTP 409 +- [ ] Implement `PermissionsService`: `findAll(page, limit)`, `findOne(id)`, `create(data)`, `update(id, data)`, `remove(id)` +- [ ] Business rule: a permission still attached to at least one role cannot be deleted, throw HTTP 409 +- [ ] Create `UsersController`, `RolesController`, `PermissionsController`, thin, `@inject()`, calling their services and using `respond()` +- [ ] Declare all administration routes under `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` +- [ ] Unit test: `UsersService.assignRole` attaches without duplicating an existing pivot row; `RolesService.remove` throws 409 when users are still assigned; `PermissionsService.remove` throws 409 when a role still has it +- [ ] Functional test: full CRUD lifecycle for users, roles and permissions; assigning and revoking a role on a user changes what that user's JWT is authorized to do on a subsequent request; all responses match `ApiResponse` shape +- [ ] Functional test (auth): every administration route returns 401 without a token and 403 with a non `ADMIN` token --- From 6f3257f3799d512c98a677b1766b1a1d758da4f7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:28:42 +0400 Subject: [PATCH 31/89] fix(auth): address code review findings on the JWT guard and auth flow Four issues surfaced by review, each independently verified before being fixed rather than taken on faith. JWT_EXPIRY's bare numeric form ("3600") was being converted to a JS number and hand to jose's setExpirationTime, which treats a plain number as an absolute Unix timestamp rather than a duration, so a token issued under that documented, supported env format would already be expired the instant it was signed; verified directly against jose's own numericDate implementation before fixing it to append an explicit "s" suffix instead. AuthController re-parsed the Authorization header with a looser regex than JwtGuard's own exact-prefix check to extract the bearer token for logout, a second implementation of the same parsing that could drift out of sync with the guard's; the guard now exposes the token it already parsed as a property, and the controller reads that instead. register() performed three separate writes, User.create, the role attach, and ActivationToken.create, with no transaction wrapping them, so a crash or a thrown error partway through would leave an unrecoverable orphaned user row blocked by the unique email constraint. It now runs as a single db.transaction(). The unit test file's catch blocks accessed properties on a variable TypeScript types as unknown by default, which failed npm run typecheck; the catch parameters are now explicitly typed. A dead ternary in the shared test helper that returned the same literal on both branches is also fixed to actually differentiate the admin and user fixtures it names. --- app/auth/jwt_guard.ts | 15 +++++-- app/controllers/auth_controller.ts | 16 ++++---- app/services/auth_service.ts | 50 ++++++++++++++++-------- tests/helpers/auth_helper.ts | 2 +- tests/unit/services/auth_service.spec.ts | 22 ++++++----- 5 files changed, 65 insertions(+), 40 deletions(-) diff --git a/app/auth/jwt_guard.ts b/app/auth/jwt_guard.ts index e68dda4..194f7a1 100644 --- a/app/auth/jwt_guard.ts +++ b/app/auth/jwt_guard.ts @@ -32,12 +32,16 @@ function secretKey() { /** * JWT_EXPIRY is documented as either a bare number of seconds ("3600") or a - * jose style time span ("7d", "24h"). jose's own string parser requires a - * unit suffix, so a purely numeric string is converted to a number first. + * jose style time span ("7d", "24h"). jose's setExpirationTime treats a + * plain JS number as an absolute Unix timestamp, not a duration, so passing + * Number("3600") would set exp to one hour past the epoch, a token that is + * already expired the instant it is issued. A purely numeric string is + * given an explicit "s" suffix instead, so jose's own relative time span + * parser handles it the same way it handles "7d" or "24h". */ -function expiresIn(): number | string { +function expiresIn(): string { const value = env.get('JWT_EXPIRY') - return /^\d+$/.test(value) ? Number(value) : value + return /^\d+$/.test(value) ? `${value}s` : value } export async function signJwt(user: User): Promise { @@ -56,6 +60,8 @@ export class JwtGuard implements GuardContract { readonly driverName = 'jwt' as const user?: User payload?: JwtPayload + /** The raw bearer token this request authenticated with, set once authenticate() succeeds. */ + token?: string isAuthenticated = false authenticationAttempted = false; @@ -107,6 +113,7 @@ export class JwtGuard implements GuardContract { this.payload = payload this.user = user + this.token = token this.isAuthenticated = true return user } diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts index 3e553c6..267b34d 100644 --- a/app/controllers/auth_controller.ts +++ b/app/controllers/auth_controller.ts @@ -42,18 +42,18 @@ export default class AuthController { /** * `middleware.auth()` on this route has already authenticated the * request and rejected a blacklisted jti before this action ever runs, - * so the guard's cached user and payload are read directly rather than - * re-authenticating. The raw bearer token itself is only available on - * the request header (JwtGuard resolves the user and payload from it - * but does not expose the token string back out), so it is extracted - * here and handed to the service, which needs it for the NOT NULL - * `token` column on `blacklisted_tokens`. + * so the guard's cached user, payload and token are read directly rather + * than re-authenticating or re-parsing the Authorization header. Reading + * `guard.token` instead of parsing the header a second time keeps token + * extraction defined in exactly one place, JwtGuard.authenticate(), so + * this controller cannot drift out of sync with what the guard itself + * considers a valid bearer token. */ - async logout({ auth, request, response }: HttpContext) { + async logout({ auth, response }: HttpContext) { const guard = auth.use('jwt') const user = guard.getUserOrFail() const payload = guard.payload! - const rawToken = request.header('authorization')!.replace(/^Bearer\s+/, '') + const rawToken = guard.token! await this.authService.logout(user, payload, rawToken) return response.ok(respond(null, 'Logged out successfully')) diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts index 18abda8..66d9ce1 100644 --- a/app/services/auth_service.ts +++ b/app/services/auth_service.ts @@ -1,6 +1,7 @@ import { errors as authErrors } from '@adonisjs/auth' import { createError } from '@adonisjs/core/exceptions' import hash from '@adonisjs/core/services/hash' +import db from '@adonisjs/lucid/services/db' import { DateTime } from 'luxon' import type { Infer } from '@vinejs/vine/types' @@ -80,28 +81,43 @@ export default class AuthService { * it by email, the same pattern `forgotPassword` uses below. Without * this, the register -> activate -> login functional test flow the * README's own task list requires would have no way to obtain the token. + * + * All three writes run inside a single transaction. Without one, a + * crash or a thrown error between `User.create` and `ActivationToken.create` + * would leave a user row permanently stuck: disabled, no role attached, + * no activation token to ever enable it, and unrecoverable through the + * unique email constraint. A transaction makes the whole registration + * succeed or fail as one unit instead. */ async register(data: RegisterPayload) { const userRole = await Role.findByOrFail('roleName', DEFAULT_USER_ROLE) - const user = await User.create({ - firstName: data.firstName, - lastName: data.lastName, - email: data.email, - password: data.password, - enabled: false, - accountLocked: false, + return db.transaction(async (trx) => { + const user = await User.create( + { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + password: data.password, + enabled: false, + accountLocked: false, + }, + { client: trx } + ) + + await user.related('roles').attach([userRole.id]) + + const activationToken = await ActivationToken.create( + { + userId: user.id, + token: crypto.randomUUID(), + expiresAt: DateTime.now().plus({ hours: ACTIVATION_TOKEN_TTL_HOURS }), + }, + { client: trx } + ) + + return { user, activationToken: activationToken.token } }) - - await user.related('roles').attach([userRole.id]) - - const activationToken = await ActivationToken.create({ - userId: user.id, - token: crypto.randomUUID(), - expiresAt: DateTime.now().plus({ hours: ACTIVATION_TOKEN_TTL_HOURS }), - }) - - return { user, activationToken: activationToken.token } } /** diff --git a/tests/helpers/auth_helper.ts b/tests/helpers/auth_helper.ts index bb50bd2..415bb21 100644 --- a/tests/helpers/auth_helper.ts +++ b/tests/helpers/auth_helper.ts @@ -63,7 +63,7 @@ export async function ensureSeededUser(role: SeededRole): Promise { let user = await User.findBy('email', email) if (!user) { user = await User.create({ - firstName: role === 'admin' ? 'Seeded' : 'Seeded', + firstName: 'Seeded', lastName: role === 'admin' ? 'Admin' : 'User', email, password, diff --git a/tests/unit/services/auth_service.spec.ts b/tests/unit/services/auth_service.spec.ts index 5a7ad1a..7811b3e 100644 --- a/tests/unit/services/auth_service.spec.ts +++ b/tests/unit/services/auth_service.spec.ts @@ -32,12 +32,14 @@ import { ensureRole } from '#tests/helpers/auth_helper' const authService = new AuthService() -async function createUser(overrides: Partial<{ - email: string - password: string - enabled: boolean - accountLocked: boolean -}> = {}) { +async function createUser( + overrides: Partial<{ + email: string + password: string + enabled: boolean + accountLocked: boolean + }> = {} +) { return User.create({ firstName: 'Test', lastName: 'Account', @@ -126,7 +128,7 @@ test.group('AuthService - login', (group) => { try { await authService.login({ email, password: 'WrongPass1!' }) assert.fail('expected login to throw for a wrong password') - } catch (error) { + } catch (error: any) { assert.equal(error.code, 'E_INVALID_CREDENTIALS') assert.equal(error.status, 400) } @@ -139,7 +141,7 @@ test.group('AuthService - login', (group) => { try { await authService.login({ email, password: 'CorrectPass1!' }) assert.fail('expected login to throw for a locked account') - } catch (error) { + } catch (error: any) { assert.equal(error.code, 'E_ACCOUNT_LOCKED') assert.equal(error.status, 403) } @@ -152,7 +154,7 @@ test.group('AuthService - login', (group) => { try { await authService.login({ email, password: 'CorrectPass1!' }) assert.fail('expected login to throw for an inactive account') - } catch (error) { + } catch (error: any) { assert.equal(error.code, 'E_ACCOUNT_NOT_ACTIVATED') assert.equal(error.status, 403) } @@ -167,7 +169,7 @@ test.group('AuthService - login', (group) => { try { await authService.login({ email, password: 'CorrectPass1!' }) assert.fail('expected login to throw') - } catch (error) { + } catch (error: any) { assert.equal(error.code, 'E_ACCOUNT_NOT_ACTIVATED') } }).timeout(10000) From 35dce49ef73466f95401286cc79de90b71500a8d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:09 +0400 Subject: [PATCH 32/89] chore(mail): add the @adonisjs/mail dependency Registration and password reset need to deliver a real email rather than the raw-token placeholder AuthService currently returns in the HTTP response, and neither this dependency nor any mail provider existed anywhere in the project before now. --- package-lock.json | 122 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23cec5f..18bc0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@adonisjs/cors": "^3.0.0", "@adonisjs/limiter": "^3.0.1", "@adonisjs/lucid": "^22.4.2", + "@adonisjs/mail": "^10.4.0", "@adonisjs/session": "^8.1.0", "@adonisjs/shield": "^9.0.0", "@japa/api-client": "^3.2.1", @@ -629,6 +630,57 @@ } } }, + "node_modules/@adonisjs/mail": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@adonisjs/mail/-/mail-10.4.0.tgz", + "integrity": "sha512-+o5qYB26gJgOGQysR/1KVEziM+MhSX9uP5ZBZjQuPC2xPTfvL5k10jHFQe5fZimhRHnETtNhoi4h3KIlen4KmQ==", + "license": "MIT", + "dependencies": { + "@poppinss/macroable": "^1.1.2", + "@poppinss/object-builder": "^1.1.0", + "@types/nodemailer": "^8.0.1", + "fastq": "^1.20.1", + "ical-generator": "^11.0.0", + "ky": "^2.0.2", + "nodemailer": "^9.0.3" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@adonisjs/assembler": "^8.0.0-next.14 || ^8.0.0", + "@adonisjs/core": "^7.0.0-next.8 || ^7.0.0", + "@aws-sdk/client-sesv2": "^3.997.0", + "edge.js": "^6.0.1", + "mjml": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@adonisjs/assembler": { + "optional": true + }, + "@aws-sdk/client-sesv2": { + "optional": true + }, + "edge.js": { + "optional": true + }, + "mjml": { + "optional": true + } + } + }, + "node_modules/@adonisjs/mail/node_modules/ky": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", + "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/@adonisjs/presets": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@adonisjs/presets/-/presets-3.0.0.tgz", @@ -2096,7 +2148,7 @@ "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/methods": { @@ -2114,6 +2166,15 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -3907,7 +3968,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4412,6 +4472,54 @@ "node": ">=18.18.0" } }, + "node_modules/ical-generator": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-11.1.0.tgz", + "integrity": "sha512-/CSyXcelySK5qYAUxXnwzhAi1UPVnapnVMXqkRQ+L0RXHCFtzqLm92o5PD0ZRhaWBCtkS0hP8YIxMrSRZ+8Zug==", + "license": "MIT", + "engines": { + "node": "22 || >=24.0.0" + }, + "peerDependencies": { + "@touch4it/ical-timezones": ">=1.6.0", + "@types/luxon": ">= 1.26.0", + "@types/mocha": ">= 8.2.1", + "dayjs": ">= 1.10.0", + "luxon": ">= 1.26.0", + "moment": ">= 2.29.0", + "moment-timezone": ">= 0.5.33", + "rrule": ">= 2.6.8" + }, + "peerDependenciesMeta": { + "@touch4it/ical-timezones": { + "optional": true + }, + "@types/luxon": { + "optional": true + }, + "@types/mocha": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-timezone": { + "optional": true + }, + "rrule": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -5324,6 +5432,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", @@ -6296,7 +6413,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", diff --git a/package.json b/package.json index 32fae6c..f2c8ff9 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@adonisjs/cors": "^3.0.0", "@adonisjs/limiter": "^3.0.1", "@adonisjs/lucid": "^22.4.2", + "@adonisjs/mail": "^10.4.0", "@adonisjs/session": "^8.1.0", "@adonisjs/shield": "^9.0.0", "@japa/api-client": "^3.2.1", From b09bdf8c44ca08615dfcd61ff0f2018bc990754a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:09 +0400 Subject: [PATCH 33/89] chore(mail): register the mail commands and provider @adonisjs/mail's own configure command wires this up the same way bouncer and limiter were wired earlier in this branch, registering the provider so the IoC container can resolve the mail service and adding the package's ace commands. --- adonisrc.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adonisrc.ts b/adonisrc.ts index df65430..e781201 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -30,6 +30,7 @@ export default defineConfig({ () => import('@adonisjs/lucid/commands'), () => import('@adonisjs/session/commands'), () => import('@adonisjs/bouncer/commands'), + () => import('@adonisjs/mail/commands'), ], /* @@ -57,6 +58,7 @@ export default defineConfig({ () => import('#providers/api_provider'), () => import('@adonisjs/bouncer/bouncer_provider'), () => import('@adonisjs/limiter/limiter_provider'), + () => import('@adonisjs/mail/mail_provider'), ], /* From 2ae0dc8cbf49b5b4d691a8536e95b675f07cebf1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:19 +0400 Subject: [PATCH 34/89] chore(mail): validate mail env variables at boot MAIL_MAILER, MAIL_FROM_NAME, MAIL_FROM_ADDRESS, SMTP_HOST and SMTP_PORT need to be validated the same way every other environment variable in this project is, through the typed env service, so the app refuses to boot with an incomplete mail configuration instead of failing silently the first time AuthService tries to send an email. MAIL_MAILER is constrained to the single smtp value since that is the only transport this project configures. --- start/env.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/start/env.ts b/start/env.ts index 3463eb7..805452d 100644 --- a/start/env.ts +++ b/start/env.ts @@ -53,4 +53,11 @@ export default await Env.create(new URL('../', import.meta.url), { TEST_ADMIN_PASSWORD: Env.schema.string.optional(), TEST_USER_EMAIL: Env.schema.string.optional(), TEST_USER_PASSWORD: Env.schema.string.optional(), + + // Mail + MAIL_MAILER: Env.schema.enum(['smtp'] as const), + MAIL_FROM_NAME: Env.schema.string(), + MAIL_FROM_ADDRESS: Env.schema.string(), + SMTP_HOST: Env.schema.string({ format: 'host' }), + SMTP_PORT: Env.schema.number(), }) From 3b77fdda094292538d03a5a90387fe319da145c8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:19 +0400 Subject: [PATCH 35/89] feat(mail): configure the SMTP mailer against Mailhog SMTP is the only transport this project needs, pointed at Mailhog in every environment so registration and password reset emails are always visible in Mailhog's web UI rather than attempting real delivery, matching the project's existing pattern of running every external dependency through docker-compose rather than a live third-party service. The package's own configure command also generated a globals block for Edge template variables and a placeholder brand name, neither of which apply here: this project has no view provider anywhere, mail bodies are built as plain strings in the mailer classes themselves, so that block is removed rather than filled in with a value nothing reads. --- config/mail.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 config/mail.ts diff --git a/config/mail.ts b/config/mail.ts new file mode 100644 index 0000000..022fdb7 --- /dev/null +++ b/config/mail.ts @@ -0,0 +1,36 @@ +import env from '#start/env' +import { defineConfig, transports } from '@adonisjs/mail' + +/** + * Mail bodies in this project are built as plain strings inside the mailer + * classes themselves (see app/mails/), not rendered from Edge templates. + * This project has no view provider configured anywhere, it is an API only + * service per the README's own architecture, so there is no globals block + * here for template variables the way the package's own scaffold suggests. + */ +const mailConfig = defineConfig({ + default: env.get('MAIL_MAILER'), + + from: { + address: env.get('MAIL_FROM_ADDRESS'), + name: env.get('MAIL_FROM_NAME'), + }, + + /** + * SMTP pointed at Mailhog in every environment, development and test + * alike, so registration and password reset emails are always visible in + * Mailhog's web UI instead of attempting real delivery. + */ + mailers: { + smtp: transports.smtp({ + host: env.get('SMTP_HOST'), + port: env.get('SMTP_PORT'), + }), + }, +}) + +export default mailConfig + +declare module '@adonisjs/mail/types' { + export interface MailersList extends InferMailers {} +} From b0e5563dce94d39d6df5a8dab07d8ef7f318a4ce Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:28 +0400 Subject: [PATCH 36/89] chore(mail): add Mailhog to the development docker-compose stack The SMTP transport this branch configures needs somewhere to actually deliver to. Mailhog catches every outgoing email and exposes it through a web UI on port 8025 instead of attempting real delivery, the standard local development pattern for testing email flows without a real mail provider. It joins the existing postgres service in this file rather than living in a separate compose file, since both are development-only infrastructure this project's docker-compose workflow already groups together. --- docker-compose.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index df2808b..04cb8d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,14 +7,21 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: adonisjs_tutorial ports: - - "5432:5432" + - '5432:5432' volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 10s timeout: 5s retries: 5 + mailhog: + image: mailhog/mailhog + restart: unless-stopped + ports: + - '1025:1025' + - '8025:8025' + volumes: postgres_data: From 4478638c0243c28d250414ab72c6b38c7f793a22 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:28 +0400 Subject: [PATCH 37/89] chore(mail): add Mailhog to the test docker-compose stack Functional tests covering registration and password reset need an SMTP server to send to, the same requirement as development, but listening on different host ports so both compose files can run at once without a conflict, the same reason docker-compose.test.yml already maps its postgres service to 5433 instead of 5432. Mailhog's SMTP port is mapped to 1026 here and its web UI to 8026. --- docker-compose.test.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index fab2c4a..6109c3b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -7,14 +7,21 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: adonisjs_tutorial_test ports: - - "5433:5432" + - '5433:5432' volumes: - postgres_test_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 10s timeout: 5s retries: 5 + mailhog_test: + image: mailhog/mailhog + restart: unless-stopped + ports: + - '1026:1025' + - '8026:8025' + volumes: postgres_test_data: From a6a4b14b543d9efbe8b52d416039efb6030c0dd7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:38 +0400 Subject: [PATCH 38/89] docs(mail): document the mail env variables The configure command appended these variables to the end of the file with no section header or trailing newline, inconsistent with every other block in this file. Reformatted into a proper Mail section matching the existing style, with SMTP_HOST/SMTP_PORT pointed at the Mailhog service docker-compose.yml now runs, and a comment noting the 1026 port docker-compose.test.yml uses instead. --- .env.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.example b/.env.example index 13d5f98..bc56405 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,10 @@ HASH_DRIVER=argon LIMITER_STORE=database THROTTLE_AUTH_MAX=10 THROTTLE_AUTH_WINDOW=60 + +# ── Mail ────────────────────────────────────────────────── +MAIL_MAILER=smtp # only smtp is configured, pointed at Mailhog +MAIL_FROM_NAME=AdonisJS Tutorial +MAIL_FROM_ADDRESS=no-reply@adonisjs-tutorial.test +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 # Mailhog SMTP port from docker-compose.yml (1026 for docker-compose.test.yml) From dba4b381397fb910c41e6403579fcdff15cda828 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:38 +0400 Subject: [PATCH 39/89] chore(mail): add mail variables to the development env file Points at the Mailhog instance docker-compose.yml now runs on its default SMTP port 1025, so a local node ace serve can send activation and password reset emails without any further setup beyond docker-compose up. --- .env.development | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.development b/.env.development index 9f6739f..89ae036 100644 --- a/.env.development +++ b/.env.development @@ -25,3 +25,10 @@ HASH_DRIVER=argon LIMITER_STORE=database THROTTLE_AUTH_MAX=10 THROTTLE_AUTH_WINDOW=60 + +# ── Mail ────────────────────────────────────────────────── +MAIL_MAILER=smtp +MAIL_FROM_NAME=AdonisJS Tutorial +MAIL_FROM_ADDRESS=no-reply@adonisjs-tutorial.test +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 From 372eea26db9cd57b7605d79d1ddd4ba43e14953c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:38 +0400 Subject: [PATCH 40/89] chore(mail): add mail variables to the test env file Points at the Mailhog instance docker-compose.test.yml runs on port 1026, offset from the development stack's 1025 the same way the test database is offset to 5433, so both compose stacks can run side by side without a port conflict during local development. --- .env.test | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.test b/.env.test index f3eba82..28e3b08 100644 --- a/.env.test +++ b/.env.test @@ -21,6 +21,12 @@ LIMITER_STORE=database THROTTLE_AUTH_MAX=10 THROTTLE_AUTH_WINDOW=60 +MAIL_MAILER=smtp +MAIL_FROM_NAME=AdonisJS Tutorial +MAIL_FROM_ADDRESS=no-reply@adonisjs-tutorial.test +SMTP_HOST=127.0.0.1 +SMTP_PORT=1026 + TEST_ADMIN_EMAIL=admin@example.com TEST_ADMIN_PASSWORD=Admin1234! TEST_USER_EMAIL=user@example.com From 45908afb7b6ad29e199cb4a9615ef5fb1e9c6f78 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:46 +0400 Subject: [PATCH 41/89] ci: add a Mailhog service for the email flows this branch adds Registration and password reset now send real emails through SMTP instead of returning the raw token in the API response, so CI needs somewhere for those emails to land. Mailhog runs alongside the existing postgres service on its standard ports; only one compose stack runs per CI job so there is no need for the offset ports docker-compose.test.yml uses locally. --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3fc45..e975b18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ env: LIMITER_STORE: database THROTTLE_AUTH_MAX: 5 THROTTLE_AUTH_WINDOW: 60 + MAIL_MAILER: smtp + MAIL_FROM_NAME: AdonisJS Tutorial + MAIL_FROM_ADDRESS: no-reply@adonisjs-tutorial.test + SMTP_HOST: 127.0.0.1 + SMTP_PORT: 1025 jobs: ci: @@ -46,6 +51,12 @@ jobs: --health-timeout 3s --health-retries 5 + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: - name: Checkout repository uses: actions/checkout@v4 From 28ea624b87b835b0fb5cc5d33ff8cf6b427d2a09 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:33:46 +0400 Subject: [PATCH 42/89] ci: add a Mailhog service for the email flows this branch adds Mirrors the same addition in ci.yml. pr-checks.yml shares the same gap, a functional test asserting on a sent registration or password reset email would have nowhere to send to without this service. --- .github/workflows/pr-checks.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 21ae17e..527490a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,6 +29,11 @@ env: LIMITER_STORE: database THROTTLE_AUTH_MAX: 5 THROTTLE_AUTH_WINDOW: 60 + MAIL_MAILER: smtp + MAIL_FROM_NAME: AdonisJS Tutorial + MAIL_FROM_ADDRESS: no-reply@adonisjs-tutorial.test + SMTP_HOST: 127.0.0.1 + SMTP_PORT: 1025 jobs: checks: @@ -52,6 +57,14 @@ jobs: --health-timeout 3s --health-retries 5 + # Catches registration and password reset emails so functional tests + # can assert on what was sent without any real delivery. + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: - name: Checkout repository uses: actions/checkout@v4 From 8b23ecc1fc17ea97c7611b265b1409bb9d8e5e8e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:47:49 +0400 Subject: [PATCH 43/89] feat(mail): add ActivationMail for the register flow Registration now has a real mailer wired up against Mailhog, so the activation token AuthService generates has somewhere real to go instead of being handed back in the HTTP response. This project has no Edge or view provider anywhere, being API only end to end, so the body is written as plain text and HTML strings directly inside the class rather than rendered from a template file the way the default AdonisJS mail scaffold suggests. The activation link is a placeholder pointing at APP_URL with a query string token, since there is no frontend anywhere in this project to actually receive that route. The point of the link existing is only so the token is easy to read and copy out of Mailhog's web UI while testing the account lifecycle by hand, not to be a working end to end redirect. --- app/mails/activation_mail.ts | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 app/mails/activation_mail.ts diff --git a/app/mails/activation_mail.ts b/app/mails/activation_mail.ts new file mode 100644 index 0000000..18d57bf --- /dev/null +++ b/app/mails/activation_mail.ts @@ -0,0 +1,55 @@ +import { BaseMail } from '@adonisjs/mail' +import env from '#start/env' +import type User from '#models/user' + +/** + * Sent once, right after AuthService.register creates the account. The + * activation link carries the raw ActivationToken value that + * AuthService.activate later looks up by exact match; this class never + * touches the database itself, it only formats what AuthService already + * generated and persisted. + * + * This project has no Edge/view provider (see config/mail.ts's own note), + * so the body is built as a plain string right here instead of being + * rendered from a template file. Both a text and an HTML view are set so + * the message renders correctly regardless of the mail client's + * capabilities; Mailhog (this project's only mail sink, in every + * environment) shows both. + */ +export default class ActivationMail extends BaseMail { + subject = 'Activate your account' + + constructor( + private user: User, + private token: string + ) { + super() + } + + /** + * NOTE: `/activate?token=...` is not a real route anywhere in this + * API-only project (there is no frontend to receive the redirect); the + * README explicitly allows a placeholder link here since the goal is + * only for the token to be readable in Mailhog, not to be clickable + * end-to-end. + */ + prepare() { + const activationUrl = `${env.get('APP_URL')}/activate?token=${this.token}` + + this.message + .to(this.user.email) + .subject(this.subject) + .text( + `Hi ${this.user.firstName},\n\n` + + `Welcome to AdonisJS Tutorial. Activate your account by visiting the link below:\n\n` + + `${activationUrl}\n\n` + + `This link expires in 24 hours.` + ) + .html( + `

Hi ${this.user.firstName},

` + + `

Welcome to AdonisJS Tutorial. Activate your account by clicking the link below:

` + + `

${activationUrl}

` + + `

This link expires in 24 hours.

` + ) + } +} From 6ee61b225a6d0918c241cb051b0511fbfc85714d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:47:56 +0400 Subject: [PATCH 44/89] feat(mail): add PasswordResetMail for the forgot-password flow Mirrors ActivationMail's shape: a plain text and HTML body built directly in the class, sent through the same SMTP mailer against Mailhog. The token this class carries is short lived, one hour versus the activation token's twenty four, matching PASSWORD_RESET_TOKEN_TTL_HOURS in auth_service.ts, so the copy in the email body states that explicitly instead of repeating a generic expiry claim that would no longer match the actual constant if it ever changes. Sending this by email rather than returning it in the API response matters more here than for activation: a leaked password reset token is a direct account takeover, not just an early activation. Keeping the token out of the HTTP response entirely, and only ever putting it in an email sent to the account's own address, closes off the response body as a leak vector for that token. --- app/mails/password_reset_mail.ts | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 app/mails/password_reset_mail.ts diff --git a/app/mails/password_reset_mail.ts b/app/mails/password_reset_mail.ts new file mode 100644 index 0000000..2a45546 --- /dev/null +++ b/app/mails/password_reset_mail.ts @@ -0,0 +1,50 @@ +import { BaseMail } from '@adonisjs/mail' +import env from '#start/env' +import type User from '#models/user' + +/** + * Sent by AuthService.forgotPassword after a PasswordResetToken row is + * persisted. The token is only ever readable by whoever receives this + * email (or, in this tutorial, whoever opens Mailhog); it is never echoed + * back in the HTTP response, since a response body is a far more likely + * leak vector than an email sent to the account's own address. + * + * Shorter-lived than ActivationMail's link (1 hour vs 24), matching + * PASSWORD_RESET_TOKEN_TTL_HOURS in auth_service.ts, so the copy below + * states that explicitly rather than repeating a generic expiry claim. + */ +export default class PasswordResetMail extends BaseMail { + subject = 'Reset your password' + + constructor( + private user: User, + private token: string + ) { + super() + } + + /** + * NOTE: same placeholder-link reasoning as ActivationMail — this is an + * API-only project, `/reset-password?token=...` does not resolve to a + * real frontend route, it only needs to be readable in Mailhog. + */ + prepare() { + const resetUrl = `${env.get('APP_URL')}/reset-password?token=${this.token}` + + this.message + .to(this.user.email) + .subject(this.subject) + .text( + `Hi ${this.user.firstName},\n\n` + + `We received a request to reset your password. Visit the link below to choose a new one:\n\n` + + `${resetUrl}\n\n` + + `This link expires in 1 hour. If you did not request this, you can ignore this email.` + ) + .html( + `

Hi ${this.user.firstName},

` + + `

We received a request to reset your password. Click the link below to choose a new one:

` + + `

${resetUrl}

` + + `

This link expires in 1 hour. If you did not request this, you can ignore this email.

` + ) + } +} From d92af08a04d6d76dc8be3667d1f568cfc08cea5b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:05 +0400 Subject: [PATCH 45/89] feat(auth): send real activation and reset emails instead of returning raw tokens register() and forgotPassword() used to hand the raw ActivationToken and PasswordResetToken values straight back to the caller, documented at the time as a placeholder for the email delivery this branch had not built yet. Now that a real SMTP mailer is configured against Mailhog, that placeholder is gone: register() sends ActivationMail and returns only the created user, and forgotPassword() sends PasswordResetMail and returns void. Both tokens are still persisted on their respective models exactly as before; only the delivery channel and the return value changed. The mail send in register() happens after the db.transaction block commits, not inside it. Sending mail cannot be rolled back the way a database write can, so keeping it outside the transaction means a mail delivery failure can never turn an otherwise successful registration into a failure, and a transaction rollback can never result in an email being sent for a user row that was never actually committed to the database. This is a breaking change for any test that read activationToken or the forgot-password token straight out of the HTTP response body, since that data no longer appears there. That is expected here; those tests are updated separately to read the token from the database instead, which is also a more honest test of what actually persisted rather than trusting the response echo. --- app/services/auth_service.ts | 59 ++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts index 66d9ce1..39f5afe 100644 --- a/app/services/auth_service.ts +++ b/app/services/auth_service.ts @@ -2,6 +2,7 @@ import { errors as authErrors } from '@adonisjs/auth' import { createError } from '@adonisjs/core/exceptions' import hash from '@adonisjs/core/services/hash' import db from '@adonisjs/lucid/services/db' +import mail from '@adonisjs/mail/services/main' import { DateTime } from 'luxon' import type { Infer } from '@vinejs/vine/types' @@ -11,6 +12,8 @@ import ActivationToken from '#models/activation_token' import BlacklistedToken from '#models/blacklisted_token' import PasswordResetToken from '#models/password_reset_token' import { signJwt, type JwtPayload } from '#auth/jwt_guard' +import ActivationMail from '#mails/activation_mail' +import PasswordResetMail from '#mails/password_reset_mail' import type { registerSchema, loginSchema, resetPasswordSchema } from '#validators/auth_validator' type RegisterPayload = Infer @@ -72,15 +75,24 @@ const E_INVALID_RESET_TOKEN = createError( export default class AuthService { /** * Creates the user (password hashing happens in User's @beforeSave hook, - * never here), assigns the default USER role, and issues an activation - * token. + * never here), assigns the default USER role, issues an activation + * token, and emails it via ActivationMail. * - * The raw activation token is returned to the caller rather than only - * being persisted. There is no mail provider wired up on this branch, - * so returning the token is the documented placeholder for delivering - * it by email, the same pattern `forgotPassword` uses below. Without - * this, the register -> activate -> login functional test flow the - * README's own task list requires would have no way to obtain the token. + * The raw activation token is no longer returned to the caller. A real + * mailer is wired up on this branch (SMTP against Mailhog), so echoing + * the token in the HTTP response would defeat the point of delivering it + * out of band: anyone who could read the API response could also + * activate the account, which is exactly the class of leak email + * delivery exists to close. The token is still persisted on + * ActivationToken exactly as before; only its delivery channel changed. + * + * The mail send happens after the transaction commits, not inside it. + * Sending mail is not transactional (there is no way to "roll back" an + * SMTP call), so keeping it outside the `db.transaction` block means a + * mail delivery failure can never mark an otherwise-successful + * registration as failed, and a transaction rollback (for example a + * unique-email race) can never result in an activation email for a user + * row that was never actually committed. * * All three writes run inside a single transaction. Without one, a * crash or a thrown error between `User.create` and `ActivationToken.create` @@ -89,11 +101,11 @@ export default class AuthService { * unique email constraint. A transaction makes the whole registration * succeed or fail as one unit instead. */ - async register(data: RegisterPayload) { + async register(data: RegisterPayload): Promise { const userRole = await Role.findByOrFail('roleName', DEFAULT_USER_ROLE) - return db.transaction(async (trx) => { - const user = await User.create( + const { user, token } = await db.transaction(async (trx) => { + const newUser = await User.create( { firstName: data.firstName, lastName: data.lastName, @@ -105,19 +117,23 @@ export default class AuthService { { client: trx } ) - await user.related('roles').attach([userRole.id]) + await newUser.related('roles').attach([userRole.id]) const activationToken = await ActivationToken.create( { - userId: user.id, + userId: newUser.id, token: crypto.randomUUID(), expiresAt: DateTime.now().plus({ hours: ACTIVATION_TOKEN_TTL_HOURS }), }, { client: trx } ) - return { user, activationToken: activationToken.token } + return { user: newUser, token: activationToken.token } }) + + await mail.send(new ActivationMail(user, token)) + + return user } /** @@ -214,10 +230,13 @@ export default class AuthService { } /** - * Issues a PasswordResetToken and returns the raw token. As with - * `register`'s activation token, there is no mail provider on this - * branch, so returning the raw value is the documented placeholder for - * email delivery. + * Issues a PasswordResetToken and emails it via PasswordResetMail. As + * with `register`'s activation token, the raw value is no longer + * returned to the caller now that a real mailer is wired up; the token + * is still persisted on PasswordResetToken exactly as before, only its + * delivery channel and this method's return value changed. Returning + * void rather than the user keeps the controller from having anything + * meaningful to echo back beyond a confirmation message. * * NOTE: a production system would return a generic success response * regardless of whether the email exists, to avoid leaking which @@ -225,7 +244,7 @@ export default class AuthService { * an unknown email) instead, favouring explicit, testable behaviour * over that hardening, consistent with the rest of this branch's scope. */ - async forgotPassword(email: string) { + async forgotPassword(email: string): Promise { const user = await User.findByOrFail('email', email) const resetToken = await PasswordResetToken.create({ @@ -235,7 +254,7 @@ export default class AuthService { expiryDate: DateTime.now().plus({ hours: PASSWORD_RESET_TOKEN_TTL_HOURS }), }) - return resetToken.token + await mail.send(new PasswordResetMail(user, resetToken.token)) } /** From 2c26477019cb9e8bf58e9a3ec1c857c82b6d6605 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:12 +0400 Subject: [PATCH 46/89] feat(auth): stop echoing activation and reset tokens in HTTP responses AuthController's register and forgotPassword actions built their response bodies around the token being part of AuthService's return value, {user, activationToken} and a bare token string respectively. Now that AuthService.register returns only the created user and AuthService.forgotPassword returns void, those actions are updated to match: register responds with just the user under a message that points the caller at their inbox instead of the token, and forgotPassword responds with a null data payload and a confirmation message. Nothing about the controller's own shape changed beyond that, it still validates with the compiled schema, delegates to the service, and wraps the result with respond(), the same thin pattern every other action here follows. --- app/controllers/auth_controller.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts index 267b34d..99be102 100644 --- a/app/controllers/auth_controller.ts +++ b/app/controllers/auth_controller.ts @@ -23,8 +23,10 @@ export default class AuthController { async register({ request, response }: HttpContext) { const data = await request.validateUsing(registerSchema) - const result = await this.authService.register(data) - return response.created(respond(result, 'User registered, activation token issued')) + const user = await this.authService.register(data) + return response.created( + respond(user, 'User registered, check your email to activate your account') + ) } async activate({ request, response }: HttpContext) { @@ -67,8 +69,8 @@ export default class AuthController { async forgotPassword({ request, response }: HttpContext) { const { email } = await request.validateUsing(forgotPasswordSchema) - const token = await this.authService.forgotPassword(email) - return response.ok(respond({ token }, 'Password reset token issued')) + await this.authService.forgotPassword(email) + return response.ok(respond(null, 'Password reset email sent')) } async resetPassword({ request, response }: HttpContext) { From af56fde9d3a8e7ac9f3a01268cc83bbdf27eda14 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:20 +0400 Subject: [PATCH 47/89] feat(auth): add user_validator with updateUserSchema and assignRoleSchema updateUserSchema backs PUT /api/v1/users/:id. Every field is optional since this is a partial update, an ADMIN calling this endpoint might only want to flip enabled or accountLocked without resending the rest of the record. firstName, lastName and email reuse the same length limits as registerSchema in auth_validator.ts since they map to the identical users columns. There is deliberately no password field here, this endpoint edits account metadata, it is not a side channel for an ADMIN to set another user's password without going through the reset-password flow's own token verification. assignRoleSchema backs the request body of POST /api/v1/users/:id/roles. The README names updateUserSchema as this file's only schema, it does not name one for the role assignment body, but that body still needs a roleId read out of it and the project's hard rule against inline VineJS schemas in controllers means it needs a compiled schema of its own somewhere. It lives in this file rather than role_validator.ts because the endpoint is declared under the /users resource and owned by UsersController. --- app/validators/user_validator.ts | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 app/validators/user_validator.ts diff --git a/app/validators/user_validator.ts b/app/validators/user_validator.ts new file mode 100644 index 0000000..f02b987 --- /dev/null +++ b/app/validators/user_validator.ts @@ -0,0 +1,42 @@ +import vine from '@vinejs/vine' + +/** + * PUT /api/v1/users/:id + * + * Every field is optional since this is a partial update: an ADMIN caller + * may want to change only `enabled` (to disable an account) or only + * `accountLocked` (to lock it) without having to resend the rest of the + * record. `email`, `firstName` and `lastName` reuse the same length limits + * as `registerSchema` in auth_validator.ts, since they map to the exact + * same `users` columns. There is no `password` field here on purpose: this + * is an administration endpoint for editing account metadata, not a way + * for an ADMIN to set another user's password, that would bypass the + * reset-password flow's own token verification entirely. + */ +export const updateUserSchema = vine.compile( + vine.object({ + firstName: vine.string().trim().minLength(1).maxLength(50).optional(), + lastName: vine.string().trim().minLength(1).maxLength(100).optional(), + email: vine.string().trim().maxLength(100).email().optional(), + enabled: vine.boolean().optional(), + accountLocked: vine.boolean().optional(), + }) +) + +/** + * POST /api/v1/users/:id/roles + * + * NOTE: the README enumerates `updateUserSchema` as the only schema owned + * by this file; it does not name a schema for the role-assignment request + * body. `POST /users/:id/roles` still needs to read a `roleId` from + * somewhere, and per this project's hard rule against inline VineJS + * schemas in controllers, that body needs a compiled schema of its own. + * It lives here rather than in role_validator.ts because the endpoint is + * declared under the `/users` resource, mirroring how UsersController (not + * RolesController) owns the assign/revoke actions. + */ +export const assignRoleSchema = vine.compile( + vine.object({ + roleId: vine.number().positive(), + }) +) From 5cde323f5884c487ec601b1bb48c0b67e5adbc07 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:27 +0400 Subject: [PATCH 48/89] feat(auth): add role_validator with createRoleSchema, updateRoleSchema and assignPermissionSchema createRoleSchema and updateRoleSchema back POST and PUT /api/v1/roles(/:id). roleName mirrors the roles.role_name VARCHAR(50) NOT NULL UNIQUE column from the README's Auth Model section. Uniqueness itself is left to the database's own unique index rather than duplicated here or in RolesService, since a race there would only ever surface as a database constraint error, not a business rule this branch was explicitly asked to guard against the way the 409-on-delete-while-in-use rule is. assignPermissionSchema backs the request body of POST /api/v1/roles/:id/permissions, for the same reason user_validator.ts carries assignRoleSchema: the README does not name a schema for this body but the hard rule against inline VineJS schemas means it needs one, and it lives here because the endpoint is declared under /roles and owned by RolesController. --- app/validators/role_validator.ts | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 app/validators/role_validator.ts diff --git a/app/validators/role_validator.ts b/app/validators/role_validator.ts new file mode 100644 index 0000000..4d0d3fa --- /dev/null +++ b/app/validators/role_validator.ts @@ -0,0 +1,45 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/roles + * + * `roleName` mirrors the `roles.role_name VARCHAR(50) NOT NULL, UNIQUE` + * column from the README's Auth Model section. Uniqueness itself is + * enforced by the database's own unique index; RolesService does not + * duplicate that check at the service layer the way UsersService does for + * email, since a race there would only ever surface as a clean 500 turned + * into a DB constraint error, not a business rule this branch was asked to + * cover explicitly. + */ +export const createRoleSchema = vine.compile( + vine.object({ + roleName: vine.string().trim().minLength(1).maxLength(50), + }) +) + +/** + * PUT /api/v1/roles/:id + * + * Optional since this is a partial update, though in practice `roleName` + * is the only field a role has to update. + */ +export const updateRoleSchema = vine.compile( + vine.object({ + roleName: vine.string().trim().minLength(1).maxLength(50).optional(), + }) +) + +/** + * POST /api/v1/roles/:id/permissions + * + * NOTE: same reasoning as `assignRoleSchema` in user_validator.ts, the + * README names `createRoleSchema` and `updateRoleSchema` as this file's + * schemas but does not name one for the permission-assignment request + * body. It lives here because the endpoint is declared under the `/roles` + * resource and owned by RolesController. + */ +export const assignPermissionSchema = vine.compile( + vine.object({ + permissionId: vine.number().positive(), + }) +) From 4c3b222189345a1fe99318ec438f88102ba79ffe Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:32 +0400 Subject: [PATCH 49/89] feat(auth): add permission_validator with createPermissionSchema and updatePermissionSchema Both schemas mirror the permissions table's resource and action columns from the README's Auth Model section, VARCHAR(50) NOT NULL each. A permission is the pair of the two, for example resource categories with action delete, so create requires both while update leaves both optional since it is a partial update. --- app/validators/permission_validator.ts | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/validators/permission_validator.ts diff --git a/app/validators/permission_validator.ts b/app/validators/permission_validator.ts new file mode 100644 index 0000000..63e334b --- /dev/null +++ b/app/validators/permission_validator.ts @@ -0,0 +1,28 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/permissions + * + * `resource` and `action` mirror the `permissions` table's own columns + * (`VARCHAR(50) NOT NULL` each) from the README's Auth Model section. A + * permission is the pair of the two, for example `resource: 'categories'`, + * `action: 'delete'`, so both are required on create. + */ +export const createPermissionSchema = vine.compile( + vine.object({ + resource: vine.string().trim().minLength(1).maxLength(50), + action: vine.string().trim().minLength(1).maxLength(50), + }) +) + +/** + * PUT /api/v1/permissions/:id + * + * Both fields optional since this is a partial update. + */ +export const updatePermissionSchema = vine.compile( + vine.object({ + resource: vine.string().trim().minLength(1).maxLength(50).optional(), + action: vine.string().trim().minLength(1).maxLength(50).optional(), + }) +) From 6576448b534e76281e6b7c780a4866b4f6afccc4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:41 +0400 Subject: [PATCH 50/89] feat(auth): add UsersService for the ADMIN user administration endpoints Registration always assigns the default USER role and nothing else in the app has ever managed users afterward, so this service is the first thing that can list, edit, delete or re-role an existing account. It is kept deliberately separate from AuthService rather than folded into it: AuthService owns the self-service lifecycle a user runs against their own account, register through reset-password, while UsersService owns what an ADMIN does to someone else's account. Those are different authorization stories even though both end up touching the same User model, and merging them would grow AuthService well past what its own name promises. assignRole and revokeRole are both idempotent by design. Assigning a role the user already has does not insert a second pivot row, checked by preloading roles and testing membership before attaching; findOrFail on both User and Role produces the "404 if user or role does not exist" behaviour the README asks for without any extra existence check. Revoking is idempotent in the other direction: Lucid's detach deletes at most one matching pivot row and never errors when none matches, so a role that was never assigned, or a roleId that never existed as a row at all, both resolve as a silent no-op rather than an error. The pagination mapping to PageResponse is duplicated here rather than pulled into a shared helper, matching the project's own page_response.ts docstring which already presents this exact mapping as the pattern every service is expected to repeat rather than share, the same way the later category, product, customer and order services will each repeat it too. --- app/services/users_service.ts | 128 ++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 app/services/users_service.ts diff --git a/app/services/users_service.ts b/app/services/users_service.ts new file mode 100644 index 0000000..ebf760c --- /dev/null +++ b/app/services/users_service.ts @@ -0,0 +1,128 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' +import type { Infer } from '@vinejs/vine/types' + +import User from '#models/user' +import Role from '#models/role' +import type { PageResponse } from '#helpers/page_response' +import type { updateUserSchema } from '#validators/user_validator' + +type UpdateUserPayload = Infer + +const E_EMAIL_TAKEN = createError('Email is already in use by another user', 'E_EMAIL_TAKEN', 409) + +/** + * Maps a Lucid ModelPaginatorContract onto this project's stable + * PageResponse envelope. Duplicated identically in roles_service.ts and + * permissions_service.ts rather than factored into a shared helper: the + * README's project structure only lists app/helpers/api_response.ts and + * app/helpers/page_response.ts, no shared pagination mapper, and + * page_response.ts's own docstring already shows this exact mapping as the + * pattern every service is expected to repeat, the same way the later + * category/product/customer/order services will each repeat it too. + */ +function toPageResponse(paginator: ModelPaginatorContract): PageResponse { + return { + items: paginator.all(), + total: paginator.total, + page: paginator.currentPage, + limit: paginator.perPage, + totalPages: paginator.lastPage, + hasNext: paginator.hasMorePages, + hasPrevious: paginator.currentPage > 1, + } +} + +/** + * Owns every read and write to the `users` table from the administration + * endpoints. This is deliberately separate from AuthService: AuthService + * covers the self-service account lifecycle (register/activate/login/...) + * a user runs against their own account, while UsersService covers an + * ADMIN acting on someone else's account (list, edit, delete, change + * roles). Splitting them keeps AuthService's own scope from growing to + * cover both "what a user can do to themselves" and "what an ADMIN can do + * to anyone", which are different authorization stories even though both + * ultimately touch the same User model. + * + * No constructor dependencies, so no @inject() here, the same reasoning + * AuthService documents for itself: every collaborator is a static Lucid + * model, and UsersController is the class that actually needs @inject() + * since it takes this service as a constructor argument. + */ +export default class UsersService { + async findAll(page: number, limit: number): Promise> { + const paginator = await User.query().paginate(page, limit) + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + return User.query().where('id', id).preload('roles').firstOrFail() + } + + /** + * Only re-checks email uniqueness when `email` is present in the payload + * and actually differs from the user's current value; updating a user + * without touching their email (for example flipping `accountLocked`) + * should never trip a false conflict against the user's own row. + */ + async update(id: number, data: UpdateUserPayload): Promise { + const user = await User.findOrFail(id) + + if (data.email && data.email !== user.email) { + const existing = await User.query().where('email', data.email).whereNot('id', id).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } + } + + user.merge(data) + await user.save() + return user + } + + async remove(id: number): Promise { + const user = await User.findOrFail(id) + await user.delete() + } + + /** + * Idempotent: attaching a role the user already has is a no-op rather + * than a duplicate pivot row or a thrown error. `findOrFail` on both + * User and Role naturally produces the "404 if user or role does not + * exist" behaviour the README asks for, no separate existence check + * needed. + */ + async assignRole(id: number, roleId: number): Promise { + const user = await User.findOrFail(id) + const role = await Role.findOrFail(roleId) + + await user.load('roles') + const alreadyAssigned = user.roles.some((r) => r.id === role.id) + if (!alreadyAssigned) { + await user.related('roles').attach([role.id]) + await user.load('roles') + } + + return user + } + + /** + * Idempotent in the other direction: detaching a role the user never + * had, or a roleId that does not even exist as a row, is a no-op. + * Lucid's `detach` deletes at most one pivot row and never errors when + * none matches, so there is nothing extra to guard here. + * + * NOTE: unlike `assignRole`, this method does not `findOrFail` the role + * itself. The README only specifies "404 if user or role does not + * exist" for assign; revoke's own spec is "idempotent, no error if the + * role was not assigned", which by construction also covers a roleId + * that never existed at all, so validating the user's own existence is + * enough here. + */ + async revokeRole(id: number, roleId: number): Promise { + const user = await User.findOrFail(id) + await user.related('roles').detach([roleId]) + await user.load('roles') + return user + } +} From 697b03b539d4de767d6cc9c9506d9712df8b9973 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:48 +0400 Subject: [PATCH 51/89] feat(auth): add RolesService for the ADMIN role administration endpoints Owns every read and write to roles and its two pivots, role_user and role_permission, from the ADMIN-only administration endpoints. assignPermission and revokePermission follow the exact idempotency reasoning UsersService documents for assignRole and revokeRole: attaching an already-attached permission is a no-op rather than a duplicate pivot row, and detaching one that was never attached is a no-op rather than an error, since Lucid's detach only ever deletes a pivot row that actually exists. remove() enforces the README's business rule that a role still assigned to at least one user cannot be deleted, throwing a 409 instead. That check queries the role_user pivot through the users relation and stops at the first match rather than counting every row, since the only thing the rule cares about is whether at least one exists, not how many. --- app/services/roles_service.ts | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 app/services/roles_service.ts diff --git a/app/services/roles_service.ts b/app/services/roles_service.ts new file mode 100644 index 0000000..d32a115 --- /dev/null +++ b/app/services/roles_service.ts @@ -0,0 +1,101 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' +import type { Infer } from '@vinejs/vine/types' + +import Role from '#models/role' +import Permission from '#models/permission' +import type { PageResponse } from '#helpers/page_response' +import type { createRoleSchema, updateRoleSchema } from '#validators/role_validator' + +type CreateRolePayload = Infer +type UpdateRolePayload = Infer + +const E_ROLE_IN_USE = createError( + 'Role is still assigned to at least one user', + 'E_ROLE_IN_USE', + 409 +) + +// Same pagination mapping as users_service.ts; see that file's own comment +// on why this is duplicated per-service rather than shared. +function toPageResponse(paginator: ModelPaginatorContract): PageResponse { + return { + items: paginator.all(), + total: paginator.total, + page: paginator.currentPage, + limit: paginator.perPage, + totalPages: paginator.lastPage, + hasNext: paginator.hasMorePages, + hasPrevious: paginator.currentPage > 1, + } +} + +/** + * Owns every read and write to the `roles` table and the `role_user` / + * `role_permission` pivots from the ADMIN-only administration endpoints. + * No constructor dependencies, so no @inject(), matching AuthService and + * UsersService's own reasoning for the same omission. + */ +export default class RolesService { + async findAll(page: number, limit: number): Promise> { + const paginator = await Role.query().paginate(page, limit) + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + return Role.query().where('id', id).preload('permissions').firstOrFail() + } + + async create(data: CreateRolePayload): Promise { + return Role.create(data) + } + + async update(id: number, data: UpdateRolePayload): Promise { + const role = await Role.findOrFail(id) + role.merge(data) + await role.save() + return role + } + + /** + * Business rule from the README: a role with at least one user still + * assigned cannot be deleted, 409 instead. Checked by querying the + * `role_user` pivot through the relation rather than counting every row + * (`related('users').query().first()` stops at the first match), since + * the only thing that matters here is "does at least one exist", not + * how many. + */ + async remove(id: number): Promise { + const role = await Role.findOrFail(id) + + const stillAssigned = await role.related('users').query().first() + if (stillAssigned) { + throw new E_ROLE_IN_USE() + } + + await role.delete() + } + + /** Idempotent, same reasoning as UsersService.assignRole. */ + async assignPermission(id: number, permissionId: number): Promise { + const role = await Role.findOrFail(id) + const permission = await Permission.findOrFail(permissionId) + + await role.load('permissions') + const alreadyAssigned = role.permissions.some((p) => p.id === permission.id) + if (!alreadyAssigned) { + await role.related('permissions').attach([permission.id]) + await role.load('permissions') + } + + return role + } + + /** Idempotent, same reasoning as UsersService.revokeRole. */ + async revokePermission(id: number, permissionId: number): Promise { + const role = await Role.findOrFail(id) + await role.related('permissions').detach([permissionId]) + await role.load('permissions') + return role + } +} From d31a5ebb192cbb47db678bf2f882da9782befea5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:48:56 +0400 Subject: [PATCH 52/89] feat(auth): add PermissionsService for the ADMIN permission administration endpoints Owns every read and write to permissions from the ADMIN-only administration endpoints. remove() enforces the README's business rule that a permission still attached to at least one role cannot be deleted, throwing a 409 instead, using the same stop-at-the-first-match query against the role_permission pivot that RolesService.remove uses against role_user. findOne exists even though no route calls it directly: the README's Endpoints table has no GET /api/v1/permissions/:id row, unlike users and roles, but the README's own task checklist for this service still lists findOne explicitly, and update needs it internally to fetch the record before mutating it. PermissionsController deliberately does not add a show action or route for it. --- app/services/permissions_service.ts | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 app/services/permissions_service.ts diff --git a/app/services/permissions_service.ts b/app/services/permissions_service.ts new file mode 100644 index 0000000..83a68ea --- /dev/null +++ b/app/services/permissions_service.ts @@ -0,0 +1,85 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' +import type { Infer } from '@vinejs/vine/types' + +import Permission from '#models/permission' +import type { PageResponse } from '#helpers/page_response' +import type { + createPermissionSchema, + updatePermissionSchema, +} from '#validators/permission_validator' + +type CreatePermissionPayload = Infer +type UpdatePermissionPayload = Infer + +const E_PERMISSION_IN_USE = createError( + 'Permission is still attached to at least one role', + 'E_PERMISSION_IN_USE', + 409 +) + +// Same pagination mapping as users_service.ts; see that file's own comment +// on why this is duplicated per-service rather than shared. +function toPageResponse(paginator: ModelPaginatorContract): PageResponse { + return { + items: paginator.all(), + total: paginator.total, + page: paginator.currentPage, + limit: paginator.perPage, + totalPages: paginator.lastPage, + hasNext: paginator.hasMorePages, + hasPrevious: paginator.currentPage > 1, + } +} + +/** + * Owns every read and write to the `permissions` table from the + * ADMIN-only administration endpoints. No constructor dependencies, so no + * @inject(), matching AuthService, UsersService and RolesService's own + * reasoning for the same omission. + */ +export default class PermissionsService { + async findAll(page: number, limit: number): Promise> { + const paginator = await Permission.query().paginate(page, limit) + return toPageResponse(paginator) + } + + /** + * Not exposed by any route (the README's Endpoints table has no `GET + * /api/v1/permissions/:id` row, unlike users and roles), but still + * needed internally by `update` to fetch the record before mutating it, + * and listed explicitly in the README's own task checklist for this + * service. + */ + async findOne(id: number): Promise { + return Permission.findOrFail(id) + } + + async create(data: CreatePermissionPayload): Promise { + return Permission.create(data) + } + + async update(id: number, data: UpdatePermissionPayload): Promise { + const permission = await Permission.findOrFail(id) + permission.merge(data) + await permission.save() + return permission + } + + /** + * Business rule from the README: a permission still attached to at + * least one role cannot be deleted, 409 instead. Same "stop at the + * first match" reasoning as RolesService.remove's own check against + * `role_user`. + */ + async remove(id: number): Promise { + const permission = await Permission.findOrFail(id) + + const stillAttached = await permission.related('roles').query().first() + if (stillAttached) { + throw new E_PERMISSION_IN_USE() + } + + await permission.delete() + } +} From dbcf6df3ead4289cb808d9b5332015b69299aaad Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:49:02 +0400 Subject: [PATCH 53/89] feat(auth): add UsersController for the ADMIN user administration endpoints Thin HTTP layer over UsersService: every action either reads a route param or validates the request body against a compiled schema, delegates to the service, and wraps the result with respond(). No business logic and no direct User model access live here, the same hard rule every other controller in this codebase follows. Every route this controller serves is declared behind middleware.auth() plus middleware.role({ roles: ['ADMIN'] }) in start/routes.ts at route declaration time, so no per-action authorization check needs to be repeated inside the controller itself. --- app/controllers/users_controller.ts | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 app/controllers/users_controller.ts diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts new file mode 100644 index 0000000..744c165 --- /dev/null +++ b/app/controllers/users_controller.ts @@ -0,0 +1,52 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import UsersService from '#services/users_service' +import { updateUserSchema, assignRoleSchema } from '#validators/user_validator' + +/** + * Thin HTTP layer over UsersService: every action validates its input (or + * reads a route param), delegates to the service, and wraps the result + * with `respond()`. Every route this controller serves is declared behind + * `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` in + * start/routes.ts, so no per-action authorization check is repeated here. + */ +@inject() +export default class UsersController { + constructor(private usersService: UsersService) {} + + async index({ request, response }: HttpContext) { + const page = Number(request.input('page', 1)) + const limit = Number(request.input('limit', 10)) + const result = await this.usersService.findAll(page, limit) + return response.ok(respond(result)) + } + + async show({ params, response }: HttpContext) { + const user = await this.usersService.findOne(Number(params.id)) + return response.ok(respond(user, 'User detail')) + } + + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateUserSchema) + const user = await this.usersService.update(Number(params.id), data) + return response.ok(respond(user, 'User updated')) + } + + async destroy({ params, response }: HttpContext) { + await this.usersService.remove(Number(params.id)) + return response.ok(respond(null, 'User deleted')) + } + + async assignRole({ params, request, response }: HttpContext) { + const { roleId } = await request.validateUsing(assignRoleSchema) + const user = await this.usersService.assignRole(Number(params.id), roleId) + return response.created(respond(user, 'Role assigned')) + } + + async revokeRole({ params, response }: HttpContext) { + const user = await this.usersService.revokeRole(Number(params.id), Number(params.roleId)) + return response.ok(respond(user, 'Role revoked')) + } +} From 7eb2eb4e7105593dae893878095e800af16ed024 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:49:07 +0400 Subject: [PATCH 54/89] feat(auth): add RolesController for the ADMIN role administration endpoints Thin HTTP layer over RolesService, same shape as UsersController: validate or read params, delegate to the service, wrap with respond(). Every route is declared behind middleware.auth() plus middleware.role({ roles: ['ADMIN'] }) in start/routes.ts. --- app/controllers/roles_controller.ts | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/controllers/roles_controller.ts diff --git a/app/controllers/roles_controller.ts b/app/controllers/roles_controller.ts new file mode 100644 index 0000000..905aa57 --- /dev/null +++ b/app/controllers/roles_controller.ts @@ -0,0 +1,63 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import RolesService from '#services/roles_service' +import { + createRoleSchema, + updateRoleSchema, + assignPermissionSchema, +} from '#validators/role_validator' + +/** + * Thin HTTP layer over RolesService. Every route this controller serves is + * declared behind `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` + * in start/routes.ts, same as UsersController. + */ +@inject() +export default class RolesController { + constructor(private rolesService: RolesService) {} + + async index({ request, response }: HttpContext) { + const page = Number(request.input('page', 1)) + const limit = Number(request.input('limit', 10)) + const result = await this.rolesService.findAll(page, limit) + return response.ok(respond(result)) + } + + async show({ params, response }: HttpContext) { + const role = await this.rolesService.findOne(Number(params.id)) + return response.ok(respond(role, 'Role detail')) + } + + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createRoleSchema) + const role = await this.rolesService.create(data) + return response.created(respond(role, 'Role created')) + } + + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateRoleSchema) + const role = await this.rolesService.update(Number(params.id), data) + return response.ok(respond(role, 'Role updated')) + } + + async destroy({ params, response }: HttpContext) { + await this.rolesService.remove(Number(params.id)) + return response.ok(respond(null, 'Role deleted')) + } + + async assignPermission({ params, request, response }: HttpContext) { + const { permissionId } = await request.validateUsing(assignPermissionSchema) + const role = await this.rolesService.assignPermission(Number(params.id), permissionId) + return response.created(respond(role, 'Permission assigned')) + } + + async revokePermission({ params, response }: HttpContext) { + const role = await this.rolesService.revokePermission( + Number(params.id), + Number(params.permissionId) + ) + return response.ok(respond(role, 'Permission revoked')) + } +} From 7e32cff52eced600f2264d526b29c9de37c7044b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:49:14 +0400 Subject: [PATCH 55/89] feat(auth): add PermissionsController for the ADMIN permission administration endpoints Thin HTTP layer over PermissionsService, same shape as UsersController and RolesController. Deliberately has no show action: the README's Endpoints table lists no GET /api/v1/permissions/:id route, unlike users and roles, so none is added here even though PermissionsService.findOne exists for the update action's own internal use. Every route is declared behind middleware.auth() plus middleware.role({ roles: ['ADMIN'] }) in start/routes.ts. --- app/controllers/permissions_controller.ts | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 app/controllers/permissions_controller.ts diff --git a/app/controllers/permissions_controller.ts b/app/controllers/permissions_controller.ts new file mode 100644 index 0000000..6bba91c --- /dev/null +++ b/app/controllers/permissions_controller.ts @@ -0,0 +1,44 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import PermissionsService from '#services/permissions_service' +import { createPermissionSchema, updatePermissionSchema } from '#validators/permission_validator' + +/** + * Thin HTTP layer over PermissionsService. Every route this controller + * serves is declared behind `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` + * in start/routes.ts, same as UsersController and RolesController. + * + * No `show` action: the README's Endpoints table has no `GET + * /api/v1/permissions/:id` row, unlike users and roles, so none is added + * here even though PermissionsService.findOne exists for internal reuse. + */ +@inject() +export default class PermissionsController { + constructor(private permissionsService: PermissionsService) {} + + async index({ request, response }: HttpContext) { + const page = Number(request.input('page', 1)) + const limit = Number(request.input('limit', 10)) + const result = await this.permissionsService.findAll(page, limit) + return response.ok(respond(result)) + } + + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createPermissionSchema) + const permission = await this.permissionsService.create(data) + return response.created(respond(permission, 'Permission created')) + } + + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updatePermissionSchema) + const permission = await this.permissionsService.update(Number(params.id), data) + return response.ok(respond(permission, 'Permission updated')) + } + + async destroy({ params, response }: HttpContext) { + await this.permissionsService.remove(Number(params.id)) + return response.ok(respond(null, 'Permission deleted')) + } +} From 99a9bad4373a8ec5201b5385685f1c791d9c2fbb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:49:21 +0400 Subject: [PATCH 56/89] feat(auth): declare the user, role and permission administration routes Adds /api/v1/users, /api/v1/roles and /api/v1/permissions, plus the nested /users/:id/roles and /roles/:id/permissions assign and revoke routes, in one router.group() carrying .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) at the group level. That satisfies the project's hard rule that auth and role middleware are applied at route declaration time and never added retroactively, every route in the group gets both checks the moment it is declared, there is no route here or added later that could slip through unprotected. There is deliberately no GET /api/v1/permissions/:id route, matching the README's Endpoints table for this subsection exactly, which lists a detail route for users and roles but not for permissions. --- start/routes.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index b884820..c7da70d 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -20,6 +20,9 @@ import { respond } from '#helpers/api_response' // Lazy import — AdonisJS resolves the module on the first matching request const HealthController = () => import('#controllers/health_controller') const AuthController = () => import('#controllers/auth_controller') +const UsersController = () => import('#controllers/users_controller') +const RolesController = () => import('#controllers/roles_controller') +const PermissionsController = () => import('#controllers/permissions_controller') /* |-------------------------------------------------------------------------- @@ -70,6 +73,50 @@ router }) .prefix('/api/v1/auth') +/* +|-------------------------------------------------------------------------- +| User, role and permission administration +|-------------------------------------------------------------------------- +| +| Registration always assigns the default USER role and nothing else in +| the app manages `users`, `roles` or `permissions` afterward — these +| endpoints are the only way to promote an account to ADMIN, create a new +| role, or change what a role can do. Every single route below operates on +| other accounts or on the authorization model itself, so all of them are +| ADMIN only, applied at route declaration time via the group-level +| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) rather +| than added retroactively per-route. +| +| Note there is deliberately no GET /api/v1/permissions/:id route: the +| README's Endpoints table for this subsection lists a detail route for +| users and roles but not for permissions. +| +*/ +router + .group(() => { + router.get('users', [UsersController, 'index']) + router.get('users/:id', [UsersController, 'show']) + router.put('users/:id', [UsersController, 'update']) + router.delete('users/:id', [UsersController, 'destroy']) + router.post('users/:id/roles', [UsersController, 'assignRole']) + router.delete('users/:id/roles/:roleId', [UsersController, 'revokeRole']) + + router.get('roles', [RolesController, 'index']) + router.get('roles/:id', [RolesController, 'show']) + router.post('roles', [RolesController, 'store']) + router.put('roles/:id', [RolesController, 'update']) + router.delete('roles/:id', [RolesController, 'destroy']) + router.post('roles/:id/permissions', [RolesController, 'assignPermission']) + router.delete('roles/:id/permissions/:permissionId', [RolesController, 'revokePermission']) + + router.get('permissions', [PermissionsController, 'index']) + router.post('permissions', [PermissionsController, 'store']) + router.put('permissions/:id', [PermissionsController, 'update']) + router.delete('permissions/:id', [PermissionsController, 'destroy']) + }) + .prefix('/api/v1') + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) + /* |-------------------------------------------------------------------------- | Temporary middleware smoke-test stubs (feature/auth only) From 88cc56aad65d94ae633ffdc6246ac83f6b198361 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:49:28 +0400 Subject: [PATCH 57/89] chore(codegen): regenerate the Tuyau API client registry and route manifest .adonisjs/client and .adonisjs/server are generated by the indexEntities and generateRegistry hooks in adonisrc.ts every time the app boots, reflecting whatever controllers and routes currently exist. Adding UsersController, RolesController and PermissionsController along with their routes changed what those hooks produce, so the checked-in snapshot needed regenerating to match, the same pattern feature/data-modeling's own "commit the regenerated schema snapshot" commit established for this repo. --- .adonisjs/client/manifest.d.ts | 1 + .adonisjs/client/registry/index.ts | 102 +++++++++++++ .adonisjs/client/registry/schema.d.ts | 204 ++++++++++++++++++++++++++ .adonisjs/client/registry/tree.d.ts | 23 +++ .adonisjs/server/controllers.ts | 3 + .adonisjs/server/routes.d.ts | 43 ++++++ 6 files changed, 376 insertions(+) diff --git a/.adonisjs/client/manifest.d.ts b/.adonisjs/client/manifest.d.ts index b1c625c..caa1e21 100644 --- a/.adonisjs/client/manifest.d.ts +++ b/.adonisjs/client/manifest.d.ts @@ -8,3 +8,4 @@ /// /// /// +/// diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index 44f2010..c7d5de1 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -60,6 +60,108 @@ const routes = { tokens: [{"old":"/api/v1/auth/me","type":0,"val":"api","end":""},{"old":"/api/v1/auth/me","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/me","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/me","type":0,"val":"me","end":""}], types: placeholder as Registry['auth.me']['types'], }, + 'users.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/users', + tokens: [{"old":"/api/v1/users","type":0,"val":"api","end":""},{"old":"/api/v1/users","type":0,"val":"v1","end":""},{"old":"/api/v1/users","type":0,"val":"users","end":""}], + types: placeholder as Registry['users.index']['types'], + }, + 'users.show': { + methods: ["GET","HEAD"], + pattern: '/api/v1/users/:id', + tokens: [{"old":"/api/v1/users/:id","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['users.show']['types'], + }, + 'users.update': { + methods: ["PUT"], + pattern: '/api/v1/users/:id', + tokens: [{"old":"/api/v1/users/:id","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['users.update']['types'], + }, + 'users.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/users/:id', + tokens: [{"old":"/api/v1/users/:id","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['users.destroy']['types'], + }, + 'users.assign_role': { + methods: ["POST"], + pattern: '/api/v1/users/:id/roles', + tokens: [{"old":"/api/v1/users/:id/roles","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id/roles","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id/roles","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id/roles","type":1,"val":"id","end":""},{"old":"/api/v1/users/:id/roles","type":0,"val":"roles","end":""}], + types: placeholder as Registry['users.assign_role']['types'], + }, + 'users.revoke_role': { + methods: ["DELETE"], + pattern: '/api/v1/users/:id/roles/:roleId', + tokens: [{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":1,"val":"id","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"roles","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":1,"val":"roleId","end":""}], + types: placeholder as Registry['users.revoke_role']['types'], + }, + 'roles.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/roles', + tokens: [{"old":"/api/v1/roles","type":0,"val":"api","end":""},{"old":"/api/v1/roles","type":0,"val":"v1","end":""},{"old":"/api/v1/roles","type":0,"val":"roles","end":""}], + types: placeholder as Registry['roles.index']['types'], + }, + 'roles.show': { + methods: ["GET","HEAD"], + pattern: '/api/v1/roles/:id', + tokens: [{"old":"/api/v1/roles/:id","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['roles.show']['types'], + }, + 'roles.store': { + methods: ["POST"], + pattern: '/api/v1/roles', + tokens: [{"old":"/api/v1/roles","type":0,"val":"api","end":""},{"old":"/api/v1/roles","type":0,"val":"v1","end":""},{"old":"/api/v1/roles","type":0,"val":"roles","end":""}], + types: placeholder as Registry['roles.store']['types'], + }, + 'roles.update': { + methods: ["PUT"], + pattern: '/api/v1/roles/:id', + tokens: [{"old":"/api/v1/roles/:id","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['roles.update']['types'], + }, + 'roles.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/roles/:id', + tokens: [{"old":"/api/v1/roles/:id","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['roles.destroy']['types'], + }, + 'roles.assign_permission': { + methods: ["POST"], + pattern: '/api/v1/roles/:id/permissions', + tokens: [{"old":"/api/v1/roles/:id/permissions","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id/permissions","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id/permissions","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id/permissions","type":1,"val":"id","end":""},{"old":"/api/v1/roles/:id/permissions","type":0,"val":"permissions","end":""}], + types: placeholder as Registry['roles.assign_permission']['types'], + }, + 'roles.revoke_permission': { + methods: ["DELETE"], + pattern: '/api/v1/roles/:id/permissions/:permissionId', + tokens: [{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":1,"val":"id","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"permissions","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":1,"val":"permissionId","end":""}], + types: placeholder as Registry['roles.revoke_permission']['types'], + }, + 'permissions.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/permissions', + tokens: [{"old":"/api/v1/permissions","type":0,"val":"api","end":""},{"old":"/api/v1/permissions","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions","type":0,"val":"permissions","end":""}], + types: placeholder as Registry['permissions.index']['types'], + }, + 'permissions.store': { + methods: ["POST"], + pattern: '/api/v1/permissions', + tokens: [{"old":"/api/v1/permissions","type":0,"val":"api","end":""},{"old":"/api/v1/permissions","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions","type":0,"val":"permissions","end":""}], + types: placeholder as Registry['permissions.store']['types'], + }, + 'permissions.update': { + methods: ["PUT"], + pattern: '/api/v1/permissions/:id', + tokens: [{"old":"/api/v1/permissions/:id","type":0,"val":"api","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"permissions","end":""},{"old":"/api/v1/permissions/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['permissions.update']['types'], + }, + 'permissions.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/permissions/:id', + tokens: [{"old":"/api/v1/permissions/:id","type":0,"val":"api","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"permissions","end":""},{"old":"/api/v1/permissions/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['permissions.destroy']['types'], + }, } as const satisfies Record export { routes } diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index 4684849..e068413 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -115,4 +115,208 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } + 'users.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/users' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.show': { + methods: ["GET","HEAD"] + pattern: '/api/v1/users/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.update': { + methods: ["PUT"] + pattern: '/api/v1/users/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'users.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/users/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.assign_role': { + methods: ["POST"] + pattern: '/api/v1/users/:id/roles' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'users.revoke_role': { + methods: ["DELETE"] + pattern: '/api/v1/users/:id/roles/:roleId' + types: { + body: {} + paramsTuple: [ParamValue, ParamValue] + params: { id: ParamValue; roleId: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'roles.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/roles' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'roles.show': { + methods: ["GET","HEAD"] + pattern: '/api/v1/roles/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'roles.store': { + methods: ["POST"] + pattern: '/api/v1/roles' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'roles.update': { + methods: ["PUT"] + pattern: '/api/v1/roles/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'roles.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/roles/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'roles.assign_permission': { + methods: ["POST"] + pattern: '/api/v1/roles/:id/permissions' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'roles.revoke_permission': { + methods: ["DELETE"] + pattern: '/api/v1/roles/:id/permissions/:permissionId' + types: { + body: {} + paramsTuple: [ParamValue, ParamValue] + params: { id: ParamValue; permissionId: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'permissions.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/permissions' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'permissions.store': { + methods: ["POST"] + pattern: '/api/v1/permissions' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'permissions.update': { + methods: ["PUT"] + pattern: '/api/v1/permissions/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'permissions.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/permissions/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } } diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index 39b6c5e..811db0a 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -15,4 +15,27 @@ export interface ApiDefinition { resetPassword: typeof routes['auth.reset_password'] me: typeof routes['auth.me'] } + users: { + index: typeof routes['users.index'] + show: typeof routes['users.show'] + update: typeof routes['users.update'] + destroy: typeof routes['users.destroy'] + assignRole: typeof routes['users.assign_role'] + revokeRole: typeof routes['users.revoke_role'] + } + roles: { + index: typeof routes['roles.index'] + show: typeof routes['roles.show'] + store: typeof routes['roles.store'] + update: typeof routes['roles.update'] + destroy: typeof routes['roles.destroy'] + assignPermission: typeof routes['roles.assign_permission'] + revokePermission: typeof routes['roles.revoke_permission'] + } + permissions: { + index: typeof routes['permissions.index'] + store: typeof routes['permissions.store'] + update: typeof routes['permissions.update'] + destroy: typeof routes['permissions.destroy'] + } } diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 98852cf..dfc2309 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -6,4 +6,7 @@ export const controllers = { Auth: () => import('#controllers/auth_controller'), Health: () => import('#controllers/health_controller'), + Permissions: () => import('#controllers/permissions_controller'), + Roles: () => import('#controllers/roles_controller'), + Users: () => import('#controllers/users_controller'), } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index 304b0e3..a3d18bf 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -13,14 +13,41 @@ export type ScannedRoutes = { 'auth.forgot_password': { paramsTuple?: []; params?: {} } 'auth.reset_password': { paramsTuple?: []; params?: {} } 'auth.me': { paramsTuple?: []; params?: {} } + 'users.index': { paramsTuple?: []; params?: {} } + 'users.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.assign_role': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.revoke_role': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'roleId': ParamValue} } + 'roles.index': { paramsTuple?: []; params?: {} } + 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.store': { paramsTuple?: []; params?: {} } + 'roles.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.assign_permission': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.revoke_permission': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'permissionId': ParamValue} } + 'permissions.index': { paramsTuple?: []; params?: {} } + 'permissions.store': { paramsTuple?: []; params?: {} } + 'permissions.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } GET: { 'health.index': { paramsTuple?: []; params?: {} } 'auth.me': { paramsTuple?: []; params?: {} } + 'users.index': { paramsTuple?: []; params?: {} } + 'users.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.index': { paramsTuple?: []; params?: {} } + 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.index': { paramsTuple?: []; params?: {} } } HEAD: { 'health.index': { paramsTuple?: []; params?: {} } 'auth.me': { paramsTuple?: []; params?: {} } + 'users.index': { paramsTuple?: []; params?: {} } + 'users.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.index': { paramsTuple?: []; params?: {} } + 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.index': { paramsTuple?: []; params?: {} } } POST: { 'auth.register': { paramsTuple?: []; params?: {} } @@ -30,6 +57,22 @@ export type ScannedRoutes = { 'auth.refresh': { paramsTuple?: []; params?: {} } 'auth.forgot_password': { paramsTuple?: []; params?: {} } 'auth.reset_password': { paramsTuple?: []; params?: {} } + 'users.assign_role': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.store': { paramsTuple?: []; params?: {} } + 'roles.assign_permission': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.store': { paramsTuple?: []; params?: {} } + } + PUT: { + 'users.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + } + DELETE: { + 'users.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.revoke_role': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'roleId': ParamValue} } + 'roles.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.revoke_permission': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'permissionId': ParamValue} } + 'permissions.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } } declare module '@adonisjs/core/types/http' { From 14f66022fe455fce8a3a3416f478c6c803f291dd Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:54:00 +0400 Subject: [PATCH 58/89] test(auth): read the activation token from the database instead of the register response AuthService.register used to return the raw activation token alongside the new user so this spec could pass it straight into activate. Now that a real mailer is wired up and the token is delivered by email instead of being echoed back, register only returns the User, and the two tests that depended on the old return shape (asserting the token was a string, and destructuring it out to feed activate) needed a different way to get at the value they exist to check. Both tests now query ActivationToken directly by userId, ordered by createdAt descending, which is the same thing any other out of band consumer of the token would have to do. The assertions themselves are unchanged in substance: register still has to produce a persisted, unvalidated token tied to the right user, and activate still has to flip enabled and mark that token's validatedAt, so the behavior proven here is exactly what was proven before, just read from its actual source of truth rather than from a response shape that no longer exists. --- tests/unit/services/auth_service.spec.ts | 40 +++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/unit/services/auth_service.spec.ts b/tests/unit/services/auth_service.spec.ts index 7811b3e..0e2867b 100644 --- a/tests/unit/services/auth_service.spec.ts +++ b/tests/unit/services/auth_service.spec.ts @@ -67,48 +67,60 @@ test.group('AuthService - register / activate', (group) => { }) => { const email = `register-${crypto.randomUUID()}@example.com` - const result = await authService.register({ + const user = await authService.register({ firstName: 'Ada', lastName: 'Lovelace', email, password: 'Password1!', }) - assert.isNumber(result.user.id) - assert.equal(result.user.email, email) - assert.isFalse(result.user.enabled) + assert.isNumber(user.id) + assert.equal(user.email, email) + assert.isFalse(user.enabled) - await result.user.load('roles') - assert.isTrue(result.user.roles.some((role) => role.roleName === 'USER')) - - assert.isString(result.activationToken) + await user.load('roles') + assert.isTrue(user.roles.some((role) => role.roleName === 'USER')) + // register() no longer returns the activation token directly (it is + // emailed via ActivationMail instead), so the persisted row is looked + // up by userId, most recent first, the same way a caller with only the + // user's id would have to. const tokenRow = await ActivationToken.query() - .where('token', result.activationToken) + .where('userId', user.id) + .orderBy('createdAt', 'desc') .firstOrFail() - assert.equal(tokenRow.userId, result.user.id) + assert.isString(tokenRow.token) + assert.isNotEmpty(tokenRow.token) + assert.equal(tokenRow.userId, user.id) assert.isNull(tokenRow.validatedAt) }).timeout(10000) test('activate sets enabled to true and marks the activation token as used', async ({ assert, }) => { - const { user, activationToken } = await authService.register({ + const user = await authService.register({ firstName: 'Grace', lastName: 'Hopper', email: `activate-${crypto.randomUUID()}@example.com`, password: 'Password1!', }) - const activatedUser = await authService.activate(activationToken) + const tokenRow = await ActivationToken.query() + .where('userId', user.id) + .orderBy('createdAt', 'desc') + .firstOrFail() + + const activatedUser = await authService.activate(tokenRow.token) assert.isTrue(activatedUser.enabled) const reloadedUser = await User.findOrFail(user.id) assert.isTrue(reloadedUser.enabled) - const tokenRow = await ActivationToken.query().where('token', activationToken).firstOrFail() - assert.isNotNull(tokenRow.validatedAt) + const reloadedToken = await ActivationToken.query() + .where('token', tokenRow.token) + .firstOrFail() + assert.isNotNull(reloadedToken.validatedAt) }).timeout(10000) }) From 105503fd7bae06edc7f30958d65576422db07226 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 14:54:04 +0400 Subject: [PATCH 59/89] test(auth): look up the activation token in the database for the full lifecycle test The full register-activate-login-me-logout functional test used to read the activation token straight off the register HTTP response body. Since AuthController.register no longer includes that token in its response (it is emailed via ActivationMail against Mailhog instead), the test had no way left to reach the activate step of the flow it is meant to exercise end to end. The fix reads the new user's id from the register response, which is unchanged, then queries ActivationToken for that userId, most recent first, to recover the token exactly as the real activation email delivery path would produce it. This keeps the test proving the same thing it always did, that register really does hand off a working token to activate over real HTTP calls, without asserting anything about how that token reaches the end user, which is now email delivery rather than the API response and is out of scope for this test. --- tests/functional/auth.spec.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/functional/auth.spec.ts b/tests/functional/auth.spec.ts index a1c1a36..d65c7b0 100644 --- a/tests/functional/auth.spec.ts +++ b/tests/functional/auth.spec.ts @@ -14,6 +14,7 @@ import { test } from '@japa/runner' import db from '@adonisjs/lucid/services/db' import env from '#start/env' +import ActivationToken from '#models/activation_token' import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' test.group('Auth - full lifecycle (register -> activate -> login -> me -> logout)', (group) => { @@ -44,7 +45,20 @@ test.group('Auth - full lifecycle (register -> activate -> login -> me -> logout }) registerResponse.assertStatus(201) registerResponse.assertBodyContains({ success: true }) - const activationToken = registerResponse.body().data.activationToken as string + const userId = registerResponse.body().data.id as number + assert.isNumber(userId) + + // The activation token is no longer echoed in the response: it is + // emailed via ActivationMail against Mailhog now that a real mailer is + // wired up on this branch. The row is still persisted exactly as + // before, so it is looked up directly by the new user's id, most + // recent first, the same way any other out-of-band consumer of the + // token would have to. + const tokenRow = await ActivationToken.query() + .where('userId', userId) + .orderBy('createdAt', 'desc') + .firstOrFail() + const activationToken = tokenRow.token assert.isString(activationToken) // 2. activate From b65c36b8b7ee9b4f8ac1d836befd3688d77fb6d6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:32 +0400 Subject: [PATCH 60/89] test(auth): verify UsersService.assignRole is idempotent The service's own docstring promises that attaching a role a user already has is a no-op rather than a duplicate pivot row or a thrown error, backed by the composite primary key on role_user(user_id, role_id): a naive attach() called twice without the existence check UsersService performs would raise a unique constraint violation on the second call. Nothing in this branch proved the check actually runs before the attach, only that the migration and the relation declaration exist. These tests hit the real test database inside a rolled back global transaction, the same pattern as auth_service.spec.ts, since the only way to prove no duplicate pivot row is to read the pivot table back after calling the service twice. --- tests/unit/services/users_service.spec.ts | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/unit/services/users_service.spec.ts diff --git a/tests/unit/services/users_service.spec.ts b/tests/unit/services/users_service.spec.ts new file mode 100644 index 0000000..f397835 --- /dev/null +++ b/tests/unit/services/users_service.spec.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for UsersService.assignRole's idempotency guarantee. + * + * The service's own docstring promises that attaching a role a user already + * has is a no-op rather than a duplicate pivot row or a thrown error, and + * the `role_user` pivot table backs that promise with a composite primary + * key on (user_id, role_id): a naive `attach()` called twice without the + * existence check RolesService performs would raise a unique-constraint + * violation on the second call. Nothing in this branch proved that the + * check actually runs before the attach, only that the migration and the + * relation declaration exist. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as auth_service.spec.ts, since the only way + * to prove "no duplicate pivot row" is to read the pivot table back after + * calling the service twice. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import UsersService from '#services/users_service' +import User from '#models/user' +import { ensureRole } from '#tests/helpers/auth_helper' + +const usersService = new UsersService() + +async function createUser() { + return User.create({ + firstName: 'Test', + lastName: 'User', + email: `user-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) +} + +test.group('UsersService - assignRole idempotency', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('attaches a role the user does not have yet', async ({ assert }) => { + const user = await createUser() + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + + const updated = await usersService.assignRole(user.id, role.id) + + assert.isTrue(updated.roles.some((r) => r.id === role.id)) + + const pivotRows = await db + .from('role_user') + .where('user_id', user.id) + .where('role_id', role.id) + assert.lengthOf(pivotRows, 1) + }).timeout(10000) + + test('assigning the same role twice does not duplicate the pivot row', async ({ assert }) => { + const user = await createUser() + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + + await usersService.assignRole(user.id, role.id) + const updated = await usersService.assignRole(user.id, role.id) + + assert.lengthOf( + updated.roles.filter((r) => r.id === role.id), + 1 + ) + + const pivotRows = await db + .from('role_user') + .where('user_id', user.id) + .where('role_id', role.id) + assert.lengthOf(pivotRows, 1) + }).timeout(10000) +}) From 88436c600ac82b02b1f07d5123db2769a2cdc1eb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:32 +0400 Subject: [PATCH 61/89] test(auth): verify RolesService.remove blocks deletion while a user has the role A role still assigned to at least one user cannot be deleted, and must fail with 409 rather than either silently succeeding, leaving that user's role_user row pointing at a deleted role, or letting the database's own FK constraint surface as an unhandled 500. Nothing in this branch proved the guard actually runs before the delete, or that a role really is removable once it is no longer assigned to anyone. These tests hit the real test database inside a rolled back global transaction, the same pattern as auth_service.spec.ts, since the rule only means anything once a real pivot row exists to be checked against. --- tests/unit/services/roles_service.spec.ts | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/unit/services/roles_service.spec.ts diff --git a/tests/unit/services/roles_service.spec.ts b/tests/unit/services/roles_service.spec.ts new file mode 100644 index 0000000..5ed2804 --- /dev/null +++ b/tests/unit/services/roles_service.spec.ts @@ -0,0 +1,71 @@ +/** + * Unit tests for RolesService.remove's business rule: a role still assigned + * to at least one user cannot be deleted, and must fail with 409 rather + * than either silently succeeding (leaving that user's `role_user` row + * pointing at a deleted role) or letting the database's own FK constraint + * surface as an unhandled 500. Nothing in this branch proved the guard + * actually runs before the delete, or that a role really is removable once + * it is no longer assigned to anyone. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as auth_service.spec.ts, since the rule + * only means anything once a real pivot row exists to be checked against. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import RolesService from '#services/roles_service' +import Role from '#models/role' +import User from '#models/user' +import { ensureRole } from '#tests/helpers/auth_helper' + +const rolesService = new RolesService() + +async function createUser() { + return User.create({ + firstName: 'Test', + lastName: 'User', + email: `user-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) +} + +test.group('RolesService - remove', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('throws E_ROLE_IN_USE (409) when a user is still assigned to the role', async ({ + assert, + }) => { + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + const user = await createUser() + await user.related('roles').attach([role.id]) + + try { + await rolesService.remove(role.id) + assert.fail('expected remove to throw while a user still has the role') + } catch (error: any) { + assert.equal(error.code, 'E_ROLE_IN_USE') + assert.equal(error.status, 409) + } + + const stillExists = await Role.find(role.id) + assert.isNotNull(stillExists) + }).timeout(10000) + + test('deletes the role once no user has it assigned', async ({ assert }) => { + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + + await rolesService.remove(role.id) + + const deleted = await Role.find(role.id) + assert.isNull(deleted) + }).timeout(10000) +}) From 619178a542806bddefe2d3fa1c2a962e623f0daf Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:32 +0400 Subject: [PATCH 62/89] test(auth): verify PermissionsService.remove blocks deletion while a role has it Mirrors RolesService's rule for roles still assigned to a user: a permission still attached to at least one role cannot be deleted. Without this guard, deleting a permission a role still grants would either violate the role_permission FK constraint as an unhandled 500 or, worse, silently leave that role pointing at a permission row that no longer exists. These tests hit the real test database inside a rolled back global transaction, the same pattern as roles_service.spec.ts, since the rule only means anything once a real role_permission pivot row exists to be checked against. --- .../unit/services/permissions_service.spec.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/unit/services/permissions_service.spec.ts diff --git a/tests/unit/services/permissions_service.spec.ts b/tests/unit/services/permissions_service.spec.ts new file mode 100644 index 0000000..76ca497 --- /dev/null +++ b/tests/unit/services/permissions_service.spec.ts @@ -0,0 +1,67 @@ +/** + * Unit tests for PermissionsService.remove's business rule: a permission + * still attached to at least one role cannot be deleted, mirroring + * RolesService's rule for roles still assigned to a user. Without this + * guard, deleting a permission a role still grants would either violate + * the `role_permission` FK constraint as an unhandled 500 or, worse, + * silently leave that role pointing at a permission row that no longer + * exists. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as roles_service.spec.ts, since the rule + * only means anything once a real `role_permission` pivot row exists to be + * checked against. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import PermissionsService from '#services/permissions_service' +import Permission from '#models/permission' +import { ensureRole } from '#tests/helpers/auth_helper' + +const permissionsService = new PermissionsService() + +async function createPermission() { + return Permission.create({ + resource: `resource-${crypto.randomUUID()}`, + action: 'read', + }) +} + +test.group('PermissionsService - remove', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('throws E_PERMISSION_IN_USE (409) when a role still has the permission', async ({ + assert, + }) => { + const permission = await createPermission() + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + await role.related('permissions').attach([permission.id]) + + try { + await permissionsService.remove(permission.id) + assert.fail('expected remove to throw while a role still has the permission') + } catch (error: any) { + assert.equal(error.code, 'E_PERMISSION_IN_USE') + assert.equal(error.status, 409) + } + + const stillExists = await Permission.find(permission.id) + assert.isNotNull(stillExists) + }).timeout(10000) + + test('deletes the permission once no role has it attached', async ({ assert }) => { + const permission = await createPermission() + + await permissionsService.remove(permission.id) + + const deleted = await Permission.find(permission.id) + assert.isNull(deleted) + }).timeout(10000) +}) From d6873ac191f621b0bd1e2bab0338306108acf42c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:50 +0400 Subject: [PATCH 63/89] test(auth): cover the user administration HTTP surface end to end Two groups. The first proves the full CRUD lifecycle over real requests, list, detail, update, assign a role, revoke it, delete, and that every response matches the ApiResponse envelope, not just the service layer in isolation. The second, and more important one, proves something no test in this branch had touched yet: that assigning or revoking a role through the admin API actually changes what a user's own already-issued JWT is authorized to do on the very next request, without any re-login. Getting that wrong in either direction is dangerous. A stale check that keeps trusting a revoked ADMIN role would let a demoted account keep acting as an admin until their token expires on its own; one that never picks up a newly granted role would force every promoted user to log out and back in before the promotion takes effect. RoleMiddleware calls user.load('roles') fresh on every request rather than caching anything from the token itself, and this test is what actually proves that design choice behaves the way its own code comments claim. --- tests/functional/users.spec.ts | 202 +++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/functional/users.spec.ts diff --git a/tests/functional/users.spec.ts b/tests/functional/users.spec.ts new file mode 100644 index 0000000..2efdb02 --- /dev/null +++ b/tests/functional/users.spec.ts @@ -0,0 +1,202 @@ +/** + * Functional tests for the user administration HTTP surface: the full CRUD + * lifecycle (list, detail, update, delete) and, most importantly, the + * behavioural proof that assigning or revoking a role through + * `POST /users/:id/roles` and `DELETE /users/:id/roles/:roleId` actually + * changes what that user's own JWT is authorized to do on a subsequent + * request, not just what the `users` table says. + * + * Before this branch there was no coverage proving RoleMiddleware really + * re-checks roles fresh on every request rather than caching whatever was + * true when the JWT was issued. Getting that wrong in either direction + * would be dangerous: a stale cache that keeps trusting a revoked ADMIN + * role would let a demoted account keep acting as an admin until their + * token expires, and one that never picks up a newly granted role would + * force every promoted user to log out and back in before the promotion + * takes effect, silently breaking the endpoint this branch exists to add. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Role from '#models/role' +import ActivationToken from '#models/activation_token' +import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' + +test.group('Users admin - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('list, read, update, assign/revoke a role, then delete a user, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // seed the account this test exercises + const targetEmail = `admin-crud-${crypto.randomUUID()}@example.com` + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Target', + lastName: 'User', + email: targetEmail, + password: 'Password1!', + }) + registerResponse.assertStatus(201) + const userId = registerResponse.body().data.id as number + + // GET /api/v1/users - paginated list + const listResponse = await client + .get('/api/v1/users') + .qs({ page: 1, limit: 10 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(listResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.isTrue(listResponse.body().success) + assert.properties(listResponse.body().data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.isArray(listResponse.body().data.items) + + // GET /api/v1/users/:id - detail with roles preloaded + const showResponse = await client.get(`/api/v1/users/${userId}`).bearerToken(adminToken) + showResponse.assertStatus(200) + assert.properties(showResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.equal(showResponse.body().data.id, userId) + assert.isArray(showResponse.body().data.roles) + assert.isTrue( + showResponse.body().data.roles.some((r: { roleName: string }) => r.roleName === 'USER') + ) + + // PUT /api/v1/users/:id - partial update + const updateResponse = await client + .put(`/api/v1/users/${userId}`) + .json({ firstName: 'Updated', accountLocked: true }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.properties(updateResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.equal(updateResponse.body().data.firstName, 'Updated') + assert.isTrue(updateResponse.body().data.accountLocked) + + // POST /api/v1/users/:id/roles - assign a second role + const extraRole = await Role.firstOrCreate( + { roleName: `EXTRA_${crypto.randomUUID()}` }, + { roleName: `EXTRA_${crypto.randomUUID()}` } + ) + const assignResponse = await client + .post(`/api/v1/users/${userId}/roles`) + .json({ roleId: extraRole.id }) + .bearerToken(adminToken) + assignResponse.assertStatus(201) + assert.properties(assignResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.isTrue( + assignResponse.body().data.roles.some((r: { id: number }) => r.id === extraRole.id) + ) + + // DELETE /api/v1/users/:id/roles/:roleId - revoke it back off + const revokeResponse = await client + .delete(`/api/v1/users/${userId}/roles/${extraRole.id}`) + .bearerToken(adminToken) + revokeResponse.assertStatus(200) + assert.properties(revokeResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.isFalse( + revokeResponse.body().data.roles.some((r: { id: number }) => r.id === extraRole.id) + ) + + // DELETE /api/v1/users/:id + const deleteResponse = await client.delete(`/api/v1/users/${userId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + assert.properties(deleteResponse.body(), ['success', 'message', 'data', 'timestamp']) + + const afterDeleteResponse = await client + .get(`/api/v1/users/${userId}`) + .bearerToken(adminToken) + afterDeleteResponse.assertStatus(404) + }).timeout(20000) +}) + +test.group('Users admin - assigning/revoking a role changes JWT authorization', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureRole('USER') + await ensureRole('ADMIN') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('promoting then demoting a user changes what their own JWT can do, without re-login', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const adminRole = await ensureRole('ADMIN') + + // Register and activate a fresh, ordinary USER account, then log in as + // them to get the JWT under test. This token is never re-issued for the + // rest of the test: RoleMiddleware re-checks roles fresh on every + // request via user.load('roles'), so the same token must flip between + // 403 and 200 purely because of what the admin API does to the user's + // roles in between. + const email = `promote-${crypto.randomUUID()}@example.com` + const password = 'Password1!' + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Promote', + lastName: 'Me', + email, + password, + }) + registerResponse.assertStatus(201) + const userId = registerResponse.body().data.id as number + + const tokenRow = await ActivationToken.query() + .where('userId', userId) + .orderBy('createdAt', 'desc') + .firstOrFail() + await client.post('/api/v1/auth/activate').json({ token: tokenRow.token }) + + const loginResponse = await client.post('/api/v1/auth/login').json({ email, password }) + loginResponse.assertStatus(200) + const userToken = loginResponse.body().data.token as string + + // Not yet an ADMIN: an ADMIN-only route must reject this user's own JWT + const beforeResponse = await client.get('/api/v1/users').bearerToken(userToken) + beforeResponse.assertStatus(403) + + // Promote via the admin API, logged in as the seeded ADMIN + const assignResponse = await client + .post(`/api/v1/users/${userId}/roles`) + .json({ roleId: adminRole.id }) + .bearerToken(adminToken) + assignResponse.assertStatus(201) + + // Same original JWT, never re-issued, now succeeds + const afterAssignResponse = await client.get('/api/v1/users').bearerToken(userToken) + afterAssignResponse.assertStatus(200) + + // Revoke the ADMIN role + const revokeResponse = await client + .delete(`/api/v1/users/${userId}/roles/${adminRole.id}`) + .bearerToken(adminToken) + revokeResponse.assertStatus(200) + + // Same original JWT, back to 403 + const afterRevokeResponse = await client.get('/api/v1/users').bearerToken(userToken) + afterRevokeResponse.assertStatus(403) + + assert.isNumber(userId) + }).timeout(20000) +}) From ea4f228934ea0ef31e6fbeb622a558dc0088c40b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:50 +0400 Subject: [PATCH 64/89] test(auth): cover the role administration HTTP surface end to end The unit spec for RolesService already proves its 409 guard throws the right error at the service layer, but nothing proved the HTTP layer actually turns it into a 409 response in the ApiResponse envelope rather than falling through to a generic 500, or that the full create, read, update, assign a permission, revoke it, delete cycle a real ADMIN client would perform actually works end to end over real requests rather than only through direct service calls. The second test in this file walks the same 409 scenario through the HTTP layer specifically: create a role, assign it to a user, attempt to delete it and confirm 409, revoke the assignment, then confirm the same delete now succeeds, proving the guard is not just correct in isolation but wired all the way through the controller. --- tests/functional/roles.spec.ts | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/functional/roles.spec.ts diff --git a/tests/functional/roles.spec.ts b/tests/functional/roles.spec.ts new file mode 100644 index 0000000..6cf1543 --- /dev/null +++ b/tests/functional/roles.spec.ts @@ -0,0 +1,152 @@ +/** + * Functional tests for the role administration HTTP surface: the full CRUD + * lifecycle (create, list, detail, update, assign/revoke a permission, + * delete) plus the 409 business rule that a role still assigned to at + * least one user cannot be deleted. + * + * The unit spec for RolesService already proves the guard throws the right + * error at the service layer; what was still unproven is that the HTTP + * layer actually surfaces it as a 409 with the ApiResponse envelope rather + * than, say, letting it fall through to a generic 500, and that the whole + * create -> read -> update -> assign -> revoke -> delete cycle a real ADMIN + * client would perform actually works end to end over real requests rather + * than only through direct service calls. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Permission from '#models/permission' +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' + +test.group('Roles admin - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create, list, read, update, assign/revoke a permission, then delete a role, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // POST /api/v1/roles + const roleName = `ROLE_${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/roles') + .json({ roleName }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(createResponse.body(), ['success', 'message', 'data', 'timestamp']) + const roleId = createResponse.body().data.id as number + assert.equal(createResponse.body().data.roleName, roleName) + + // GET /api/v1/roles + const listResponse = await client + .get('/api/v1/roles') + .qs({ page: 1, limit: 10 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(listResponse.body().data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + + // GET /api/v1/roles/:id + const showResponse = await client.get(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + showResponse.assertStatus(200) + assert.equal(showResponse.body().data.id, roleId) + assert.isArray(showResponse.body().data.permissions) + + // PUT /api/v1/roles/:id + const newRoleName = `${roleName}_UPDATED` + const updateResponse = await client + .put(`/api/v1/roles/${roleId}`) + .json({ roleName: newRoleName }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(updateResponse.body().data.roleName, newRoleName) + + // POST /api/v1/roles/:id/permissions + const permission = await Permission.create({ + resource: `resource-${crypto.randomUUID()}`, + action: 'read', + }) + const assignResponse = await client + .post(`/api/v1/roles/${roleId}/permissions`) + .json({ permissionId: permission.id }) + .bearerToken(adminToken) + assignResponse.assertStatus(201) + assert.isTrue( + assignResponse.body().data.permissions.some((p: { id: number }) => p.id === permission.id) + ) + + // DELETE /api/v1/roles/:id/permissions/:permissionId + const revokeResponse = await client + .delete(`/api/v1/roles/${roleId}/permissions/${permission.id}`) + .bearerToken(adminToken) + revokeResponse.assertStatus(200) + assert.isFalse( + revokeResponse.body().data.permissions.some((p: { id: number }) => p.id === permission.id) + ) + + // DELETE /api/v1/roles/:id + const deleteResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + + const afterDeleteResponse = await client + .get(`/api/v1/roles/${roleId}`) + .bearerToken(adminToken) + afterDeleteResponse.assertStatus(404) + }).timeout(20000) + + test('deleting a role still assigned to a user returns 409, and succeeds once revoked', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const roleName = `ROLE_${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/roles') + .json({ roleName }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + const roleId = createResponse.body().data.id as number + + const email = `rolebusy-${crypto.randomUUID()}@example.com` + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Role', + lastName: 'Busy', + email, + password: 'Password1!', + }) + registerResponse.assertStatus(201) + const userId = registerResponse.body().data.id as number + + await client + .post(`/api/v1/users/${userId}/roles`) + .json({ roleId }) + .bearerToken(adminToken) + + const blockedResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(blockedResponse.body().success) + + await client + .delete(`/api/v1/users/${userId}/roles/${roleId}`) + .bearerToken(adminToken) + + const deleteResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) From ae779d07e901ed690e185defdb03e23c01f34a22 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:50 +0400 Subject: [PATCH 65/89] test(auth): cover the permission administration HTTP surface end to end Mirrors roles.spec.ts's reasoning for the permission side: the unit spec already proves PermissionsService.remove's 409 guard throws the right error, but nothing proved the HTTP layer surfaces it correctly or that the create, list, update, delete cycle works end to end. Deletion is confirmed by re-listing rather than a detail route fetch, since the README does not define a GET /api/v1/permissions/:id endpoint and this test respects that rather than assuming one should exist. --- tests/functional/permissions.spec.ts | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/functional/permissions.spec.ts diff --git a/tests/functional/permissions.spec.ts b/tests/functional/permissions.spec.ts new file mode 100644 index 0000000..2b1912e --- /dev/null +++ b/tests/functional/permissions.spec.ts @@ -0,0 +1,132 @@ +/** + * Functional tests for the permission administration HTTP surface: create, + * list, update and delete a permission, plus the 409 business rule that a + * permission still attached to at least one role cannot be deleted. + * + * Mirrors roles.spec.ts's reasoning: the unit spec for PermissionsService + * already proves the guard throws the right error at the service layer, + * but nothing proved the HTTP layer actually turns it into a 409 + * ApiResponse, or that the create -> list -> update -> delete cycle works + * end to end. There is deliberately no detail-route assertion here (no + * `GET /api/v1/permissions/:id` exists per the README), so removal is + * confirmed by re-listing instead. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' + +test.group('Permissions admin - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create, list, update, then delete a permission, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // POST /api/v1/permissions + const resource = `resource-${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/permissions') + .json({ resource, action: 'read' }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(createResponse.body(), ['success', 'message', 'data', 'timestamp']) + const permissionId = createResponse.body().data.id as number + assert.equal(createResponse.body().data.resource, resource) + assert.equal(createResponse.body().data.action, 'read') + + // GET /api/v1/permissions + const listResponse = await client + .get('/api/v1/permissions') + .qs({ page: 1, limit: 100 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(listResponse.body().data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.isTrue( + listResponse.body().data.items.some((p: { id: number }) => p.id === permissionId) + ) + + // PUT /api/v1/permissions/:id + const updateResponse = await client + .put(`/api/v1/permissions/${permissionId}`) + .json({ action: 'write' }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(updateResponse.body().data.action, 'write') + + // DELETE /api/v1/permissions/:id + const deleteResponse = await client + .delete(`/api/v1/permissions/${permissionId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + + const afterDeleteListResponse = await client + .get('/api/v1/permissions') + .qs({ page: 1, limit: 100 }) + .bearerToken(adminToken) + afterDeleteListResponse.assertStatus(200) + assert.isFalse( + afterDeleteListResponse.body().data.items.some((p: { id: number }) => p.id === permissionId) + ) + }).timeout(20000) + + test('deleting a permission still attached to a role returns 409, and succeeds once revoked', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const resource = `resource-${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/permissions') + .json({ resource, action: 'delete' }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + const permissionId = createResponse.body().data.id as number + + const roleName = `ROLE_${crypto.randomUUID()}` + const createRoleResponse = await client + .post('/api/v1/roles') + .json({ roleName }) + .bearerToken(adminToken) + createRoleResponse.assertStatus(201) + const roleId = createRoleResponse.body().data.id as number + + await client + .post(`/api/v1/roles/${roleId}/permissions`) + .json({ permissionId }) + .bearerToken(adminToken) + + const blockedResponse = await client + .delete(`/api/v1/permissions/${permissionId}`) + .bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(blockedResponse.body().success) + + await client + .delete(`/api/v1/roles/${roleId}/permissions/${permissionId}`) + .bearerToken(adminToken) + + const deleteResponse = await client + .delete(`/api/v1/permissions/${permissionId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) From d853138bcafb9ed40dfe001fe825d329387855f1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:01:50 +0400 Subject: [PATCH 66/89] test(auth): verify every admin route is gated by auth and role middleware The README requires every user, role and permission administration route to return 401 without a token and 403 with a non-ADMIN token, but the CRUD tests in users.spec.ts, roles.spec.ts and permissions.spec.ts only ever exercise a handful of routes with a valid ADMIN token, they say nothing about the other sixteen. A route added later that forgets middleware.role({ roles: ['ADMIN'] }), or gets it applied in the wrong order relative to middleware.auth(), would pass every other test in this branch while quietly granting a regular USER account admin-level access. This file exercises all seventeen administration routes twice, once with no token and once with a seeded USER token, table-driven rather than one test per route, so adding a new admin route later only means adding one entry to the list instead of writing a new test. The ids in each URL are placeholders since both checks happen in middleware before any controller action reads the database, a route rejecting with 401 or 403 never depends on whether that id actually exists. --- tests/functional/admin_auth_matrix.spec.ts | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/functional/admin_auth_matrix.spec.ts diff --git a/tests/functional/admin_auth_matrix.spec.ts b/tests/functional/admin_auth_matrix.spec.ts new file mode 100644 index 0000000..e2d84f4 --- /dev/null +++ b/tests/functional/admin_auth_matrix.spec.ts @@ -0,0 +1,84 @@ +/** + * Functional tests proving every user/role/permission administration route + * is actually gated by both `middleware.auth()` and + * `middleware.role({ roles: ['ADMIN'] })`, not just the routes someone + * happened to write a CRUD test against. Each of the seventeen admin + * routes is exercised twice: once with no token at all, which must be + * rejected by AuthMiddleware before RoleMiddleware or the controller ever + * runs, and once with a valid but non-ADMIN (seeded USER) token, which must + * pass authentication but be rejected by RoleMiddleware. + * + * Route params in the URLs below (`1`) are placeholders rather than real + * ids: both checks happen in middleware, before any controller action + * touches the database, so a route returning 401 or 403 never depends on + * whether a user, role or permission with that id actually exists. Missing + * this coverage on even one route would leave a silent hole in the + * authorization model this whole administration surface exists to protect. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import type { ApiClient } from '@japa/api-client' + +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' + +type AdminRoute = { method: 'get' | 'post' | 'put' | 'delete'; url: string } + +const adminRoutes: AdminRoute[] = [ + { method: 'get', url: '/api/v1/users' }, + { method: 'get', url: '/api/v1/users/1' }, + { method: 'put', url: '/api/v1/users/1' }, + { method: 'delete', url: '/api/v1/users/1' }, + { method: 'post', url: '/api/v1/users/1/roles' }, + { method: 'delete', url: '/api/v1/users/1/roles/1' }, + { method: 'get', url: '/api/v1/roles' }, + { method: 'get', url: '/api/v1/roles/1' }, + { method: 'post', url: '/api/v1/roles' }, + { method: 'put', url: '/api/v1/roles/1' }, + { method: 'delete', url: '/api/v1/roles/1' }, + { method: 'post', url: '/api/v1/roles/1/permissions' }, + { method: 'delete', url: '/api/v1/roles/1/permissions/1' }, + { method: 'get', url: '/api/v1/permissions' }, + { method: 'post', url: '/api/v1/permissions' }, + { method: 'put', url: '/api/v1/permissions/1' }, + { method: 'delete', url: '/api/v1/permissions/1' }, +] + +function requestFor(client: ApiClient, route: AdminRoute, token?: string) { + const base = + route.method === 'get' + ? client.get(route.url) + : route.method === 'delete' + ? client.delete(route.url) + : route.method === 'post' + ? client.post(route.url).json({}) + : client.put(route.url).json({}) + + return token ? base.bearerToken(token) : base +} + +test.group('Admin routes - authorization matrix', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('every admin route returns 401 without a token', async ({ client }) => { + for (const route of adminRoutes) { + const response = await requestFor(client, route) + response.assertStatus(401) + } + }).timeout(30000) + + test('every admin route returns 403 with a non-ADMIN (USER) token', async ({ client }) => { + const token = await loginAsSeeded(client, 'user') + + for (const route of adminRoutes) { + const response = await requestFor(client, route, token) + response.assertStatus(403) + } + }).timeout(30000) +}) From 87afc2b4b2cd48150ffa1fa8b063c5e9ce4f8aee Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:07:26 +0400 Subject: [PATCH 67/89] fix(auth): ensure the USER role exists before registering in roles.spec.ts This file's group.setup() only ensured the ADMIN role existed (via ensureSeededUser('admin')), but its second test registers a fresh account through the public /api/v1/auth/register endpoint to have a real user to assign the role under test to. AuthService.register looks up the USER role by name and throws when it is missing, which the exception handler turns into a 404, not the 201 this test expected. Running the full suite exposed this immediately: users.spec.ts already ensures USER in its own setup, this file never did. Also casts response.body() at the two POST /api/v1/roles call sites. That route resolves to a union of every action registered on the same literal path (create and list), which TypeScript cannot narrow to the specific shape a given response actually has, unlike the dynamic :id routes elsewhere in this file that fall back to a loose type on their own. --- tests/functional/roles.spec.ts | 41 +++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/tests/functional/roles.spec.ts b/tests/functional/roles.spec.ts index 6cf1543..6872ef0 100644 --- a/tests/functional/roles.spec.ts +++ b/tests/functional/roles.spec.ts @@ -16,12 +16,30 @@ import { test } from '@japa/runner' import db from '@adonisjs/lucid/services/db' import Permission from '#models/permission' -import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' +import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' + +/** + * `response.body()` on a literal-path match (POST and GET both resolve to + * /api/v1/roles) is typed against the union of every action on that route, + * so TypeScript cannot narrow `.data` to the specific shape a given call + * actually returns. This cast is only needed at the two create-role call + * sites below; every other `.body()` access in this file hits a route with + * a dynamic `:id` segment, which Tuyau does not resolve to a literal type. + */ +function body(response: { body(): unknown }): any { + return response.body() +} test.group('Roles admin - CRUD lifecycle', (group) => { group.setup(async () => { await db.beginGlobalTransaction() await ensureSeededUser('admin') + // The second test registers a fresh account through the public auth + // flow to have a real user to assign the role under test to. + // AuthService.register looks up the USER role by name, so it must + // exist in this transaction the same way users.spec.ts already + // ensures it, ensureSeededUser('admin') alone only guarantees ADMIN. + await ensureRole('USER') }) group.teardown(async () => { @@ -41,9 +59,9 @@ test.group('Roles admin - CRUD lifecycle', (group) => { .json({ roleName }) .bearerToken(adminToken) createResponse.assertStatus(201) - assert.properties(createResponse.body(), ['success', 'message', 'data', 'timestamp']) - const roleId = createResponse.body().data.id as number - assert.equal(createResponse.body().data.roleName, roleName) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const roleId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.roleName, roleName) // GET /api/v1/roles const listResponse = await client @@ -103,9 +121,7 @@ test.group('Roles admin - CRUD lifecycle', (group) => { const deleteResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) deleteResponse.assertStatus(200) - const afterDeleteResponse = await client - .get(`/api/v1/roles/${roleId}`) - .bearerToken(adminToken) + const afterDeleteResponse = await client.get(`/api/v1/roles/${roleId}`).bearerToken(adminToken) afterDeleteResponse.assertStatus(404) }).timeout(20000) @@ -121,7 +137,7 @@ test.group('Roles admin - CRUD lifecycle', (group) => { .json({ roleName }) .bearerToken(adminToken) createResponse.assertStatus(201) - const roleId = createResponse.body().data.id as number + const roleId = body(createResponse).data.id as number const email = `rolebusy-${crypto.randomUUID()}@example.com` const registerResponse = await client.post('/api/v1/auth/register').json({ @@ -133,18 +149,13 @@ test.group('Roles admin - CRUD lifecycle', (group) => { registerResponse.assertStatus(201) const userId = registerResponse.body().data.id as number - await client - .post(`/api/v1/users/${userId}/roles`) - .json({ roleId }) - .bearerToken(adminToken) + await client.post(`/api/v1/users/${userId}/roles`).json({ roleId }).bearerToken(adminToken) const blockedResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) blockedResponse.assertStatus(409) assert.isFalse(blockedResponse.body().success) - await client - .delete(`/api/v1/users/${userId}/roles/${roleId}`) - .bearerToken(adminToken) + await client.delete(`/api/v1/users/${userId}/roles/${roleId}`).bearerToken(adminToken) const deleteResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) deleteResponse.assertStatus(200) From f64eda245cab8e155297e69d2ad089de7d931d28 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:07:26 +0400 Subject: [PATCH 68/89] fix(auth): cast response.body() where the roles/permissions routes collide POST and GET both resolve to /api/v1/permissions and /api/v1/roles, so response.body() is typed against the union of every action registered on that literal path, and TypeScript cannot narrow .data to the shape a specific call actually returns. npm run typecheck failed on every property access following one of these calls. A small local body() helper casts once at the call site instead of scattering the same inline cast across ten different assertions. --- tests/functional/permissions.spec.ts | 36 ++++++++++++++++++---------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/functional/permissions.spec.ts b/tests/functional/permissions.spec.ts index 2b1912e..774856f 100644 --- a/tests/functional/permissions.spec.ts +++ b/tests/functional/permissions.spec.ts @@ -16,6 +16,18 @@ import db from '@adonisjs/lucid/services/db' import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' +/** + * `response.body()` is typed against the union of every action registered + * on a matching literal route (for example both PermissionsController.index + * and .store resolve to /api/v1/permissions), so TypeScript cannot narrow + * `.data` to the specific shape a given call actually returns. This helper + * casts to `any` at the single point every access in this file goes + * through, rather than repeating the same cast at every property read. + */ +function body(response: { body(): unknown }): any { + return response.body() +} + test.group('Permissions admin - CRUD lifecycle', (group) => { group.setup(async () => { await db.beginGlobalTransaction() @@ -39,10 +51,10 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .json({ resource, action: 'read' }) .bearerToken(adminToken) createResponse.assertStatus(201) - assert.properties(createResponse.body(), ['success', 'message', 'data', 'timestamp']) - const permissionId = createResponse.body().data.id as number - assert.equal(createResponse.body().data.resource, resource) - assert.equal(createResponse.body().data.action, 'read') + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const permissionId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.resource, resource) + assert.equal(body(createResponse).data.action, 'read') // GET /api/v1/permissions const listResponse = await client @@ -50,7 +62,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .qs({ page: 1, limit: 100 }) .bearerToken(adminToken) listResponse.assertStatus(200) - assert.properties(listResponse.body().data, [ + assert.properties(body(listResponse).data, [ 'items', 'total', 'page', @@ -59,9 +71,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { 'hasNext', 'hasPrevious', ]) - assert.isTrue( - listResponse.body().data.items.some((p: { id: number }) => p.id === permissionId) - ) + assert.isTrue(body(listResponse).data.items.some((p: { id: number }) => p.id === permissionId)) // PUT /api/v1/permissions/:id const updateResponse = await client @@ -69,7 +79,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .json({ action: 'write' }) .bearerToken(adminToken) updateResponse.assertStatus(200) - assert.equal(updateResponse.body().data.action, 'write') + assert.equal(body(updateResponse).data.action, 'write') // DELETE /api/v1/permissions/:id const deleteResponse = await client @@ -83,7 +93,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .bearerToken(adminToken) afterDeleteListResponse.assertStatus(200) assert.isFalse( - afterDeleteListResponse.body().data.items.some((p: { id: number }) => p.id === permissionId) + body(afterDeleteListResponse).data.items.some((p: { id: number }) => p.id === permissionId) ) }).timeout(20000) @@ -99,7 +109,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .json({ resource, action: 'delete' }) .bearerToken(adminToken) createResponse.assertStatus(201) - const permissionId = createResponse.body().data.id as number + const permissionId = body(createResponse).data.id as number const roleName = `ROLE_${crypto.randomUUID()}` const createRoleResponse = await client @@ -107,7 +117,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .json({ roleName }) .bearerToken(adminToken) createRoleResponse.assertStatus(201) - const roleId = createRoleResponse.body().data.id as number + const roleId = body(createRoleResponse).data.id as number await client .post(`/api/v1/roles/${roleId}/permissions`) @@ -118,7 +128,7 @@ test.group('Permissions admin - CRUD lifecycle', (group) => { .delete(`/api/v1/permissions/${permissionId}`) .bearerToken(adminToken) blockedResponse.assertStatus(409) - assert.isFalse(blockedResponse.body().success) + assert.isFalse(body(blockedResponse).success) await client .delete(`/api/v1/roles/${roleId}/permissions/${permissionId}`) From 6104d91b970fef4d01d6c43a3ef46fdd41e775f2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:07:32 +0400 Subject: [PATCH 69/89] style(auth): apply Prettier formatting to the RBAC and auth service tests npm run lint flagged formatting-only issues in these three files after the roles.spec.ts and permissions.spec.ts fixes, caught during the same verification pass. No assertions or test behavior changed. --- tests/functional/users.spec.ts | 4 +--- tests/unit/services/auth_service.spec.ts | 4 +--- tests/unit/services/users_service.spec.ts | 10 ++-------- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/tests/functional/users.spec.ts b/tests/functional/users.spec.ts index 2efdb02..420c2e7 100644 --- a/tests/functional/users.spec.ts +++ b/tests/functional/users.spec.ts @@ -119,9 +119,7 @@ test.group('Users admin - CRUD lifecycle', (group) => { deleteResponse.assertStatus(200) assert.properties(deleteResponse.body(), ['success', 'message', 'data', 'timestamp']) - const afterDeleteResponse = await client - .get(`/api/v1/users/${userId}`) - .bearerToken(adminToken) + const afterDeleteResponse = await client.get(`/api/v1/users/${userId}`).bearerToken(adminToken) afterDeleteResponse.assertStatus(404) }).timeout(20000) }) diff --git a/tests/unit/services/auth_service.spec.ts b/tests/unit/services/auth_service.spec.ts index 0e2867b..98239cd 100644 --- a/tests/unit/services/auth_service.spec.ts +++ b/tests/unit/services/auth_service.spec.ts @@ -117,9 +117,7 @@ test.group('AuthService - register / activate', (group) => { const reloadedUser = await User.findOrFail(user.id) assert.isTrue(reloadedUser.enabled) - const reloadedToken = await ActivationToken.query() - .where('token', tokenRow.token) - .firstOrFail() + const reloadedToken = await ActivationToken.query().where('token', tokenRow.token).firstOrFail() assert.isNotNull(reloadedToken.validatedAt) }).timeout(10000) }) diff --git a/tests/unit/services/users_service.spec.ts b/tests/unit/services/users_service.spec.ts index f397835..297876d 100644 --- a/tests/unit/services/users_service.spec.ts +++ b/tests/unit/services/users_service.spec.ts @@ -52,10 +52,7 @@ test.group('UsersService - assignRole idempotency', (group) => { assert.isTrue(updated.roles.some((r) => r.id === role.id)) - const pivotRows = await db - .from('role_user') - .where('user_id', user.id) - .where('role_id', role.id) + const pivotRows = await db.from('role_user').where('user_id', user.id).where('role_id', role.id) assert.lengthOf(pivotRows, 1) }).timeout(10000) @@ -71,10 +68,7 @@ test.group('UsersService - assignRole idempotency', (group) => { 1 ) - const pivotRows = await db - .from('role_user') - .where('user_id', user.id) - .where('role_id', role.id) + const pivotRows = await db.from('role_user').where('user_id', user.id).where('role_id', role.id) assert.lengthOf(pivotRows, 1) }).timeout(10000) }) From c9a930b9c4388471e7d235e77b560e5b29c0d1a1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:09 +0400 Subject: [PATCH 70/89] fix(auth): restore Secret redaction on APP_KEY and mark JWT_SECRET the same way APP_KEY was declared as Env.schema.secret() on develop before this branch, wrapping the value in a Secret object that redacts itself from logs, console output and error traces. Feature/core-architecture had already weakened it to a plain string() schema, losing that protection for the key backing session and cookie encryption; this branch's code review caught it while auditing everything touching start/env.ts. JWT_SECRET, new in this branch, signs and verifies every issued token and deserves the identical protection: a debugging log statement or a crash dump that happens to serialize env state should never print either secret in plaintext. app/auth/jwt_guard.ts is updated in the next commit to call .release() on the value, the only way to get the raw string back out of a Secret. --- start/env.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start/env.ts b/start/env.ts index 805452d..6365387 100644 --- a/start/env.ts +++ b/start/env.ts @@ -19,7 +19,7 @@ export default await Env.create(new URL('../', import.meta.url), { LOG_LEVEL: Env.schema.string(), // App - APP_KEY: Env.schema.string(), + APP_KEY: Env.schema.secret(), APP_URL: Env.schema.string({ format: 'url', tld: false }), // Session @@ -33,7 +33,7 @@ export default await Env.create(new URL('../', import.meta.url), { DB_DATABASE: Env.schema.string(), // Auth - JWT_SECRET: Env.schema.string(), + JWT_SECRET: Env.schema.secret(), JWT_EXPIRY: Env.schema.string(), // Hashing From 8f4ce90b9865221c6087c8393c92c15215162bb3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:09 +0400 Subject: [PATCH 71/89] fix(auth): release the JWT_SECRET Secret before encoding it JWT_SECRET is now Env.schema.secret(), so env.get() returns a Secret rather than a plain string. TextEncoder.encode has no awareness of Secret the way config/encryption.ts's aes256gcm driver does for APP_KEY, it needs the raw string, so this call site needs an explicit .release() to unwrap it, matching the corresponding env schema change in start/env.ts. --- app/auth/jwt_guard.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/auth/jwt_guard.ts b/app/auth/jwt_guard.ts index 194f7a1..77d1271 100644 --- a/app/auth/jwt_guard.ts +++ b/app/auth/jwt_guard.ts @@ -27,7 +27,12 @@ export type JwtPayload = { } function secretKey() { - return new TextEncoder().encode(env.get('JWT_SECRET')) + // JWT_SECRET is Env.schema.secret(), so env.get() returns a Secret + // that redacts itself from logs and console output. release() is the only + // way to get the raw value back out, needed here since TextEncoder has no + // awareness of Secret the way the encryption driver in config/encryption.ts + // does. + return new TextEncoder().encode(env.get('JWT_SECRET').release()) } /** From f66f44cbde15965b5bc5d55887fee71f5015698a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:17 +0400 Subject: [PATCH 72/89] refactor(auth): move toPageResponse into page_response.ts The mapping from a Lucid ModelPaginatorContract to this project's PageResponse envelope had been copy-pasted verbatim into users_service.ts, roles_service.ts and permissions_service.ts, each carrying a comment acknowledging the duplication and predicting the categories, products, customers and orders services would repeat it a fourth and fifth time. The code review flagged this from three independent angles. page_response.ts is where PageResponse itself is defined and its docstring already documented this exact mapping as the pattern every service was expected to reimplement, so exporting the function from here instead gives every current and future list endpoint one implementation to fix instead of a growing set of hand-synced copies. The three services are updated in the following commits to import it. --- app/helpers/page_response.ts | 47 ++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/app/helpers/page_response.ts b/app/helpers/page_response.ts index bc3e3d1..a5e5a40 100644 --- a/app/helpers/page_response.ts +++ b/app/helpers/page_response.ts @@ -1,3 +1,5 @@ +import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' + /** * Typed pagination envelope for all list endpoints. * @@ -6,23 +8,11 @@ * would couple consumers to implementation specifics that can change across * ORM versions. * - * Services map the paginator output to this stable interface before returning - * it, so controllers and API clients depend only on this contract. + * Services map the paginator output to this stable interface, via + * `toPageResponse()` below, before returning it, so controllers and API + * clients depend only on this contract. * * @template T - The type of each item in the paginated collection. - * - * @example - * // In a service method - * const paginator = await Category.query().paginate(page, limit) - * const result: PageResponse = { - * items: paginator.all(), - * total: paginator.total, - * page: paginator.currentPage, - * limit: paginator.perPage, - * totalPages: paginator.lastPage, - * hasNext: paginator.hasMorePages, - * hasPrevious: paginator.currentPage > 1, - * } */ export interface PageResponse { /** The records on the current page. */ @@ -40,3 +30,30 @@ export interface PageResponse { /** True when the current page is greater than 1. */ hasPrevious: boolean } + +/** + * Maps a Lucid ModelPaginatorContract onto PageResponse. This was + * originally copy-pasted identically into UsersService, RolesService and + * PermissionsService; factored into the one place PageResponse itself is + * defined so every list endpoint, including the categories/products/ + * customers/orders services added by later branches, shares a single + * implementation instead of a growing set of hand-synced copies. + * + * @example + * // In a service method + * const paginator = await Category.query().paginate(page, limit) + * return toPageResponse(paginator) + */ +export function toPageResponse( + paginator: ModelPaginatorContract +): PageResponse { + return { + items: paginator.all(), + total: paginator.total, + page: paginator.currentPage, + limit: paginator.perPage, + totalPages: paginator.lastPage, + hasNext: paginator.hasMorePages, + hasPrevious: paginator.currentPage > 1, + } +} From c6f74ca1df8324941f9b5ca0eb47d4621ebb5ebb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:27 +0400 Subject: [PATCH 73/89] fix(auth): dedupe pagination, normalize email casing, avoid a redundant reload Three fixes to the same file, caught together during code review. toPageResponse now imports from app/helpers/page_response.ts instead of a locally duplicated copy. Email uniqueness checks in update() were case sensitive, since none of the auth or user validators normalize casing, meaning Test@Example.com and test@example.com could coexist as two distinct rows against a column that is only unique per exact string; the email is now lowercased before comparison and before being persisted. assignRole and revokeRole each reloaded the roles relation from the database a second time purely to refresh the in-memory array after an attach or detach whose effect was already fully known, an avoidable round trip since attach only adds the one role already held in memory and detach only removes it; both now mutate the loaded array in place instead. user.roles is Lucid's opaque ManyToMany at the type level, array-like at runtime but not reassignable, so revokeRole splices the detached role out by index rather than filtering into a new array, which the type checker rejects as an assignment. --- app/services/users_service.ts | 53 +++++++++++++++-------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/app/services/users_service.ts b/app/services/users_service.ts index ebf760c..ac24da2 100644 --- a/app/services/users_service.ts +++ b/app/services/users_service.ts @@ -1,38 +1,15 @@ import { createError } from '@adonisjs/core/exceptions' -import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' import type { Infer } from '@vinejs/vine/types' import User from '#models/user' import Role from '#models/role' -import type { PageResponse } from '#helpers/page_response' +import { toPageResponse, type PageResponse } from '#helpers/page_response' import type { updateUserSchema } from '#validators/user_validator' type UpdateUserPayload = Infer const E_EMAIL_TAKEN = createError('Email is already in use by another user', 'E_EMAIL_TAKEN', 409) -/** - * Maps a Lucid ModelPaginatorContract onto this project's stable - * PageResponse envelope. Duplicated identically in roles_service.ts and - * permissions_service.ts rather than factored into a shared helper: the - * README's project structure only lists app/helpers/api_response.ts and - * app/helpers/page_response.ts, no shared pagination mapper, and - * page_response.ts's own docstring already shows this exact mapping as the - * pattern every service is expected to repeat, the same way the later - * category/product/customer/order services will each repeat it too. - */ -function toPageResponse(paginator: ModelPaginatorContract): PageResponse { - return { - items: paginator.all(), - total: paginator.total, - page: paginator.currentPage, - limit: paginator.perPage, - totalPages: paginator.lastPage, - hasNext: paginator.hasMorePages, - hasPrevious: paginator.currentPage > 1, - } -} - /** * Owns every read and write to the `users` table from the administration * endpoints. This is deliberately separate from AuthService: AuthService @@ -68,11 +45,15 @@ export default class UsersService { async update(id: number, data: UpdateUserPayload): Promise { const user = await User.findOrFail(id) - if (data.email && data.email !== user.email) { - const existing = await User.query().where('email', data.email).whereNot('id', id).first() - if (existing) { - throw new E_EMAIL_TAKEN() + if (data.email) { + const email = data.email.toLowerCase() + if (email !== user.email) { + const existing = await User.query().where('email', email).whereNot('id', id).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } } + data = { ...data, email } } user.merge(data) @@ -100,7 +81,10 @@ export default class UsersService { const alreadyAssigned = user.roles.some((r) => r.id === role.id) if (!alreadyAssigned) { await user.related('roles').attach([role.id]) - await user.load('roles') + // attach() only adds the one pivot row already known here, so the + // freshly loaded roles array plus this role is exactly what a reload + // would return, without a second round trip to prove it. + user.roles.push(role) } return user @@ -121,8 +105,17 @@ export default class UsersService { */ async revokeRole(id: number, roleId: number): Promise { const user = await User.findOrFail(id) - await user.related('roles').detach([roleId]) await user.load('roles') + await user.related('roles').detach([roleId]) + + // user.roles is Lucid's opaque ManyToMany at the type + // level, array-like at runtime but not reassignable, so the detached + // role is spliced out in place rather than filtered into a new array. + const index = user.roles.findIndex((role) => role.id === roleId) + if (index !== -1) { + user.roles.splice(index, 1) + } + return user } } From a88de89012be2fbcb2a7eb974f7329943dfedadb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:35 +0400 Subject: [PATCH 74/89] fix(auth): dedupe pagination, guard duplicate role names, avoid a redundant reload Same three fixes as the previous commit to users_service.ts, applied to the role side. toPageResponse now imports from the shared helper. create() and update() never checked roleName uniqueness before this, even though roles.role_name carries a UNIQUE constraint at the database level; a duplicate name fell through to a raw Postgres constraint error with no .status property, which app/exceptions/handler.ts's generic branch turns into an unhandled 500 instead of a clean 409, the exact gap UsersService.update already guards against for email. assignPermission and revokePermission each reloaded the permissions relation a second time after an attach or detach whose effect was already known in memory; both now mutate the loaded array in place, splicing by index on the revoke side since role.permissions is Lucid's opaque, non-reassignable ManyToMany type. --- app/services/roles_service.ts | 55 +++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/app/services/roles_service.ts b/app/services/roles_service.ts index d32a115..34457ce 100644 --- a/app/services/roles_service.ts +++ b/app/services/roles_service.ts @@ -1,10 +1,9 @@ import { createError } from '@adonisjs/core/exceptions' -import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' import type { Infer } from '@vinejs/vine/types' import Role from '#models/role' import Permission from '#models/permission' -import type { PageResponse } from '#helpers/page_response' +import { toPageResponse, type PageResponse } from '#helpers/page_response' import type { createRoleSchema, updateRoleSchema } from '#validators/role_validator' type CreateRolePayload = Infer @@ -15,20 +14,7 @@ const E_ROLE_IN_USE = createError( 'E_ROLE_IN_USE', 409 ) - -// Same pagination mapping as users_service.ts; see that file's own comment -// on why this is duplicated per-service rather than shared. -function toPageResponse(paginator: ModelPaginatorContract): PageResponse { - return { - items: paginator.all(), - total: paginator.total, - page: paginator.currentPage, - limit: paginator.perPage, - totalPages: paginator.lastPage, - hasNext: paginator.hasMorePages, - hasPrevious: paginator.currentPage > 1, - } -} +const E_ROLE_NAME_TAKEN = createError('Role name is already in use', 'E_ROLE_NAME_TAKEN', 409) /** * Owns every read and write to the `roles` table and the `role_user` / @@ -46,12 +32,35 @@ export default class RolesService { return Role.query().where('id', id).preload('permissions').firstOrFail() } + /** + * Pre-checks roleName uniqueness rather than letting the migration's + * UNIQUE constraint reject the insert: a raw Postgres constraint error has + * no .status property, so app/exceptions/handler.ts's generic branch + * would turn a routine duplicate-name conflict into an unhandled 500 + * instead of a clean 409, the same class of gap UsersService.update + * already guards against for email. + */ async create(data: CreateRolePayload): Promise { + const existing = await Role.findBy('roleName', data.roleName) + if (existing) { + throw new E_ROLE_NAME_TAKEN() + } return Role.create(data) } async update(id: number, data: UpdateRolePayload): Promise { const role = await Role.findOrFail(id) + + if (data.roleName && data.roleName !== role.roleName) { + const existing = await Role.query() + .where('roleName', data.roleName) + .whereNot('id', id) + .first() + if (existing) { + throw new E_ROLE_NAME_TAKEN() + } + } + role.merge(data) await role.save() return role @@ -85,7 +94,7 @@ export default class RolesService { const alreadyAssigned = role.permissions.some((p) => p.id === permission.id) if (!alreadyAssigned) { await role.related('permissions').attach([permission.id]) - await role.load('permissions') + role.permissions.push(permission) } return role @@ -94,8 +103,18 @@ export default class RolesService { /** Idempotent, same reasoning as UsersService.revokeRole. */ async revokePermission(id: number, permissionId: number): Promise { const role = await Role.findOrFail(id) - await role.related('permissions').detach([permissionId]) await role.load('permissions') + await role.related('permissions').detach([permissionId]) + + // role.permissions is Lucid's opaque ManyToMany at + // the type level, array-like at runtime but not reassignable, so the + // detached permission is spliced out in place rather than filtered + // into a new array. + const index = role.permissions.findIndex((permission) => permission.id === permissionId) + if (index !== -1) { + role.permissions.splice(index, 1) + } + return role } } From 3d4123d738fc045880a897366d24ab997797a470 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:41 +0400 Subject: [PATCH 75/89] refactor(auth): dedupe toPageResponse in permissions_service.ts Same pagination mapping duplication as users_service.ts and roles_service.ts, now importing toPageResponse from app/helpers/page_response.ts instead of a third locally duplicated copy. Permissions has no uniqueness constraint in the schema (unlike users.email and roles.role_name), so create() needed no equivalent pre-check fix here. --- app/services/permissions_service.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/app/services/permissions_service.ts b/app/services/permissions_service.ts index 83a68ea..cdf76e8 100644 --- a/app/services/permissions_service.ts +++ b/app/services/permissions_service.ts @@ -1,9 +1,8 @@ import { createError } from '@adonisjs/core/exceptions' -import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' import type { Infer } from '@vinejs/vine/types' import Permission from '#models/permission' -import type { PageResponse } from '#helpers/page_response' +import { toPageResponse, type PageResponse } from '#helpers/page_response' import type { createPermissionSchema, updatePermissionSchema, @@ -18,20 +17,6 @@ const E_PERMISSION_IN_USE = createError( 409 ) -// Same pagination mapping as users_service.ts; see that file's own comment -// on why this is duplicated per-service rather than shared. -function toPageResponse(paginator: ModelPaginatorContract): PageResponse { - return { - items: paginator.all(), - total: paginator.total, - page: paginator.currentPage, - limit: paginator.perPage, - totalPages: paginator.lastPage, - hasNext: paginator.hasMorePages, - hasPrevious: paginator.currentPage > 1, - } -} - /** * Owns every read and write to the `permissions` table from the * ADMIN-only administration endpoints. No constructor dependencies, so no From ac50ba454ed830261fa6a1d01a3f498d0de0200d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:49 +0400 Subject: [PATCH 76/89] fix(auth): add a shared pagination validator Every list endpoint's index action coerced page and limit with a bare Number(request.input(...)), duplicated identically across three controllers, flagged from two independent code review angles. Beyond the duplication, the coercion itself was unsafe: a non-numeric value produced NaN, which Lucid's paginate() forwarded unchecked into the SQL LIMIT/OFFSET clause, and limit had no upper bound at all, letting a client request limit=999999999 and receive an entire table in one response, exactly what the project's own no-unbounded-queries rule exists to prevent. page and limit are both optional and validated as positive numbers, with limit additionally capped at 100. The three admin controllers are updated in the following commits to validate through this schema instead of coercing the raw query string themselves. --- app/validators/pagination_validator.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 app/validators/pagination_validator.ts diff --git a/app/validators/pagination_validator.ts b/app/validators/pagination_validator.ts new file mode 100644 index 0000000..295a7e2 --- /dev/null +++ b/app/validators/pagination_validator.ts @@ -0,0 +1,17 @@ +import vine from '@vinejs/vine' + +/** + * Shared by every paginated list endpoint (users, roles, permissions here, + * categories/products/customers/orders in later branches). Replaces a bare + * `Number(request.input('page', 1))` in each controller, which let a + * non-numeric value flow through as NaN straight into Lucid's `paginate()` + * and had no upper bound on `limit`, letting a client request an entire + * table in one response, exactly what the project's "no unbounded queries" + * rule exists to prevent. + */ +export const paginationSchema = vine.compile( + vine.object({ + page: vine.number().positive().optional(), + limit: vine.number().positive().max(100).optional(), + }) +) From 7b8f32e958505fa2bb3bc175aacf100a4ee5cc27 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:56 +0400 Subject: [PATCH 77/89] fix(auth): validate pagination params in UsersController.index Replaces the bare Number(request.input('page'/'limit', ...)) coercion with request.validateUsing(paginationSchema), closing the NaN and unbounded-limit gaps that helper fixes for the whole admin surface. --- app/controllers/users_controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts index 744c165..553ce7e 100644 --- a/app/controllers/users_controller.ts +++ b/app/controllers/users_controller.ts @@ -4,6 +4,7 @@ import type { HttpContext } from '@adonisjs/core/http' import { respond } from '#helpers/api_response' import UsersService from '#services/users_service' import { updateUserSchema, assignRoleSchema } from '#validators/user_validator' +import { paginationSchema } from '#validators/pagination_validator' /** * Thin HTTP layer over UsersService: every action validates its input (or @@ -17,9 +18,8 @@ export default class UsersController { constructor(private usersService: UsersService) {} async index({ request, response }: HttpContext) { - const page = Number(request.input('page', 1)) - const limit = Number(request.input('limit', 10)) - const result = await this.usersService.findAll(page, limit) + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.usersService.findAll(page ?? 1, limit ?? 10) return response.ok(respond(result)) } From 6e8007cefd7e4703caa8ff6563cbde4d6a41fdf6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:56 +0400 Subject: [PATCH 78/89] fix(auth): validate pagination params in RolesController.index Same fix as UsersController: request.validateUsing(paginationSchema) replaces the unchecked Number() coercion on page and limit. --- app/controllers/roles_controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/roles_controller.ts b/app/controllers/roles_controller.ts index 905aa57..5e2b806 100644 --- a/app/controllers/roles_controller.ts +++ b/app/controllers/roles_controller.ts @@ -8,6 +8,7 @@ import { updateRoleSchema, assignPermissionSchema, } from '#validators/role_validator' +import { paginationSchema } from '#validators/pagination_validator' /** * Thin HTTP layer over RolesService. Every route this controller serves is @@ -19,9 +20,8 @@ export default class RolesController { constructor(private rolesService: RolesService) {} async index({ request, response }: HttpContext) { - const page = Number(request.input('page', 1)) - const limit = Number(request.input('limit', 10)) - const result = await this.rolesService.findAll(page, limit) + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.rolesService.findAll(page ?? 1, limit ?? 10) return response.ok(respond(result)) } From 4587bc0820cb8c3288685d8cfc249df1aee69a0f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:28:56 +0400 Subject: [PATCH 79/89] fix(auth): validate pagination params in PermissionsController.index Same fix as UsersController and RolesController: request.validateUsing (paginationSchema) replaces the unchecked Number() coercion on page and limit. --- app/controllers/permissions_controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/permissions_controller.ts b/app/controllers/permissions_controller.ts index 6bba91c..0f8bbc8 100644 --- a/app/controllers/permissions_controller.ts +++ b/app/controllers/permissions_controller.ts @@ -4,6 +4,7 @@ import type { HttpContext } from '@adonisjs/core/http' import { respond } from '#helpers/api_response' import PermissionsService from '#services/permissions_service' import { createPermissionSchema, updatePermissionSchema } from '#validators/permission_validator' +import { paginationSchema } from '#validators/pagination_validator' /** * Thin HTTP layer over PermissionsService. Every route this controller @@ -19,9 +20,8 @@ export default class PermissionsController { constructor(private permissionsService: PermissionsService) {} async index({ request, response }: HttpContext) { - const page = Number(request.input('page', 1)) - const limit = Number(request.input('limit', 10)) - const result = await this.permissionsService.findAll(page, limit) + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.permissionsService.findAll(page ?? 1, limit ?? 10) return response.ok(respond(result)) } From 2dd330a4ed1a23eb9b80b7fcf31a7a80156d905a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:03 +0400 Subject: [PATCH 80/89] refactor(auth): add a fail() helper for the error response envelope The { success: false, message, timestamp, path } shape was hand-built independently at five call sites: three branches of the global exception handler, and once each in AuthMiddleware and RoleMiddleware. respond() already exists in this file as the success-side counterpart; fail() is the error-side equivalent, so both halves of the ApiResponse contract live in one place instead of the success path being centralized while the error path stays scattered. The five call sites are updated in the following commits. --- app/helpers/api_response.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/helpers/api_response.ts b/app/helpers/api_response.ts index 85b42ef..a5144fc 100644 --- a/app/helpers/api_response.ts +++ b/app/helpers/api_response.ts @@ -46,3 +46,27 @@ export function respond(data: T, message = 'OK') { timestamp: new Date().toISOString(), } } + +/** + * Constructs a failed ApiResponse envelope. + * + * Was previously hand-built independently at five call sites (three in the + * global exception handler, one each in AuthMiddleware and RoleMiddleware), + * all constructing the same `{ success: false, message, timestamp, path }` + * shape by hand. This is the error-side counterpart to `respond()`, kept in + * the same file since both exist to guarantee one envelope shape across the + * whole API surface. + * + * @param message - A short human-readable description of what failed. + * @param path - The request path, for error tracing. + * @param errors - Field-level validation messages, when applicable. + */ +export function fail(message: string, path: string, errors?: string[]) { + return { + success: false, + message, + ...(errors ? { errors } : {}), + timestamp: new Date().toISOString(), + path, + } +} From 6457f50e6a904e3322b90f28e86426500e333e4e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:14 +0400 Subject: [PATCH 81/89] fix(auth): use fail() and simplify a redundant status check All three branches of the exception handler now call fail() from app/helpers/api_response.ts instead of hand-building the same error envelope three times. Also simplifies a redundant condition: the HTTP exception branch cast error to { status?: number } and then checked both the cast field's truthiness and typeof httpError.status === 'number', two clauses expressing the same guard, since the cast type already declares status as number | undefined. A single typeof check covers both "present" and "numeric" in one clause. --- app/exceptions/handler.ts | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/app/exceptions/handler.ts b/app/exceptions/handler.ts index 9b728c1..a9edd51 100644 --- a/app/exceptions/handler.ts +++ b/app/exceptions/handler.ts @@ -14,6 +14,7 @@ import app from '@adonisjs/core/services/app' import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http' import { errors as vineErrors } from '@vinejs/vine' +import { fail } from '#helpers/api_response' export default class HttpExceptionHandler extends ExceptionHandler { /** @@ -46,33 +47,21 @@ export default class HttpExceptionHandler extends ExceptionHandler { // VineJS validation errors: preserve structured field messages if (error instanceof vineErrors.E_VALIDATION_ERROR) { - return response.status(422).json({ - success: false, - message: 'Validation failed', - errors: error.messages, - timestamp: new Date().toISOString(), - path: request.url(), - }) + return response.status(422).json(fail('Validation failed', request.url(), error.messages)) } // AdonisJS HTTP exceptions carry a numeric status code const httpError = error as { status?: number; message?: string } - if (httpError?.status && typeof httpError.status === 'number') { - return response.status(httpError.status).json({ - success: false, - message: httpError.message ?? 'An error occurred', - timestamp: new Date().toISOString(), - path: request.url(), - }) + if (typeof httpError?.status === 'number') { + return response + .status(httpError.status) + .json(fail(httpError.message ?? 'An error occurred', request.url())) } // Unexpected server errors — mask details in production - return response.status(500).json({ - success: false, - message: app.inProduction ? 'Internal server error' : String(error), - timestamp: new Date().toISOString(), - path: request.url(), - }) + return response + .status(500) + .json(fail(app.inProduction ? 'Internal server error' : String(error), request.url())) } /** From 5a532f985f1d9cdff6c6c9749684008b357fa201 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:14 +0400 Subject: [PATCH 82/89] fix(auth): use fail() and reject requests for a disabled or locked account Uses fail() from app/helpers/api_response.ts instead of hand-building the error envelope inline, matching the exception handler's own fix. More importantly, this closes a real gap the code review caught: this middleware only ever checked the jti blacklist, never enabled or accountLocked, so a JWT issued before an ADMIN disabled or locked that account via PUT /api/v1/users/:id would keep authenticating successfully against every protected route until the token's own natural expiry. AuthService only checks those flags at login and refresh time, not on every request, so nothing else in the request pipeline was catching this. The check runs immediately after authenticateUsing() resolves the user, before the blacklist lookup, since there is no reason to query blacklisted_tokens for a request that is going to be rejected anyway. --- app/middleware/auth_middleware.ts | 42 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts index 0c87e0f..814ba7e 100644 --- a/app/middleware/auth_middleware.ts +++ b/app/middleware/auth_middleware.ts @@ -2,22 +2,31 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' import type { Authenticators } from '@adonisjs/auth/types' import BlacklistedToken from '#models/blacklisted_token' +import { fail } from '#helpers/api_response' /** * Auth middleware authenticates HTTP requests and denies access to * unauthenticated users. It also rejects requests carrying a JWT that has * been explicitly revoked (logout writes the token's `jti` into - * `blacklisted_tokens`). + * `blacklisted_tokens`), and requests for a user an ADMIN has since + * disabled or locked. * - * The `jti` check lives here rather than inside JwtGuard itself. JwtGuard - * (app/auth/jwt_guard.ts) is deliberately scoped to proving a token is - * validly signed, not expired, and resolves to a real user, nothing more. - * Blacklisting is an application-level policy, not a property of the - * token's cryptographic validity, so it is layered on top of - * `authenticateUsing()` here instead of being baked into the guard. Every - * route that applies this middleware gets the check; nothing bypasses it - * by calling the guard directly, since the guard is never used outside - * this middleware and RoleMiddleware (which always runs after this one). + * The `jti` and `enabled`/`accountLocked` checks live here rather than + * inside JwtGuard itself. JwtGuard (app/auth/jwt_guard.ts) is deliberately + * scoped to proving a token is validly signed, not expired, and resolves to + * a real user, nothing more. Both of these are application-level policy, + * not a property of the token's cryptographic validity, so they are + * layered on top of `authenticateUsing()` here instead of being baked into + * the guard. Every route that applies this middleware gets the check; + * nothing bypasses it by calling the guard directly, since the guard is + * never used outside this middleware and RoleMiddleware (which always runs + * after this one). + * + * Without the enabled/accountLocked recheck, a JWT issued before an ADMIN + * disables or locks that account (via PUT /api/v1/users/:id) would keep + * authenticating successfully against every protected route until the + * token's own expiry, since AuthService only checks those flags at login + * and refresh time, not on every request. */ export default class AuthMiddleware { async handle( @@ -27,7 +36,11 @@ export default class AuthMiddleware { guards?: (keyof Authenticators)[] } = {} ) { - await ctx.auth.authenticateUsing(options.guards) + const user = await ctx.auth.authenticateUsing(options.guards) + + if (!user.enabled || user.accountLocked) { + return ctx.response.unauthorized(fail('Account is no longer active', ctx.request.url())) + } const payload = ctx.auth.use('jwt').payload const jti = payload?.jti @@ -39,12 +52,7 @@ export default class AuthMiddleware { if (jti) { const blacklisted = await BlacklistedToken.findBy('jti', jti) if (blacklisted) { - return ctx.response.unauthorized({ - success: false, - message: 'Token has been revoked', - timestamp: new Date().toISOString(), - path: ctx.request.url(), - }) + return ctx.response.unauthorized(fail('Token has been revoked', ctx.request.url())) } } From c349f57f3370f18c76de4f7bd8c1ceb56026156d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:15 +0400 Subject: [PATCH 83/89] refactor(auth): use fail() for the error response envelope Same dedup as the exception handler and AuthMiddleware: the 403 response now calls fail() from app/helpers/api_response.ts instead of hand-building the same envelope shape a fifth time. --- app/middleware/role_middleware.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/middleware/role_middleware.ts b/app/middleware/role_middleware.ts index fb06a2e..70fe77a 100644 --- a/app/middleware/role_middleware.ts +++ b/app/middleware/role_middleware.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' +import { fail } from '#helpers/api_response' /** * Role-based access control middleware. @@ -34,12 +35,7 @@ export default class RoleMiddleware { const hasRole = user.roles.some((role) => options.roles.includes(role.roleName)) if (!hasRole) { - return ctx.response.forbidden({ - success: false, - message: 'Insufficient role', - timestamp: new Date().toISOString(), - path: ctx.request.url(), - }) + return ctx.response.forbidden(fail('Insufficient role', ctx.request.url())) } return next() From 6a63ba384d28fffb7ea8c53047580d065f513847 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:24 +0400 Subject: [PATCH 84/89] fix(auth): pre-check duplicate email, normalize casing, don't fail on mail errors Three fixes to the account lifecycle, caught during code review. register() never checked email uniqueness before User.create(), unlike UsersService.update which already does for the same column; a duplicate registration fell through to a raw Postgres unique constraint violation with no .status property, which the exception handler's generic branch turns into an unhandled 500 instead of a clean 409. None of register, login or forgotPassword normalized email casing either, so Ed@Example.com and ed@example.com could register as two distinct accounts, and logging in with different casing than what was registered would fail as invalid credentials even with the correct password; every lookup and write now lowercases the email first. mail.send() in both register() and forgotPassword() was previously unguarded. Both the user row and the activation or reset token are already committed to the database by the time mail is sent, so an SMTP failure (Mailhog unreachable, for example) must not turn an otherwise- successful request into a 500 the client would retry straight into the new E_EMAIL_TAKEN conflict, unable to activate an account they cannot obtain a fresh token for. Both sends are now wrapped in try/catch and logged rather than left to propagate. --- app/services/auth_service.ts | 41 +++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts index 39f5afe..3a754d0 100644 --- a/app/services/auth_service.ts +++ b/app/services/auth_service.ts @@ -1,6 +1,7 @@ import { errors as authErrors } from '@adonisjs/auth' import { createError } from '@adonisjs/core/exceptions' import hash from '@adonisjs/core/services/hash' +import logger from '@adonisjs/core/services/logger' import db from '@adonisjs/lucid/services/db' import mail from '@adonisjs/mail/services/main' import { DateTime } from 'luxon' @@ -51,6 +52,7 @@ const E_ACCOUNT_NOT_ACTIVATED = createError( 403 ) const E_ACCOUNT_LOCKED = createError('Account is locked', 'E_ACCOUNT_LOCKED', 403) +const E_EMAIL_TAKEN = createError('Email is already registered', 'E_EMAIL_TAKEN', 409) const E_TOKEN_ALREADY_USED = createError('Token has already been used', 'E_TOKEN_ALREADY_USED', 400) const E_TOKEN_EXPIRED = createError('Token has expired', 'E_TOKEN_EXPIRED', 400) const E_INVALID_RESET_TOKEN = createError( @@ -102,14 +104,26 @@ export default class AuthService { * succeed or fail as one unit instead. */ async register(data: RegisterPayload): Promise { + const email = data.email.toLowerCase() const userRole = await Role.findByOrFail('roleName', DEFAULT_USER_ROLE) + // Pre-checked here rather than letting the migration's UNIQUE constraint + // reject the insert: a raw Postgres constraint error has no .status + // property, so app/exceptions/handler.ts's generic branch would turn a + // routine duplicate registration into an unhandled 500 instead of a + // clean 409. This is the same class of race UsersService.update already + // guards against, just at creation time instead of update time. + const existing = await User.findBy('email', email) + if (existing) { + throw new E_EMAIL_TAKEN() + } + const { user, token } = await db.transaction(async (trx) => { const newUser = await User.create( { firstName: data.firstName, lastName: data.lastName, - email: data.email, + email, password: data.password, enabled: false, accountLocked: false, @@ -131,7 +145,17 @@ export default class AuthService { return { user: newUser, token: activationToken.token } }) - await mail.send(new ActivationMail(user, token)) + // The user, role attachment and activation token are already committed + // at this point. A mail delivery failure (SMTP down, Mailhog + // unreachable) must not turn an otherwise-successful registration into + // a 500 the client would retry into an E_EMAIL_TAKEN conflict against + // an account they cannot yet activate. Logged and swallowed instead; + // the token is still in the database for a future resend flow to use. + try { + await mail.send(new ActivationMail(user, token)) + } catch (error) { + logger.error({ err: error, userId: user.id }, 'failed to send activation email') + } return user } @@ -168,7 +192,7 @@ export default class AuthService { * an email address is registered. */ async login(data: LoginPayload) { - const user = await User.query().where('email', data.email).first() + const user = await User.query().where('email', data.email.toLowerCase()).first() if (!user || !user.password || !(await hash.verify(user.password, data.password))) { throw new authErrors.E_INVALID_CREDENTIALS('Invalid email or password') @@ -245,7 +269,7 @@ export default class AuthService { * over that hardening, consistent with the rest of this branch's scope. */ async forgotPassword(email: string): Promise { - const user = await User.findByOrFail('email', email) + const user = await User.findByOrFail('email', email.toLowerCase()) const resetToken = await PasswordResetToken.create({ userId: user.id, @@ -254,7 +278,14 @@ export default class AuthService { expiryDate: DateTime.now().plus({ hours: PASSWORD_RESET_TOKEN_TTL_HOURS }), }) - await mail.send(new PasswordResetMail(user, resetToken.token)) + // Same reasoning as register(): the reset token is already committed, + // so a mail delivery failure here must not surface as a 500 for a + // request that otherwise succeeded. + try { + await mail.send(new PasswordResetMail(user, resetToken.token)) + } catch (error) { + logger.error({ err: error, userId: user.id }, 'failed to send password reset email') + } } /** From 8b9ddd77b37a0f070219999e84abc7974bb13971 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:33 +0400 Subject: [PATCH 85/89] fix(auth): cap password length against an Argon2 cost-amplification vector None of registerSchema, loginSchema or resetPasswordSchema bounded password length. config/hash.ts hashes with Argon2id, whose cost scales with input size, and register and forgot-password are public, unauthenticated endpoints; a client could submit a multi-megabyte password field (config/bodyparser.ts allows large JSON bodies) and force expensive hashing work per request, a cost-amplification vector authThrottle's request-count limit alone does not prevent since it caps how often a client can hit the endpoint, not how expensive each hit can be. maxLength(128) is added to all three password fields, including login's, which intentionally still accepts any password shorter than that cap without a complexity check, since login validates a credential against whatever is already stored and rejecting a short password before it reaches AuthService.login would turn a wrong password into a confusing 422 instead of the expected 400 E_INVALID_CREDENTIALS. --- app/validators/auth_validator.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/validators/auth_validator.ts b/app/validators/auth_validator.ts index 313ae66..d73d90f 100644 --- a/app/validators/auth_validator.ts +++ b/app/validators/auth_validator.ts @@ -11,6 +11,14 @@ import vine from '@vinejs/vine' * * NOTE: this decision (the exact complexity rule) is not spelled out in * the README and is left to this validator's judgment. + * + * `maxLength(128)` on every password field below is a deliberate cap, not + * an arbitrary one: config/hash.ts hashes with Argon2id, whose cost scales + * with input size, and the register/reset-password endpoints are public. + * Without a bound, a client could submit a multi-megabyte password field + * (config/bodyparser.ts allows large JSON bodies) and force expensive + * hashing work per request, a cost-amplification vector authThrottle's + * request-count limit alone does not prevent. */ const passwordComplexity = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/ @@ -27,7 +35,7 @@ export const registerSchema = vine.compile( firstName: vine.string().trim().minLength(1).maxLength(50), lastName: vine.string().trim().minLength(1).maxLength(100), email: vine.string().trim().maxLength(100).email(), - password: vine.string().minLength(8).regex(passwordComplexity), + password: vine.string().minLength(8).maxLength(128).regex(passwordComplexity), }) ) @@ -43,7 +51,7 @@ export const registerSchema = vine.compile( export const loginSchema = vine.compile( vine.object({ email: vine.string().trim().email(), - password: vine.string(), + password: vine.string().maxLength(128), }) ) @@ -84,6 +92,6 @@ export const forgotPasswordSchema = vine.compile( export const resetPasswordSchema = vine.compile( vine.object({ token: vine.string().trim().minLength(1), - password: vine.string().minLength(8).regex(passwordComplexity), + password: vine.string().minLength(8).maxLength(128).regex(passwordComplexity), }) ) From 92dd417781869d41254c075b4a4ae2e21582643c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:41 +0400 Subject: [PATCH 86/89] fix(mail): add an HTML-escaping helper for untrusted mail content registerSchema only bounds firstName and lastName by length, per the README's Auth Model column widths, not by character set, so a value like passes validation. ActivationMail and PasswordResetMail interpolate firstName directly into their .html() bodies, so an unescaped value would render live in whatever HTML capable client opens the mail, Mailhog in every environment this project runs in, a stored injection vector reachable from unauthenticated registration input. The following two commits apply this helper at both call sites. --- app/mails/escape_html.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/mails/escape_html.ts diff --git a/app/mails/escape_html.ts b/app/mails/escape_html.ts new file mode 100644 index 0000000..0f6e6ef --- /dev/null +++ b/app/mails/escape_html.ts @@ -0,0 +1,19 @@ +/** + * Escapes the five HTML-significant characters in untrusted user input + * before it is interpolated into a mail's `.html()` body. + * + * registerSchema only bounds firstName/lastName by length (README's Auth + * Model column widths), not by character set, so a value like + * `` passes validation and would otherwise + * render live in whatever HTML-capable client opens the mail, Mailhog in + * every environment this project runs in. The `.text()` body next to it + * needs no escaping, plain text has no markup to inject into. + */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} From 8ef286fea6aa5f194620ce5d682c2910a152f029 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:42 +0400 Subject: [PATCH 87/89] fix(mail): escape firstName in ActivationMail's HTML body Closes the injection vector app/mails/escape_html.ts was added for. The plain text body needs no change, there is no markup there to inject into. --- app/mails/activation_mail.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/mails/activation_mail.ts b/app/mails/activation_mail.ts index 18d57bf..42e1c62 100644 --- a/app/mails/activation_mail.ts +++ b/app/mails/activation_mail.ts @@ -1,6 +1,7 @@ import { BaseMail } from '@adonisjs/mail' import env from '#start/env' import type User from '#models/user' +import { escapeHtml } from '#mails/escape_html' /** * Sent once, right after AuthService.register creates the account. The @@ -46,7 +47,7 @@ export default class ActivationMail extends BaseMail { `This link expires in 24 hours.` ) .html( - `

Hi ${this.user.firstName},

` + + `

Hi ${escapeHtml(this.user.firstName)},

` + `

Welcome to AdonisJS Tutorial. Activate your account by clicking the link below:

` + `

${activationUrl}

` + `

This link expires in 24 hours.

` From 1f9718642cfafd4b2f99df82462f1f8c37dbe362 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:42 +0400 Subject: [PATCH 88/89] fix(mail): escape firstName in PasswordResetMail's HTML body Same fix as ActivationMail, closing the same injection vector in the other mail class that interpolates firstName into an HTML body. --- app/mails/password_reset_mail.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/mails/password_reset_mail.ts b/app/mails/password_reset_mail.ts index 2a45546..0584bf0 100644 --- a/app/mails/password_reset_mail.ts +++ b/app/mails/password_reset_mail.ts @@ -1,6 +1,7 @@ import { BaseMail } from '@adonisjs/mail' import env from '#start/env' import type User from '#models/user' +import { escapeHtml } from '#mails/escape_html' /** * Sent by AuthService.forgotPassword after a PasswordResetToken row is @@ -41,7 +42,7 @@ export default class PasswordResetMail extends BaseMail { `This link expires in 1 hour. If you did not request this, you can ignore this email.` ) .html( - `

Hi ${this.user.firstName},

` + + `

Hi ${escapeHtml(this.user.firstName)},

` + `

We received a request to reset your password. Click the link below to choose a new one:

` + `

${resetUrl}

` + `

This link expires in 1 hour. If you did not request this, you can ignore this email.

` From 50b73ddd8f2056a5a3def49beb4964ed13ced687 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 07:29:50 +0400 Subject: [PATCH 89/89] fix(auth): update the AuthMiddleware unit test for the enabled/locked check AuthMiddleware.handle now reads user.enabled and user.accountLocked immediately after authenticateUsing() resolves the user, but this file's fake HttpContext never actually returned a user from its authenticateUsing mock, only undefined, which the middleware's new check threw against. The mock now accepts and returns a real user created in the test database. A third test is added specifically for the new behavior: a request carrying a valid, non-blacklisted jti for an account with accountLocked set is still rejected, since neither of the two existing tests exercised that branch, only the jti blacklist check the file's own docstring says it exists to pin down. --- tests/unit/middleware/auth_middleware.spec.ts | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/unit/middleware/auth_middleware.spec.ts b/tests/unit/middleware/auth_middleware.spec.ts index 5f47c51..508d45c 100644 --- a/tests/unit/middleware/auth_middleware.spec.ts +++ b/tests/unit/middleware/auth_middleware.spec.ts @@ -26,12 +26,12 @@ import AuthMiddleware from '#middleware/auth_middleware' import User from '#models/user' import BlacklistedToken from '#models/blacklisted_token' -function fakeCtx(jti: string) { +function fakeCtx(jti: string, user: User) { const responses: { unauthorized?: unknown } = {} const ctx = { auth: { - authenticateUsing: async () => {}, + authenticateUsing: async () => user, use: () => ({ payload: { jti } }), }, request: { @@ -78,7 +78,7 @@ test.group('AuthMiddleware - blacklisted jti rejection', (group) => { const middleware = new AuthMiddleware() let nextCalled = false - const { ctx, responses } = fakeCtx(jti) + const { ctx, responses } = fakeCtx(jti, user) await middleware.handle(ctx, async () => { nextCalled = true @@ -90,9 +90,18 @@ test.group('AuthMiddleware - blacklisted jti rejection', (group) => { }).timeout(10000) test('calls next() when the jti is not blacklisted', async ({ assert }) => { + const user = await User.create({ + firstName: 'Active', + lastName: 'User', + email: `active-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + const middleware = new AuthMiddleware() let nextCalled = false - const { ctx, responses } = fakeCtx(crypto.randomUUID()) + const { ctx, responses } = fakeCtx(crypto.randomUUID(), user) await middleware.handle(ctx, async () => { nextCalled = true @@ -101,4 +110,29 @@ test.group('AuthMiddleware - blacklisted jti rejection', (group) => { assert.isTrue(nextCalled) assert.isUndefined(responses.unauthorized) }).timeout(10000) + + test('rejects a request for a user who is disabled or locked, even with a valid non-blacklisted jti', async ({ + assert, + }) => { + const user = await User.create({ + firstName: 'Locked', + lastName: 'Out', + email: `locked-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: true, + }) + + const middleware = new AuthMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(crypto.randomUUID(), user) + + await middleware.handle(ctx, async () => { + nextCalled = true + }) + + assert.isFalse(nextCalled) + assert.isDefined(responses.unauthorized) + assert.deepInclude(responses.unauthorized, { success: false }) + }).timeout(10000) })