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

27 KiB
Raw Permalink Blame History

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:

"workspaces": [
  "apps/*",
  "packages/*"
]
  • Step 2: Create the shared package manifest

packages/shared/package.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:

{
  "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:

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
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:

{
  "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:

{
  "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:

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:

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:

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:

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:

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:

// 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:

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
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):

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:

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:

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:

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:

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:

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
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:

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:

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:

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
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:

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):

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:

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
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):

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):

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):

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:

# 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
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.