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
This commit is contained in:
958
docs/plans/phase-0-foundation.md
Normal file
958
docs/plans/phase-0-foundation.md
Normal file
@@ -0,0 +1,958 @@
|
||||
# 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 4–5):** 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.
|
||||
```
|
||||
Reference in New Issue
Block a user