From c89affe7e123074866636c1f091a73c005378fad Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Aug 2026 13:52:33 +0000 Subject: [PATCH 1/2] feat: one container, and real authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes you asked for. SINGLE CONTAINER Deletes the three-service split. apps/server is now the only entrypoint: one Bun process that serves the Hono API in-process, supervises the Next PWA as a loopback child and proxies to it, and runs the background loop. One Dockerfile, one railway.json, one deploy, one log stream. Next ships its own server with no supported way to mount it inside another Bun server, hence the child process. Only the supervisor binds a public port. The worker loop is single-instance by design — two copies would both claim the same pending deletion job — so railway.json pins numReplicas to 1 and says why. AUTHENTICATION Replaces the shared bearer token as the human path. - argon2id password hashing via Bun.password - sessions stored as a SHA-256 hash of the cookie, never the value, so a database leak yields no usable sessions - httpOnly + SameSite=Lax cookies, Secure in production - identical response for unknown user and wrong password, so the endpoint cannot enumerate accounts - lockout after repeated failures - workspace scoping derived from membership, not from client headers - registration provisions an organization and first workspace, because a user without a workspace cannot do anything here The service token survives for internal machine callers only, and now needs explicit scope headers and a constant-time comparison. Also wires the approval buttons, which were decorative: approve, edit, skip and do-not-contact now call the API, and a policy denial shows the gate that blocked it rather than failing silently. 289 tests, typecheck clean, PWA builds. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 123 ++++---- apps/api/src/app.ts | 138 ++++++++- apps/api/src/auth.test.ts | 326 ++++++++++++++++++++++ apps/api/src/auth.ts | 388 ++++++++++++++++++++++++++ apps/api/src/index.ts | 70 ----- apps/server/package.json | 24 ++ apps/server/src/index.ts | 175 ++++++++++++ apps/web/app/approvals/page.tsx | 22 +- apps/web/app/login/page.tsx | 18 ++ apps/web/app/page.tsx | 28 +- apps/web/app/signals/page.tsx | 16 +- apps/web/components/approval-card.tsx | 128 ++++++++- apps/web/components/login-form.tsx | 106 +++++++ apps/web/lib/api.ts | 127 +++------ apps/web/lib/format.ts | 28 ++ apps/web/lib/types.ts | 43 +++ apps/web/railway.json | 23 -- apps/worker/railway.json | 23 -- apps/worker/src/index.ts | 87 ------ bun.lock | 18 ++ docker/Dockerfile | 60 ++++ docker/api.Dockerfile | 51 ---- docker/web.Dockerfile | 50 ---- docker/worker.Dockerfile | 38 --- migrations/0005_auth_sessions.sql | 29 ++ package.json | 6 +- packages/contracts/src/index.ts | 16 ++ packages/domain/src/ids.ts | 1 + railway.json | 17 +- 29 files changed, 1608 insertions(+), 571 deletions(-) create mode 100644 apps/api/src/auth.test.ts create mode 100644 apps/api/src/auth.ts delete mode 100644 apps/api/src/index.ts create mode 100644 apps/server/package.json create mode 100644 apps/server/src/index.ts create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/components/login-form.tsx create mode 100644 apps/web/lib/format.ts create mode 100644 apps/web/lib/types.ts delete mode 100644 apps/web/railway.json delete mode 100644 apps/worker/railway.json delete mode 100644 apps/worker/src/index.ts create mode 100644 docker/Dockerfile delete mode 100644 docker/api.Dockerfile delete mode 100644 docker/web.Dockerfile delete mode 100644 docker/worker.Dockerfile create mode 100644 migrations/0005_auth_sessions.sql 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/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..fbb464f --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,175 @@ +#!/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, 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(); + +// ---------------------------------------------------------------------- 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 ? ( +