Merge pull request 'feat(backend): Phase 1 MVP — auth, pantry, recipes, shopping lists, sync API' (#1) from feature/backend-implementation into main

Reviewed-on: #1
This commit is contained in:
2026-05-11 15:45:14 +00:00
4 changed files with 45 additions and 20 deletions

View File

@@ -9,18 +9,22 @@ function requireEnv(key: string): string {
return value; return value;
} }
const isTest = process.env.NODE_ENV === 'test';
export const config = { export const config = {
nodeEnv: process.env.NODE_ENV ?? 'development', nodeEnv: process.env.NODE_ENV ?? 'development',
port: parseInt(process.env.PORT ?? '3000', 10), port: parseInt(process.env.PORT ?? '3000', 10),
databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test', databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test',
jwtSecret: process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever', jwtSecret: isTest
? (process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever')
: requireEnv('JWT_SECRET'),
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h', jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h',
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '', googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '', sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app', sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000', frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password', passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
isTest: process.env.NODE_ENV === 'test', isTest,
isDev: process.env.NODE_ENV === 'development', isDev: process.env.NODE_ENV === 'development',
isProd: process.env.NODE_ENV === 'production', isProd: process.env.NODE_ENV === 'production',
}; };

View File

@@ -5,12 +5,14 @@ export async function up(knex: Knex): Promise<void> {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE'); table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
table.string('token_hash', 255).notNullable(); table.string('token_hash', 255).notNullable();
table.string('lookup_hash', 64).notNullable();
table.timestamp('expires_at', { useTz: true }).notNullable(); table.timestamp('expires_at', { useTz: true }).notNullable();
table.timestamp('used_at', { useTz: true }).nullable(); table.timestamp('used_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
}); });
await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`); await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`);
await knex.raw(`CREATE UNIQUE INDEX idx_prt_lookup_hash ON password_reset_tokens (lookup_hash)`);
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {

View File

@@ -28,10 +28,9 @@ export async function authMiddleware(
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
} }
// Verify user still exists and is not hard-deleted // Verify user still exists (including soft-deleted — they may be hitting /restore-account)
const user = await db('users') const user = await db('users')
.where({ id: payload.userId }) .where({ id: payload.userId })
.whereNull('deletion_scheduled_at')
.select('id', 'deleted_at', 'deletion_scheduled_at') .select('id', 'deleted_at', 'deletion_scheduled_at')
.first(); .first();
@@ -39,10 +38,16 @@ export async function authMiddleware(
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
} }
// Block access for soft-deleted accounts (except restore endpoint) // Soft-deleted accounts may only access the restore-account route
if (user.deleted_at && !req.path.includes('/restore-account')) { if (user.deleted_at) {
const isRestorePath =
req.path === '/auth/restore-account' ||
req.path.endsWith('/restore-account');
if (!isRestorePath) {
return next(createError('Account is pending deletion.', 403, 'FORBIDDEN')); return next(createError('Account is pending deletion.', 403, 'FORBIDDEN'));
} }
}
req.userId = payload.userId; req.userId = payload.userId;
next(); next();

View File

@@ -21,6 +21,18 @@ function formatUser(user: Record<string, unknown>) {
}; };
} }
/**
* Derive a fast, constant-length lookup key from a raw reset token.
* HMAC-SHA256 with the JWT secret as the key — not a secret in itself,
* but prevents offline dictionary attacks against the stored hashes.
*/
function hmacToken(rawToken: string): string {
return crypto
.createHmac('sha256', config.jwtSecret)
.update(rawToken)
.digest('hex');
}
export const authService = { export const authService = {
async signup(email: string, password: string, name: string) { async signup(email: string, password: string, name: string) {
const existing = await db('users') const existing = await db('users')
@@ -166,6 +178,7 @@ export const authService = {
const rawToken = crypto.randomBytes(32).toString('hex'); const rawToken = crypto.randomBytes(32).toString('hex');
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS); const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
const lookup_hash = hmacToken(rawToken);
const expires_at = new Date( const expires_at = new Date(
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000 Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
).toISOString(); ).toISOString();
@@ -173,6 +186,7 @@ export const authService = {
await db('password_reset_tokens').insert({ await db('password_reset_tokens').insert({
user_id: user.id, user_id: user.id,
token_hash, token_hash,
lookup_hash,
expires_at, expires_at,
}); });
@@ -180,22 +194,22 @@ export const authService = {
}, },
async confirmPasswordReset(rawToken: string, newPassword: string) { async confirmPasswordReset(rawToken: string, newPassword: string) {
// Find all unexpired, unused tokens and check each // Derive the lookup key and fetch the single matching row — no full-table scan
const tokens = await db('password_reset_tokens') const lookup_hash = hmacToken(rawToken);
const candidate = await db('password_reset_tokens')
.where({ lookup_hash })
.whereNull('used_at') .whereNull('used_at')
.where('expires_at', '>', db.fn.now()) .where('expires_at', '>', db.fn.now())
.orderBy('created_at', 'desc'); .first();
let matchedToken = null; if (!candidate) {
for (const t of tokens) { throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
const match = await bcrypt.compare(rawToken, t.token_hash);
if (match) {
matchedToken = t;
break;
}
} }
if (!matchedToken) { // bcrypt-verify the raw token against the stored hash
const valid = await bcrypt.compare(rawToken, candidate.token_hash);
if (!valid) {
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN'); throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
} }
@@ -203,11 +217,11 @@ export const authService = {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx('users') await trx('users')
.where({ id: matchedToken.user_id }) .where({ id: candidate.user_id })
.update({ password_hash, updated_at: trx.fn.now() }); .update({ password_hash, updated_at: trx.fn.now() });
await trx('password_reset_tokens') await trx('password_reset_tokens')
.where({ id: matchedToken.id }) .where({ id: candidate.id })
.update({ used_at: trx.fn.now() }); .update({ used_at: trx.fn.now() });
}); });
}, },