diff --git a/ai/backend-context.md b/ai/backend-context.md index 088ed62..8ab6a97 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -92,7 +92,8 @@ pattern. - Rebuild the development database with: ```sh - php artisan migrate:fresh --seed + direnv exec "$(git rev-parse --show-toplevel)" \ + php artisan migrate:fresh --seed ``` - Once a production database exists, replace this policy with additive, @@ -101,7 +102,12 @@ pattern. ## Before completing backend work - Run the focused test during development. -- Run `php artisan test` before completion. +- Run the full test suite before completion: + + ```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 precisely rather than hiding them or expanding scope without authorization. diff --git a/ai/frontend-context.md b/ai/frontend-context.md index af25c82..3462801 100644 --- a/ai/frontend-context.md +++ b/ai/frontend-context.md @@ -33,13 +33,14 @@ Use npm and keep `package-lock.json` committed. Run commands from Install dependencies in a fresh checkout or worktree: ```sh -npm install +direnv exec "$(git rev-parse --show-toplevel)" npm install ``` Start the development server on the port assigned by the shell hook: ```sh -npm run dev -- --port "$VITE_PORT" +direnv exec "$(git rev-parse --show-toplevel)" \ + npm run dev -- --port "$VITE_PORT" ``` `process-compose` does not start or proxy the frontend. @@ -47,10 +48,10 @@ npm run dev -- --port "$VITE_PORT" The available validation commands are: ```sh -npm run format -npm run lint -npm run type-check -npm run build +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. diff --git a/ai/shared.md b/ai/shared.md index 2f792aa..9c921c2 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -45,28 +45,43 @@ those changes with the most relevant parser, formatter, dry run, or check. is the user's stack. Do not start, restart, or stop it unless the user asks. - A worktree owns its own isolated stack. The flake shell hook assigns a deterministic port offset and creates worktree-local PostgreSQL state. -- Start a worktree stack from its root with `process-compose up`. For - non-interactive use, run `process-compose up -D` and stop it with - `process-compose down`. +- Start a worktree stack from its root. For non-interactive use, start it + detached and stop it when finished, as shown below. - Do not use `process-compose -t=false` for a detached stack. It can leave an orphaned PostgreSQL process holding the data directory. -- Non-interactive agent shells do not automatically load direnv. A bare - `process-compose`, `php artisan`, or database command from a worktree can - silently use default ports and target the main checkout. -- Prefix worktree stack and database commands with `direnv exec `. - Examples: +- Non-interactive agent shells do not automatically load direnv. Bare project + commands can use missing tools, default ports, or paths from the main + checkout. +- Run project tooling that depends on the repository development environment + through direnv. This includes PHP, Composer, Artisan, npm, tests, builds, + database clients, and services. +- Resolve the direnv target from the worktree containing the agent's current + working directory. Never target the main checkout or a different worktree: ```sh - direnv exec process-compose up -D - direnv exec process-compose down - direnv exec php artisan migrate:fresh --seed + direnv exec "$(git rev-parse --show-toplevel)" + ``` + +- Git and environment-neutral read-only file inspection do not need the + direnv wrapper. +- Worktree stack examples: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D + direnv exec "$(git rev-parse --show-toplevel)" process-compose down ``` - Run backend commands from `backend/`, or explicitly change into it in the - command. + command. The direnv target remains the worktree root. - Run frontend commands from `frontend/website/`. - `process-compose` does not start the frontend. Start it separately with - `npm run dev -- --port "$VITE_PORT"` when needed. + the worktree's assigned port when needed: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" \ + npm run dev -- --port "$VITE_PORT" + ``` + - When a normally valid check fails because a required service is down, surface the environmental failure. Do not skip the check or silently switch to a different database or service. @@ -143,8 +158,11 @@ those changes with the most relevant parser, formatter, dry run, or check. another checkout. Dependency paths and generated files must remain worktree-local. - The shell hook installs backend dependencies but does not install frontend - dependencies. Run `npm install` from `frontend/website/` when provisioning a - fresh worktree. + dependencies. From `frontend/website/`, provision them with: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" npm install + ``` Do not push anything. Make commits as the TDD workflow requires. @@ -155,12 +173,17 @@ gate affected by the change. ### Backend -- Run tests from `backend/` with `php artisan test`. +- Run tests from `backend/`: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" php artisan test + ``` + - Run the Composer checks defined by `backend/composer.json`: ```sh - composer types:check - composer test + direnv exec "$(git rev-parse --show-toplevel)" composer types:check + direnv exec "$(git rev-parse --show-toplevel)" composer test ``` - Do not claim a green gate when a command fails. If the failure predates the @@ -171,10 +194,10 @@ gate affected by the change. - Run these commands from `frontend/website/`: ```sh - npm run format - npm run lint - npm run type-check - npm run build + 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 @@ -184,8 +207,20 @@ gate affected by the change. ### Environment and integration -- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`. -- For service configuration changes, run `process-compose --dry-run`. +- 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. diff --git a/backend/app/Auth/Clock.php b/backend/app/Auth/Clock.php new file mode 100644 index 0000000..b96ad32 --- /dev/null +++ b/backend/app/Auth/Clock.php @@ -0,0 +1,10 @@ + $dto->token, + 'user_id' => $dto->user->getId(), + 'created_at' => $dto->createdAt, + 'expires_at' => $dto->expiresAt, + ]); + + return new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + } + + public function findByToken(string $token): ?Session + { + $model = SessionModel::find($token); + if ($model === null) { + return null; + } + + $user = $this->userRepository->find($model->user_id); + if ($user === null) { + return null; + } + + return new Session( + token: $model->token, + user: $user, + createdAt: $this->toUtc($model->created_at), + expiresAt: $this->toUtc($model->expires_at), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } + + private function toUtc(DateTimeImmutable $dateTime): DateTimeImmutable + { + return DateTimeImmutable::createFromInterface($dateTime) + ->setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/backend/app/Auth/Session.php b/backend/app/Auth/Session.php new file mode 100644 index 0000000..5fd9cb1 --- /dev/null +++ b/backend/app/Auth/Session.php @@ -0,0 +1,41 @@ +token; + } + + public function getUser(): User + { + return $this->user; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php new file mode 100644 index 0000000..37b30cb --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,50 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'token', + 'user_id', + 'created_at', + 'expires_at', +])] +class SessionModel extends Model +{ + protected $table = 'sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } +} diff --git a/backend/app/Auth/SessionRepository.php b/backend/app/Auth/SessionRepository.php new file mode 100644 index 0000000..cabae60 --- /dev/null +++ b/backend/app/Auth/SessionRepository.php @@ -0,0 +1,12 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepository->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepository->deleteByToken($token); + + return $this->unauthorized(); + } + + $request->attributes->set('user', $session->getUser()); + + return $next($request); + } + + private function unauthorized(): JsonResponse + { + return new JsonResponse(['error' => 'unauthenticated'], 401); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php deleted file mode 100644 index 4c7c078..0000000 --- a/backend/app/Models/User.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index f1525e9..52192d0 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,12 @@ namespace App\Providers; +use App\Auth\Clock; +use App\Auth\EloquentSessionRepository; +use App\Auth\SessionRepository; +use App\Auth\SystemClock; +use App\User\EloquentUserRepository; +use App\User\UserRepository; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -15,7 +21,15 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind( + UserRepository::class, + EloquentUserRepository::class, + ); + $this->app->bind( + SessionRepository::class, + EloquentSessionRepository::class, + ); + $this->app->bind(Clock::class, SystemClock::class); } /** diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..de15bd9 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,40 @@ +normalized = $normalizedEmail; + } + + public function value(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..735320b --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,12 @@ + $dto->email->value(), + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + if ($model === null) { + return null; + } + + return $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + ); + } +} diff --git a/backend/app/User/User.php b/backend/app/User/User.php new file mode 100644 index 0000000..8c9fa9c --- /dev/null +++ b/backend/app/User/User.php @@ -0,0 +1,23 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..74f3211 --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,25 @@ +|UserModel newModelQuery() + * @method static Builder|UserModel newQuery() + * @method static Builder|UserModel query() + * + * @mixin \Eloquent + */ +#[Fillable(['email'])] +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..5fcd6eb --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,10 @@ + [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..dafb6c2 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,21 @@ +id(); + $table->string('email')->unique(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/backend/database/migrations/2026_07_31_000000_create_sessions_table.php b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php new file mode 100644 index 0000000..687d9fb --- /dev/null +++ b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php @@ -0,0 +1,25 @@ +string('token', 64)->primary(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->timestamp('created_at'); + $table->timestamp('expires_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..30374d3 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,18 @@ +currentTime; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..d64efd5 --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,38 @@ + + */ + private array $sessions = []; + + public function create(CreateSessionDto $dto): Session + { + $session = new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + return $this->sessions[$token] ?? null; + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php new file mode 100644 index 0000000..8d84a1d --- /dev/null +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -0,0 +1,119 @@ +now = $this->utc('2026-07-31T12:00:00'); + $this->app->instance(Clock::class, new FakeClock($this->now)); + + Route::middleware(AuthMiddleware::class)->get( + '/test/authenticated-user', + function (Request $request): JsonResponse { + $user = $request->attributes->get('user'); + if (! $user instanceof User) { + return new JsonResponse(['error' => 'missing user'], 500); + } + + return new JsonResponse([ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]); + }, + ); + } + + public function test_valid_cookie_reaches_the_protected_route(): void + { + $user = $this->createUserAndSession( + token: 'valid-token', + expiresAt: $this->now->modify('+7 days'), + ); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); + + $response->assertOk()->assertExactJson([ + 'id' => $user->getId(), + 'email' => 'user@example.com', + ]); + } + + public function test_missing_cookie_is_rejected(): void + { + $response = $this->getJson('/test/authenticated-user'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } + + public function test_expired_cookie_is_rejected_and_deleted(): void + { + $this->createUserAndSession( + token: 'expired-token', + expiresAt: $this->now->modify('-1 day'), + ); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); + + $response->assertStatus(401); + $this->assertNull( + app(SessionRepository::class)->findByToken('expired-token'), + ); + } + + private function createUserAndSession( + string $token, + DateTimeImmutable $expiresAt, + ): User { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $this->now, + expiresAt: $expiresAt, + )); + + return $user; + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php new file mode 100644 index 0000000..32d170d --- /dev/null +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -0,0 +1,85 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $createdAt = $this->utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $repository = app(SessionRepository::class); + + $session = $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + )); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertDatabaseHas('sessions', [ + 'token' => 'session-token', + 'user_id' => $user->getId(), + ]); + + $foundSession = $repository->findByToken('session-token'); + + $this->assertNotNull($foundSession); + $this->assertSame( + $user->getId(), + $foundSession->getUser()->getId(), + ); + $this->assertEquals($createdAt, $foundSession->getCreatedAt()); + $this->assertEquals($expiresAt, $foundSession->getExpiresAt()); + } + + public function test_it_returns_null_for_an_unknown_token(): void + { + $repository = app(SessionRepository::class); + + $this->assertNull($repository->findByToken('unknown-token')); + } + + public function test_it_deletes_a_session_by_token(): void + { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $repository = app(SessionRepository::class); + $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $this->utc('2026-08-07T12:00:00'), + )); + + $repository->deleteByToken('session-token'); + + $this->assertNull($repository->findByToken('session-token')); + $this->assertDatabaseMissing('sessions', [ + 'token' => 'session-token', + ]); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php new file mode 100644 index 0000000..207523f --- /dev/null +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -0,0 +1,48 @@ +create(new CreateUserDto( + email: new EmailAddress('Founder@EXAMPLE.COM'), + )); + + $this->assertGreaterThan(0, $user->getId()); + $this->assertSame( + 'Founder@example.com', + $user->getEmail()->value(), + ); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'email' => 'Founder@example.com', + ]); + + $foundUser = $repository->find($user->getId()); + + $this->assertNotNull($foundUser); + $this->assertSame($user->getId(), $foundUser->getId()); + $this->assertSame( + $user->getEmail()->value(), + $foundUser->getEmail()->value(), + ); + } + + public function test_it_returns_null_for_an_unknown_user(): void + { + $repository = app(UserRepository::class); + + $this->assertNull($repository->find(999)); + } +} diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..65908c7 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,159 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->middleware = new AuthMiddleware( + sessionRepository: $this->sessionRepository, + clock: new FakeClock($this->now), + ); + } + + public function test_missing_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(null), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($capturedRequest); + } + + public function test_empty_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(''), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_unknown_token_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('unknown-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_expired_session_is_deleted(): void + { + $this->sessionRepository->create(new CreateSessionDto( + token: 'expired-token', + user: $this->user(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('expired-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + $this->assertNull( + $this->sessionRepository->findByToken('expired-token'), + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $user = $this->user(); + $this->sessionRepository->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('valid-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($capturedRequest); + $this->assertSame( + $user, + $capturedRequest->attributes->get('user'), + ); + } + + private function requestWithToken(?string $token): Request + { + $request = Request::create('/anything', 'GET'); + if ($token !== null) { + $request->cookies->set(AuthMiddleware::COOKIE_NAME, $token); + } + + return $request; + } + + /** + * @param Request|null $capturedRequest + * @return Closure(Request): JsonResponse + */ + private function captureNextRequest( + ?Request &$capturedRequest, + ): Closure { + return function (Request $request) use (&$capturedRequest) { + $capturedRequest = $request; + + return new JsonResponse(['ok' => true]); + }; + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + ); + } +} diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php new file mode 100644 index 0000000..7804333 --- /dev/null +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -0,0 +1,61 @@ +utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + ); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertSame($createdAt, $session->getCreatedAt()); + $this->assertSame($expiresAt, $session->getExpiresAt()); + } + + public function test_it_expires_at_the_expiry_time(): void + { + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: new User( + id: 7, + email: new EmailAddress('user@example.com'), + ), + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + ); + + $this->assertFalse( + $session->isExpired($expiresAt->modify('-1 second')), + ); + $this->assertTrue($session->isExpired($expiresAt)); + $this->assertTrue( + $session->isExpired($expiresAt->modify('+1 second')), + ); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php new file mode 100644 index 0000000..c47b3e4 --- /dev/null +++ b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php @@ -0,0 +1,27 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid email address: invalid-email', + ); + + new EmailAddress('invalid-email'); + } + + public function test_it_trims_and_normalizes_the_domain(): void + { + $email = new EmailAddress(' Founder@EXAMPLE.COM '); + + $this->assertSame('Founder@example.com', $email->value()); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..0ac8d53 --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,19 @@ +assertSame(42, $user->getId()); + $this->assertSame($email, $user->getEmail()); + } +} diff --git a/flake.nix b/flake.nix index 68a882c..820c512 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,7 @@ mailpit process-compose mkcert + nssTools caddy ]; diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts new file mode 100644 index 0000000..2697f45 --- /dev/null +++ b/frontend/website/cypress.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'cypress' + +const frontendPort = process.env.VITE_PORT ?? '5173' + +export default defineConfig({ + allowCypressEnv: false, + e2e: { + baseUrl: `http://127.0.0.1:${frontendPort}`, + supportFile: false, + }, + video: false, +}) diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts new file mode 100644 index 0000000..f4c1ba8 --- /dev/null +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -0,0 +1,78 @@ +describe('guest authentication pages', () => { + it('shows a public home route to guests', () => { + cy.visit('/') + + cy.location('pathname').should('equal', '/') + cy.get('h1').should('have.text', 'Make big goals feel possible.') + cy.contains('a', 'Log in').should('have.attr', 'href', '/login') + cy.contains('a', 'Get started').should('have.attr', 'href', '/signup') + }) + + it('redirects guests away from the protected dashboard', () => { + cy.visit('/dashboard') + + cy.location('pathname').should('equal', '/login') + cy.location('search').should('include', 'redirect=/dashboard') + }) + + it('shows the login form and links to signup', () => { + cy.visit('/login') + + cy.get('h1').should('have.text', 'Welcome back') + cy.get('label[for="login-email"]').should('have.text', 'Email address') + cy.get('#login-email').should('have.attr', 'autocomplete', 'email') + cy.get('label[for="login-password"]').should('have.text', 'Password') + cy.get('#login-password').should('have.attr', 'autocomplete', 'current-password') + cy.get('button[type="submit"]').should('have.text', 'Log in') + + cy.contains('a', 'Create an account').click() + cy.location('pathname').should('equal', '/signup') + }) + + it('shows the signup form and links to login', () => { + cy.visit('/signup') + + cy.get('h1').should('have.text', 'Start your journey') + cy.get('label[for="signup-name"]').should('have.text', 'Full name') + cy.get('#signup-name').should('have.attr', 'autocomplete', 'name') + cy.get('label[for="signup-email"]').should('have.text', 'Email address') + cy.get('#signup-email').should('have.attr', 'autocomplete', 'email') + cy.get('label[for="signup-password"]').should('have.text', 'Password') + cy.get('#signup-password').should('have.attr', 'autocomplete', 'new-password') + cy.get('label[for="signup-password-confirmation"]').should( + 'have.text', + 'Confirm password', + ) + cy.get('#signup-password-confirmation').should( + 'have.attr', + 'autocomplete', + 'new-password', + ) + cy.get('button[type="submit"]').should('have.text', 'Create account') + + cy.contains('a', 'Log in').click() + cy.location('pathname').should('equal', '/login') + }) + + it('keeps UI-only form submissions on their current route', () => { + cy.visit('/login') + cy.get('form').submit() + cy.location('pathname').should('equal', '/login') + + cy.visit('/signup') + cy.get('form').submit() + cy.location('pathname').should('equal', '/signup') + }) + + it('fits the signup page within a mobile viewport', () => { + cy.viewport(390, 844) + cy.visit('/signup') + + cy.get('main').should('be.visible') + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth, + ) + }) + }) +}) diff --git a/frontend/website/package.json b/frontend/website/package.json index 5ba881d..ebd0942 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build", + "test:e2e": "cypress run", "lint": "run-s \"lint:*\"", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", diff --git a/frontend/website/src/App.vue b/frontend/website/src/App.vue index abfd315..7c2aa3f 100644 --- a/frontend/website/src/App.vue +++ b/frontend/website/src/App.vue @@ -1,11 +1,3 @@ - - - - diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue new file mode 100644 index 0000000..709091f --- /dev/null +++ b/frontend/website/src/components/AuthForm.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/website/src/components/AuthLayout.vue b/frontend/website/src/components/AuthLayout.vue new file mode 100644 index 0000000..2eeace0 --- /dev/null +++ b/frontend/website/src/components/AuthLayout.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue new file mode 100644 index 0000000..e9ec98c --- /dev/null +++ b/frontend/website/src/components/AuthTextField.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/website/src/components/BrandWordmark.vue b/frontend/website/src/components/BrandWordmark.vue new file mode 100644 index 0000000..b41aa80 --- /dev/null +++ b/frontend/website/src/components/BrandWordmark.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/website/src/main.ts b/frontend/website/src/main.ts index fda1e6e..62f3c02 100644 --- a/frontend/website/src/main.ts +++ b/frontend/website/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' +import './styles/main.css' const app = createApp(App) diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index e1eab52..b726d17 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -1,8 +1,59 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue'), + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { + guestOnly: true, + }, + }, + { + path: '/signup', + name: 'signup', + component: () => import('@/views/SignupView.vue'), + meta: { + guestOnly: true, + }, + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/DashboardView.vue'), + meta: { + requiresAuth: true, + }, + }, + ], +}) + +router.beforeEach((to) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + return { + name: 'login', + query: { + redirect: to.fullPath, + }, + } + } + + if (to.meta.guestOnly && authStore.isAuthenticated) { + return { + name: 'dashboard', + } + } }) export default router diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts new file mode 100644 index 0000000..50688f1 --- /dev/null +++ b/frontend/website/src/stores/auth.ts @@ -0,0 +1,10 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export const useAuthStore = defineStore('auth', () => { + const isAuthenticated = ref(false) + + return { + isAuthenticated, + } +}) diff --git a/frontend/website/src/styles/main.css b/frontend/website/src/styles/main.css new file mode 100644 index 0000000..a121333 --- /dev/null +++ b/frontend/website/src/styles/main.css @@ -0,0 +1,50 @@ +:root { + color: #1b2924; + background: #f3f1eb; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: #f3f1eb; +} + +body { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +button, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; +} + +#app { + min-height: 100vh; + min-height: 100svh; +} diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue new file mode 100644 index 0000000..90b1b1d --- /dev/null +++ b/frontend/website/src/views/DashboardView.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/website/src/views/HomeView.vue b/frontend/website/src/views/HomeView.vue new file mode 100644 index 0000000..b061d02 --- /dev/null +++ b/frontend/website/src/views/HomeView.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue new file mode 100644 index 0000000..e4215a4 --- /dev/null +++ b/frontend/website/src/views/LoginView.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/website/src/views/SignupView.vue b/frontend/website/src/views/SignupView.vue new file mode 100644 index 0000000..123bf72 --- /dev/null +++ b/frontend/website/src/views/SignupView.vue @@ -0,0 +1,49 @@ + + +