feat(F-006): users profile, addresses and RBAC
- users module: profile + address CRUD behind use cases (users_profiles, users_addresses) - roles customer/admin on identity_users; role resolved from DB per request - shared auth contract (Authenticate, requireRole, requireOwnerOrAdmin) injected from composition root; users never imports identity - authorization runs before existence checks; address SQL scoped by user_id - @fastify/cookie registered once at app root (cross-module) - migrations 003_identity_roles + 004_users (reversible) - no new npm dependencies; tests: unit 52, integration 22 Gates: reviewer/security/qa APPROVED; verify.sh green
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
* calls use cases, maps domain errors to the shared error envelope.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { z } from 'zod';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
@@ -47,8 +46,6 @@ export async function registerIdentityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: IdentityRoutesDeps,
|
||||
): Promise<void> {
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const cookieSecure = deps.cookieSecure ?? true;
|
||||
const hasher = deps.hasher ?? new Argon2PasswordHasher();
|
||||
const users = new PgUserRepository(deps.pool);
|
||||
@@ -70,7 +67,9 @@ export async function registerIdentityRoutes(
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt });
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
||||
} catch (error) {
|
||||
if (error instanceof EmailAlreadyRegisteredError) {
|
||||
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
||||
@@ -84,7 +83,9 @@ export async function registerIdentityRoutes(
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
setSessionCookie(reply, result.token, cookieSecure);
|
||||
return reply.code(200).send({ id: result.user.id, email: result.user.email });
|
||||
return reply
|
||||
.code(200)
|
||||
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitedError) {
|
||||
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
* Identity domain. Pure types and rules: no framework, no infrastructure.
|
||||
*/
|
||||
|
||||
import type { Role } from '../../../shared/auth.js';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
SESSION_COOKIE_NAME,
|
||||
type IdentityRoutesDeps,
|
||||
} from './api/identity.routes.js';
|
||||
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
import type pg from 'pg';
|
||||
import type { UserRepository } from '../domain/ports.js';
|
||||
import type { NewUser, User } from '../domain/user.js';
|
||||
import type { Role } from '../../../shared/auth.js';
|
||||
import { EmailAlreadyRegisteredError } from '../domain/errors.js';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: Role;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@@ -24,14 +26,14 @@ export class PgUserRepository implements UserRepository {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`INSERT INTO identity_users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, created_at`,
|
||||
RETURNING id, email, role, created_at`,
|
||||
[user.email, user.passwordHash],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('identity_users INSERT returned no row');
|
||||
}
|
||||
return { id: row.id, email: row.email, createdAt: row.created_at };
|
||||
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
|
||||
} catch (error) {
|
||||
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
|
||||
throw new EmailAlreadyRegisteredError();
|
||||
@@ -42,7 +44,7 @@ export class PgUserRepository implements UserRepository {
|
||||
|
||||
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, email, password_hash, created_at
|
||||
`SELECT id, email, password_hash, role, created_at
|
||||
FROM identity_users
|
||||
WHERE email = $1`,
|
||||
[email],
|
||||
@@ -54,6 +56,7 @@ export class PgUserRepository implements UserRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
passwordHash: row.password_hash,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Resolves the session cookie into the current user.
|
||||
* Server-side truth: validity (expiry + revocation) and role come from the DB,
|
||||
* never from the client. Exported through the module index so the composition
|
||||
* root can inject it into other modules without cross-module imports.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { hashSessionToken } from './session-token.js';
|
||||
import { SESSION_COOKIE_NAME } from '../api/identity.routes.js';
|
||||
|
||||
interface ResolvedRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const RESOLVE_SQL = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM identity_sessions s
|
||||
JOIN identity_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > now()
|
||||
`;
|
||||
|
||||
export function createSessionAuthenticator(pool: pg.Pool): Authenticate {
|
||||
return async (request: FastifyRequest): Promise<CurrentUser> => {
|
||||
const token = request.cookies[SESSION_COOKIE_NAME];
|
||||
if (!token) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashSessionToken(token)]);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
return { id: row.id, email: row.email, role: row.role as CurrentUser['role'] };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user