diff --git a/ai/shared.md b/ai/shared.md
index 97f3e33..00a1273 100644
--- a/ai/shared.md
+++ b/ai/shared.md
@@ -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
diff --git a/backend/.env.example b/backend/.env.example
index c3214a6..027ae21 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -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
diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php
new file mode 100644
index 0000000..de82e1a
--- /dev/null
+++ b/backend/app/Http/Controllers/AuthController.php
@@ -0,0 +1,23 @@
+attributes->get('user');
+
+ return new JsonResponse([
+ 'user' => [
+ 'id' => $user->getId(),
+ 'email' => $user->getEmail()->value(),
+ ],
+ ]);
+ }
+}
diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php
index 324b7c2..141b221 100644
--- a/backend/bootstrap/app.php
+++ b/backend/bootstrap/app.php
@@ -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',
)
diff --git a/backend/config/cors.php b/backend/config/cors.php
new file mode 100644
index 0000000..5189eb1
--- /dev/null
+++ b/backend/config/cors.php
@@ -0,0 +1,22 @@
+ ['api/*'],
+ 'allowed_methods' => ['*'],
+ 'allowed_origins' => $allowedOrigins,
+ 'allowed_origins_patterns' => [],
+ 'allowed_headers' => ['*'],
+ 'exposed_headers' => [],
+ 'max_age' => 0,
+ 'supports_credentials' => true,
+];
diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php
index dc2b6db..def224c 100644
--- a/backend/database/seeders/DatabaseSeeder.php
+++ b/backend/database/seeders/DatabaseSeeder.php
@@ -11,5 +11,6 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
+ $this->call(UserSeeder::class);
}
}
diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php
new file mode 100644
index 0000000..886e4c9
--- /dev/null
+++ b/backend/database/seeders/UserSeeder.php
@@ -0,0 +1,16 @@
+ 'user@example.com',
+ ]);
+ }
+}
diff --git a/backend/phpunit.xml b/backend/phpunit.xml
index e7f0a48..dafc3f1 100644
--- a/backend/phpunit.xml
+++ b/backend/phpunit.xml
@@ -26,6 +26,7 @@
+
diff --git a/backend/routes/api.php b/backend/routes/api.php
new file mode 100644
index 0000000..d8fba04
--- /dev/null
+++ b/backend/routes/api.php
@@ -0,0 +1,9 @@
+group(function (): void {
+ Route::get('/me', [AuthController::class, 'me']);
+});
diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php
new file mode 100644
index 0000000..ec19fc3
--- /dev/null
+++ b/backend/tests/Feature/Auth/MeEndpointTest.php
@@ -0,0 +1,74 @@
+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');
+ }
+}
diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php
new file mode 100644
index 0000000..26b6b06
--- /dev/null
+++ b/backend/tests/Feature/Database/DatabaseSeederTest.php
@@ -0,0 +1,22 @@
+seed();
+ $this->seed();
+
+ $this->assertDatabaseHas('users', [
+ 'email' => 'user@example.com',
+ ]);
+ $this->assertDatabaseCount('users', 1);
+ }
+}
diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts
index 2697f45..4a2d276 100644
--- a/frontend/website/cypress.config.ts
+++ b/frontend/website/cypress.config.ts
@@ -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,
diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts
index f4c1ba8..2dea54b 100644
--- a/frontend/website/cypress/e2e/guest-auth.cy.ts
+++ b/frontend/website/cypress/e2e/guest-auth.cy.ts
@@ -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')
diff --git a/frontend/website/cypress/e2e/session-auth.cy.ts b/frontend/website/cypress/e2e/session-auth.cy.ts
new file mode 100644
index 0000000..d7fb723
--- /dev/null
+++ b/frontend/website/cypress/e2e/session-auth.cy.ts
@@ -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')
+ })
+})
diff --git a/frontend/website/env.d.ts b/frontend/website/env.d.ts
index 11f02fe..b54b4c9 100644
--- a/frontend/website/env.d.ts
+++ b/frontend/website/env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/frontend/website/index.html b/frontend/website/index.html
index 9e5fc8f..ca72e36 100644
--- a/frontend/website/index.html
+++ b/frontend/website/index.html
@@ -2,9 +2,9 @@
-
+
- Vite App
+ Attainly
diff --git a/frontend/website/package-lock.json b/frontend/website/package-lock.json
index 6cbfc4d..e47680a 100644
--- a/frontend/website/package-lock.json
+++ b/frontend/website/package-lock.json
@@ -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"
+ }
}
}
}
diff --git a/frontend/website/package.json b/frontend/website/package.json
index ebd0942..e70648e 100644
--- a/frontend/website/package.json
+++ b/frontend/website/package.json
@@ -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",
diff --git a/frontend/website/public/favicon.ico b/frontend/website/public/favicon.ico
deleted file mode 100644
index df36fcf..0000000
Binary files a/frontend/website/public/favicon.ico and /dev/null differ
diff --git a/frontend/website/public/favicon.svg b/frontend/website/public/favicon.svg
new file mode 100644
index 0000000..3d796bf
--- /dev/null
+++ b/frontend/website/public/favicon.svg
@@ -0,0 +1,15 @@
+
diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts
index b726d17..51e7184 100644
--- a/frontend/website/src/router/index.ts
+++ b/frontend/website/src/router/index.ts
@@ -37,21 +37,31 @@ const router = createRouter({
],
})
-router.beforeEach((to) => {
+router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
- return {
- name: 'login',
- query: {
- redirect: to.fullPath,
- },
+ const restoredSession = await authStore.fetchMe()
+ if (!restoredSession) {
+ return {
+ name: 'login',
+ query: {
+ redirect: to.fullPath,
+ },
+ }
}
}
- if (to.meta.guestOnly && authStore.isAuthenticated) {
- return {
- name: 'dashboard',
+ if (to.meta.guestOnly) {
+ let restoredSession = authStore.isAuthenticated
+ if (!restoredSession) {
+ restoredSession = await authStore.fetchMe()
+ }
+
+ if (restoredSession) {
+ return {
+ name: 'dashboard',
+ }
}
}
})
diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts
index 50688f1..399a301 100644
--- a/frontend/website/src/stores/auth.ts
+++ b/frontend/website/src/stores/auth.ts
@@ -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
export const useAuthStore = defineStore('auth', () => {
- const isAuthenticated = ref(false)
+ const user = ref(null)
+ const loading = ref(false)
+ const error = ref(null)
+ const isAuthenticated = computed(() => user.value !== null)
+
+ async function fetchMe(): Promise {
+ 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,
}
})
diff --git a/frontend/website/src/utils/apiBase.ts b/frontend/website/src/utils/apiBase.ts
new file mode 100644
index 0000000..0a1babc
--- /dev/null
+++ b/frontend/website/src/utils/apiBase.ts
@@ -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()
diff --git a/frontend/website/vite.config.ts b/frontend/website/vite.config.ts
index ace127e..a6fdcaa 100644
--- a/frontend/website/vite.config.ts
+++ b/frontend/website/vite.config.ts
@@ -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(),
diff --git a/nix/shell-hook.sh b/nix/shell-hook.sh
index d835131..73d1874 100644
--- a/nix/shell-hook.sh
+++ b/nix/shell-hook.sh
@@ -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)
diff --git a/process-compose.yaml b/process-compose.yaml
index ad7a160..fcddf50 100644
--- a/process-compose.yaml
+++ b/process-compose.yaml
@@ -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