Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 61 additions & 62 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
138 changes: 132 additions & 6 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,42 @@ 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';

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<RequestActor | undefined>;
/**
* Machine credential for internal callers (the worker, scripts). Humans
* authenticate with a session; this is not a login.
*/
readonly authenticate: (request: Request) => Promise<RequestActor | undefined>;
readonly serviceToken?: string;
/** Set false for plain-HTTP local development so the cookie still sets. */
readonly secureCookies?: boolean;
readonly version?: string;
readonly commitHash?: string;
}
Expand Down Expand Up @@ -70,11 +90,103 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
}
});

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<RequestActor | undefined> => {
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<AppEnv>();

// 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<AppEnv>();

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);
Expand Down Expand Up @@ -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 };
Loading
Loading