Previously an expired token left the frontend in a stuck state: the
router guard only checked token presence (never the exp claim), so the
user could still navigate to protected pages, and every API call then
failed with a generic Swedish "Kunde inte hämta…" message while the
header kept showing the logged-in UI. There was no global response
interceptor, and the backend returned an ambiguous 403 (no body) for
unauthenticated requests because no AuthenticationEntryPoint was
configured, making 403 mean both "no/invalid token" and "forbidden".
Backend:
- Add an AuthenticationEntryPoint in SecurityConfig that returns 401
with a Swedish {"message": ...} ErrorResponse body for
unauthenticated/expired-token requests, and an AccessDeniedHandler
returning 403 with the same body shape for genuine authorization
failures. This makes 401 = not authenticated/expired and
403 = authenticated but forbidden, the standard REST convention.
- Make JwtService(String, long) constructor public so integration
tests can mint expired tokens (was package-private).
- Update the 6 no-auth controller tests from 403 to 401
(OrderControllerTest, AdminControllerTest, PaymentControllerTest,
AuthControllerTest change-password/change-email) and assert the
message body exists; keep shouldReturn403ForNonAdminUser as 403.
- Add OrderControllerTest.shouldReturn401WithSwedishMessageWhenTokenExpired
(expired JWT via TTL -1000ms) and shouldReturn401WithMessageWhenNoAuthHeader.
Frontend:
- Add isTokenExpired() to utils/jwt.ts using the previously-unused exp
claim, and expose it on the auth store.
- Add a global 401 interceptor in api/client.ts: on a 401 from any
non-/auth/ endpoint, call auth.logout() and redirect to
/logga-in?redirect=<currentPath>. Skip /auth/ so wrong-password 401s
on login/change-password stay handled locally. Add isSessionExpired
and isForbidden helpers for per-page catch blocks.
- Harden the router guard to reject tokens whose exp is in the past
(logout + redirect to login with ?redirect=), and let expired-token
users open /logga-in and /registrera instead of bouncing to home.
- Refactor the generic-error catch blocks on OrdersPage, EditOrderPage,
ComposePage, PaymentRedirect, useAdminOrders, and useAdminOrderActions
to skip the generic Swedish message on 401 (handled globally) while
preserving wrong-password 401 handling on change-pw/email pages.
Tests:
- New frontend/src/__tests__/client.spec.ts covering 401 -> logout +
redirect, 401 from /auth/ -> no logout, 403 -> no logout, no-token
401 -> no redirect, and isSessionExpired/isForbidden helpers.
- Add authStore.spec.ts cases for isTokenExpired (no token, past exp,
future exp, missing exp, after logout).
- Add Router.spec.ts cases for expired-token redirects, token clearing,
future-exp access, and guest pages not bouncing expired users.
- Add OrdersPage.spec.ts case asserting 401 triggers no generic error
and the global logout/redirect.
- New E2E expired-token.spec.ts (Docker) covering both the router-guard
expired-token redirect and the API-401 redirect, with logged-out
header and cleared localStorage assertions.
- Mock the API in two pre-existing fake-JWT E2E tests
(auth-guards admin access, header-auth logout redirect) that broke
because the backend now correctly 401s their unsigned test-sig tokens.
Verified with ./gradlew check (frontend lint + 267 unit tests, backend
tests + coverage, Flyway, 92 E2E tests in Docker) and ./gradlew coverage;
all coverage thresholds maintained (jwt.ts at 100%).
13 KiB
AGENTS.md — BilHej / Bilhälsning.se
Project-specific instructions for OpenCode. Commit this file.
Project Identity
BilHej is a web platform letting Swedish residents send physical letters to vehicle owners by entering a registration number. The sender composes a letter, pays 49 SEK, and BilHej prints+mails it via PostNord. The sender never sees the recipient's name or address.
Phase 0 (current): Manual workflow. No Transportstyrelsen or PostNord API integration yet. Owner address is obtained manually by a human and entered into the admin panel.
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend + PostgreSQL 16. Deployed via Docker Compose.
Build, Lint, Test & Run Commands
Always run these after making changes to verify nothing is broken.
Gradle lives at repo root. All commands below run from the repo root unless noted.
Quick start (everything)
cp .env.example .env # first time only, then fill in keys
docker compose up -d # starts postgres, backend, frontend
./gradlew up # same as above (Gradle wrapper)
All-in-one
./gradlew check # frontend lint → frontend test → backend test+coverage → E2E (Docker)
./gradlew coverage # backend + frontend tests with coverage reports
./gradlew up # docker compose up -d
./gradlew down # docker compose down
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
Frontend (Vue.js 3 + Vite)
cd frontend
npm install # first time only
npm run dev # dev server on :3000 with HMR
npm run build # production build
npm run lint # ESLint
npm run test # vitest
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
Backend (Spring Boot 4 + Java 21)
./gradlew :backend:bootRun # dev server on :8080
./gradlew :backend:test # JUnit 5 + Mockito (backend only)
Stripe webhooks (local testing)
stripe listen --forward-to localhost:8080/api/webhooks/stripe
Database
Flyway migrations run automatically on Spring Boot startup. Migration files
live in backend/src/main/resources/db/migration/. Naming: V<number>__descriptive_name.sql.
Before adding a migration: run ./scripts/next-flyway-version.sh and use that
version. Never reuse a version number already on master. Never edit a migration
after it has merged — add a new higher version instead. CI runs
scripts/check-flyway-migrations.sh against origin/master.
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
after switching branches, run ./gradlew reset (wipes the Docker DB volume).
To reset: docker compose down -v && docker compose up -d.
Flyway schema migrations live in db/migration/; dev-only seeds (test users,
sample orders) are in db/dev-migration/ and run only without the prod profile.
Production admin is created from ADMIN_EMAIL / ADMIN_PASSWORD on first boot.
Project Structure
bilhej/
├── frontend/ # Vue.js 3 SPA (Vite)
│ ├── src/
│ │ ├── pages/ # Route-level page components
│ │ ├── components/ # Reusable UI components
│ │ ├── composables/ # useXxx.ts shared logic
│ │ ├── stores/ # Pinia stores
│ │ ├── api/ # API client modules
│ │ ├── router/ # Vue Router config
│ │ └── assets/ # Static files, CSS
│ └── ...
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
│ ├── src/main/java/se/bilhalsning/
│ │ ├── config/ # @Configuration classes
│ │ ├── controller/ # REST controllers
│ │ ├── dto/ # Request/response DTOs
│ │ ├── entity/ # JPA entities
│ │ ├── repository/ # Spring Data JPA repos
│ │ ├── service/ # Business logic
│ │ ├── security/ # JWT filter, UserDetailsService
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice
│ │ └── mapper/ # Entity ↔ DTO mapping
│ └── src/main/resources/
│ ├── application.yml # default (H2, IDE dev)
│ ├── application-docker.yml # docker profile (PostgreSQL)
│ └── db/migration/ # Flyway migrations
├── docker/ # Dockerfiles
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root)
│ ├── frontend.Dockerfile # dev: Node 24 + vite dev server
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node build → nginx)
│ ├── nginx.conf # prod: SPA fallback + /api reverse proxy
│ └── entrypoint.sh # prod: self-signed cert generation
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
├── docker-compose.prod.yml # prod: multi-stage images, no source mounts, restart always
├── gradlew # Gradle wrapper (repo root)
├── gradle/
│ └── wrapper/
├── settings.gradle # rootProject.name + include 'backend'
├── build.gradle # convenience tasks: check, up, down, reset
├── .env.example
├── AGENTS.md # This file
├── README.md
├── REQUIREMENTS.md
└── CODING_GUIDELINES.md
Conventions (Summary)
Full details in @CODING_GUIDELINES.md. Key rules:
Both sides
- Code and comments in English. User-facing strings in Swedish.
- No commented-out code. Delete it.
- Functions stay small (<30 lines).
Git
- Create
feature/*,fix/*, orchore/*branches fromdevelop. - Never commit directly to
masterordevelop. - Merge strategy: fast-forward or merge — either is fine.
- Commit messages must be thorough: describe what changed, why, and list concrete changes as bullet points. Never write single-line "feat: add X" messages.
Before every commit (mandatory — agents must not skip):
# from repo root; needs Docker running
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
./gradlew check
This runs frontend lint, frontend unit tests, backend tests, coverage
thresholds, Flyway checks, and all E2E tests in Docker. Do not commit or
push if this fails. Optional local guard: ./scripts/install-pre-commit-hook.sh
(runs the same check on every git commit).
Note for agents: The pre-commit hook runs the full ./gradlew check which
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
agent tool calls), increase it to ≥300 000 ms, or use --no-verify and run
./gradlew check manually before committing.
Frontend (Vue.js 3)
<script setup>with Composition API only. Never Options API.- File naming: PascalCase for pages/components, camelCase (
useXxx) for composables. - API calls live in
api/modules, never in components. - Component styles are scoped.
Backend (Spring Boot 4)
- Constructor injection with
@RequiredArgsConstructor. No@Autowired. - DTOs: prefer Java records. No bare entities in responses.
- Controllers stay thin. All logic in services.
- Use
@ControllerAdvicefor consistent error responses ({ "message": "..." }). - Lombok:
@RequiredArgsConstructor,@Getter,@Setter,@NoArgsConstructorare all fine. Prefer records for DTOs.
Database
- Table names: snake_case, plural. PKs: UUID, generated in code.
- Timestamps:
created_at,updated_at. UseInstantin Java. - Enums: stored as VARCHAR.
- Index every FK and every column used in WHERE.
Critical Gotchas
Phase 0: Address lookup is MANUAL
There is no Transportstyrelsen API integration yet. When an order is paid, a human must manually request the owner address via the "Fråga om fordonsägare" form and update the admin panel. Do NOT write code that calls an API that doesn't exist yet. The backend should store the order and wait for an admin to mark it as processed.
Never store recipient addresses
After the address is used to mail the letter, it must be deleted. The Order entity must NOT have an address field. The address lookup and mailing are external/human processes in Phase 0.
E2E must use Docker (not host Playwright)
See Testing Approach → E2E (Playwright) — Docker only above. Do not run npx playwright install or npm run test:e2e on the host when verifying E2E.
Local email (Mailpit)
docker compose up includes Mailpit (ghcr.io/axllent/mailpit:v1.28); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (frontend/e2e/helpers/mailpit.ts). Production uses Resend SMTP—see docs/production-email-checklist.md.
Password reset test token (never in production)
app.password-reset.expose-token must stay false in prod/default; it is only enabled in application-docker.yml for CI E2E so Playwright can read testToken from the forgot-password response.
Stripe webhook signature verification
Always verify stripe-signature header using STRIPE_WEBHOOK_SECRET.
Webhook endpoint is public (no auth). Without signature verification an
attacker could mark orders as paid.
Swedish UI strings
All text visible to end users must be in Swedish. Button labels, error messages, validation text, email content, template bodies. Only developer facing content is in English.
JWT in Authorization header
Backend expects Authorization: Bearer <token>. Frontend interceptor must
attach this to all API calls. Unauthorized APIs (register, login, webhook,
public vehicle info) must be excluded from the Spring Security filter chain.
Testing Approach
This project follows Test-Driven Development (TDD). Write tests before or alongside implementation. Every feature ticket should include tests in the same PR — never merge code without corresponding tests.
Backend
- JUnit 5 + Mockito for service layer tests.
@WebMvcTestfor controller tests.- Test naming:
shouldXxxWhenYyy. - Use test profile with H2 or Testcontainers for DB-dependent tests.
- Flyway migrations must run in test profile too.
Frontend
- Vitest for composables and utility functions.
- Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in
frontend/e2e/.
E2E (Playwright) — Docker only
Agents and humans: never run Playwright on the host.
| Do not run | Why |
|---|---|
npx playwright test |
Wrong environment; needs Docker stack |
npm run test:e2e |
Same — host Playwright, not supported for agents |
npx playwright install |
Do not install browsers on the host; the Playwright image already includes them |
Always use Docker (docker-compose.e2e.yml): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the e2e network (PLAYWRIGHT_BASE_URL=http://frontend).
Full E2E suite (same as Forgejo CI / ./gradlew check):
# from repo root — set env (or use .env; see .env.example)
cd frontend && npm run test:e2e:ci
# equivalent:
./gradlew frontendE2E
Single spec or project (stack must be reachable on the e2e network):
# from repo root, after exporting the same vars as frontendE2E / .env
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
docker compose -f docker-compose.e2e.yml down
- Config:
frontend/playwright.config.ts - Tests:
frontend/e2e/*.spec.ts - Serial specs (shared Mailpit / seeded DB):
admin-fulfillment,deferred-payment-admin,admin-dashboard,account-settings,password-reset— projectchromium-serialruns after parallelchromium,workers: 1
CI (future)
./gradlew checkandnpm run test && npm run lintmust pass before merge.
Coverage
./gradlew coverage # backend + frontend tests with coverage
Coverage thresholds are enforced during ./gradlew check. PRs must maintain
or improve coverage.
| Layer | Lines | Branches | Functions |
|---|---|---|---|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
HTML reports:
- Backend:
backend/build/reports/jacoco/index.html - Frontend:
frontend/coverage/index.html
External References
For detailed conventions, load @CODING_GUIDELINES.md.
For product requirements and business logic, load @REQUIREMENTS.md.
For setup and quick start, load @README.md.
These are lazy-loaded by OpenCode — only read them when the task at hand needs the detail.