diff --git a/ai/backend-context.md b/ai/backend-context.md index 8ab6a97..088ed62 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -92,8 +92,7 @@ pattern. - Rebuild the development database with: ```sh - direnv exec "$(git rev-parse --show-toplevel)" \ - php artisan migrate:fresh --seed + php artisan migrate:fresh --seed ``` - Once a production database exists, replace this policy with additive, @@ -102,12 +101,7 @@ pattern. ## Before completing backend work - Run the focused test during development. -- Run the full test suite before completion: - - ```sh - direnv exec "$(git rev-parse --show-toplevel)" php artisan test - ``` - +- Run `php artisan test` before completion. - 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 3462801..af25c82 100644 --- a/ai/frontend-context.md +++ b/ai/frontend-context.md @@ -33,14 +33,13 @@ Use npm and keep `package-lock.json` committed. Run commands from Install dependencies in a fresh checkout or worktree: ```sh -direnv exec "$(git rev-parse --show-toplevel)" npm install +npm install ``` Start the development server on the port assigned by the shell hook: ```sh -direnv exec "$(git rev-parse --show-toplevel)" \ - npm run dev -- --port "$VITE_PORT" +npm run dev -- --port "$VITE_PORT" ``` `process-compose` does not start or proxy the frontend. @@ -48,10 +47,10 @@ direnv exec "$(git rev-parse --show-toplevel)" \ 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 +npm run lint +npm run type-check +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 9c921c2..2f792aa 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -45,43 +45,28 @@ 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. For non-interactive use, start it - detached and stop it when finished, as shown below. +- 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`. - 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. 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: +- 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: ```sh - 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 + direnv exec process-compose up -D + direnv exec process-compose down + direnv exec php artisan migrate:fresh --seed ``` - Run backend commands from `backend/`, or explicitly change into it in the - command. The direnv target remains the worktree root. + command. - Run frontend commands from `frontend/website/`. - `process-compose` does not start the frontend. Start it separately with - the worktree's assigned port when needed: - - ```sh - direnv exec "$(git rev-parse --show-toplevel)" \ - npm run dev -- --port "$VITE_PORT" - ``` - + `npm run dev -- --port "$VITE_PORT"` when needed. - 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. @@ -158,11 +143,8 @@ 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. From `frontend/website/`, provision them with: - - ```sh - direnv exec "$(git rev-parse --show-toplevel)" npm install - ``` + dependencies. Run `npm install` from `frontend/website/` when provisioning a + fresh worktree. Do not push anything. Make commits as the TDD workflow requires. @@ -173,17 +155,12 @@ gate affected by the change. ### Backend -- Run tests from `backend/`: - - ```sh - direnv exec "$(git rev-parse --show-toplevel)" php artisan test - ``` - +- Run tests from `backend/` with `php artisan test`. - Run the Composer checks defined by `backend/composer.json`: ```sh - direnv exec "$(git rev-parse --show-toplevel)" composer types:check - direnv exec "$(git rev-parse --show-toplevel)" composer test + composer types:check + composer test ``` - Do not claim a green gate when a command fails. If the failure predates the @@ -194,10 +171,10 @@ gate affected by the change. - Run these commands from `frontend/website/`: ```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 + npm run lint + npm run type-check + npm run build ``` - The formatter and linters rewrite files. Review their changes before @@ -207,20 +184,8 @@ gate affected by the change. ### 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 - ``` - +- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`. +- For service configuration changes, run `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 deleted file mode 100644 index b96ad32..0000000 --- a/backend/app/Auth/Clock.php +++ /dev/null @@ -1,10 +0,0 @@ - $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 deleted file mode 100644 index 5fd9cb1..0000000 --- a/backend/app/Auth/Session.php +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 37b30cb..0000000 --- a/backend/app/Auth/SessionModel.php +++ /dev/null @@ -1,50 +0,0 @@ -|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 deleted file mode 100644 index cabae60..0000000 --- a/backend/app/Auth/SessionRepository.php +++ /dev/null @@ -1,12 +0,0 @@ -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 new file mode 100644 index 0000000..4c7c078 --- /dev/null +++ b/backend/app/Models/User.php @@ -0,0 +1,40 @@ + + */ + 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 52192d0..f1525e9 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,12 +2,6 @@ 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; @@ -21,15 +15,7 @@ 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 deleted file mode 100644 index de15bd9..0000000 --- a/backend/app/Shared/ValueObject/EmailAddress.php +++ /dev/null @@ -1,40 +0,0 @@ -normalized = $normalizedEmail; - } - - public function value(): string - { - return $this->normalized; - } -} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php deleted file mode 100644 index 735320b..0000000 --- a/backend/app/User/CreateUserDto.php +++ /dev/null @@ -1,12 +0,0 @@ - $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 deleted file mode 100644 index 8c9fa9c..0000000 --- a/backend/app/User/User.php +++ /dev/null @@ -1,23 +0,0 @@ -id; - } - - public function getEmail(): EmailAddress - { - return $this->email; - } -} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php deleted file mode 100644 index 74f3211..0000000 --- a/backend/app/User/UserModel.php +++ /dev/null @@ -1,25 +0,0 @@ -|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 deleted file mode 100644 index 5fcd6eb..0000000 --- a/backend/app/User/UserRepository.php +++ /dev/null @@ -1,10 +0,0 @@ - [ + '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 deleted file mode 100644 index dafb6c2..0000000 --- a/backend/database/migrations/0001_01_01_000000_create_users_table.php +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 687d9fb..0000000 --- a/backend/database/migrations/2026_07_31_000000_create_sessions_table.php +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 30374d3..0000000 --- a/backend/tests/Fakes/FakeClock.php +++ /dev/null @@ -1,18 +0,0 @@ -currentTime; - } -} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php deleted file mode 100644 index d64efd5..0000000 --- a/backend/tests/Fakes/FakeSessionRepository.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ - 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 deleted file mode 100644 index 8d84a1d..0000000 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 32d170d..0000000 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 207523f..0000000 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 65908c7..0000000 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 7804333..0000000 --- a/backend/tests/Unit/Auth/SessionTest.php +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index c47b3e4..0000000 --- a/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 0ac8d53..0000000 --- a/backend/tests/Unit/User/UserTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertSame(42, $user->getId()); - $this->assertSame($email, $user->getEmail()); - } -} diff --git a/flake.nix b/flake.nix index 820c512..68a882c 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,6 @@ mailpit process-compose mkcert - nssTools caddy ]; diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts deleted file mode 100644 index 2697f45..0000000 --- a/frontend/website/cypress.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index f4c1ba8..0000000 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 ebd0942..5ba881d 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -9,7 +9,6 @@ "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 7c2aa3f..abfd315 100644 --- a/frontend/website/src/App.vue +++ b/frontend/website/src/App.vue @@ -1,3 +1,11 @@ + + + + diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue deleted file mode 100644 index 709091f..0000000 --- a/frontend/website/src/components/AuthForm.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/frontend/website/src/components/AuthLayout.vue b/frontend/website/src/components/AuthLayout.vue deleted file mode 100644 index 2eeace0..0000000 --- a/frontend/website/src/components/AuthLayout.vue +++ /dev/null @@ -1,426 +0,0 @@ - - - - - diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue deleted file mode 100644 index e9ec98c..0000000 --- a/frontend/website/src/components/AuthTextField.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/frontend/website/src/components/BrandWordmark.vue b/frontend/website/src/components/BrandWordmark.vue deleted file mode 100644 index b41aa80..0000000 --- a/frontend/website/src/components/BrandWordmark.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - - diff --git a/frontend/website/src/main.ts b/frontend/website/src/main.ts index 62f3c02..fda1e6e 100644 --- a/frontend/website/src/main.ts +++ b/frontend/website/src/main.ts @@ -3,7 +3,6 @@ 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 b726d17..e1eab52 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -1,59 +1,8 @@ import { createRouter, createWebHistory } from 'vue-router' -import { useAuthStore } from '@/stores/auth' - const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - 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', - } - } + routes: [], }) export default router diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts deleted file mode 100644 index 50688f1..0000000 --- a/frontend/website/src/stores/auth.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index a121333..0000000 --- a/frontend/website/src/styles/main.css +++ /dev/null @@ -1,50 +0,0 @@ -: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 deleted file mode 100644 index 90b1b1d..0000000 --- a/frontend/website/src/views/DashboardView.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - - - diff --git a/frontend/website/src/views/HomeView.vue b/frontend/website/src/views/HomeView.vue deleted file mode 100644 index b061d02..0000000 --- a/frontend/website/src/views/HomeView.vue +++ /dev/null @@ -1,599 +0,0 @@ - - - - - diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue deleted file mode 100644 index e4215a4..0000000 --- a/frontend/website/src/views/LoginView.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/frontend/website/src/views/SignupView.vue b/frontend/website/src/views/SignupView.vue deleted file mode 100644 index 123bf72..0000000 --- a/frontend/website/src/views/SignupView.vue +++ /dev/null @@ -1,49 +0,0 @@ - - -