feat(club-004): club recovery: recovery codes and device reassignment

This commit is contained in:
Deploy
2026-08-26 20:47:06 +02:00
parent 3ccbf9f450
commit 3aecd2bec0
16 changed files with 510 additions and 8 deletions

View File

@@ -7,13 +7,15 @@ import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { ClubService } from '../application/club-service.js';
import type { ClubMember, ClubSettings, ClubTransaction } from '../domain/club.js';
import type { ClubMember, ClubRecoveryCode, ClubSettings, ClubTransaction } from '../domain/club.js';
import {
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubDisabledError,
ClubInsufficientBalanceError,
ClubMemberNotFoundError,
ClubRecoveryCodeInvalidError,
ClubRecoveryCodesDisabledError,
InvalidClubTransactionError,
} from '../domain/errors.js';
import { PgClubRepository } from '../infrastructure/pg-club-repository.js';
@@ -144,6 +146,115 @@ export async function registerClubRoutes(
},
);
// ── Recovery codes ───────────────────────────────────────────────────────────
// Generate new recovery codes (plaintext codes returned — show once only)
app.post(
'/club/recovery-codes/generate',
{
schema: {
tags: ['Club'],
summary: 'Generate new recovery codes for current member',
body: {
type: 'object',
properties: { count: { type: 'integer', minimum: 1, maximum: 10, default: 3 } },
},
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
const body = request.body as { count?: number } | null;
const count = body?.count ?? 3;
try {
const result = await clubs.generateRecoveryCodes(
{
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
},
count,
);
const config = await clubs.getPublicConfig();
return reply.send({
member: serializeMember(result.member),
codes: result.codes, // plaintext — show once
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// List active (unused, not expired) recovery codes
app.get(
'/club/recovery-codes',
{
schema: {
tags: ['Club'],
summary: 'List active recovery codes for current member (fingerprints only)',
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
try {
const result = await clubs.listRecoveryCodes({
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
});
return reply.send({
member: serializeMember(result.member),
codes: result.codes.map((code: ClubRecoveryCode) => ({
id: code.id,
fingerprint: code.codeFingerprint,
expiresAt: code.expiresAt.toISOString(),
createdAt: code.createdAt.toISOString(),
})),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── Recovery: use a code to link a new device ───────────────────────────────
app.post(
'/club/recover',
{
schema: {
tags: ['Club'],
summary: 'Recover Club membership using a recovery code and a new device token',
body: {
type: 'object',
required: ['code', 'newDeviceToken'],
properties: {
code: { type: 'string', minLength: 1 },
newDeviceToken: { type: 'string', minLength: 1 },
},
},
response: { 200: { type: 'object' }, 400: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const body = request.body as { code?: string; newDeviceToken?: string };
if (!body?.code || !body?.newDeviceToken) {
throw new AppError(400, 'CLUB_INVALID_RECOVER', 'code y newDeviceToken son obligatorios');
}
try {
const result = await clubs.recoverByCode(body.code, body.newDeviceToken);
setClubDeviceCookie(reply, result.deviceToken, isSecureRequest(request));
const config = await clubs.getPublicConfig();
return reply.send({
member: serializeMember(result.member),
deviceToken: result.deviceToken,
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── POS / public resolve by member code ───────────────────────────────────
app.get(
'/club/resolve',
@@ -291,6 +402,12 @@ function mapClubError(error: unknown): Error {
if (error instanceof InvalidClubTransactionError) {
return new AppError(400, 'CLUB_INVALID_TRANSACTION', error.message);
}
if (error instanceof ClubRecoveryCodeInvalidError) {
return new AppError(400, 'CLUB_RECOVERY_CODE_INVALID', error.message);
}
if (error instanceof ClubRecoveryCodesDisabledError) {
return new AppError(403, 'CLUB_RECOVERY_CODES_DISABLED', error.message);
}
return error instanceof Error ? error : new Error('Unknown club error');
}

View File

@@ -2,7 +2,9 @@ import type {
ClubMember,
ClubResolveInput,
ClubSettings,
GenerateRecoveryCodesResult,
JoinClubResult,
RecoverByCodeResult,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
@@ -12,6 +14,8 @@ import {
ClubDeviceTokenRequiredError,
ClubDisabledError,
ClubMemberNotFoundError,
ClubRecoveryCodeInvalidError,
ClubRecoveryCodesDisabledError,
InvalidClubTransactionError,
} from '../domain/errors.js';
import type { ClubRepository } from '../domain/ports.js';
@@ -109,6 +113,62 @@ export class ClubService {
return { member, items };
}
async generateRecoveryCodes(input: ClubResolveInput, count = 3): Promise<GenerateRecoveryCodesResult> {
const member = await this.getMemberOrThrow(input);
const settings = await this.clubs.getSettings();
if (!settings.allowRecoveryCodes) throw new ClubRecoveryCodesDisabledError();
const safeCount = Math.min(Math.max(count, 1), 10);
const generated = await this.clubs.generateRecoveryCodes(member.id, safeCount, 30);
return {
member,
codes: generated.map((g) => g.code),
};
}
async listRecoveryCodes(input: ClubResolveInput) {
const member = await this.getMemberOrThrow(input);
const settings = await this.clubs.getSettings();
if (!settings.allowRecoveryCodes) throw new ClubRecoveryCodesDisabledError();
const codes = await this.clubs.listActiveRecoveryCodes(member.id);
return { member, codes };
}
async recoverByCode(code: string, newDeviceToken: string): Promise<RecoverByCodeResult> {
if (!code || typeof code !== 'string' || !code.trim()) {
throw new ClubRecoveryCodeInvalidError('Código de recuperación obligatorio');
}
if (!newDeviceToken || typeof newDeviceToken !== 'string' || !newDeviceToken.trim()) {
throw new ClubDeviceTokenRequiredError();
}
const settings = await this.clubs.getSettings();
if (!settings.allowRecoveryCodes) throw new ClubRecoveryCodesDisabledError();
const normalizedCode = code.trim().toUpperCase();
const fingerprint = hashDeviceToken(normalizedCode).slice(0, 16);
const newDeviceTokenHash = hashDeviceToken(newDeviceToken.trim());
let result: { memberId: string; fingerprint: string };
try {
result = await this.clubs.consumeRecoveryCode(fingerprint, newDeviceTokenHash);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
throw new ClubRecoveryCodeInvalidError();
}
throw error;
}
const member = await this.clubs.findMemberByUserId(result.memberId);
if (!member) {
const byDevice = await this.clubs.findMemberByDeviceTokenHash(result.memberId, false);
if (!byDevice) throw new ClubMemberNotFoundError();
return { member: byDevice, deviceToken: newDeviceToken.trim(), usedCodeFingerprint: result.fingerprint };
}
return { member, deviceToken: newDeviceToken.trim(), usedCodeFingerprint: result.fingerprint };
}
async recordTransaction(
input: RecordClubTransactionCommand,
): Promise<RecordClubTransactionResult> {

View File

@@ -76,3 +76,23 @@ export interface RecordClubTransactionResult {
transaction: ClubTransaction;
created: boolean;
}
export interface ClubRecoveryCode {
id: string;
memberId: string;
codeFingerprint: string;
usedAt: Date | null;
expiresAt: Date;
createdAt: Date;
}
export interface GenerateRecoveryCodesResult {
member: ClubMember;
codes: string[]; // plaintext — only returned on generation, never stored
}
export interface RecoverByCodeResult {
member: ClubMember;
deviceToken: string;
usedCodeFingerprint: string;
}

View File

@@ -39,3 +39,17 @@ export class InvalidClubTransactionError extends Error {
this.name = 'InvalidClubTransactionError';
}
}
export class ClubRecoveryCodeInvalidError extends Error {
constructor(message = 'Código de recuperación inválido o caducado') {
super(message);
this.name = 'ClubRecoveryCodeInvalidError';
}
}
export class ClubRecoveryCodesDisabledError extends Error {
constructor() {
super('Los códigos de recuperación del Club están desactivados');
this.name = 'ClubRecoveryCodesDisabledError';
}
}

View File

@@ -1,5 +1,6 @@
import type {
ClubMember,
ClubRecoveryCode,
ClubResolveInput,
ClubSettings,
ClubTransaction,
@@ -24,4 +25,9 @@ export interface ClubRepository {
resolveMember(input: ClubResolveInput & { deviceTokenHash?: string | null }): Promise<ClubMember | null>;
listTransactions(memberId: string, limit: number): Promise<ClubTransaction[]>;
recordTransaction(input: RecordClubTransactionCommand): Promise<RecordClubTransactionResult>;
// Recovery codes
generateRecoveryCodes(memberId: string, count: number, expiresInDays: number): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]>;
listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]>;
consumeRecoveryCode(fingerprint: string, newDeviceTokenHash: string): Promise<{ memberId: string; fingerprint: string }>;
}

View File

@@ -1,6 +1,8 @@
import { createHash, randomBytes } from 'node:crypto';
import type pg from 'pg';
import type {
ClubMember,
ClubRecoveryCode,
ClubResolveInput,
ClubSettings,
ClubTransaction,
@@ -11,6 +13,7 @@ import type {
import { DEFAULT_CLUB_SETTINGS } from '../domain/club.js';
import { ClubInsufficientBalanceError } from '../domain/errors.js';
import type { ClubRepository, CreateClubMemberCommand } from '../domain/ports.js';
import { hashDeviceToken } from './device-token.js';
interface SettingRow {
key: string;
@@ -200,6 +203,97 @@ export class PgClubRepository implements ClubRepository {
return result.rows.map(toTransaction);
}
// ── Recovery codes ──────────────────────────────────────────────────────────
async generateRecoveryCodes(
memberId: string,
count: number,
expiresInDays: number,
): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]> {
const results: { code: string; fingerprint: string; expiresAt: Date }[] = [];
const expiresAt = new Date(Date.now() + expiresInDays * 86_400_000);
const safeCount = Math.min(Math.max(count, 1), 10);
for (let i = 0; i < safeCount; i++) {
const code = generateRecoveryCode();
const codeHash = hashDeviceToken(code); // same hash fn as device tokens
const fingerprint = createHash('sha256').update(code.toUpperCase()).digest('hex').slice(0, 16);
await this.pool.query(
`INSERT INTO club_recovery_codes (member_id, code_hash, code_fingerprint, expires_at)
VALUES ($1, $2, $3, $4)`,
[memberId, codeHash, fingerprint, expiresAt],
);
results.push({ code, fingerprint, expiresAt });
}
return results;
}
async listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]> {
const result = await this.pool.query<{
id: string;
member_id: string;
code_fingerprint: string;
used_at: Date | null;
expires_at: Date;
created_at: Date;
}>(
`SELECT * FROM club_recovery_codes
WHERE member_id = $1 AND used_at IS NULL AND expires_at > now()
ORDER BY created_at DESC`,
[memberId],
);
return result.rows.map((row) => ({
id: row.id,
memberId: row.member_id,
codeFingerprint: row.code_fingerprint,
usedAt: row.used_at,
expiresAt: row.expires_at,
createdAt: row.created_at,
}));
}
async consumeRecoveryCode(
fingerprint: string,
newDeviceTokenHash: string,
): Promise<{ memberId: string; fingerprint: string }> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const codeRow = await client.query<{ id: string; member_id: string }>(
`SELECT id, member_id FROM club_recovery_codes
WHERE code_fingerprint = $1 AND used_at IS NULL AND expires_at > now()
FOR UPDATE`,
[fingerprint],
);
const row = codeRow.rows[0];
if (!row) {
await client.query('ROLLBACK');
throw new Error('Código de recuperación no encontrado o ya usado');
}
await client.query(
`UPDATE club_recovery_codes SET used_at = now() WHERE id = $1`,
[row.id],
);
await client.query(
`INSERT INTO club_devices (member_id, device_token_hash) VALUES ($1, $2)`,
[row.member_id, newDeviceTokenHash],
);
await client.query('COMMIT');
return { memberId: row.member_id, fingerprint };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ── Existing recordTransaction ─────────────────────────────────────────────
async recordTransaction(
input: RecordClubTransactionCommand,
): Promise<RecordClubTransactionResult> {
@@ -349,3 +443,22 @@ function toTransaction(row: TransactionRow): ClubTransaction {
createdAt: row.created_at,
};
}
/**
* Generate a human-readable recovery code in the format:
* XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (24 alphanumeric chars, no confusing chars)
*/
const RECOVERY_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function generateRecoveryCode(): string {
const groups: string[] = [];
for (let g = 0; g < 6; g++) {
let group = '';
const bytes = randomBytes(4);
for (let b = 0; b < 4; b++) {
group += RECOVERY_CHARS[bytes[b]! % RECOVERY_CHARS.length];
}
groups.push(group);
}
return groups.join('-');
}