Compare commits

...

11 commits

15 changed files with 577 additions and 222 deletions

View file

@ -8,57 +8,76 @@ Read `ai/shared.md` first. This file covers backend-specific rules.
**Location:** `backend/`. **Location:** `backend/`.
The application is still close to the Laravel starter structure. Do not The backend follows a domain-oriented structure. Existing areas use entities,
introduce a domain architecture, repository layer, service layer, or other DTOs, repository interfaces, Eloquent implementations, use cases, and test
abstraction before the codebase and requested behavior justify it. Match fakes. Extend those patterns when adding behavior to an established area. Do
existing Laravel conventions and inspect similar code before adding a new not add speculative layers or interfaces that the requested behavior does not
pattern. need.
## Laravel patterns The Vue application remains a separate project under `frontend/website/`.
Keep frontend source, dependencies, builds, and delivery out of the backend
unless the user explicitly asks to integrate them. The backend root route is
intentionally unclaimed; the built-in health endpoint is `/up`.
- Keep controllers thin. Put reusable business behavior in an appropriately ## Code patterns
named application or domain class once the behavior warrants extraction.
- Use dedicated request validation rather than validating substantial payloads - Inspect similar domain code before adding a new entity, DTO, repository,
inline in controllers. use case, controller action, or fake.
- Let unexpected exceptions reach Laravel's exception handler. Catch only - Entities own domain state and behavior and expose descriptive methods.
exceptions that can be handled meaningfully at the current boundary. - DTOs are explicit data containers for creation or transfer between seams.
- Repository interfaces define domain-facing persistence operations. Keep
Eloquent details in their implementations and register bindings in the
application provider.
- Put reusable business behavior in a use case. Give a use case a request DTO
when it accepts a payload; call `execute()` directly when it has no input.
- Document use-case exceptions with `@throws` when callers are expected to
handle them.
- Keep controllers thin. Translate HTTP input to use-case input and domain
output to the response contract.
- Catch only documented, expected exceptions at the controller boundary. Let
unexpected exceptions reach Laravel's exception handler.
- Fake repositories are in-memory implementations used by unit tests. Return
a new entity instance from create and lookup methods rather than exposing a
stored reference.
- Use Eloquent relationships and query scopes consistently rather than - Use Eloquent relationships and query scopes consistently rather than
duplicating query fragments. duplicating query fragments.
- Avoid speculative interfaces and abstractions with only one trivial
implementation.
- The Vue application is a separate project under `frontend/website/`.
- Keep frontend source, dependencies, builds, and delivery out of the backend
unless the user explicitly asks to integrate them.
- The backend root route is intentionally unclaimed. The built-in health
endpoint is `/up`.
## Tests ## Unit tests
- Follow the existing PHPUnit organization under `tests/Unit/` and - Follow the existing organization under `tests/Unit/<Area>/`.
`tests/Feature/`. - Use fake repositories and fake collaborators for the behavior under test.
- Prefer `PHPUnit\Framework\TestCase` when a test only exercises plain PHP. Construct unrelated dependency entities directly instead of routing them
- Extend `Tests\TestCase` only when the test needs Laravel's container, through additional repositories.
facades, database, routing, or HTTP kernel. - Test use-case branches at the use-case seam. Do not repeat every branch in
- HTTP feature tests extend `Tests\TestCase`. controller or HTTP tests.
- Use `RefreshDatabase` when a test reads or writes database state. - Plain entities, value objects, use cases, middleware, and controller units
- Assert behavior at the appropriate seam: should extend `PHPUnit\Framework\TestCase` when they do not need Laravel.
- Unit tests cover isolated business behavior and edge cases. - Extend `Tests\TestCase` only when a test needs Laravel's container, facades,
- Feature tests cover routing, middleware, validation, persistence, and database, routing, or HTTP kernel.
response shape. - Reserve direct Eloquent repository feature tests for persistence mapping,
- Do not duplicate every business branch through the HTTP layer when unit query behavior, or database constraints that cannot be proven through a
coverage already proves it. Feature tests should focus on wiring and the plain unit test.
public contract.
## Test database ## Feature tests
- Feature tests exercise the HTTP seam: routing, middleware, real Eloquent
bindings, cookies, persistence, validation, and response shape.
- Feature tests are additive to unit tests. Prefer a happy path and the
relevant authorization guard instead of duplicating all business branches.
- Place endpoint tests under `tests/Feature/<Area>/`, extend
`Tests\TestCase`, and use `RefreshDatabase` when database state is involved.
- Development and runtime use PostgreSQL through the local Unix socket. - Development and runtime use PostgreSQL through the local Unix socket.
- PHPUnit intentionally uses SQLite `:memory:` as configured in PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature
`phpunit.xml`. tests are self-contained and do not need the worktree stack.
- Feature tests are self-contained and do not require the process-compose
PostgreSQL service.
- Never point `RefreshDatabase` tests at the development PostgreSQL database. - Never point `RefreshDatabase` tests at the development PostgreSQL database.
- Keep mail set to the PHPUnit `array` transport unless a test explicitly - Keep mail on PHPUnit's `array` transport unless a test explicitly exercises
exercises a real mail integration. a real mail integration.
- Authentication uses a custom database-session cookie, not a Laravel guard.
`actingAs()` does not apply. Credentialed requests use
`withCredentials()` and `withUnencryptedCookie()` because API middleware
reads the raw cookie.
- Read an unencrypted response cookie with
`$response->getCookie($name, false)`.
## PHP rules ## PHP rules
@ -80,7 +99,7 @@ pattern.
- Do not add production repository or model APIs solely to make seeding - Do not add production repository or model APIs solely to make seeding
convenient. convenient.
- Use existing lookup methods for cross-seeder relationships. When a group of - Use existing lookup methods for cross-seeder relationships. When a group of
records only makes sense together, keep them in one seeder and retain local records only makes sense together, keep it in one seeder and retain local
references. references.
## Migrations ## Migrations
@ -99,15 +118,12 @@ pattern.
- Once a production database exists, replace this policy with additive, - Once a production database exists, replace this policy with additive,
forward-only migrations. forward-only migrations.
## Before completing backend work ## Backend workflow
- Run the focused test during development. - Run the focused PHPUnit test while developing.
- Run the full test suite before completion: - Use `just backend-types-check` for Larastan and `just backend-test` for the
full PHPUnit suite during iteration.
```sh
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
```
- Run the Composer static-analysis scripts.
- Fix failures caused by the change. Report unrelated baseline failures - Fix failures caused by the change. Report unrelated baseline failures
precisely rather than hiding them or expanding scope without authorization. precisely rather than expanding scope silently.
- The shared `just test-all` command is the required completion gate. Focused
backend recipes never replace it.

View file

@ -4,7 +4,8 @@ Read `ai/shared.md` first. This file covers frontend-specific rules.
## Project context ## Project context
**Stack:** Vue 3.5, TypeScript 6, Vite 8, Vue Router 5, Pinia 4, npm. **Stack:** Vue 3.5, TypeScript 6, Vite 8, Vue Router 5, Pinia 4, Zod 4,
Cypress 15, npm.
**Location:** `frontend/website/`. **Location:** `frontend/website/`.
@ -12,18 +13,16 @@ The frontend is a standalone application. Keep its source, dependencies,
development server, and production build independent from the backend unless development server, and production build independent from the backend unless
the user explicitly requests integration. the user explicitly requests integration.
The scaffold uses: The application currently has route-level views, reusable authentication
components, a Pinia authentication store, Zod schemas, API URL handling, and
Cypress end-to-end specs. The main entry points are:
- `src/App.vue` as the root component. - `src/App.vue` for the root component.
- `src/main.ts` to create the app and install the router and Pinia. - `src/main.ts` for app creation, Pinia, the router, and global styles.
- `src/router/index.ts` for routes. - `src/router/index.ts` for routes and authentication guards.
- `src/stores/` for Pinia stores. - `src/stores/` for Pinia stores and API boundaries.
- `@` as an alias for `src/` in both Vite and TypeScript. - `src/views/` and `src/components/` for route and reusable UI.
- `@` as the `src/` alias in Vite and TypeScript.
There are no views, shared components, API layer, or configured test suite
yet. Cypress is installed as a frontend development dependency, but there is
no Cypress configuration or npm test script. Do not invent an architecture
before requested behavior establishes one.
## Package management and commands ## Package management and commands
@ -36,32 +35,14 @@ Install dependencies in a fresh checkout or worktree:
direnv exec "$(git rev-parse --show-toplevel)" npm install direnv exec "$(git rev-parse --show-toplevel)" npm install
``` ```
To start only the development server on the port assigned by the shell hook: `npm run format` and `npm run lint` rewrite files. Their `format:check` and
`lint:check` counterparts are non-mutating completion checks. `npm run build`
runs type checking and the production build; generated `dist/` output is
ignored.
```sh `process-compose` starts the frontend as part of the complete development
direnv exec "$(git rev-parse --show-toplevel)" \ stack. The frontend is directly accessible on `VITE_PORT`; Caddy serves the
npm run dev -- \ backend and does not proxy the frontend.
--host 127.0.0.1 \
--port "$VITE_PORT" \
--strictPort
```
`process-compose` starts the frontend as part of the full development stack.
The frontend remains directly accessible on `VITE_PORT`; Caddy does not proxy
it.
The available validation commands are:
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm run format
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
direnv exec "$(git rev-parse --show-toplevel)" npm run build
```
`npm run format` and `npm run lint` rewrite files. Review the resulting diff.
`npm run build` runs type checking and the production build. Build output
under `dist/` is generated and ignored.
## Vue conventions ## Vue conventions
@ -72,58 +53,69 @@ under `dist/` is generated and ignored.
`src/router/index.ts`. `src/router/index.ts`.
- Keep page, component, composable, and store responsibilities distinct. - Keep page, component, composable, and store responsibilities distinct.
- Prefer small components with explicit props and emitted events. - Prefer small components with explicit props and emitted events.
- Keep component styles scoped until the project adopts a deliberate global
styling system.
- Use setup-style Pinia stores named `useXxxStore`. - Use setup-style Pinia stores named `useXxxStore`.
- Inspect similar files before introducing a new component, composable, - Keep application-wide resets and base styles in `src/styles/main.css`.
store, or data-access pattern. Keep component and view styles scoped and match established Attainly visual
patterns.
- Inspect similar files before introducing a component, composable, store,
route, or data-access pattern.
## TypeScript ## TypeScript and runtime validation
- Preserve the strict TypeScript configuration and - Preserve strict TypeScript and `noUncheckedIndexedAccess`.
`noUncheckedIndexedAccess`. - Do not use `any`. Model unknown external values as `unknown`, then parse or
- Do not use `any`. Model unknown external values as `unknown`, then narrow or narrow them.
validate them. - Zod schemas are the source of truth for runtime payloads. Define a schema
- Derive types from runtime schemas if the project adopts a schema library. and derive its TypeScript type with `z.infer` instead of maintaining a
Do not maintain a hand-written type that can drift from its schema. parallel hand-written interface.
- Validate payloads at trust boundaries, especially backend responses and - Parse every backend response at the store or API boundary before placing it
user-submitted forms. in application state.
- Keep request and response types close to the API or store boundary that - Keep read and write schemas separate when their shapes differ.
owns them. - Validate user-submitted forms with a Zod object schema when the form has
- Keep the `@` alias aligned across Vite, TypeScript, and any future test meaningful validation rules. Surface field errors from the schema rather
configuration. than maintaining parallel regular expressions and error logic.
- Keep request and response schemas and types near the API or store boundary
that owns them.
- Keep the `@` alias aligned across Vite, TypeScript, and future test tooling.
## State and API access ## State and API access
- Use Pinia for shared client state. Keep component-local state in components. - Use Pinia for shared client state. Keep component-local state in components.
- Keep server requests and response transformation at an API or store - Keep requests, response parsing, and response transformation at an API or
boundary, not scattered through presentation components. store boundary, not in presentation components.
- Represent loading, empty, success, validation-error, and unexpected-error - Represent loading, empty, success, validation-error, and unexpected-error
states explicitly. states explicitly.
- Do not cast unchecked JSON directly to an application interface. - Do not cast unchecked JSON to an application interface.
- Keep read and write payload types separate when their shapes differ. - Send cookie-backed API requests with the established credentials behavior.
## Testing ## Testing
Cypress is installed, but no frontend test configuration or test script - Cypress is configured under `cypress/` and runs through `npm run test:e2e`
exists yet. or `just frontend-cypress-run`.
- Prefer the cheapest test seam that proves the behavior. Cypress covers
routing, browser forms, authentication flows, request wiring, and responsive
behavior.
- Mock backend calls in frontend-focused Cypress tests. Use the worktree
backend only for a deliberately end-to-end integration scenario.
- Assert both the request contract and the rendered response behavior when a
spec intercepts an API call.
- Keep mock payloads synchronized with exported store types and Zod schemas.
Add typed builders when payloads repeat across specs, and parse builder
output through the exported schema when practical.
- Authentication requests are mockable like every other frontend boundary.
Backend persistence, cookie creation, middleware, and mail behavior belong
in PHPUnit tests.
- No Vitest unit or component suite is configured. Do not claim that coverage.
If new pure logic or component behavior cannot be proved economically with
Cypress, establish the smallest appropriate Vitest setup test-first.
- Do not invent test commands or claim frontend tests passed. ## Frontend workflow
- New frontend behavior must still follow the shared test-first workflow.
Establish the smallest appropriate test setup before implementing behavior
that needs it.
- Unit tests should cover pure transformations, composables, and store logic.
- Component tests should cover rendering, events, form behavior, and
conditional UI.
- End-to-end tests should cover routing, multi-page flows, and request wiring.
- Prefer the cheapest layer that proves the behavior.
- Mock backend requests in frontend tests. Do not retest backend persistence,
validation, authentication, or mail behavior through the frontend.
## Before completing frontend work - Run the focused Cypress spec while developing when browser behavior changes.
- Run `npm run format` and `npm run lint` before committing frontend changes,
- Run the focused test while developing once test tooling exists. then review every rewrite.
- Run the formatter, linter, type checker, production build, and every - Use the focused `just frontend-*` recipes for development feedback.
configured test script affected by the change. - The shared `just test-all` command is the required completion gate. Focused
- Do not claim a green gate when a command fails. Report a baseline or frontend checks never replace it.
environmental failure precisely. - Do not claim a green gate when a command fails. Report baseline or
environmental failures precisely.

View file

@ -39,6 +39,24 @@ Use judgment for changes that cannot meaningfully be test-driven, such as
documentation-only edits or declarative environment configuration. Validate documentation-only edits or declarative environment configuration. Validate
those changes with the most relevant parser, formatter, dry run, or check. those changes with the most relevant parser, formatter, dry run, or check.
## Maintaining these instructions
- Treat user steering as durable when it corrects or establishes a reusable
project rule for workflow, architecture, conventions, validation, safety,
or scope.
- When durable steering is received during work, update the appropriate
`ai/*.md` file in the active worktree before completing the task. Do not
wait for a separate request to maintain the instructions.
- Put repository-wide rules in `shared.md` and stack-specific rules in the
matching backend or frontend guide.
- Merge new guidance into the existing rule set. Keep it concise, remove
duplication, and resolve conflicts between shared and stack-specific text.
- Do not persist task-specific scope, temporary directions, secrets,
environment incidents, or instructions that conflict with higher-priority
guidance.
- Commit a durable instruction update separately from implementation unless
the task itself is solely an instruction change.
## Approval discipline ## Approval discipline
- Treat dependency installation, tests, static analysis, formatting, linting, - Treat dependency installation, tests, static analysis, formatting, linting,
@ -64,58 +82,42 @@ those changes with the most relevant parser, formatter, dry run, or check.
- A worktree owns its own isolated stack. The flake shell hook assigns a - A worktree owns its own isolated stack. The flake shell hook assigns a
deterministic port offset and creates worktree-local PostgreSQL state. deterministic port offset and creates worktree-local PostgreSQL state.
- Start a worktree stack only when runtime or integration validation requires - Start a worktree stack only when runtime or integration validation requires
it. Start it once from the worktree root, reuse it throughout validation, it. Start it once, reuse it throughout validation, and stop it once when
and stop it once when finished. finished.
- Do not start any service for PHPUnit, frontend formatting, linting, type - PHPUnit, frontend formatting, linting, type checking, and production builds
checking, or production builds. do not require services. The Cypress completion suite does require the
- When authorized worktree stack control is necessary, operate it directly. worktree stack.
Do not ask the user to start or stop worktree services. - When worktree stack control is necessary, operate it directly. Do not ask
- For non-interactive use, start the stack detached and stop it when finished, the user to start or stop worktree services.
as shown below. - Never use `process-compose -t=false` for a detached stack. It can leave an
- Do not use `process-compose -t=false` for a detached stack. It can leave an
orphaned PostgreSQL process holding the data directory. orphaned PostgreSQL process holding the data directory.
- Non-interactive agent shells do not automatically load direnv. Bare project - Non-interactive agent shells do not automatically load direnv. Bare project
commands can use missing tools, default ports, or paths from the main commands can use missing tools, default ports, or paths from the main
checkout. checkout.
- Run project tooling that depends on the repository development environment - Run PHP, Composer, Artisan, npm, tests, builds, database clients, and
through direnv. This includes PHP, Composer, Artisan, npm, tests, builds, services through the development environment.
database clients, and services. - Resolve the direnv target from the worktree containing the current working
- Resolve the direnv target from the worktree containing the agent's current directory. Never target the main checkout or a different worktree:
working directory. Never target the main checkout or a different worktree:
```sh ```sh
direnv exec "$(git rev-parse --show-toplevel)" <command> direnv exec "$(git rev-parse --show-toplevel)" <command>
``` ```
- Git and environment-neutral read-only file inspection do not need the - Git and environment-neutral read-only inspection do not need direnv.
direnv wrapper. - Start and stop a worktree stack from its root:
- Worktree stack examples:
```sh ```sh
direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D
direnv exec "$(git rev-parse --show-toplevel)" process-compose down direnv exec "$(git rev-parse --show-toplevel)" process-compose down
``` ```
- Run backend commands from `backend/`, or explicitly change into it in the - Run backend commands from `backend/` and frontend commands from
command. The direnv target remains the worktree root. `frontend/website/`. The direnv target remains the worktree root.
- Run frontend commands from `frontend/website/`.
- `process-compose` starts the frontend on the worktree's assigned port. To
start only the frontend:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
npm run dev -- \
--host 127.0.0.1 \
--port "$VITE_PORT" \
--strictPort
```
- When a normally valid check fails because a required service is down, - When a normally valid check fails because a required service is down,
surface the environmental failure. Do not skip the check or silently switch surface the environmental failure. Do not skip the check, change databases,
to a different database or service. or claim the work is complete.
- PHPUnit is self-contained and uses in-memory SQLite. It does not require the - PHPUnit is self-contained and uses in-memory SQLite. It does not require the
PostgreSQL stack unless a future test is explicitly designed as a real PostgreSQL stack unless a future test explicitly targets real PostgreSQL.
PostgreSQL integration test.
## Code style ## Code style
@ -155,14 +157,13 @@ those changes with the most relevant parser, formatter, dry run, or check.
- Do not include drive-by formatter or linter changes. Restore them or land - Do not include drive-by formatter or linter changes. Restore them or land
them as a separate formatting commit. them as a separate formatting commit.
- If a check fails on untouched code, do not bundle an unrelated fix. Report - If a check fails on untouched code, do not bundle an unrelated fix. Report
the pre-existing failure or handle it as its own explicitly scoped change. the baseline failure or handle it as its own explicitly scoped change.
## Branching ## Branching
- Never implement features directly in the main checkout or on - Never implement features directly in the main checkout or on
`master`/`main`. `master`/`main`.
- Use a dedicated worktree under - Use a dedicated worktree under `<repo-root>/.worktrees/<branch>`.
`<repo-root>/.worktrees/<branch>`.
- Create it with: - Create it with:
```sh ```sh
@ -183,10 +184,10 @@ those changes with the most relevant parser, formatter, dry run, or check.
``` ```
- Never symlink `backend/vendor` or `frontend/website/node_modules` from - Never symlink `backend/vendor` or `frontend/website/node_modules` from
another checkout. Dependency paths and generated files must remain another checkout. Dependencies and generated files must remain
worktree-local. worktree-local.
- The shell hook installs backend dependencies but does not install frontend - The shell hook installs backend dependencies but not frontend dependencies.
dependencies. From `frontend/website/`, provision them with: Install frontend dependencies from `frontend/website/`:
```sh ```sh
direnv exec "$(git rev-parse --show-toplevel)" npm install direnv exec "$(git rev-parse --show-toplevel)" npm install
@ -196,59 +197,35 @@ Do not push anything. Make commits as the TDD workflow requires.
## Before completing a change ## Before completing a change
Run the smallest relevant checks while iterating, then run every repository A change is not complete until the worktree stack is ready and the unified
gate affected by the change. gate passes against that worktree:
### Backend 1. Start the stack detached from the worktree root:
- Run tests from `backend/`: ```sh
direnv exec "$(git rev-parse --show-toplevel)" \
process-compose up -D
```
```sh 2. Poll `process-compose process list` until every process is running and
direnv exec "$(git rev-parse --show-toplevel)" php artisan test ready.
``` 3. Run the complete gate from the worktree root:
- Run the Composer checks defined by `backend/composer.json`: ```sh
direnv exec "$(git rev-parse --show-toplevel)" just test-all
```
```sh 4. Do not hand-assemble a substitute from focused commands. `test-all` runs
direnv exec "$(git rev-parse --show-toplevel)" composer types:check frontend format and lint checks, frontend type checking, Larastan, the
direnv exec "$(git rev-parse --show-toplevel)" composer test production build, PHPUnit, and Cypress in fail-fast order.
``` 5. Everything must pass before completion. Report exact baseline or
environmental failures rather than hiding them.
6. If the stack was started only for validation, stop it when finished:
- Do not claim a green gate when a command fails. If the failure predates the ```sh
change, report the precise baseline failure. direnv exec "$(git rev-parse --show-toplevel)" process-compose down
```
### Frontend Focused `just` recipes are for iteration only. For Nix or shell-hook changes,
also run `nix fmt` and `nix flake check`. For service configuration changes,
- Run these commands from `frontend/website/`: also run `process-compose --dry-run`.
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm run format
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
direnv exec "$(git rev-parse --show-toplevel)" npm run build
```
- The formatter and linters rewrite files. Review their changes before
committing.
- No frontend test runner is configured yet. Do not claim unit, component, or
end-to-end test coverage until the relevant scripts exist and pass.
### Environment and integration
- For Nix or shell-hook changes, run:
```sh
direnv exec "$(git rev-parse --show-toplevel)" nix fmt
direnv exec "$(git rev-parse --show-toplevel)" nix flake check
```
- For service configuration changes, run:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
process-compose --dry-run
```
- When a change affects runtime wiring, start the worktree's stack and verify
the relevant endpoint or service against that worktree.
- If you started the stack only for validation, stop it before finishing.

View file

@ -0,0 +1,17 @@
<?php
namespace App\Auth\UseCases\Logout;
use App\Auth\SessionRepository;
class Logout
{
public function __construct(
private SessionRepository $sessionRepository,
) {}
public function execute(string $token): void
{
$this->sessionRepository->deleteByToken($token);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest; use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
use App\Auth\UseCases\CreateSession\CreateSession; use App\Auth\UseCases\CreateSession\CreateSession;
use App\Auth\UseCases\Logout\Logout;
use App\Exceptions\BadRequestException; use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException; use App\Exceptions\UnauthorizedException;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
@ -19,6 +20,7 @@ class AuthController extends Controller
public function __construct( public function __construct(
private AuthenticateUser $authenticateUser, private AuthenticateUser $authenticateUser,
private CreateSession $createSession, private CreateSession $createSession,
private Logout $logout,
) {} ) {}
public function login(Request $request): JsonResponse public function login(Request $request): JsonResponse
@ -71,6 +73,28 @@ class AuthController extends Controller
]); ]);
} }
public function logout(Request $request): JsonResponse
{
$token = $request->cookie(AuthMiddleware::COOKIE_NAME);
if (is_string($token) && $token !== '') {
$this->logout->execute($token);
}
$response = new JsonResponse(null, 204);
return $response->withCookie(Cookie::create(
name: AuthMiddleware::COOKIE_NAME,
value: '',
expire: 1,
path: '/',
domain: null,
secure: false,
httpOnly: true,
raw: false,
sameSite: Cookie::SAMESITE_LAX,
));
}
/** /**
* @return array{id: int, email: string} * @return array{id: int, email: string}
*/ */

View file

@ -8,4 +8,5 @@ Route::post('/login', [AuthController::class, 'login']);
Route::middleware(AuthMiddleware::class)->group(function (): void { Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/me', [AuthController::class, 'me']); Route::get('/me', [AuthController::class, 'me']);
Route::post('/logout', [AuthController::class, 'logout']);
}); });

View file

@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Auth;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LogoutEndpointTest extends TestCase
{
use RefreshDatabase;
public function test_logout_deletes_session_and_clears_cookie(): void
{
$now = new DateTimeImmutable(
'2026-07-31T12:00:00',
new DateTimeZone('UTC'),
);
$user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
));
app(SessionRepository::class)->create(new CreateSessionDto(
token: 'session-token',
user: $user,
createdAt: $now,
expiresAt: $now->modify('+7 days'),
));
$response = $this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'session-token',
)->postJson('/api/logout');
$response->assertNoContent();
$response->assertCookieExpired(AuthMiddleware::COOKIE_NAME);
$this->assertNull(
app(SessionRepository::class)->findByToken('session-token'),
);
}
public function test_logout_rejects_a_request_without_a_cookie(): void
{
$response = $this->postJson('/api/logout');
$response
->assertStatus(401)
->assertExactJson(['error' => 'unauthenticated']);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Tests\Unit\Auth\UseCases;
use App\Auth\CreateSessionDto;
use App\Auth\UseCases\Logout\Logout;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeSessionRepository;
class LogoutTest extends TestCase
{
private FakeSessionRepository $sessionRepository;
private Logout $useCase;
protected function setUp(): void
{
$this->sessionRepository = new FakeSessionRepository;
$this->useCase = new Logout($this->sessionRepository);
}
public function test_existing_token_session_is_removed(): void
{
$now = new DateTimeImmutable(
'2026-07-31T12:00:00',
new DateTimeZone('UTC'),
);
$this->sessionRepository->create(new CreateSessionDto(
token: 'session-token',
user: new User(
id: 7,
email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
),
createdAt: $now,
expiresAt: $now->modify('+7 days'),
));
$this->useCase->execute('session-token');
$this->assertNull(
$this->sessionRepository->findByToken('session-token'),
);
}
public function test_unknown_token_does_not_throw(): void
{
$this->useCase->execute('unknown-token');
$this->assertNull(
$this->sessionRepository->findByToken('unknown-token'),
);
}
}

View file

@ -4,6 +4,7 @@ namespace Tests\Unit\Http\Controllers;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\CreateSession\CreateSession; use App\Auth\UseCases\CreateSession\CreateSession;
use App\Auth\UseCases\Logout\Logout;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
@ -45,9 +46,11 @@ class AuthControllerTest extends TestCase
new DateTimeZone('UTC'), new DateTimeZone('UTC'),
)), )),
); );
$logout = new Logout($this->sessionRepository);
$this->controller = new AuthController( $this->controller = new AuthController(
$authenticateUser, $authenticateUser,
$createSession, $createSession,
$logout,
); );
} }
@ -117,6 +120,37 @@ class AuthControllerTest extends TestCase
); );
} }
public function test_logout_deletes_session_and_clears_cookie(): void
{
$this->createUser('correct-password');
$this->controller->login(new Request([
'email' => 'user@example.com',
'password' => 'correct-password',
]));
$request = new Request;
$request->cookies->set(
AuthMiddleware::COOKIE_NAME,
'session-token',
);
$response = $this->controller->logout($request);
$this->assertSame(204, $response->getStatusCode());
$this->assertNull(
$this->sessionRepository->findByToken('session-token'),
);
$cookies = $response->headers->getCookies();
$this->assertCount(1, $cookies);
$this->assertSame(
AuthMiddleware::COOKIE_NAME,
$cookies[0]->getName(),
);
$this->assertSame('', $cookies[0]->getValue());
$this->assertSame(1, $cookies[0]->getExpiresTime());
$this->assertTrue($cookies[0]->isHttpOnly());
$this->assertSame('lax', $cookies[0]->getSameSite());
}
private function createUser(string $password): void private function createUser(string $password): void
{ {
$this->userRepository->create(new CreateUserDto( $this->userRepository->create(new CreateUserDto(

View file

@ -3,6 +3,40 @@ const authenticatedUser = {
email: 'user@example.com', email: 'user@example.com',
} }
function interceptLogoutFlow(): void {
let authenticated = true
cy.intercept('GET', '**/api/me', (request) => {
if (authenticated) {
request.alias = 'me'
request.reply({
statusCode: 200,
body: { user: authenticatedUser },
})
return
}
request.alias = 'loggedOutMe'
request.reply({
statusCode: 401,
body: { error: 'unauthenticated' },
})
})
cy.intercept('POST', '**/api/logout', (request) => {
expect(request.headers.accept).to.equal('application/json')
authenticated = false
request.reply({ statusCode: 204 })
}).as('logout')
}
function visitDashboardAndLogout(): void {
cy.visit('/dashboard')
cy.wait('@me')
cy.contains('button', 'Log out').click()
cy.wait('@logout')
cy.wait('@loggedOutMe')
}
describe('session authentication', () => { describe('session authentication', () => {
it('restores an authenticated session on a protected route', () => { it('restores an authenticated session on a protected route', () => {
cy.intercept('GET', '**/api/me', { cy.intercept('GET', '**/api/me', {
@ -42,6 +76,18 @@ describe('session authentication', () => {
cy.location('pathname').should('equal', '/dashboard') cy.location('pathname').should('equal', '/dashboard')
}) })
it('redirects a restored session away from the home route', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('me')
cy.visit('/')
cy.wait('@me')
cy.location('pathname').should('equal', '/dashboard')
})
it('rejects a malformed authenticated-user response', () => { it('rejects a malformed authenticated-user response', () => {
cy.intercept('GET', '**/api/me', { cy.intercept('GET', '**/api/me', {
statusCode: 200, statusCode: 200,
@ -53,4 +99,23 @@ describe('session authentication', () => {
cy.location('pathname').should('equal', '/login') cy.location('pathname').should('equal', '/login')
}) })
it('logs out and redirects to login', () => {
interceptLogoutFlow()
visitDashboardAndLogout()
cy.location('pathname').should('equal', '/login')
})
it('keeps protected routes inaccessible after logout', () => {
interceptLogoutFlow()
visitDashboardAndLogout()
cy.visit('/dashboard')
cy.wait('@loggedOutMe')
cy.location('pathname').should('equal', '/login')
cy.location('search').should('include', 'redirect=/dashboard')
})
}) })

View file

@ -10,10 +10,14 @@
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"test:e2e": "cypress run", "test:e2e": "cypress run",
"lint": "run-s \"lint:*\"", "lint": "run-s lint:oxlint lint:eslint",
"lint:oxlint": "oxlint . --fix", "lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache", "lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/" "lint:check": "run-s lint:oxlint:check lint:eslint:check",
"lint:oxlint:check": "oxlint .",
"lint:eslint:check": "eslint . --cache",
"format": "oxfmt src/",
"format:check": "oxfmt --check src/"
}, },
"dependencies": { "dependencies": {
"pinia": "^4.0.2", "pinia": "^4.0.2",

View file

@ -9,6 +9,9 @@ const router = createRouter({
path: '/', path: '/',
name: 'home', name: 'home',
component: () => import('@/views/HomeView.vue'), component: () => import('@/views/HomeView.vue'),
meta: {
guestOnly: true,
},
}, },
{ {
path: '/login', path: '/login',

View file

@ -101,6 +101,20 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
async function logout(): Promise<void> {
try {
await fetch(`${API_BASE}/api/logout`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} finally {
user.value = null
}
}
return { return {
user, user,
loading, loading,
@ -108,5 +122,6 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated, isAuthenticated,
fetchMe, fetchMe,
login, login,
logout,
} }
}) })

View file

@ -1,11 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'
import BrandWordmark from '@/components/BrandWordmark.vue' import BrandWordmark from '@/components/BrandWordmark.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
async function handleLogout(): Promise<void> {
await authStore.logout()
await router.push({ name: 'login' })
}
</script> </script>
<template> <template>
<main class="dashboard-page"> <main class="dashboard-page">
<header> <header>
<BrandWordmark theme="dark" /> <BrandWordmark theme="dark" />
<button type="button" class="logout-button" @click="handleLogout">Log out</button>
</header> </header>
<section> <section>
@ -29,10 +41,41 @@ import BrandWordmark from '@/components/BrandWordmark.vue'
} }
header { header {
display: flex;
width: min(100%, 76rem); width: min(100%, 76rem);
align-items: center;
justify-content: space-between;
margin: 0 auto; margin: 0 auto;
} }
.logout-button {
min-height: 2.75rem;
padding: 0.7rem 1.1rem;
border: 0;
border-radius: 0.7rem;
color: #fffdf7;
background: #183a31;
box-shadow: 0 0.55rem 1.2rem rgb(24 58 49 / 14%);
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
transition:
background-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.logout-button:hover {
background: #285c4e;
box-shadow: 0 0.7rem 1.4rem rgb(24 58 49 / 18%);
transform: translateY(-1px);
}
.logout-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
section { section {
width: min(100%, 42rem); width: min(100%, 42rem);
margin: clamp(6rem, 18vh, 12rem) auto 0; margin: clamp(6rem, 18vh, 12rem) auto 0;

View file

@ -3,5 +3,53 @@ set shell := ["bash", "-c"]
default: default:
@just --list @just --list
# Full completion gate. Start the worktree stack before running it because
# Cypress exercises the frontend and its backend wiring.
test-all:
@echo "==> frontend format + lint checks"
just frontend-format-check
just frontend-lint-check
@echo "==> frontend type check"
just frontend-type-check
@echo "==> backend static analysis"
just backend-types-check
@echo "==> frontend production build"
just frontend-build
@echo "==> backend tests"
just backend-test
@echo "==> frontend Cypress tests"
just frontend-cypress-run
# Backend
backend-test *args:
cd backend && php artisan test {{args}}
backend-types-check:
cd backend && composer types:check
fresh: fresh:
cd backend && php artisan migrate:fresh --seed cd backend && php artisan migrate:fresh --seed
# Frontend
frontend-format:
cd frontend/website && npm run format
frontend-format-check:
cd frontend/website && npm run format:check
frontend-lint:
cd frontend/website && npm run lint
frontend-lint-check:
cd frontend/website && npm run lint:check
frontend-type-check:
cd frontend/website && npm run type-check
frontend-build:
cd frontend/website && npm run build-only
frontend-cypress-run:
cd frontend/website && npm run test:e2e