Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,27 @@ jobs:
- name: TypeScript typecheck
run: npm run typecheck

# Catches frontend calls whose verb no route accepts — that mismatch answers 404,
# not 405, so it reads like a wrong URL and is easy to miss in review.
- name: Route/method parity
run: npm run check:routes
env:
TZ: UTC
NODE_ENV: test
APP_KEY: test-app-key-for-ci-only-123456789
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_USER: sbf
DB_PASSWORD: sbf
DB_DATABASE: sbf_test
SESSION_DRIVER: memory
LOG_LEVEL: error
SMTP_HOST: localhost
SMTP_PORT: 1025
OIDC_ENABLED: 'false'
API_SECRET: test-api-secret
APP_URL: http://localhost:3334

- name: Run unit and functional tests
run: node ace test --no-color
env:
Expand Down
32 changes: 24 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,16 +171,32 @@ await user.load((loader) => loader.load('orders'))
// Role check — role middleware handles this, but in code:
// admin implicitly has supplier access (check middleware/role.ts)

// Method spoofing works ONLY from the query string — never from the body:
// POST /supplier/products/16?_method=PUT ✅
// POST /supplier/products/16 + `_method: 'PUT'` field in the body ❌ 404
// The bodyparser is router middleware, so it runs AFTER route matching —
// `_method` in the body is invisible to the router. From Inertia always issue a
// real router.put()/form.put()/router.delete(); it works with forceFormData too
// (unlike PHP, the Adonis bodyparser parses multipart on PUT/PATCH/DELETE), and
// the Inertia middleware upgrades the 302 redirect to 303 for you.
// Method spoofing is DISABLED (config/app.ts) and must stay that way:
// - `_method` is only ever read from the query string, never the body — the bodyparser
// is router middleware, so it runs AFTER route matching and the body does not exist yet
// - Shield picks whether to validate CSRF from request.method(), so a spoofable verb lets
// a cross-site POST with `_method=GET` in its body skip validation entirely
// From Inertia always issue a real router.put()/form.put()/router.delete(). That works with
// forceFormData too (unlike PHP, the Adonis bodyparser parses multipart on PUT/PATCH/DELETE),
// and the Inertia middleware upgrades the 302 redirect to 303 for you.
// `npm run check:routes` fails the build on any frontend call whose verb no route accepts.
```

### Framework mechanics worth knowing (verified against node_modules, not from memory)

| Mechanism | The rule |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Middleware order | `server.use` → `router.match` → `router.use` → named → controller. Server middleware sees no `ctx.route`, `ctx.params`, `request.body()`, `ctx.session` or `ctx.auth`. |
| Verb mismatch | Routes are indexed by method, so a wrong verb returns **404**, never 405. A 404 does not mean the URL is wrong. |
| CSRF gate | Shield validates exactly the verbs in `config/shield.ts`. Never add GET/HEAD/OPTIONS — every navigation would then demand a token. |
| Inertia errors | `errors` must be shared from `inertia_middleware.share()`; without it `form.errors` is always empty, `onError` never fires and `onSuccess` runs after a validation failure. |
| Inertia mutations | A 4xx carrying a `Location` makes the client raise an error modal and swallow the flash. Use a plain 302 plus a flash, and `redirect('back', true)` to keep the query string. |
| `inertia_middleware` placement | Registered inside the session middleware (`router.use`), because its `dispose()` reflashes on a 409 and that only survives if it runs before the session commit. |
| FormData | Everything arrives as a string, and an empty array has no representation at all — the key is simply absent. Serialise "clear all" cases as JSON. |
| Advisory locks | `pg_advisory_xact_lock` releases at commit, so allocation and insert must share one transaction. |
| Throttle keys | Derive from `request.ip()` (honours `trustProxy`), never from the `X-Forwarded-For` header directly. |
| Token lifetime | Remember-me (2y) and API tokens outlive sessions — revoke them when an account is disabled or its password is reset (`#services/credential_revocation`). |

### Vue / Inertia Patterns

```typescript
Expand Down
1 change: 1 addition & 0 deletions adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default defineConfig({
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/i18n'),
{
file: () => import('#start/scheduler'),
environment: ['console'],
Expand Down
20 changes: 19 additions & 1 deletion app/auth/api_or_entra_guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ApplicationService, ConfigProvider } from '@adonisjs/core/types'
import { AccessTokensGuard } from '@adonisjs/auth/access_tokens'
import type { GuardConfigProvider } from '@adonisjs/auth/types'
import type { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens'
import { errors as authErrors } from '@adonisjs/auth'
import { entraIdJwtVerifier } from '#services/entra_id_jwt_verifier'
import { looksLikeJwt } from '#utils/bearer_token'

Expand All @@ -29,13 +30,27 @@ class ApiOrEntraGuard<
this.#httpContext = args[1]
}

/**
* Disabled accounts must not authenticate. Tokens outlive the account state, so this
* is checked on every request rather than only at issuance — otherwise a token minted
* before an account was disabled would keep working indefinitely.
*/
#rejectIfDisabled(user: unknown) {
if ((user as { isDisabled?: boolean } | null)?.isDisabled) {
throw new authErrors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
}

async authenticate() {
const authHeader = this.#httpContext.request.header('authorization')
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7)
if (looksLikeJwt(token)) {
const user = await entraIdJwtVerifier.resolveUser(token)
if (user) {
this.#rejectIfDisabled(user)
this.authenticationAttempted = true
this.isAuthenticated = true
// Entra-resolved users have no currentAccessToken; API controllers only
Expand All @@ -46,7 +61,10 @@ class ApiOrEntraGuard<
// A JWT that doesn't resolve to a linked user falls through and fails below.
}
}
return super.authenticate()

const user = await super.authenticate()
this.#rejectIfDisabled(user)
return user
}
}

Expand Down
6 changes: 6 additions & 0 deletions app/controllers/api/auth_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export default class AuthController {
return response.unauthorized({ error: 'User not found or disabled.' })
}

// Honour the user's own "disable keypad sign-in" preference on both identifiers —
// the keypad and the card are the two ways this endpoint identifies somebody.
if (user.keypadDisabled) {
return response.unauthorized({ error: 'Keypad sign-in is disabled for this user.' })
}

const token = await User.accessTokens.create(user, ['*'], {
name: 'kiosk-token',
expiresIn: '24h',
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/web/admin/allergens_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { HttpContext } from '@adonisjs/core/http'
import AdminService from '#services/admin_service'
import { createAllergenValidator, updateAllergenValidator } from '#validators/allergen'
import AuditService from '#services/audit_service'
import { isUniqueViolation } from '#services/unique_violation'
import Allergen from '#models/allergen'

export default class AllergensController {
Expand All @@ -26,7 +27,16 @@ export default class AllergensController {
const data = await request.validateUsing(createAllergenValidator)

const service = new AdminService()
const allergen = await service.createAllergen(data.name)
let allergen: Allergen
try {
allergen = await service.createAllergen(data.name)
} catch (err) {
if (isUniqueViolation(err, 'name')) {
session.flash('alert', { type: 'danger', message: i18n.t('messages.name_taken') })
return response.redirect().back()
}
throw err
}

await AuditService.log(auth.user!.id, 'allergen.created', 'allergen', allergen.id, null, {
name: allergen.name,
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/web/admin/audit_controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { HttpContext } from '@adonisjs/core/http'
import { resolvePage } from '#helpers/pagination'
import AuditService from '#services/audit_service'
import User from '#models/user'

export default class AdminAuditController {
async index({ inertia, request }: HttpContext) {
const page = request.input('page', 1)
const page = resolvePage(request.input('page', 1))
const action = request.input('action')
const entityType = request.input('entityType')
const userId = request.input('userId')
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/web/admin/categories_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { HttpContext } from '@adonisjs/core/http'
import AdminService from '#services/admin_service'
import { createCategoryValidator, updateCategoryValidator } from '#validators/category'
import AuditService from '#services/audit_service'
import { isUniqueViolation } from '#services/unique_violation'
import Category from '#models/category'

export default class CategoriesController {
Expand All @@ -27,7 +28,16 @@ export default class CategoriesController {
const data = await request.validateUsing(createCategoryValidator)

const service = new AdminService()
const category = await service.createCategory(data.name, data.color)
let category: Category
try {
category = await service.createCategory(data.name, data.color)
} catch (err) {
if (isUniqueViolation(err, 'name')) {
session.flash('alert', { type: 'danger', message: i18n.t('messages.name_taken') })
return response.redirect().back()
}
throw err
}

const metadata = {
name: category.name,
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/web/admin/invoices_controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { HttpContext } from '@adonisjs/core/http'
import { resolvePage } from '#helpers/pagination'
import AdminService from '#services/admin_service'
import db from '@adonisjs/lucid/services/db'

export default class InvoicesController {
async index({ inertia, request }: HttpContext) {
const page = request.input('page', 1)
const page = resolvePage(request.input('page', 1))
const status = request.input('status')
const buyerId = request.input('buyerId')
const supplierId = request.input('supplierId')
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/web/admin/orders_controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { HttpContext } from '@adonisjs/core/http'
import { resolvePage } from '#helpers/pagination'
import AdminService from '#services/admin_service'
import db from '@adonisjs/lucid/services/db'

export default class OrdersController {
async index({ inertia, request }: HttpContext) {
const page = request.input('page', 1)
const page = resolvePage(request.input('page', 1))
const channel = request.input('channel')
const invoiced = request.input('invoiced')
const buyerId = request.input('buyerId')
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/web/admin/storno_controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { HttpContext } from '@adonisjs/core/http'
import { listRedirectUrl } from '#helpers/list_redirect'
import AdminService from '#services/admin_service'
import AuditService from '#services/audit_service'
import NotificationService from '#services/notification_service'
import Order from '#models/order'
import logger from '@adonisjs/core/services/logger'

export default class StornoController {
async store({ params, response, session, i18n, auth }: HttpContext) {
async store({ params, request, response, session, i18n, auth }: HttpContext) {
const service = new AdminService()

try {
Expand Down Expand Up @@ -50,6 +51,6 @@ export default class StornoController {
session.flash('alert', { type: 'danger', message })
}

return response.redirect('/admin/orders')
return response.redirect(listRedirectUrl(request, '/admin/orders'))
}
}
35 changes: 11 additions & 24 deletions app/controllers/web/admin/users_controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { HttpContext } from '@adonisjs/core/http'
import { resolvePage } from '#helpers/pagination'
import { listRedirectUrl } from '#helpers/list_redirect'
import { DateTime } from 'luxon'
import AdminService from '#services/admin_service'
import InvoiceService from '#services/invoice_service'
Expand All @@ -12,25 +14,10 @@ import RegistrationPolicyService from '#services/registration_policy_service'
import PasswordResetService from '#services/password_reset_service'
import InvitationService from '#services/invitation_service'

/**
* Return the referer URL if it points to /admin/users (preserving active filters),
* otherwise fall back to /admin/users without filters.
*/
function usersUrl(request: HttpContext['request']): string {
const referer = request.header('referer') ?? ''
try {
const { pathname, search } = new URL(referer)
if (pathname === '/admin/users') return pathname + search
} catch {
// invalid URL — use fallback
}
return '/admin/users'
}

export default class UsersController {
async index({ inertia, request }: HttpContext) {
const page = request.input('page', 1)
const invitePage = Number(request.input('invitePage', 1))
const page = resolvePage(request.input('page', 1))
const invitePage = resolvePage(request.input('invitePage', 1))
const role = request.input('role')
const userId = request.input('userId')
const disabled = request.input('disabled')
Expand Down Expand Up @@ -134,17 +121,17 @@ export default class UsersController {
type: 'danger',
message: i18n.t('messages.last_active_admin_required'),
})
return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}
if (err instanceof Error && err.message === 'USER_HAS_UNINVOICED_ORDERS') {
return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}
if (err instanceof Error && err.message === 'KEYPAD_ID_TAKEN') {
session.flash('alert', {
type: 'danger',
message: i18n.t('messages.keypad_id_taken'),
})
return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}
throw err
}
Expand All @@ -170,7 +157,7 @@ export default class UsersController {
message: i18n.t('messages.user_updated', { name: user.displayName }),
})

return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}

async generateInvoice({ params, request, response, session, i18n, auth }: HttpContext) {
Expand Down Expand Up @@ -202,7 +189,7 @@ export default class UsersController {
}
}

return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}

async sendPasswordReset({ params, request, response, session, i18n, auth }: HttpContext) {
Expand All @@ -213,7 +200,7 @@ export default class UsersController {
const payload = await resetService.createToken(user.email)
if (!payload) {
session.flash('alert', { type: 'danger', message: i18n.t('messages.action_failed') })
return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}

const notificationService = new NotificationService()
Expand All @@ -235,6 +222,6 @@ export default class UsersController {
type: 'success',
message: i18n.t('messages.password_reset_email_sent'),
})
return response.redirect(usersUrl(request))
return response.redirect(listRedirectUrl(request, '/admin/users'))
}
}
3 changes: 2 additions & 1 deletion app/controllers/web/audit_controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { HttpContext } from '@adonisjs/core/http'
import { resolvePage } from '#helpers/pagination'
import AuditService from '#services/audit_service'

export default class AuditController {
async index({ inertia, auth, request }: HttpContext) {
const page = request.input('page', 1)
const page = resolvePage(request.input('page', 1))
const action = request.input('action')
const sortOrder = request.input('sortOrder')

Expand Down
28 changes: 19 additions & 9 deletions app/controllers/web/bootstrap_controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import User from '#models/user'
Expand Down Expand Up @@ -49,16 +50,25 @@ export default class BootstrapController {
return response.redirect('/setup/bootstrap')
}

const nextKeypadId = await this.keypadIds.getNextAvailableUserKeypadId()
// Allocation and insert share one transaction — the keypad-id advisory lock is
// transaction-scoped and would otherwise be released before the row exists.
const user = await db.transaction(async (trx) => {
const nextKeypadId = await this.keypadIds.getNextAvailableUserKeypadId(
trx as unknown as Parameters<typeof this.keypadIds.getNextAvailableUserKeypadId>[0]
)

const user = await User.create({
displayName: data.displayName,
email: data.email.trim().toLowerCase(),
password: data.password,
keypadId: nextKeypadId,
role: 'admin',
emailVerifiedAt: DateTime.utc(),
pendingEmail: null,
return User.create(
{
displayName: data.displayName,
email: data.email.trim().toLowerCase(),
password: data.password,
keypadId: nextKeypadId,
role: 'admin',
emailVerifiedAt: DateTime.utc(),
pendingEmail: null,
},
{ client: trx }
)
})

await auth.use('web').login(user, true)
Expand Down
Loading
Loading