Files
solelog/docs/plans/phase-0-foundation.md
Bas van Rossem ac2f9c669b docs: add project roadmap and Phase 0 (Foundation) implementation plan
Tracked planning docs under docs/ (the project's documentation source of
truth per CLAUDE.local.md):
- docs/roadmap.md      — vision, decisions, architecture, 6-phase roadmap
- docs/plans/phase-0-foundation.md — TDD plan to stand up the dockerized
  Hono + better-auth + Drizzle + libsql backend with an auth round-trip
2026-06-17 13:09:12 +02:00

959 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 0 — Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up a dockerized, dedicated backend service (Hono + better-auth + Drizzle + SQLite/libsql) where a client can sign up, sign in, and use the returned token to call a protected endpoint — proven end-to-end by automated tests and `docker compose up`.
**Architecture:** A new greenfield monorepo structure is added alongside the inherited code (which stays untouched as reference). A single backend service (`apps/api`) owns auth and all DB access; a shared package (`packages/shared`) holds the TypeScript/zod API contracts. The database is a local SQLite file accessed through libsql (no native compilation — Windows-friendly and identical in Docker).
**Tech Stack:** Node 22, TypeScript (ESM), Hono, better-auth, Drizzle ORM + drizzle-kit, `@libsql/client`, zod, Vitest, tsx, Docker.
## Global Constraints
- **Package manager:** Yarn 4 (Berry), already configured at the repo root. Run via corepack.
- **Do NOT touch** the inherited `apps/mobile` or `apps/web` in this phase — they are reference only.
- **Package names:** scoped `@solelog/*` (project is "SoleLog"). This phase adds `@solelog/api` and `@solelog/shared`.
- **Module system:** ESM everywhere (`"type": "module"`), `moduleResolution: "Bundler"` or `"NodeNext"` in tsconfig.
- **DB driver:** libsql (`@libsql/client`) only — never `better-sqlite3` (avoids native build on Windows).
- **Dependency majors:** `hono@^4`, `@hono/node-server@^1`, `better-auth@^1`, `drizzle-orm@^0.36`, `drizzle-kit@^0.30`, `@libsql/client@^0.14`, `zod@^3`, `vitest@^3`, `tsx@^4`. Install the latest within each major.
- **API port:** `3000` (the inherited web app used 4000 — avoid the clash).
- **Env vars (apps/api):** `DATABASE_URL` (e.g. `file:./data/app.db`), `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` (e.g. `http://localhost:3000`), `PORT`.
- **Commit style:** Conventional Commits (`feat:`, `chore:`, `test:`, `docs:`), one commit per task step where indicated.
---
## File Structure
```
apps/api/
package.json
tsconfig.json
drizzle.config.ts
.env.example
src/
index.ts entry — starts the HTTP server
app.ts builds the Hono app (exported for tests)
env.ts reads + validates env vars
db/
client.ts libsql + drizzle client (singleton)
schema.ts better-auth tables (Drizzle)
migrate.ts applies drizzle migrations
auth.ts better-auth config
routes/
health.ts GET /health (public)
me.ts GET /api/me (protected)
drizzle/ generated migration SQL (created by drizzle-kit)
test/
setup.ts vitest global setup: temp DB + migrate
health.test.ts
auth.test.ts
vitest.config.ts
Dockerfile
packages/shared/
package.json
tsconfig.json
src/index.ts zod schemas + types (API contracts)
docker-compose.yml (repo root)
```
---
## Task 1: Monorepo wiring + shared contracts package
**Files:**
- Modify: `package.json` (root) — add `packages/*` to `workspaces`
- Create: `packages/shared/package.json`
- Create: `packages/shared/tsconfig.json`
- Create: `packages/shared/src/index.ts`
**Interfaces:**
- Produces: `@solelog/shared` exporting `HealthResponse` (zod schema + type) and `MeResponse` (zod schema + type), consumed by `apps/api`.
- [ ] **Step 1: Add `packages/*` to root workspaces**
Edit root `package.json` so the `workspaces` array is:
```json
"workspaces": [
"apps/*",
"packages/*"
]
```
- [ ] **Step 2: Create the shared package manifest**
`packages/shared/package.json`:
```json
{
"name": "@solelog/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"zod": "^3.23.8"
}
}
```
- [ ] **Step 3: Create the shared tsconfig**
`packages/shared/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}
```
- [ ] **Step 4: Write the shared contracts**
`packages/shared/src/index.ts`:
```ts
import { z } from 'zod';
export const HealthResponse = z.object({
status: z.literal('ok'),
});
export type HealthResponse = z.infer<typeof HealthResponse>;
export const PublicUser = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
});
export type PublicUser = z.infer<typeof PublicUser>;
export const MeResponse = z.object({
user: PublicUser,
});
export type MeResponse = z.infer<typeof MeResponse>;
```
- [ ] **Step 5: Install and verify the workspace resolves**
Run: `yarn install`
Expected: completes; `yarn workspaces list` includes `@solelog/shared`.
Run: `yarn workspaces list`
Expected output includes a line for `packages/shared`.
- [ ] **Step 6: Commit**
```bash
git add package.json yarn.lock packages/shared
git commit -m "feat(shared): add @solelog/shared contracts package and wire packages/* workspace"
```
---
## Task 2: Hono backend skeleton + health endpoint
**Files:**
- Create: `apps/api/package.json`
- Create: `apps/api/tsconfig.json`
- Create: `apps/api/src/env.ts`
- Create: `apps/api/src/app.ts`
- Create: `apps/api/src/routes/health.ts`
- Create: `apps/api/src/index.ts`
- Create: `apps/api/vitest.config.ts`
- Test: `apps/api/test/health.test.ts`
**Interfaces:**
- Consumes: `@solelog/shared` (`HealthResponse`).
- Produces: `createApp(): Hono` from `apps/api/src/app.ts` (used by every later task and all tests); `env` object from `apps/api/src/env.ts`.
- [ ] **Step 1: Create the api package manifest**
`apps/api/package.json`:
```json
{
"name": "@solelog/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@libsql/client": "^0.14.0",
"@solelog/shared": "workspace:*",
"better-auth": "^1.1.7",
"drizzle-orm": "^0.36.4",
"hono": "^4.6.14",
"zod": "^3.23.8"
},
"devDependencies": {
"drizzle-kit": "^0.30.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^3.0.0"
}
}
```
- [ ] **Step 2: Install dependencies**
Run: `yarn install`
Expected: resolves and installs the new api dependencies without errors.
- [ ] **Step 3: Create the api tsconfig**
`apps/api/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"types": ["node"],
"noEmit": true
},
"include": ["src", "test"]
}
```
- [ ] **Step 4: Create the env reader**
`apps/api/src/env.ts`:
```ts
export const env = {
DATABASE_URL: process.env.DATABASE_URL ?? 'file:./data/app.db',
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? 'dev-insecure-secret-change-me',
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
PORT: Number(process.env.PORT ?? 3000),
};
```
- [ ] **Step 5: Create the health route**
`apps/api/src/routes/health.ts`:
```ts
import { Hono } from 'hono';
import { HealthResponse } from '@solelog/shared';
export const health = new Hono();
health.get('/health', (c) => {
const body: HealthResponse = { status: 'ok' };
return c.json(body);
});
```
- [ ] **Step 6: Create the app factory**
`apps/api/src/app.ts`:
```ts
import { Hono } from 'hono';
import { health } from './routes/health';
export function createApp(): Hono {
const app = new Hono();
app.route('/', health);
return app;
}
```
- [ ] **Step 7: Create the server entry**
`apps/api/src/index.ts`:
```ts
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { env } from './env';
const app = createApp();
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
console.log(`API listening on http://localhost:${info.port}`);
});
```
- [ ] **Step 8: Create the vitest config**
`apps/api/vitest.config.ts`:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./test/setup.ts'],
fileParallelism: false,
},
});
```
- [ ] **Step 9: Create a no-op setup (real DB setup arrives in Task 3)**
`apps/api/test/setup.ts`:
```ts
// Placeholder until Task 3 adds DB migration to the test setup.
export {};
```
- [ ] **Step 10: Write the failing health test**
`apps/api/test/health.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app';
describe('GET /health', () => {
it('returns { status: "ok" }', async () => {
const app = createApp();
const res = await app.request('/health');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: 'ok' });
});
});
```
- [ ] **Step 11: Run the test to verify it passes**
Run: `yarn workspace @solelog/api test`
Expected: PASS (1 test). If `createApp` or `health` is missing, fix the files above.
- [ ] **Step 12: Commit**
```bash
git add apps/api yarn.lock
git commit -m "feat(api): Hono backend skeleton with /health endpoint and test"
```
---
## Task 3: Database — Drizzle + libsql + better-auth schema + migrations
**Files:**
- Create: `apps/api/src/db/schema.ts`
- Create: `apps/api/src/db/client.ts`
- Create: `apps/api/drizzle.config.ts`
- Create: `apps/api/src/db/migrate.ts`
- Modify: `apps/api/test/setup.ts`
- Test: `apps/api/test/db.test.ts`
**Interfaces:**
- Produces: `db` (drizzle client) from `apps/api/src/db/client.ts`; the better-auth tables `user`, `session`, `account`, `verification` from `apps/api/src/db/schema.ts`; `runMigrations()` from `apps/api/src/db/migrate.ts` (consumed by the test setup and the Docker start).
- [ ] **Step 1: Define the better-auth Drizzle schema**
`apps/api/src/db/schema.ts` (this is better-auth's standard core schema for SQLite):
```ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const session = sqliteTable('session', {
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
});
export const account = sqliteTable('account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const verification = sqliteTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
updatedAt: integer('updated_at', { mode: 'timestamp' }),
});
```
> **Verification (do this during execution):** run `npx @better-auth/cli@latest generate --config src/auth.ts` after Task 4 and confirm the generated schema matches the columns above. If better-auth's installed version differs, adopt the generated schema (it is authoritative).
- [ ] **Step 2: Create the drizzle client**
`apps/api/src/db/client.ts`:
```ts
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import { env } from '../env';
import * as schema from './schema';
const client = createClient({ url: env.DATABASE_URL });
export const db = drizzle(client, { schema });
```
- [ ] **Step 3: Create the drizzle-kit config**
`apps/api/drizzle.config.ts`:
```ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'file:./data/app.db',
},
});
```
- [ ] **Step 4: Generate the initial migration**
Run: `yarn workspace @solelog/api db:generate`
Expected: a `apps/api/drizzle/0000_*.sql` file is created containing `CREATE TABLE` statements for `user`, `session`, `account`, `verification`.
- [ ] **Step 5: Create the migrate runner**
`apps/api/src/db/migrate.ts`:
```ts
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import { createClient } from '@libsql/client';
import { env } from '../env';
export async function runMigrations(): Promise<void> {
const client = createClient({ url: env.DATABASE_URL });
const db = drizzle(client);
await migrate(db, { migrationsFolder: './drizzle' });
client.close();
}
// Allow running directly: `tsx src/db/migrate.ts`
if (import.meta.url === `file://${process.argv[1]}`) {
runMigrations()
.then(() => {
console.log('Migrations applied.');
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}
```
- [ ] **Step 6: Wire migrations into the test setup**
Replace `apps/api/test/setup.ts` with:
```ts
import { beforeAll } from 'vitest';
import { rmSync, mkdirSync } from 'node:fs';
// Use a dedicated, freshly-migrated file DB for the test run.
process.env.DATABASE_URL = 'file:./.tmp/test.db';
process.env.BETTER_AUTH_SECRET = 'test-secret';
process.env.BETTER_AUTH_URL = 'http://localhost:3000';
beforeAll(async () => {
rmSync('./.tmp', { recursive: true, force: true });
mkdirSync('./.tmp', { recursive: true });
const { runMigrations } = await import('../src/db/migrate');
await runMigrations();
});
```
- [ ] **Step 7: Ignore the test/db scratch dirs**
Append to root `.gitignore`:
```
# api local data
apps/api/.tmp/
apps/api/data/
```
- [ ] **Step 8: Write the DB smoke test**
`apps/api/test/db.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
describe('database', () => {
it('can query the migrated user table (empty)', async () => {
const rows = await db.select().from(user);
expect(rows).toEqual([]);
});
});
```
- [ ] **Step 9: Run tests to verify they pass**
Run: `yarn workspace @solelog/api test`
Expected: PASS (health + db). The migration runs against `./.tmp/test.db` in setup; the query returns an empty array.
- [ ] **Step 10: Commit**
```bash
git add apps/api .gitignore yarn.lock
git commit -m "feat(api): Drizzle + libsql DB layer with better-auth schema and migrations"
```
---
## Task 4: better-auth wired into Hono (sign-up / sign-in)
**Files:**
- Create: `apps/api/src/auth.ts`
- Modify: `apps/api/src/app.ts`
- Test: `apps/api/test/auth.test.ts` (sign-up + sign-in only; protected route in Task 5)
**Interfaces:**
- Consumes: `db` and `schema` (Task 3).
- Produces: `auth` (better-auth instance) from `apps/api/src/auth.ts`; mounted handler at `ALL /api/auth/*`.
- [ ] **Step 1: Create the better-auth config**
`apps/api/src/auth.ts`:
```ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { bearer } from 'better-auth/plugins';
import { db } from './db/client';
import * as schema from './db/schema';
import { env } from './env';
export const auth = betterAuth({
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.BETTER_AUTH_URL, 'http://localhost:3000'],
database: drizzleAdapter(db, { provider: 'sqlite', schema }),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [bearer()],
});
```
- [ ] **Step 2: Mount the auth handler in the app**
Modify `apps/api/src/app.ts` to:
```ts
import { Hono } from 'hono';
import { health } from './routes/health';
import { auth } from './auth';
export function createApp(): Hono {
const app = new Hono();
app.route('/', health);
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
return app;
}
```
- [ ] **Step 3: Write the failing sign-up / sign-in test**
`apps/api/test/auth.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app';
const json = { 'content-type': 'application/json' };
describe('auth', () => {
it('signs a user up and signs them in, returning a bearer token', async () => {
const app = createApp();
const creds = { email: 'worker@example.com', password: 'sterk-wachtwoord-123', name: 'Worker' };
const signup = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: json,
body: JSON.stringify(creds),
});
expect(signup.status).toBe(200);
const signin = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: json,
body: JSON.stringify({ email: creds.email, password: creds.password }),
});
expect(signin.status).toBe(200);
const token = signin.headers.get('set-auth-token');
expect(token).toBeTruthy();
});
});
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `yarn workspace @solelog/api test test/auth.test.ts`
Expected: PASS. The `set-auth-token` response header is the bearer plugin's token.
If the header is absent, confirm the `bearer()` plugin is registered and check the installed better-auth docs for the token-exposure header name; adjust the assertion to match.
- [ ] **Step 5: Commit**
```bash
git add apps/api yarn.lock
git commit -m "feat(api): mount better-auth (email+password + bearer) on /api/auth"
```
---
## Task 5: Protected route + full auth round-trip (the "done when")
**Files:**
- Create: `apps/api/src/routes/me.ts`
- Modify: `apps/api/src/app.ts`
- Test: `apps/api/test/me.test.ts`
**Interfaces:**
- Consumes: `auth` (Task 4), `MeResponse` from `@solelog/shared`.
- Produces: `GET /api/me` — returns `401` without a valid token, `200` with `{ user }` when the bearer token is valid.
- [ ] **Step 1: Create the protected route**
`apps/api/src/routes/me.ts`:
```ts
import { Hono } from 'hono';
import type { MeResponse } from '@solelog/shared';
import { auth } from '../auth';
export const me = new Hono();
me.get('/api/me', async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return c.json({ error: 'Unauthorized' }, 401);
}
const body: MeResponse = {
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
};
return c.json(body);
});
```
- [ ] **Step 2: Mount the route**
Modify `apps/api/src/app.ts` to add the `me` route (full file):
```ts
import { Hono } from 'hono';
import { health } from './routes/health';
import { me } from './routes/me';
import { auth } from './auth';
export function createApp(): Hono {
const app = new Hono();
app.route('/', health);
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
app.route('/', me);
return app;
}
```
- [ ] **Step 3: Write the failing round-trip test**
`apps/api/test/me.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app';
const json = { 'content-type': 'application/json' };
describe('GET /api/me', () => {
it('rejects an unauthenticated request', async () => {
const app = createApp();
const res = await app.request('/api/me');
expect(res.status).toBe(401);
});
it('returns the user for a valid bearer token (sign-up -> sign-in -> me)', async () => {
const app = createApp();
const creds = { email: 'me@example.com', password: 'sterk-wachtwoord-123', name: 'Me' };
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: json,
body: JSON.stringify(creds),
});
const signin = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: json,
body: JSON.stringify({ email: creds.email, password: creds.password }),
});
const token = signin.headers.get('set-auth-token');
const res = await app.request('/api/me', {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.user.email).toBe(creds.email);
});
});
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `yarn workspace @solelog/api test`
Expected: PASS (health, db, auth, me — all green). This proves the Phase 0 "done when" criterion in code.
- [ ] **Step 5: Commit**
```bash
git add apps/api
git commit -m "feat(api): add protected GET /api/me and full auth round-trip test"
```
---
## Task 6: Dockerize the backend + compose + run docs
**Files:**
- Create: `apps/api/Dockerfile`
- Create: `apps/api/.env.example`
- Create: `docker-compose.yml` (repo root)
- Create: `apps/api/.dockerignore`
- Modify: `docs/README.md` (run instructions — create if absent)
**Interfaces:**
- Produces: `docker compose up` serving the API on `localhost:3000` with a persisted SQLite volume and migrations applied at start.
- [ ] **Step 1: Create the env example**
`apps/api/.env.example`:
```
DATABASE_URL=file:./data/app.db
BETTER_AUTH_SECRET=change-me-to-a-long-random-string
BETTER_AUTH_URL=http://localhost:3000
PORT=3000
```
- [ ] **Step 2: Create the dockerignore**
`apps/api/.dockerignore`:
```
node_modules
.tmp
data
drizzle/meta
```
- [ ] **Step 3: Create the Dockerfile**
`apps/api/Dockerfile` (multi-arch friendly; runs migrations then starts):
```dockerfile
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /repo
# Copy workspace manifests for cached install
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn/ ./.yarn/
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/api/package.json ./apps/api/package.json
# Copy the other workspace manifests too, so the workspace graph matches the
# lockfile (otherwise Yarn errors on a mismatch). `focus` still installs ONLY
# the @solelog/api subtree, so mobile/web deps are not pulled.
COPY apps/mobile/package.json ./apps/mobile/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN yarn workspaces focus @solelog/api
# Copy sources
COPY packages/shared/ ./packages/shared/
COPY apps/api/ ./apps/api/
WORKDIR /repo/apps/api
ENV NODE_ENV=production
EXPOSE 3000
# Apply migrations, then start the server
CMD ["sh", "-c", "yarn db:migrate && yarn start"]
```
> **Note:** `yarn workspaces focus` requires the `plugin-workspace-tools` (bundled with Yarn 4). If it is unavailable, substitute `RUN yarn install --immutable`.
- [ ] **Step 4: Create the compose file**
`docker-compose.yml` (repo root):
```yaml
services:
api:
build:
context: .
dockerfile: apps/api/Dockerfile
ports:
- '3000:3000'
environment:
DATABASE_URL: file:/data/app.db
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string}
BETTER_AUTH_URL: http://localhost:3000
PORT: '3000'
volumes:
- solelog_db:/data
volumes:
solelog_db:
```
- [ ] **Step 5: Build and start the stack**
Run: `docker compose up --build -d`
Expected: the `api` service builds and starts; `docker compose logs api` shows "Migrations applied." then "API listening on http://localhost:3000".
- [ ] **Step 6: Smoke-test the running container**
Run: `curl -s http://localhost:3000/health`
Expected: `{"status":"ok"}`
Run (full round-trip against the container):
```bash
curl -s -X POST http://localhost:3000/api/auth/sign-up/email \
-H 'content-type: application/json' \
-d '{"email":"smoke@example.com","password":"sterk-wachtwoord-123","name":"Smoke"}'
TOKEN=$(curl -s -D - -o /dev/null -X POST http://localhost:3000/api/auth/sign-in/email \
-H 'content-type: application/json' \
-d '{"email":"smoke@example.com","password":"sterk-wachtwoord-123"}' \
| grep -i '^set-auth-token:' | awk '{print $2}' | tr -d '\r')
curl -s http://localhost:3000/api/me -H "authorization: Bearer $TOKEN"
```
Expected: the final call returns `{"user":{...,"email":"smoke@example.com"}}`, confirming the token authorises `/api/me`.
- [ ] **Step 7: Tear down**
Run: `docker compose down`
Expected: container stops; the `solelog_db` volume persists for next time.
- [ ] **Step 8: Write run instructions**
Create `docs/README.md`:
```markdown
# SoleLog docs
- [Roadmap & project overview](./roadmap.md)
- [Plans](./plans/)
## Running the backend (Phase 0)
Local (dev):
\`\`\`bash
yarn install
cp apps/api/.env.example apps/api/.env # edit BETTER_AUTH_SECRET
yarn workspace @solelog/api db:migrate
yarn workspace @solelog/api dev
\`\`\`
Docker (whole stack):
\`\`\`bash
docker compose up --build
# health: curl http://localhost:3000/health
\`\`\`
```
- [ ] **Step 9: Commit**
```bash
git add apps/api/Dockerfile apps/api/.env.example apps/api/.dockerignore docker-compose.yml docs/README.md
git commit -m "feat(api): dockerize backend with compose, migrations-on-start, and run docs"
```
---
## Definition of Done (Phase 0)
- `yarn workspace @solelog/api test` is green (health, db, auth, me).
- `docker compose up --build` serves `/health` and the sign-up → sign-in → `/api/me` round-trip works against the container.
- The inherited `apps/mobile` and `apps/web` are untouched.
- All work committed; `docs/roadmap.md` and this plan are tracked.
## Notes for the executor (version-drift safety)
better-auth, Drizzle, and drizzle-kit evolve quickly. Three checkpoints where the
installed version is authoritative over this plan's sample code:
1. **Schema (Task 3):** confirm with `npx @better-auth/cli@latest generate`.
2. **Bearer token header (Tasks 45):** the assertion reads `set-auth-token`; verify against the installed bearer-plugin docs.
3. **drizzle-kit config (Task 3):** if `dialect: 'sqlite'` rejects the libsql `file:` URL, switch to `dialect: 'turso'` with `dbCredentials.url`.
The TDD loop (write test → run → fix) will surface any mismatch immediately.
```