Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5febda7
test(red): logger must redact authorization, jwt, and password_hash f…
itlamb May 7, 2026
c830b4c
feat(green): extend logger redaction to authorization, jwt, and passw…
itlamb May 7, 2026
be160c7
test(red): JWT sign/verify — valid, expired, tampered, wrong secret, …
itlamb May 7, 2026
8334432
feat(green): implement HS256 JWT sign/verify using node:crypto
itlamb May 7, 2026
2838200
test(red): boot guard must exit(1) when JWT_SECRET is missing or < 32…
itlamb May 7, 2026
aaf8df0
feat(green): add boot guard, config schema, and server entry point
itlamb May 7, 2026
fb3def1
test(red): auth routes — login success, wrong creds, /auth/me with va…
itlamb May 7, 2026
2286f04
feat(green): auth API — schema, use-cases, routes, middleware, contracts
itlamb May 7, 2026
645750f
test(red): seed script — idempotency, bcrypt cost-factor-12, profile …
itlamb May 7, 2026
47519bd
feat(green): seed script — export seedAccounts, guard CLI entry point
itlamb May 7, 2026
bbd8009
test(red): JWT verifyJwt must reject tokens with missing exp or junk …
itlamb May 7, 2026
470dacd
feat(green): validate JWT payload — reject missing exp and non-role v…
itlamb May 7, 2026
f91e660
test(red): Playwright e2e — login→dashboard→logout for doctor and pat…
itlamb May 7, 2026
8027244
feat(green): web — login page, patient/doctor dashboards, auth token …
itlamb May 7, 2026
27e01fa
refactor: extract user-repository, fix logger cast, handle db_error i…
itlamb May 7, 2026
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
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,20 @@
]
}
]
},
"permissions": {
"allow": [
"Bash(gh issue *)",
"Bash(gh issue *)",
"Bash(gh pr *)",
"Bash(gh api *)",
"Bash(git *)",
"Bash(pnpm *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(docker *)",
"Bash(docker-compose *)",
"Bash(curl *)"
]
}
}
18 changes: 17 additions & 1 deletion apps/api/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import { defineConfig } from 'drizzle-kit';
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const dir = dirname(fileURLToPath(import.meta.url));
const rootEnv = resolve(dir, '../../.env');
if (existsSync(rootEnv)) {
for (const line of readFileSync(rootEnv, 'utf-8').split('\n')) {
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
const val = line.slice(eq + 1).trim();
if (key && !(key in process.env)) process.env[key] = val;
}
}

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
Expand All @@ -13,5 +29,5 @@ export default defineConfig({
url: databaseUrl,
},
verbose: true,
strict: true,
strict: false,
});
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
"@hono/node-server": "^1.13.0",
"@medbridge/contracts": "workspace:*",
"@ts-rest/core": "^3.51.0",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.36.0",
"hono": "^4.6.0",
"postgres": "^3.4.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
Expand Down
117 changes: 117 additions & 0 deletions apps/api/scripts/seed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import bcrypt from 'bcryptjs';
import { eq, inArray } from 'drizzle-orm';
import { createDb } from '../src/infrastructure/db';
import { users, patients, doctors } from '../src/db/schema';
import { seedAccounts } from './seed';

const DATABASE_URL = process.env['DATABASE_URL'];

const testAccounts = [
{
email: 'seed.test.doctor@medbridge.test',
password: 'TestPass1!',
firstName: 'SeedTest',
lastName: 'Doctor',
accountType: 'doctor' as const,
},
{
email: 'seed.test.patient@medbridge.test',
password: 'TestPass2!',
firstName: 'SeedTest',
lastName: 'Patient',
accountType: 'patient' as const,
},
];

const testEmails = testAccounts.map((a) => a.email);

const describeWithDb = DATABASE_URL ? describe : describe.skip;

describeWithDb('seed script (integration)', () => {
const db = createDb(DATABASE_URL!);

beforeAll(async () => {
await db.delete(users).where(inArray(users.email, testEmails));
});

afterAll(async () => {
await db.delete(users).where(inArray(users.email, testEmails));
});

it('inserts users, patients, and doctors rows on first run', async () => {
await seedAccounts(db, testAccounts, 4);

const rows = await db
.select()
.from(users)
.where(inArray(users.email, testEmails));

expect(rows).toHaveLength(2);
expect(rows.map((r) => r.email).sort()).toEqual(testEmails.slice().sort());
});

it('is idempotent — running twice does not create duplicate rows', async () => {
await seedAccounts(db, testAccounts, 4);

const rows = await db
.select()
.from(users)
.where(inArray(users.email, testEmails));

expect(rows).toHaveLength(2);
});

it('stores bcrypt cost-factor-12 hashes when saltRounds=12', async () => {
const singleAccount = [testAccounts[0]!];
await seedAccounts(db, singleAccount, 12);

const [row] = await db
.select({ passwordHash: users.passwordHash })
.from(users)
.where(eq(users.email, singleAccount[0]!.email))
.limit(1);

expect(row).toBeDefined();
expect(row!.passwordHash).toMatch(/^\$2[ab]\$12\$/);
});

it('stored hash verifies against the original plaintext password', async () => {
const [row] = await db
.select({ passwordHash: users.passwordHash })
.from(users)
.where(eq(users.email, testAccounts[1]!.email))
.limit(1);

expect(row).toBeDefined();
const valid = await bcrypt.compare(testAccounts[1]!.password, row!.passwordHash);
expect(valid).toBe(true);
});

it('seeds the matching patient and doctor profile rows', async () => {
const [doctorUser] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, testAccounts[0]!.email))
.limit(1);
const [patientUser] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, testAccounts[1]!.email))
.limit(1);

const [doctorRow] = await db
.select()
.from(doctors)
.where(eq(doctors.userId, doctorUser!.id))
.limit(1);
const [patientRow] = await db
.select()
.from(patients)
.where(eq(patients.userId, patientUser!.id))
.limit(1);

expect(doctorRow?.firstName).toBe('SeedTest');
expect(patientRow?.firstName).toBe('SeedTest');
});
});
98 changes: 98 additions & 0 deletions apps/api/scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import bcrypt from 'bcryptjs';
import { sql } from 'drizzle-orm';
import type { Db } from '../src/infrastructure/db';
import { createDb } from '../src/infrastructure/db';
import { users, patients, doctors } from '../src/db/schema';

const __dirname = dirname(fileURLToPath(import.meta.url));

export interface SeedAccount {
email: string;
password: string;
firstName: string;
lastName: string;
accountType: 'doctor' | 'patient';
}

export async function seedAccounts(
db: Db,
accounts: SeedAccount[],
saltRounds = 12,
): Promise<void> {
for (const account of accounts) {
const hash = await bcrypt.hash(account.password, saltRounds);
const email = account.email.toLowerCase();

const [user] = await db
.insert(users)
.values({ email, passwordHash: hash, role: account.accountType })
.onConflictDoUpdate({
target: users.email,
set: { passwordHash: hash, updatedAt: sql`now()` },
})
.returning({ id: users.id });

const userId = user!.id;

if (account.accountType === 'patient') {
await db
.insert(patients)
.values({ userId, firstName: account.firstName, lastName: account.lastName })
.onConflictDoUpdate({
target: patients.userId,
set: {
firstName: account.firstName,
lastName: account.lastName,
updatedAt: sql`now()`,
},
});
} else {
await db
.insert(doctors)
.values({ userId, firstName: account.firstName, lastName: account.lastName })
.onConflictDoUpdate({
target: doctors.userId,
set: {
firstName: account.firstName,
lastName: account.lastName,
updatedAt: sql`now()`,
},
});
}

process.stdout.write(` ✓ ${email} (${account.accountType})\n`);
}
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const rootEnv = resolve(__dirname, '../../../.env');
if (existsSync(rootEnv)) {
for (const line of readFileSync(rootEnv, 'utf-8').split('\n')) {
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
const val = line.slice(eq + 1).trim();
if (key && !(key in process.env)) process.env[key] = val;
}
}

const DATABASE_URL = process.env['DATABASE_URL'];
if (!DATABASE_URL) {
process.stderr.write('FATAL: DATABASE_URL is required. Copy .env.example to .env\n');
process.exit(1);
}

const dataPath = resolve(__dirname, '../../..', 'data/seed-accounts.json');
const accounts = JSON.parse(readFileSync(dataPath, 'utf8')) as SeedAccount[];

const db = createDb(DATABASE_URL);

process.stdout.write(`Seeding ${accounts.length} accounts…\n`);
await seedAccounts(db, accounts, 12);
process.stdout.write('Seed complete.\n');
process.exit(0);
}
15 changes: 14 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { healthRouter } from './routes/health';
import { createAuthRouter } from './routes/auth/auth-router';
import type { Db } from './infrastructure/db';
import type { AppConfig } from './config';
import type { Logger } from './lib/logger';

export function createApp(): Hono {
export interface AppDeps {
db: Db;
config: Pick<AppConfig, 'JWT_SECRET'>;
logger: Logger;
}

export function createApp(deps?: AppDeps): Hono {
const app = new Hono();
app.use('*', cors());
app.route('/', healthRouter);
if (deps) {
app.route('/', createAuthRouter(deps));
}
return app;
}
10 changes: 4 additions & 6 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { z } from 'zod';

const configSchema = z.object({
// Optional during the kickoff slice — Slice 2 introduces the users table and
// adds a hard requirement plus a real connection at boot.
DATABASE_URL: z.string().optional(),
DATABASE_URL: z.string().min(1),
API_PORT: z.coerce.number().int().positive().default(3001),
// Read but not enforced in this slice. Slice 2 adds the ≥ 32 chars boot guard.
JWT_SECRET: z.string().optional(),
JWT_SECRET: z.string().min(32),
});

export type AppConfig = z.infer<typeof configSchema>;
Expand All @@ -17,7 +14,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const issues = parsed.error.issues
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
.join('; ');
throw new Error(`Invalid environment configuration: ${issues}`);
process.stderr.write(`FATAL: Invalid environment configuration: ${issues}\n`);
process.exit(1);
}
return parsed.data;
}
43 changes: 39 additions & 4 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
// Empty for the kickoff slice. Slice 2 introduces `users`, `patients`, `doctors`,
// and the `user_role` enum. Keeping this file in place means `drizzle-kit push`
// is wired against a real schema target from day one.
export {};
import { pgTable, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const userRoleEnum = pgEnum('user_role', ['doctor', 'patient']);

export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
role: userRoleEnum('role').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});

export const patients = pgTable('patients', {
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});

export const doctors = pgTable('doctors', {
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
14 changes: 14 additions & 0 deletions apps/api/src/domain/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type UserRole = 'doctor' | 'patient';

export interface UserProfile {
id: string;
email: string;
role: UserRole;
firstName: string;
lastName: string;
}

export type AppVariables = {
userId: string;
userRole: UserRole;
};
Loading
Loading