diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11327b..9ac1c94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,90 +50,89 @@ jobs: env: TURSO_DATABASE_URL: file:./ci.db - docker: + # One image now: the PWA, the API and the worker in a single container. + # + # Building is not enough — an image once built cleanly and then crashed on + # boot with a missing workspace dependency. This job boots the container and + # waits for both the API health endpoint and a page served by the Next child, + # so a broken supervisor or a dead proxy fails here rather than in production. + image: runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - service: [api, web, worker] + timeout-minutes: 25 steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - # Build only — publishing is Railway's job. This catches a Dockerfile - # that references a file the repository no longer has. - - name: Build ${{ matrix.service }} image + - name: Build the image uses: docker/build-push-action@v6 with: context: . - file: docker/${{ matrix.service }}.Dockerfile + file: docker/Dockerfile push: false load: true - tags: outreachgraph-${{ matrix.service }}:ci + tags: outreachgraph:ci cache-from: type=gha cache-to: type=gha,mode=max - # A building image is not a working image. The api and worker images once - # built cleanly and then crashed on boot with a missing workspace dependency, - # because the runtime stage had deleted a per-package node_modules. This job - # starts the container and waits for its health endpoint. - smoke: - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - include: - - service: api - port: 8080 - - service: worker - port: 8081 - - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - - name: Build ${{ matrix.service }} image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/${{ matrix.service }}.Dockerfile - push: false - load: true - tags: outreachgraph-${{ matrix.service }}:smoke - cache-from: type=gha - - - name: Boot ${{ matrix.service }} and wait for health + - name: Boot the container run: | - docker run -d --name smoke-${{ matrix.service }} \ - -e PORT=${{ matrix.port }} \ + docker run -d --name og \ + -e PORT=8080 \ -e API_TOKEN=ci-smoke-token \ -e TURSO_DATABASE_URL=file:./smoke.db \ - -p ${{ matrix.port }}:${{ matrix.port }} \ - outreachgraph-${{ matrix.service }}:smoke - - for i in $(seq 1 30); do - if curl -fsS "http://127.0.0.1:${{ matrix.port }}/health/live" >/dev/null 2>&1; then - echo "healthy after ${i}s" - curl -sS "http://127.0.0.1:${{ matrix.port }}/health/live" - exit 0 + -e WORKER_TICK_MS=3600000 \ + -p 8080:8080 \ + outreachgraph:ci + + for i in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8080/health/live >/dev/null 2>&1; then + echo "api healthy after ${i}s" + break fi - # Surface a crashloop immediately instead of waiting out the loop. - if [ "$(docker inspect -f '{{.State.Running}}' smoke-${{ matrix.service }})" != "true" ]; then - echo "container exited early:" - docker logs smoke-${{ matrix.service }} - exit 1 + if [ "$(docker inspect -f '{{.State.Running}}' og)" != "true" ]; then + echo "container exited early:"; docker logs og; exit 1 fi sleep 1 done - echo "never became healthy:" - docker logs smoke-${{ matrix.service }} - exit 1 + curl -fsS http://127.0.0.1:8080/health/live || { docker logs og; exit 1; } + + - name: The Next child is proxied + run: | + # /offline is prerendered and needs neither API nor database, so a + # 200 here proves the supervisor reached the web child. + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/offline || echo 000) + if [ "$code" = "200" ]; then echo "web proxied after ${i}s"; exit 0; fi + sleep 1 + done + echo "web never answered through the proxy:"; docker logs og; exit 1 + + - name: Auth is enforced + run: | + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/v1/recommendations) + echo "unauthenticated api -> $code" + [ "$code" = "401" ] || { docker logs og; exit 1; } + + - name: Registration and login work end to end + run: | + set -e + reg=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H 'content-type: application/json' \ + -d '{"email":"ci@example.com","password":"correct horse battery staple"}' \ + http://127.0.0.1:8080/api/v1/auth/register) + echo "register -> $reg" + [ "$reg" = "201" ] || { docker logs og; exit 1; } + + curl -s -c /tmp/jar -X POST -H 'content-type: application/json' \ + -d '{"email":"ci@example.com","password":"correct horse battery staple"}' \ + http://127.0.0.1:8080/api/v1/auth/login > /dev/null + + me=$(curl -s -b /tmp/jar -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/v1/auth/me) + echo "authenticated /auth/me -> $me" + [ "$me" = "200" ] || { docker logs og; exit 1; } - name: Container logs if: always() - run: docker logs smoke-${{ matrix.service }} 2>&1 | tail -50 + run: docker logs og 2>&1 | tail -60 diff --git a/CLAUDE.md b/CLAUDE.md index 6b2cbf1..859d385 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ These come from the PRD and are not style preferences: - Forward-only. Never edit an applied migration — the runner rejects it by checksum. Write the next one. - `bun run db:migrate`, `bun run db:status`, `bun run db:reset` (local file databases only). +- The container applies pending migrations at boot and refuses to start if they fail. That is safe only because the deployment is one container pinned to one replica; if `numReplicas` ever rises, set `RUN_MIGRATIONS=false` and run them as an explicit release step instead. ## Process diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d94317e..ae3284e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,11 +14,24 @@ import { approveRecommendationSchema, createSuppressionSchema, executeActionSchema, + loginSchema, privacyRequestSchema, + registerSchema, snoozeRecommendationSchema, } from '@outreachgraph/contracts'; import { newId, type ActionKind, type Network } from '@outreachgraph/domain'; -import { now, type Client } from '@outreachgraph/db'; +import { now, queryOne, type Client } from '@outreachgraph/db'; +import { + actorFromSession, + clearedCookie, + login, + logout, + readCookie, + registerUser, + SESSION_COOKIE, + sessionCookie, + workspacesForUser, +} from './auth'; import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy'; import { ApiError, canApprove, type AppEnv, type RequestActor } from './context'; import * as repo from './repository'; @@ -26,10 +39,17 @@ import * as repo from './repository'; export interface AppOptions { readonly db: Client; /** - * Resolves the caller. Injected so the test suite and a future auth provider - * plug in without the routes knowing how authentication works. + * Overrides authentication entirely. Tests use this; production leaves it + * unset and gets session cookies plus the optional service token below. + */ + readonly authenticate?: (request: Request) => Promise; + /** + * Machine credential for internal callers (the worker, scripts). Humans + * authenticate with a session; this is not a login. */ - readonly authenticate: (request: Request) => Promise; + readonly serviceToken?: string; + /** Set false for plain-HTTP local development so the cookie still sets. */ + readonly secureCookies?: boolean; readonly version?: string; readonly commitHash?: string; } @@ -70,11 +90,103 @@ export function createApp(options: AppOptions): Hono { } }); + const secure = options.secureCookies ?? true; + + /** + * Resolves the caller: a session cookie for humans, or the service token for + * internal machine callers. Injected `authenticate` wins, which is how tests + * drive the app without minting sessions. + */ + const resolveActor = async (request: Request): Promise => { + if (options.authenticate) return options.authenticate(request); + + const cookie = readCookie(request.headers.get('cookie'), SESSION_COOKIE); + if (cookie) { + const actor = await actorFromSession(options.db, cookie); + if (actor) return actor; + } + + // Machine path. Requires explicit scope headers because a service token + // has no session and therefore no implied workspace. + const header = request.headers.get('authorization'); + if (options.serviceToken && header?.startsWith('Bearer ')) { + const presented = header.slice('Bearer '.length); + // Length-independent comparison; both sides are fixed-length hex. + if (timingSafeEqual(presented, options.serviceToken)) { + const workspaceId = request.headers.get('x-workspace-id'); + const organizationId = request.headers.get('x-organization-id'); + if (workspaceId && organizationId) { + return { + userId: request.headers.get('x-user-id') ?? 'usr_service', + workspaceId, + organizationId, + role: 'owner', + }; + } + } + } + + return undefined; + }; + const api = new Hono(); - // Everything under /api/v1 is authenticated and workspace-scoped. + // ------------------------------------------------------------------- auth + // These sit before the auth guard, since they are how you get a session. + const auth = new Hono(); + + auth.post('/register', async (c) => { + const body = await parseBody(c.req.raw, registerSchema); + const result = await registerUser(options.db, body); + + // Registering logs you straight in; a signup that then demands a login is + // just a worse signup. + const session = await login(options.db, body.email, body.password, c.req.header('user-agent')); + + c.header('set-cookie', sessionCookie(session.token, session.expiresAt, secure)); + return c.json({ userId: result.userId, workspaceId: result.workspaceId }, 201); + }); + + auth.post('/login', async (c) => { + const body = await parseBody(c.req.raw, loginSchema); + const session = await login(options.db, body.email, body.password, c.req.header('user-agent')); + + c.header('set-cookie', sessionCookie(session.token, session.expiresAt, secure)); + return c.json({ userId: session.actor.userId, workspaceId: session.actor.workspaceId }); + }); + + auth.post('/logout', async (c) => { + const cookie = readCookie(c.req.header('cookie') ?? null, SESSION_COOKIE); + if (cookie) await logout(options.db, cookie); + + c.header('set-cookie', clearedCookie(secure)); + return c.json({ ok: true }); + }); + + auth.get('/me', async (c) => { + const actor = await resolveActor(c.req.raw); + if (!actor) throw ApiError.unauthorized(); + + const user = await queryOne<{ id: string; email: string; name: string | null }>( + options.db, + 'SELECT id, email, name FROM users WHERE id = ?', + [actor.userId], + ); + + return c.json({ + user: user ?? { id: actor.userId, email: null, name: null }, + workspaceId: actor.workspaceId, + organizationId: actor.organizationId, + role: actor.role, + workspaces: await workspacesForUser(options.db, actor.userId), + }); + }); + + api.route('/auth', auth); + + // Everything else under /api/v1 is authenticated and workspace-scoped. api.use('*', async (c, next) => { - const actor = await options.authenticate(c.req.raw); + const actor = await resolveActor(c.req.raw); if (!actor) throw ApiError.unauthorized(); c.set('db', options.db); @@ -648,4 +760,18 @@ function numberOr(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } +/** + * Constant-time string comparison, so a service token cannot be recovered by + * timing how long a rejection takes. + */ +function timingSafeEqual(a: string, b: string): boolean { + const left = new TextEncoder().encode(a); + const right = new TextEncoder().encode(b); + if (left.length !== right.length) return false; + + let diff = 0; + for (let i = 0; i < left.length; i += 1) diff |= left[i]! ^ right[i]!; + return diff === 0; +} + export { POLICY_VERSION }; diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts new file mode 100644 index 0000000..c0e98fb --- /dev/null +++ b/apps/api/src/auth.test.ts @@ -0,0 +1,326 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Hono } from 'hono'; +import { createApp } from './app'; +import type { AppEnv } from './context'; +import { hashToken, passwordProblem, readCookie, SESSION_COOKIE } from './auth'; +import { seedDatabase, type SeededDatabase } from './test-seed'; + +let active: SeededDatabase | undefined; + +afterEach(() => { + active?.cleanup(); + active = undefined; +}); + +async function harness(label: string): Promise<{ app: Hono; seeded: SeededDatabase }> { + const seeded = await seedDatabase(`auth-${label}`); + active = seeded; + // No injected authenticate: exercise the real session path. + return { app: createApp({ db: seeded.db, secureCookies: false }), seeded }; +} + +function post(app: Hono, path: string, body: unknown, cookie?: string) { + return app.request(`/api/v1${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body), + }); +} + +const CREDENTIALS = { email: 'Jane@Example.com', password: 'correct horse battery staple' }; + +function cookieFrom(response: Response): string { + const header = response.headers.get('set-cookie') ?? ''; + const value = readCookie(header.split(';')[0] ?? '', SESSION_COOKIE); + return `${SESSION_COOKIE}=${value}`; +} + +describe('password policy', () => { + test('rejects short passwords', () => { + expect(passwordProblem('short')).toMatch(/at least 12/); + }); + + test('accepts a long passphrase without punctuation rules', () => { + expect(passwordProblem('correct horse battery staple')).toBeUndefined(); + }); + + test('rejects absurdly long input rather than hashing it', () => { + expect(passwordProblem('x'.repeat(5000))).toMatch(/at most/); + }); +}); + +describe('registration', () => { + test('creates a user, organization and workspace, and logs in', async () => { + const { app, seeded } = await harness('register'); + const response = await post(app, '/auth/register', CREDENTIALS); + + expect(response.status).toBe(201); + expect(response.headers.get('set-cookie')).toContain(SESSION_COOKIE); + + const body = await response.json(); + expect(body.userId).toMatch(/^usr_/); + expect(body.workspaceId).toMatch(/^wsp_/); + + const membership = await seeded.db.execute({ + sql: 'SELECT role FROM organization_members WHERE user_id = ?', + args: [body.userId], + }); + expect(membership.rows[0]?.role).toBe('owner'); + }); + + test('never stores the password itself', async () => { + const { app, seeded } = await harness('nostore'); + await post(app, '/auth/register', CREDENTIALS); + + const user = await seeded.db.execute({ + sql: 'SELECT password_hash FROM users WHERE email = ?', + args: ['jane@example.com'], + }); + + const hash = String(user.rows[0]?.password_hash); + expect(hash).not.toContain(CREDENTIALS.password); + expect(hash).toMatch(/^\$argon2/); + }); + + test('normalises the email so case cannot create a duplicate', async () => { + const { app } = await harness('normalise'); + expect((await post(app, '/auth/register', CREDENTIALS)).status).toBe(201); + + const dup = await post(app, '/auth/register', { + ...CREDENTIALS, + email: 'JANE@EXAMPLE.COM', + }); + expect(dup.status).toBe(409); + }); + + test('rejects a weak password', async () => { + const { app } = await harness('weak'); + const response = await post(app, '/auth/register', { ...CREDENTIALS, password: 'hunter2' }); + expect(response.status).toBe(400); + }); + + test('rejects a malformed email', async () => { + const { app } = await harness('bademail'); + const response = await post(app, '/auth/register', { ...CREDENTIALS, email: 'not-an-email' }); + expect(response.status).toBe(400); + }); +}); + +describe('login', () => { + test('issues a session cookie', async () => { + const { app } = await harness('login'); + await post(app, '/auth/register', CREDENTIALS); + + const response = await post(app, '/auth/login', CREDENTIALS); + expect(response.status).toBe(200); + + const cookie = response.headers.get('set-cookie') ?? ''; + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('SameSite=Lax'); + }); + + test('stores only a hash of the cookie value', async () => { + const { app, seeded } = await harness('hashed'); + const registered = await post(app, '/auth/register', CREDENTIALS); + + const raw = cookieFrom(registered).split('=')[1]!; + const stored = await seeded.db.execute('SELECT token_hash FROM sessions LIMIT 1'); + + expect(String(stored.rows[0]?.token_hash)).not.toBe(raw); + expect(String(stored.rows[0]?.token_hash)).toBe(await hashToken(raw)); + }); + + test('gives the same answer for a wrong password and an unknown user', async () => { + const { app } = await harness('enumeration'); + await post(app, '/auth/register', CREDENTIALS); + + const wrongPassword = await post(app, '/auth/login', { + ...CREDENTIALS, + password: 'wrong password entirely', + }); + const unknownUser = await post(app, '/auth/login', { + email: 'nobody@example.com', + password: 'wrong password entirely', + }); + + expect(wrongPassword.status).toBe(401); + expect(unknownUser.status).toBe(401); + expect(await wrongPassword.json()).toEqual(await unknownUser.json()); + }); + + test('locks the account after repeated failures', async () => { + const { app } = await harness('lockout'); + await post(app, '/auth/register', CREDENTIALS); + + for (let i = 0; i < 8; i += 1) { + await post(app, '/auth/login', { ...CREDENTIALS, password: `guess-${i}` }); + } + + // Even the correct password is refused while locked. + const locked = await post(app, '/auth/login', CREDENTIALS); + expect(locked.status).toBe(429); + }); + + test('clears the failure count after a successful login', async () => { + const { app, seeded } = await harness('reset-count'); + await post(app, '/auth/register', CREDENTIALS); + + await post(app, '/auth/login', { ...CREDENTIALS, password: 'wrong' }); + await post(app, '/auth/login', CREDENTIALS); + + const user = await seeded.db.execute({ + sql: 'SELECT failed_login_count FROM users WHERE email = ?', + args: ['jane@example.com'], + }); + expect(Number(user.rows[0]?.failed_login_count)).toBe(0); + }); +}); + +describe('sessions', () => { + test('a session cookie authenticates subsequent requests', async () => { + const { app } = await harness('session'); + const registered = await post(app, '/auth/register', CREDENTIALS); + const cookie = cookieFrom(registered); + + const response = await app.request('/api/v1/recommendations', { headers: { cookie } }); + expect(response.status).toBe(200); + }); + + test('no cookie means 401', async () => { + const { app } = await harness('nocookie'); + const response = await app.request('/api/v1/recommendations'); + expect(response.status).toBe(401); + }); + + test('a forged cookie is rejected', async () => { + const { app } = await harness('forged'); + await post(app, '/auth/register', CREDENTIALS); + + const response = await app.request('/api/v1/recommendations', { + headers: { cookie: `${SESSION_COOKIE}=deadbeefdeadbeef` }, + }); + expect(response.status).toBe(401); + }); + + test('an expired session is rejected and deleted', async () => { + const { app, seeded } = await harness('expired'); + const registered = await post(app, '/auth/register', CREDENTIALS); + const cookie = cookieFrom(registered); + + await seeded.db.execute({ + sql: 'UPDATE sessions SET expires_at = ?', + args: [new Date(Date.now() - 1000).toISOString()], + }); + + const response = await app.request('/api/v1/recommendations', { headers: { cookie } }); + expect(response.status).toBe(401); + + const remaining = await seeded.db.execute('SELECT count(*) AS n FROM sessions'); + expect(Number(remaining.rows[0]?.n)).toBe(0); + }); + + test('logout invalidates the session', async () => { + const { app } = await harness('logout'); + const registered = await post(app, '/auth/register', CREDENTIALS); + const cookie = cookieFrom(registered); + + expect((await post(app, '/auth/logout', {}, cookie)).status).toBe(200); + expect((await app.request('/api/v1/recommendations', { headers: { cookie } })).status).toBe( + 401, + ); + }); + + test('/auth/me returns the caller and their workspaces', async () => { + const { app } = await harness('me'); + const registered = await post(app, '/auth/register', CREDENTIALS); + const cookie = cookieFrom(registered); + + const response = await app.request('/api/v1/auth/me', { headers: { cookie } }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.user.email).toBe('jane@example.com'); + expect(body.role).toBe('owner'); + expect(body.workspaces.length).toBeGreaterThan(0); + }); + + test('a session only reaches its own workspace', async () => { + const { app, seeded } = await harness('isolation'); + const registered = await post(app, '/auth/register', CREDENTIALS); + const cookie = cookieFrom(registered); + + // The seeded fixture's recommendation belongs to a different workspace. + const response = await app.request('/api/v1/recommendations', { headers: { cookie } }); + const body = await response.json(); + + expect(body.recommendations).toHaveLength(0); + + const seededRows = await seeded.db.execute('SELECT count(*) AS n FROM recommendations'); + expect(Number(seededRows.rows[0]?.n)).toBeGreaterThan(0); + }); +}); + +describe('service token', () => { + test('authenticates a machine caller with explicit scope', async () => { + const seeded = await seedDatabase('auth-service'); + active = seeded; + const app = createApp({ db: seeded.db, serviceToken: 'service-secret', secureCookies: false }); + + const response = await app.request('/api/v1/recommendations', { + headers: { + authorization: 'Bearer service-secret', + 'x-workspace-id': 'wsp_test', + 'x-organization-id': 'org_test', + }, + }); + + expect(response.status).toBe(200); + }); + + test('is refused without scope headers', async () => { + const seeded = await seedDatabase('auth-service-noscope'); + active = seeded; + const app = createApp({ db: seeded.db, serviceToken: 'service-secret', secureCookies: false }); + + const response = await app.request('/api/v1/recommendations', { + headers: { authorization: 'Bearer service-secret' }, + }); + + expect(response.status).toBe(401); + }); + + test('a wrong token is refused', async () => { + const seeded = await seedDatabase('auth-service-wrong'); + active = seeded; + const app = createApp({ db: seeded.db, serviceToken: 'service-secret', secureCookies: false }); + + const response = await app.request('/api/v1/recommendations', { + headers: { + authorization: 'Bearer not-the-secret', + 'x-workspace-id': 'wsp_test', + 'x-organization-id': 'org_test', + }, + }); + + expect(response.status).toBe(401); + }); + + test('no service token configured means the bearer path is closed', async () => { + const seeded = await seedDatabase('auth-no-service'); + active = seeded; + const app = createApp({ db: seeded.db, secureCookies: false }); + + const response = await app.request('/api/v1/recommendations', { + headers: { + authorization: 'Bearer anything', + 'x-workspace-id': 'wsp_test', + 'x-organization-id': 'org_test', + }, + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..f53da79 --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,388 @@ +/** + * Authentication (PRD §34, §38 "Account"). + * + * Sessions, not a shared secret. Design points that matter: + * + * - Passwords are hashed with argon2id via `Bun.password`, which picks and + * stores its own parameters, so there is no hand-rolled crypto here. + * - The session cookie value is random and never stored. The database keeps + * only its SHA-256 hash, so a database leak yields no usable sessions. + * - Login failures are counted and the account locks temporarily, which is + * what makes online brute force impractical. + * - The response is deliberately identical for "no such user" and "wrong + * password", so the endpoint cannot be used to enumerate accounts. + */ + +import { newId } from '@outreachgraph/domain'; +import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import type { RequestActor } from './context'; +import { ApiError } from './context'; + +export const SESSION_COOKIE = 'og_session'; + +/** Long enough to be convenient, short enough to bound a stolen cookie. */ +const SESSION_TTL_DAYS = 30; +const MAX_FAILED_LOGINS = 8; +const LOCKOUT_MINUTES = 15; + +export interface UserRow { + id: string; + email: string; + name: string | null; + password_hash: string | null; + status: string; + failed_login_count: number; + locked_until: string | null; +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** Cheap structural check; real validation is the delivery attempt. */ +export function isPlausibleEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 320; +} + +/** + * Password floor. Length does more work than composition rules, so this asks + * for length rather than punctuation the user will write on a sticky note. + */ +export function passwordProblem(password: string): string | undefined { + if (password.length < 12) return 'password must be at least 12 characters'; + if (password.length > 512) return 'password must be at most 512 characters'; + return undefined; +} + +export async function hashPassword(password: string): Promise { + return Bun.password.hash(password, { algorithm: 'argon2id' }); +} + +export async function verifyPassword(password: string, hash: string): Promise { + try { + return await Bun.password.verify(password, hash); + } catch { + // A malformed stored hash must read as "wrong password", not crash a login. + return false; + } +} + +export async function hashToken(token: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export function mintSessionToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export interface RegisterInput { + readonly email: string; + readonly password: string; + readonly name?: string; + readonly organizationName?: string; +} + +export interface RegisterResult { + readonly userId: string; + readonly organizationId: string; + readonly workspaceId: string; +} + +/** + * Creates a user with their own organization and first workspace. + * + * A user with no workspace cannot do anything in this product, so registration + * provisions one rather than leaving a half-created account. + */ +export async function registerUser(db: Client, input: RegisterInput): Promise { + const email = normalizeEmail(input.email); + + if (!isPlausibleEmail(email)) throw ApiError.badRequest('enter a valid email address'); + const problem = passwordProblem(input.password); + if (problem) throw ApiError.badRequest(problem); + + const existing = await queryOne<{ id: string }>(db, 'SELECT id FROM users WHERE email = ?', [ + email, + ]); + if (existing) throw new ApiError(409, 'email_taken', 'that email is already registered'); + + const userId = newId('user'); + const organizationId = newId('organization'); + const workspaceId = newId('workspace'); + const stamp = now(); + const passwordHash = await hashPassword(input.password); + + const orgName = input.organizationName?.trim() || `${input.name?.trim() || email} workspace`; + const slug = `${slugify(orgName)}-${userId.slice(-6)}`; + + await db.batch([ + { + sql: `INSERT INTO users (id, email, name, password_hash, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', ?, ?)`, + args: [userId, email, input.name?.trim() ?? null, passwordHash, stamp, stamp], + }, + { + sql: `INSERT INTO organizations (id, name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + args: [organizationId, orgName, slug, stamp, stamp], + }, + { + sql: `INSERT INTO organization_members (organization_id, user_id, role, created_at) + VALUES (?, ?, 'owner', ?)`, + args: [organizationId, userId, stamp], + }, + { + sql: `INSERT INTO workspaces (id, organization_id, name, slug, created_at, updated_at) + VALUES (?, ?, ?, 'default', ?, ?)`, + args: [workspaceId, organizationId, orgName, stamp, stamp], + }, + ]); + + return { userId, organizationId, workspaceId }; +} + +export interface LoginResult { + readonly token: string; + readonly expiresAt: string; + readonly actor: RequestActor; +} + +export async function login( + db: Client, + email: string, + password: string, + userAgent?: string, +): Promise { + const normalized = normalizeEmail(email); + const user = await queryOne(db, 'SELECT * FROM users WHERE email = ?', [normalized]); + + // Identical failure for unknown user and wrong password. + const invalid = new ApiError(401, 'invalid_credentials', 'email or password is incorrect'); + + if (!user || !user.password_hash) throw invalid; + if (user.status !== 'active') throw ApiError.forbidden('this account is not active'); + + if (user.locked_until && Date.parse(user.locked_until) > Date.now()) { + throw new ApiError( + 429, + 'account_locked', + 'too many failed attempts; try again in a few minutes', + ); + } + + if (!(await verifyPassword(password, user.password_hash))) { + const failures = user.failed_login_count + 1; + const lockedUntil = + failures >= MAX_FAILED_LOGINS + ? new Date(Date.now() + LOCKOUT_MINUTES * 60_000).toISOString() + : null; + + await db.execute({ + sql: 'UPDATE users SET failed_login_count = ?, locked_until = ? WHERE id = ?', + args: [failures, lockedUntil, user.id], + }); + throw invalid; + } + + const membership = await primaryMembership(db, user.id); + if (!membership) throw ApiError.forbidden('this account has no workspace'); + + const token = mintSessionToken(); + const stamp = now(); + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86_400_000).toISOString(); + + await db.batch([ + { + sql: `INSERT INTO sessions (id, user_id, token_hash, workspace_id, user_agent, + created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + newId('session'), + user.id, + await hashToken(token), + membership.workspaceId, + userAgent?.slice(0, 200) ?? null, + stamp, + stamp, + expiresAt, + ], + }, + { + sql: 'UPDATE users SET failed_login_count = 0, locked_until = NULL, last_login_at = ? WHERE id = ?', + args: [stamp, user.id], + }, + ]); + + return { + token, + expiresAt, + actor: { + userId: user.id, + workspaceId: membership.workspaceId, + organizationId: membership.organizationId, + role: membership.role, + }, + }; +} + +export async function logout(db: Client, token: string): Promise { + await db.execute({ + sql: 'DELETE FROM sessions WHERE token_hash = ?', + args: [await hashToken(token)], + }); +} + +/** + * Resolves a session cookie to an actor, or undefined. + * + * An expired session is deleted on sight rather than merely rejected, so the + * table does not accumulate dead rows between sweeps. + */ +export async function actorFromSession( + db: Client, + token: string, +): Promise { + const tokenHash = await hashToken(token); + + const session = await queryOne<{ + id: string; + user_id: string; + workspace_id: string | null; + expires_at: string; + }>(db, 'SELECT id, user_id, workspace_id, expires_at FROM sessions WHERE token_hash = ?', [ + tokenHash, + ]); + + if (!session) return undefined; + + if (Date.parse(session.expires_at) <= Date.now()) { + await db.execute({ sql: 'DELETE FROM sessions WHERE id = ?', args: [session.id] }); + return undefined; + } + + const membership = session.workspace_id + ? await membershipForWorkspace(db, session.user_id, session.workspace_id) + : await primaryMembership(db, session.user_id); + + // Membership can be revoked while a session is still valid. + if (!membership) return undefined; + + await db.execute({ + sql: 'UPDATE sessions SET last_seen_at = ? WHERE id = ?', + args: [now(), session.id], + }); + + return { + userId: session.user_id, + workspaceId: membership.workspaceId, + organizationId: membership.organizationId, + role: membership.role, + }; +} + +interface Membership { + readonly organizationId: string; + readonly workspaceId: string; + readonly role: RequestActor['role']; +} + +async function primaryMembership(db: Client, userId: string): Promise { + const row = await queryOne<{ organization_id: string; role: string; workspace_id: string }>( + db, + `SELECT m.organization_id, m.role, w.id AS workspace_id + FROM organization_members m + JOIN workspaces w ON w.organization_id = m.organization_id + WHERE m.user_id = ? AND w.status = 'active' + ORDER BY w.created_at LIMIT 1`, + [userId], + ); + + if (!row) return undefined; + return { + organizationId: row.organization_id, + workspaceId: row.workspace_id, + role: asRole(row.role), + }; +} + +/** Confirms the user may act in this specific workspace. */ +async function membershipForWorkspace( + db: Client, + userId: string, + workspaceId: string, +): Promise { + const row = await queryOne<{ organization_id: string; role: string }>( + db, + `SELECT m.organization_id, m.role + FROM organization_members m + JOIN workspaces w ON w.organization_id = m.organization_id + WHERE m.user_id = ? AND w.id = ?`, + [userId, workspaceId], + ); + + if (!row) return undefined; + return { organizationId: row.organization_id, workspaceId, role: asRole(row.role) }; +} + +export async function workspacesForUser(db: Client, userId: string) { + return queryAll<{ id: string; name: string; organization_id: string; role: string }>( + db, + `SELECT w.id, w.name, w.organization_id, m.role + FROM organization_members m + JOIN workspaces w ON w.organization_id = m.organization_id + WHERE m.user_id = ? ORDER BY w.created_at`, + [userId], + ); +} + +/** Removes expired sessions. Called by the background loop. */ +export async function pruneSessions(db: Client): Promise { + const result = await db.execute({ + sql: 'DELETE FROM sessions WHERE expires_at <= ?', + args: [now()], + }); + return Number(result.rowsAffected ?? 0); +} + +function asRole(role: string): RequestActor['role'] { + return role === 'owner' || role === 'admin' || role === 'viewer' ? role : 'member'; +} + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) || 'workspace' + ); +} + +/** Cookie attributes. `secure` is dropped only for plain-HTTP local dev. */ +export function sessionCookie(token: string, expiresAt: string, secure: boolean): string { + const parts = [ + `${SESSION_COOKIE}=${token}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Expires=${new Date(expiresAt).toUTCString()}`, + ]; + if (secure) parts.push('Secure'); + return parts.join('; '); +} + +export function clearedCookie(secure: boolean): string { + const parts = [`${SESSION_COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; + if (secure) parts.push('Secure'); + return parts.join('; '); +} + +export function readCookie(header: string | null, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(';')) { + const [key, ...rest] = part.trim().split('='); + if (key === name) return rest.join('='); + } + return undefined; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index 5261702..0000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bun -/** - * API server entry point. - * - * Railway sends SIGTERM on deploy; the handler below stops accepting - * connections and closes the database before exiting, so an in-flight request - * is not cut off mid-transaction (PRD §1.1 Docker requirements). - */ - -import { closeDatabase, getDatabase } from '@outreachgraph/db'; -import { createApp } from './app'; -import type { RequestActor } from './context'; - -const port = Number(process.env.PORT ?? 8080); -const db = getDatabase(); - -/** - * Placeholder authentication. - * - * V1 ships session auth; until then the API refuses to start outside - * development unless a token is configured, so an unauthenticated deploy is - * impossible rather than merely discouraged. - */ -const apiToken = process.env.API_TOKEN; -const environment = process.env.NODE_ENV ?? 'development'; - -if (!apiToken && environment === 'production') { - console.error('API_TOKEN must be set in production'); - process.exit(1); -} - -async function authenticate(request: Request): Promise { - const header = request.headers.get('authorization'); - if (!header?.startsWith('Bearer ')) return undefined; - - const token = header.slice('Bearer '.length); - if (!apiToken || token !== apiToken) return undefined; - - const workspaceId = request.headers.get('x-workspace-id'); - const organizationId = request.headers.get('x-organization-id'); - if (!workspaceId || !organizationId) return undefined; - - return { - userId: request.headers.get('x-user-id') ?? 'usr_service', - workspaceId, - organizationId, - role: 'owner', - }; -} - -const app = createApp({ - db, - authenticate, - version: process.env.APP_VERSION ?? '0.1.0', - ...(process.env.COMMIT_HASH ? { commitHash: process.env.COMMIT_HASH } : {}), -}); - -const server = Bun.serve({ port, fetch: app.fetch }); - -console.log(`api listening on :${port} (${environment})`); - -async function shutdown(signal: string): Promise { - console.log(`${signal} received, draining`); - await server.stop(false); - await closeDatabase(); - process.exit(0); -} - -process.on('SIGTERM', () => void shutdown('SIGTERM')); -process.on('SIGINT', () => void shutdown('SIGINT')); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..d2ba3fd --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@outreachgraph/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun src/index.ts", + "dev": "bun --watch src/index.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@outreachgraph/contracts": "workspace:*", + "@outreachgraph/db": "workspace:*", + "@outreachgraph/domain": "workspace:*", + "@outreachgraph/policy": "workspace:*", + "@outreachgraph/providers": "workspace:*", + "@outreachgraph/recommend": "workspace:*", + "@outreachgraph/scoring": "workspace:*", + "@outreachgraph/signals": "workspace:*", + "hono": "^4.6.14", + "zod": "^3.24.1" + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..2868af0 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env bun +/** + * The single OutreachGraph process. + * + * One container, one public port, three responsibilities: + * + * /api/v1/*, /health/* → the Hono API, in-process + * everything else → the Next.js PWA, a child process on loopback + * background loop → signal expiry, deletion jobs, session pruning + * + * Next ships its own server and there is no supported way to mount it inside + * another Bun server, so it runs as a child on 127.0.0.1 and this process + * proxies to it. The child is never exposed; only this listener binds a + * public port. + * + * The worker loop runs here rather than as a separate service. It is a + * single-instance loop by design — two copies would both claim the same + * pending deletion job — so scaling this container horizontally would need + * the loop split back out behind a lock. + */ + +import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db'; +import { createApp } from '../../api/src/app'; +import { pruneSessions } from '../../api/src/auth'; +import { expireSignals, processDeletion } from '../../worker/src/jobs'; + +const PORT = Number(process.env.PORT ?? 8080); +const WEB_PORT = Number(process.env.WEB_PORT ?? 3001); +const TICK_MS = Number(process.env.WORKER_TICK_MS ?? 60_000); +const ENVIRONMENT = process.env.NODE_ENV ?? 'development'; + +const db = getDatabase(); + +// --------------------------------------------------------------- migrations +/** + * Migrations run at boot, before anything serves. + * + * This is safe here specifically because the deployment is one container + * pinned to one replica — the concern with boot-time migrations is several + * replicas racing, which cannot happen while `numReplicas` is 1. It also + * removes a manual release step that is easy to forget, and forgetting it + * means the app answers requests against a schema that is not there. + * + * Set RUN_MIGRATIONS=false to take over that responsibility elsewhere. + */ +if (process.env.RUN_MIGRATIONS !== 'false') { + const migrationsDir = `${import.meta.dir}/../../../migrations`; + try { + const result = await migrate(db, migrationsDir); + if (result.applied.length > 0) { + console.log(`applied ${result.applied.length} migration(s): ${result.applied.join(', ')}`); + } else { + console.log(`schema up to date (${result.skipped.length} migrations)`); + } + } catch (error) { + // Serving against an unmigrated schema produces confusing 500s on every + // route; failing to start is the honest outcome. + console.error('migrations failed; refusing to start', error); + process.exit(1); + } +} + +// ---------------------------------------------------------------------- api +const api = createApp({ + db, + ...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}), + // Cookies must not be Secure over plain HTTP, or local development can + // never hold a session. + secureCookies: ENVIRONMENT === 'production', + version: process.env.APP_VERSION ?? '0.1.0', + ...(process.env.COMMIT_HASH ? { commitHash: process.env.COMMIT_HASH } : {}), +}); + +// ---------------------------------------------------------------------- web +// `next start` is skipped in development: `bun run dev` serves the PWA itself. +const webEntry = `${import.meta.dir}/../../web/server.js`; +const hasBuiltWeb = await Bun.file(webEntry).exists(); + +let web: Bun.Subprocess | undefined; + +if (hasBuiltWeb) { + web = Bun.spawn(['bun', webEntry], { + env: { ...process.env, PORT: String(WEB_PORT), HOSTNAME: '127.0.0.1' }, + stdout: 'inherit', + stderr: 'inherit', + onExit(_proc, code) { + // The PWA dying is not recoverable from here, and a half-serving + // container is worse than one Railway will restart. + console.error(`web child exited with code ${code}; shutting down`); + void shutdown('web-exit'); + }, + }); + console.log(`web child started on 127.0.0.1:${WEB_PORT}`); +} else { + console.warn(`no built PWA at ${webEntry}; serving API only`); +} + +/** Forwards a request to the Next child, preserving method, headers and body. */ +async function proxyToWeb(request: Request): Promise { + if (!web) { + return new Response('the web application is not built in this image', { status: 503 }); + } + + const incoming = new URL(request.url); + const target = new URL(incoming.pathname + incoming.search, `http://127.0.0.1:${WEB_PORT}`); + + try { + return await fetch(target, { + method: request.method, + headers: request.headers, + body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body, + redirect: 'manual', + // Streaming bodies require this; without it Bun buffers and rejects. + ...(request.body ? { duplex: 'half' } : {}), + } as RequestInit); + } catch (error) { + console.error('web proxy failed', error); + return new Response('the web application is not responding', { status: 502 }); + } +} + +// ------------------------------------------------------------------- server +const server = Bun.serve({ + port: PORT, + idleTimeout: 60, + async fetch(request) { + const { pathname } = new URL(request.url); + + if (pathname.startsWith('/api/') || pathname.startsWith('/health')) { + return api.fetch(request); + } + return proxyToWeb(request); + }, +}); + +console.log(`outreachgraph listening on :${PORT} (${ENVIRONMENT})`); + +// ------------------------------------------------------------------- daemon +let running = true; +let inFlight: Promise = Promise.resolve(); + +async function tick(): Promise { + const workspaces = await queryAll<{ id: string }>( + db, + `SELECT id FROM workspaces WHERE status = 'active'`, + ); + + for (const workspace of workspaces) { + const expired = await expireSignals(db, workspace.id); + const count = Number(expired.detail.expired ?? 0); + if (count > 0) console.log(`expired ${count} signals in ${workspace.id}`); + } + + const pending = await queryAll<{ id: string }>( + db, + `SELECT id FROM deletion_jobs WHERE status = 'pending' ORDER BY created_at LIMIT 50`, + ); + + for (const job of pending) { + const result = await processDeletion(db, job.id); + console.log(`deletion job ${job.id}: ${result.ok ? 'completed' : 'failed'}`); + } + + const pruned = await pruneSessions(db); + if (pruned > 0) console.log(`pruned ${pruned} expired sessions`); +} + +async function loop(): Promise { + while (running) { + inFlight = tick().catch((error: unknown) => { + // A bad tick must not kill the container; Railway would restart it into + // the same failure. + console.error('worker tick failed', error); + }); + await inFlight; + if (!running) break; + await Bun.sleep(TICK_MS); + } +} + +void loop(); + +// ----------------------------------------------------------------- shutdown +let shuttingDown = false; + +async function shutdown(signal: string): Promise { + if (shuttingDown) return; + shuttingDown = true; + + console.log(`${signal} received, draining`); + running = false; + + // Finish the current tick so a half-processed deletion is not left pending + // with rows already removed. + await inFlight; + + await server.stop(false); + web?.kill(); + await closeDatabase(); + process.exit(0); +} + +process.on('SIGTERM', () => void shutdown('SIGTERM')); +process.on('SIGINT', () => void shutdown('SIGINT')); diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/approvals/page.tsx index fa578e6..2e3847c 100644 --- a/apps/web/app/approvals/page.tsx +++ b/apps/web/app/approvals/page.tsx @@ -1,5 +1,6 @@ +import { redirect } from 'next/navigation'; import { ApprovalCard } from '../../components/approval-card'; -import { ApiAuthError, ApiUnavailableError, fetchApprovals } from '../../lib/api'; +import { ApiUnavailableError, NotAuthenticatedError, fetchApprovals } from '../../lib/api'; export const dynamic = 'force-dynamic'; @@ -11,10 +12,10 @@ export default async function ApprovalsPage() { try { cards = await fetchApprovals(); } catch (error) { + if (error instanceof NotAuthenticatedError) redirect('/login'); // A missing API in local development should show what to do, not a stack // trace — the PWA is often run before the API is up. if (error instanceof ApiUnavailableError) return ; - if (error instanceof ApiAuthError) return ; throw error; } @@ -51,27 +52,10 @@ function EmptyState() { ); } -function NotAuthorized() { - return ( -
-

The API rejected these credentials.

-

- Check API_TOKEN,{' '} - WORKSPACE_ID and{' '} - ORGANIZATION_ID on the web service. -

-
- ); -} - function ApiDown() { return (

The API is not reachable.

-

- Start it with{' '} - bun run --filter '@outreachgraph/api' dev -

); } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..2aae927 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,18 @@ +import { LoginForm } from '../../components/login-form'; + +export const metadata = { title: 'Sign in · OutreachGraph' }; + +export default function LoginPage() { + return ( +
+
+

OutreachGraph

+

+ Turn public intent signals into warm conversations. +

+
+ + +
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index d3d1073..71e2653 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,7 +1,8 @@ import Link from 'next/link'; +import { redirect } from 'next/navigation'; import { - ApiAuthError, ApiUnavailableError, + NotAuthenticatedError, fetchApprovals, fetchSignals, relativeTime, @@ -18,32 +19,15 @@ export default async function TodayPage() { let approvals: Awaited> = []; let signals: Awaited> = []; let offline = false; - let unauthorized = false; try { [approvals, signals] = await Promise.all([fetchApprovals(), fetchSignals()]); } catch (error) { - // A misconfigured deployment should explain itself, not return a 500. + if (error instanceof NotAuthenticatedError) redirect('/login'); if (error instanceof ApiUnavailableError) offline = true; - else if (error instanceof ApiAuthError) unauthorized = true; else throw error; } - if (unauthorized) { - return ( -
-

Not authorized

-

- The API rejected this deployment's credentials. Check that{' '} - API_TOKEN,{' '} - WORKSPACE_ID and{' '} - ORGANIZATION_ID are set on the web service and match the - API. -

-
- ); - } - const top = approvals[0]; return ( @@ -93,6 +77,12 @@ export default async function TodayPage() { ) : null} + + {!offline && approvals.length === 0 ? ( +

+ Nothing waiting. Recommendations appear as fresh signals arrive. +

+ ) : null} ); } diff --git a/apps/web/app/signals/page.tsx b/apps/web/app/signals/page.tsx index 219255e..d8f5c08 100644 --- a/apps/web/app/signals/page.tsx +++ b/apps/web/app/signals/page.tsx @@ -1,4 +1,10 @@ -import { ApiAuthError, ApiUnavailableError, fetchSignals, relativeTime } from '../../lib/api'; +import { redirect } from 'next/navigation'; +import { + ApiUnavailableError, + NotAuthenticatedError, + fetchSignals, + relativeTime, +} from '../../lib/api'; export const dynamic = 'force-dynamic'; @@ -11,16 +17,10 @@ export default async function SignalsPage() { try { signals = await fetchSignals(); } catch (error) { + if (error instanceof NotAuthenticatedError) redirect('/login'); if (error instanceof ApiUnavailableError) { return

The API is not reachable.

; } - if (error instanceof ApiAuthError) { - return ( -

- The API rejected this deployment's credentials. -

- ); - } throw error; } diff --git a/apps/web/components/approval-card.tsx b/apps/web/components/approval-card.tsx index fbf0b4f..61f0006 100644 --- a/apps/web/components/approval-card.tsx +++ b/apps/web/components/approval-card.tsx @@ -1,4 +1,11 @@ -import { relativeTime, type ApprovalCard as Card } from '../lib/api'; +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +// Imported from the server-safe modules, not lib/api: that pulls in +// next/headers and cannot be bundled for the browser. +import { relativeTime } from '../lib/format'; +import type { ApprovalCard as Card } from '../lib/types'; /** * The approval card (PRD §15). @@ -6,8 +13,44 @@ import { relativeTime, type ApprovalCard as Card } from '../lib/api'; * Order is deliberate: who, then why now, then what we propose, then the * words — so the reviewer forms a judgement about the evidence before reading * a draft that might read persuasively regardless. + * + * A denied approval is not a failure to hide: the API answers 409 naming the + * policy gate that stopped it, and that reason is shown verbatim. Silently + * dropping it would leave the user thinking they had sent something. */ export function ApprovalCard({ card }: { card: Card }) { + const router = useRouter(); + const [busy, setBusy] = useState(); + const [error, setError] = useState(); + const [body, setBody] = useState(card.draft_body ?? ''); + const [editing, setEditing] = useState(false); + + async function act(action: 'approve' | 'skip', payload?: Record) { + setBusy(action); + setError(undefined); + + try { + const response = await fetch(`/api/v1/recommendations/${card.id}/${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(payload ?? {}), + }); + + if (!response.ok) { + const failure = await response.json().catch(() => ({})); + setError(failure?.error?.message ?? `that failed (${response.status})`); + return; + } + + router.refresh(); + } catch { + setError('could not reach the server'); + } finally { + setBusy(undefined); + } + } + return (
@@ -70,25 +113,88 @@ export function ApprovalCard({ card }: { card: Card }) {

Draft

-

- {card.draft_body} -

+ {editing ? ( +