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:
rikrdo
2026-08-15 09:27:38 +02:00
parent 75293f39bc
commit 546971280f
37 changed files with 1732 additions and 161 deletions

View File

@@ -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)));

View File

@@ -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;
}

View File

@@ -7,3 +7,4 @@ export {
SESSION_COOKIE_NAME,
type IdentityRoutesDeps,
} from './api/identity.routes.js';
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';

View File

@@ -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,
};

View File

@@ -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'] };
};
}