Compare commits

...

13 commits

26 changed files with 432 additions and 19 deletions

View file

@ -39,14 +39,39 @@ Use judgment for changes that cannot meaningfully be test-driven, such as
documentation-only edits or declarative environment configuration. Validate
those changes with the most relevant parser, formatter, dry run, or check.
## Approval discipline
- Treat dependency installation, tests, static analysis, formatting, linting,
type checking, and builds as routine project operations. Run them without
asking for advance approval when they stay within the requested scope and
sandbox boundaries.
- If an essential operation is blocked by sandbox or network policy, batch the
necessary provisioning or validation into one narrowly scoped approval
request. Prefer a reusable command prefix when it can be safely limited to
the required tool and operation.
- Do not request repeated approvals for commands that fit a previously
approved scope.
- Do not prefix routine commands with per-command cache environment overrides.
Configure cache locations once in Codex, direnv, or the project environment.
Use temporary overrides only to diagnose a cache-specific problem.
- Truly destructive actions, external writes, main-stack control, and
operations outside the workspace still require explicit approval.
## Running processes
- The main checkout owns the canonical stack on the default ports. Assume it
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 only when runtime or integration validation requires
it. Start it once from the worktree root, reuse it throughout validation,
and stop it once when finished.
- Do not start any service for PHPUnit, frontend formatting, linting, type
checking, or production builds.
- When authorized worktree stack control is necessary, operate it directly.
Do not ask the user to start or stop worktree services.
- For non-interactive use, start the stack 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. Bare project

View file

@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://localhost:8000
FRONTEND_URL=https://localhost:5173
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use App\User\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AuthController extends Controller
{
public function me(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
return new JsonResponse([
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail()->value(),
],
]);
}
}

View file

@ -9,6 +9,7 @@ use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)

22
backend/config/cors.php Normal file
View file

@ -0,0 +1,22 @@
<?php
$frontendUrl = (string) env(
'FRONTEND_URL',
'https://localhost:5173',
);
$allowedOrigins = array_values(array_unique([
$frontendUrl,
str_replace('localhost', '127.0.0.1', $frontendUrl),
str_replace('127.0.0.1', 'localhost', $frontendUrl),
]));
return [
'paths' => ['api/*'],
'allowed_methods' => ['*'],
'allowed_origins' => $allowedOrigins,
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];

View file

@ -11,5 +11,6 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
$this->call(UserSeeder::class);
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use App\User\UserModel;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
public function run(): void
{
UserModel::firstOrCreate([
'email' => 'user@example.com',
]);
}
}

View file

@ -26,6 +26,7 @@
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="FRONTEND_URL" value="https://localhost:5173"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>

9
backend/routes/api.php Normal file
View file

@ -0,0 +1,9 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route;
Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/me', [AuthController::class, 'me']);
});

View file

@ -0,0 +1,74 @@
<?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 MeEndpointTest extends TestCase
{
use RefreshDatabase;
public function test_me_returns_the_authenticated_user(): 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'),
));
app(SessionRepository::class)->create(new CreateSessionDto(
token: 'valid-token',
user: $user,
createdAt: $now,
expiresAt: $now->modify('+7 days'),
));
$response = $this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'valid-token',
)->getJson('/api/me');
$response->assertOk()->assertExactJson([
'user' => [
'id' => $user->getId(),
'email' => 'user@example.com',
],
]);
}
public function test_me_rejects_a_request_without_a_cookie(): void
{
$response = $this->getJson('/api/me');
$response
->assertStatus(401)
->assertExactJson(['error' => 'unauthenticated']);
}
public function test_me_allows_credentialed_frontend_requests(): void
{
$response = $this->withHeaders([
'Origin' => 'https://localhost:5173',
'Access-Control-Request-Method' => 'GET',
])->options('/api/me');
$response
->assertNoContent()
->assertHeader(
'Access-Control-Allow-Origin',
'https://localhost:5173',
)
->assertHeader('Access-Control-Allow-Credentials', 'true');
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Tests\Feature\Database;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DatabaseSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_the_development_user_idempotently(): void
{
$this->seed();
$this->seed();
$this->assertDatabaseHas('users', [
'email' => 'user@example.com',
]);
$this->assertDatabaseCount('users', 1);
}
}

View file

@ -5,7 +5,7 @@ const frontendPort = process.env.VITE_PORT ?? '5173'
export default defineConfig({
allowCypressEnv: false,
e2e: {
baseUrl: `http://127.0.0.1:${frontendPort}`,
baseUrl: `https://localhost:${frontendPort}`,
supportFile: false,
},
video: false,

View file

@ -8,6 +8,15 @@ describe('guest authentication pages', () => {
cy.contains('a', 'Get started').should('have.attr', 'href', '/signup')
})
it('uses Attainly browser metadata', () => {
cy.visit('/')
cy.title().should('equal', 'Attainly')
cy.get('link[rel="icon"]')
.should('have.attr', 'type', 'image/svg+xml')
.and('have.attr', 'href', '/favicon.svg')
})
it('redirects guests away from the protected dashboard', () => {
cy.visit('/dashboard')

View file

@ -0,0 +1,56 @@
const authenticatedUser = {
id: 7,
email: 'user@example.com',
}
describe('session authentication', () => {
it('restores an authenticated session on a protected route', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('me')
cy.visit('/dashboard')
cy.wait('@me')
cy.location('pathname').should('equal', '/dashboard')
cy.get('h1').should('have.text', 'Your next step starts here.')
})
it('redirects an unauthenticated protected route to login', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 401,
body: { error: 'unauthenticated' },
}).as('me')
cy.visit('/dashboard')
cy.wait('@me')
cy.location('pathname').should('equal', '/login')
cy.location('search').should('include', 'redirect=/dashboard')
})
it('redirects a restored session away from a guest-only route', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('me')
cy.visit('/login')
cy.wait('@me')
cy.location('pathname').should('equal', '/dashboard')
})
it('rejects a malformed authenticated-user response', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
body: { user: { id: 7 } },
}).as('me')
cy.visit('/dashboard')
cy.wait('@me')
cy.location('pathname').should('equal', '/login')
})
})

View file

@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View file

@ -2,9 +2,9 @@
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>Attainly</title>
</head>
<body>
<div id="app"></div>

View file

@ -10,7 +10,8 @@
"dependencies": {
"pinia": "^4.0.2",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
"vue-router": "^5.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
@ -7620,6 +7621,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -18,7 +18,8 @@
"dependencies": {
"pinia": "^4.0.2",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
"vue-router": "^5.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
>
<circle cx="16" cy="16" r="15" fill="#183a31" />
<circle cx="12.5" cy="19.2" r="5.2" fill="#c9da73" />
<circle
cx="20"
cy="12"
r="4.2"
fill="none"
stroke="#fffdf6"
stroke-width="2"
/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -37,10 +37,12 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
const restoredSession = await authStore.fetchMe()
if (!restoredSession) {
return {
name: 'login',
query: {
@ -48,12 +50,20 @@ router.beforeEach((to) => {
},
}
}
}
if (to.meta.guestOnly && authStore.isAuthenticated) {
if (to.meta.guestOnly) {
let restoredSession = authStore.isAuthenticated
if (!restoredSession) {
restoredSession = await authStore.fetchMe()
}
if (restoredSession) {
return {
name: 'dashboard',
}
}
}
})
export default router

View file

@ -1,10 +1,67 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { z } from 'zod'
import { API_BASE } from '@/utils/apiBase'
export const authUserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
})
const meResponseSchema = z.object({
user: authUserSchema,
})
export type AuthUser = z.infer<typeof authUserSchema>
export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = ref(false)
const user = ref<AuthUser | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => user.value !== null)
async function fetchMe(): Promise<boolean> {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_BASE}/api/me`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
if (response.status === 200) {
const responseBody: unknown = await response.json()
user.value = meResponseSchema.parse(responseBody).user
return true
}
user.value = null
if (response.status !== 401) {
error.value = 'Unable to restore session'
}
return false
} catch {
user.value = null
error.value = 'Unable to restore session'
return false
} finally {
loading.value = false
}
}
return {
user,
loading,
error,
isAuthenticated,
fetchMe,
}
})

View file

@ -0,0 +1,17 @@
function resolveApiBase(): string {
const configuredApiUrl = import.meta.env.VITE_API_URL
if (configuredApiUrl === '') {
throw new Error('VITE_API_URL must be configured')
}
if (!import.meta.env.DEV) {
return configuredApiUrl
}
const apiUrl = new URL(configuredApiUrl)
apiUrl.hostname = window.location.hostname
return apiUrl.origin
}
export const API_BASE = resolveApiBase()

View file

@ -1,12 +1,30 @@
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import type { ServerOptions } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
const certificateKeyPath = fileURLToPath(
new URL('../../.cert/localhost-key.pem', import.meta.url),
)
const certificatePath = fileURLToPath(
new URL('../../.cert/localhost.pem', import.meta.url),
)
const serverOptions: ServerOptions = {}
if (existsSync(certificateKeyPath) && existsSync(certificatePath)) {
serverOptions.https = {
key: readFileSync(certificateKeyPath),
cert: readFileSync(certificatePath),
}
}
// https://vite.dev/config/
export default defineConfig({
server: serverOptions,
plugins: [
vue(),
vueJsx(),

View file

@ -38,6 +38,7 @@ export PGUSER="postgres"
export PGDATABASE="postgres"
DEV_APP_URL="https://localhost:$CADDY_PORT"
DEV_FRONTEND_URL="https://localhost:$VITE_PORT"
DEV_DB_CONNECTION="pgsql"
DEV_DB_HOST="$PGHOST"
DEV_DB_PORT="5432"
@ -99,6 +100,7 @@ set_env_value() {
}
set_env_value APP_URL "$DEV_APP_URL"
set_env_value FRONTEND_URL "$DEV_FRONTEND_URL"
set_env_value DB_CONNECTION "$DEV_DB_CONNECTION"
set_env_value DB_HOST "$DEV_DB_HOST"
set_env_value DB_PORT "$DEV_DB_PORT"
@ -109,6 +111,13 @@ set_env_value MAIL_MAILER "$DEV_MAIL_MAILER"
set_env_value MAIL_HOST "$DEV_MAIL_HOST"
set_env_value MAIL_PORT "$DEV_MAIL_PORT"
FRONTEND_ENV_FILE="$REPO_ROOT/frontend/website/.env.local"
FRONTEND_API_URL="VITE_API_URL=$DEV_APP_URL"
if [ ! -f "$FRONTEND_ENV_FILE" ] \
|| [ "$(cat "$FRONTEND_ENV_FILE")" != "$FRONTEND_API_URL" ]; then
printf '%s\n' "$FRONTEND_API_URL" > "$FRONTEND_ENV_FILE"
fi
if [ ! -d "$REPO_ROOT/backend/vendor" ]; then
echo "[composer] installing backend dependencies"
(cd "$REPO_ROOT/backend" && composer install)

View file

@ -18,6 +18,13 @@ processes:
postgres:
condition: process_healthy
seed:
command: php artisan db:seed --force
working_dir: ./backend
depends_on:
migrate:
condition: process_completed_successfully
mailpit:
command: mailpit --smtp 127.0.0.1:${MAILPIT_SMTP_PORT:-2525} --listen 127.0.0.1:${MAILPIT_UI_PORT:-8025}
readiness_probe:
@ -32,7 +39,7 @@ processes:
command: php artisan serve --host=127.0.0.1 --port=${BACKEND_PORT:-8001}
working_dir: ./backend
depends_on:
migrate:
seed:
condition: process_completed_successfully
mailpit:
condition: process_healthy
@ -56,6 +63,7 @@ processes:
host: 127.0.0.1
port: ${VITE_PORT:-5173}
path: /
scheme: https
initial_delay_seconds: 1
period_seconds: 2