feat(club-005): club registered users: account linking and automatic recovery
This commit is contained in:
@@ -8092,14 +8092,16 @@
|
|||||||
"description": "Phase 5. Link club members to Mercado de Vida users, restore card automatically after login and handle safe merge between anonymous and registered memberships.",
|
"description": "Phase 5. Link club members to Mercado de Vida users, restore card automatically after login and handle safe merge between anonymous and registered memberships.",
|
||||||
"priority": "med",
|
"priority": "med",
|
||||||
"risk": "high",
|
"risk": "high",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-26",
|
"created_at": "2026-08-26",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
|
"close": true
|
||||||
},
|
},
|
||||||
"phase": "backend"
|
"phase": "backend",
|
||||||
|
"completed_at": "2026-08-26T18:49:36Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CLUB-006",
|
"id": "CLUB-006",
|
||||||
|
|||||||
@@ -146,6 +146,79 @@ export async function registerClubRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Link anonymous member to registered user account ──────────────────────
|
||||||
|
// Called by the frontend after login/register to link the device's anonymous
|
||||||
|
// member to the newly authenticated user account.
|
||||||
|
app.patch(
|
||||||
|
'/club/me',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Club'],
|
||||||
|
summary: 'Link current anonymous Club member to the authenticated user account',
|
||||||
|
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = await deps.authenticate(request); // requires session
|
||||||
|
try {
|
||||||
|
const result = await clubs.linkMemberToUser({
|
||||||
|
userId: user.id,
|
||||||
|
deviceToken: deviceTokenFromRequest(request),
|
||||||
|
});
|
||||||
|
const config = await clubs.getPublicConfig();
|
||||||
|
return reply.send({
|
||||||
|
member: serializeMember(result.member),
|
||||||
|
linked: result.linked,
|
||||||
|
config: serializeSettings(config),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw mapClubError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Merge two Club memberships ───────────────────────────────────────────────
|
||||||
|
app.post(
|
||||||
|
'/club/merge',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Club'],
|
||||||
|
summary: 'Merge an anonymous Club member into the authenticated user\'s member',
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['anonymousMemberId'],
|
||||||
|
properties: {
|
||||||
|
anonymousMemberId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: { 200: { type: 'object' }, 400: errorSchema, 401: errorSchema },
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = await deps.authenticate(request); // requires session
|
||||||
|
const body = request.body as { anonymousMemberId?: string };
|
||||||
|
if (!body?.anonymousMemberId) {
|
||||||
|
throw new AppError(400, 'CLUB_INVALID_MERGE', 'anonymousMemberId es obligatorio');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The user's registered member is the primary
|
||||||
|
const userMember = await clubs.getMemberOrThrow({
|
||||||
|
userId: user.id,
|
||||||
|
deviceToken: null,
|
||||||
|
});
|
||||||
|
const result = await clubs.mergeMembers(userMember.id, body.anonymousMemberId);
|
||||||
|
const config = await clubs.getPublicConfig();
|
||||||
|
return reply.send({
|
||||||
|
member: serializeMember(result.primary),
|
||||||
|
mergedId: result.merged.id,
|
||||||
|
config: serializeSettings(config),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw mapClubError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Recovery codes ───────────────────────────────────────────────────────────
|
// ── Recovery codes ───────────────────────────────────────────────────────────
|
||||||
// Generate new recovery codes (plaintext codes returned — show once only)
|
// Generate new recovery codes (plaintext codes returned — show once only)
|
||||||
app.post(
|
app.post(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
ClubSettings,
|
ClubSettings,
|
||||||
GenerateRecoveryCodesResult,
|
GenerateRecoveryCodesResult,
|
||||||
JoinClubResult,
|
JoinClubResult,
|
||||||
|
LinkMemberResult,
|
||||||
|
MergeMembersResult,
|
||||||
RecoverByCodeResult,
|
RecoverByCodeResult,
|
||||||
RecordClubTransactionCommand,
|
RecordClubTransactionCommand,
|
||||||
RecordClubTransactionResult,
|
RecordClubTransactionResult,
|
||||||
@@ -135,6 +137,19 @@ export class ClubService {
|
|||||||
return { member, codes };
|
return { member, codes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async linkMemberToUser(input: ClubResolveInput): Promise<LinkMemberResult> {
|
||||||
|
if (!input.userId) throw new ClubDeviceTokenRequiredError();
|
||||||
|
const member = await this.getMemberOrThrow(input);
|
||||||
|
return this.clubs.linkMemberToUser(member.id, input.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
|
||||||
|
if (primaryMemberId === mergedMemberId) {
|
||||||
|
throw new InvalidClubTransactionError('No se puede fusionar un socio consigo mismo');
|
||||||
|
}
|
||||||
|
return this.clubs.mergeMembers(primaryMemberId, mergedMemberId);
|
||||||
|
}
|
||||||
|
|
||||||
async recoverByCode(code: string, newDeviceToken: string): Promise<RecoverByCodeResult> {
|
async recoverByCode(code: string, newDeviceToken: string): Promise<RecoverByCodeResult> {
|
||||||
if (!code || typeof code !== 'string' || !code.trim()) {
|
if (!code || typeof code !== 'string' || !code.trim()) {
|
||||||
throw new ClubRecoveryCodeInvalidError('Código de recuperación obligatorio');
|
throw new ClubRecoveryCodeInvalidError('Código de recuperación obligatorio');
|
||||||
|
|||||||
@@ -96,3 +96,13 @@ export interface RecoverByCodeResult {
|
|||||||
deviceToken: string;
|
deviceToken: string;
|
||||||
usedCodeFingerprint: string;
|
usedCodeFingerprint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkMemberResult {
|
||||||
|
member: ClubMember;
|
||||||
|
linked: boolean; // true if newly linked, false if already had userId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeMembersResult {
|
||||||
|
primary: ClubMember; // the member kept (registered one)
|
||||||
|
merged: ClubMember; // the member archived (anonymous one)
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,3 +53,17 @@ export class ClubRecoveryCodesDisabledError extends Error {
|
|||||||
this.name = 'ClubRecoveryCodesDisabledError';
|
this.name = 'ClubRecoveryCodesDisabledError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ClubMemberAlreadyLinkedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Este dispositivo ya está vinculado a otra cuenta');
|
||||||
|
this.name = 'ClubMemberAlreadyLinkedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClubMergeConflictError extends Error {
|
||||||
|
constructor(message = 'No se puede fusionar las cuentas del Club') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ClubMergeConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
ClubResolveInput,
|
ClubResolveInput,
|
||||||
ClubSettings,
|
ClubSettings,
|
||||||
ClubTransaction,
|
ClubTransaction,
|
||||||
|
LinkMemberResult,
|
||||||
|
MergeMembersResult,
|
||||||
RecordClubTransactionCommand,
|
RecordClubTransactionCommand,
|
||||||
RecordClubTransactionResult,
|
RecordClubTransactionResult,
|
||||||
UpdateClubSettingsCommand,
|
UpdateClubSettingsCommand,
|
||||||
@@ -30,4 +32,8 @@ export interface ClubRepository {
|
|||||||
generateRecoveryCodes(memberId: string, count: number, expiresInDays: number): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]>;
|
generateRecoveryCodes(memberId: string, count: number, expiresInDays: number): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]>;
|
||||||
listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]>;
|
listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]>;
|
||||||
consumeRecoveryCode(fingerprint: string, newDeviceTokenHash: string): Promise<{ memberId: string; fingerprint: string }>;
|
consumeRecoveryCode(fingerprint: string, newDeviceTokenHash: string): Promise<{ memberId: string; fingerprint: string }>;
|
||||||
|
|
||||||
|
// Member linking and merging
|
||||||
|
linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult>;
|
||||||
|
mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
ClubResolveInput,
|
ClubResolveInput,
|
||||||
ClubSettings,
|
ClubSettings,
|
||||||
ClubTransaction,
|
ClubTransaction,
|
||||||
|
LinkMemberResult,
|
||||||
|
MergeMembersResult,
|
||||||
RecordClubTransactionCommand,
|
RecordClubTransactionCommand,
|
||||||
RecordClubTransactionResult,
|
RecordClubTransactionResult,
|
||||||
UpdateClubSettingsCommand,
|
UpdateClubSettingsCommand,
|
||||||
@@ -203,6 +205,97 @@ export class PgClubRepository implements ClubRepository {
|
|||||||
return result.rows.map(toTransaction);
|
return result.rows.map(toTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Member linking ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult> {
|
||||||
|
const existing = await this.pool.query<MemberRow>(
|
||||||
|
`SELECT * FROM club_members WHERE user_id = $1 AND status <> 'merged' LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
// User already has a member — return it, don't link the device member
|
||||||
|
return { member: toMember(existing.rows[0]), linked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query<MemberRow>(
|
||||||
|
`UPDATE club_members SET user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $1 AND user_id IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
[memberId, userId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) {
|
||||||
|
// Member not found or already has a userId — treat as already linked
|
||||||
|
const current = await this.pool.query<MemberRow>(
|
||||||
|
`SELECT * FROM club_members WHERE id = $1`,
|
||||||
|
[memberId],
|
||||||
|
);
|
||||||
|
if (!current.rows[0]) throw new Error(`club_members row not found: ${memberId}`);
|
||||||
|
return { member: toMember(current.rows[0]), linked: false };
|
||||||
|
}
|
||||||
|
return { member: toMember(result.rows[0]), linked: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const primary = await client.query<MemberRow>(
|
||||||
|
`SELECT * FROM club_members WHERE id = $1 AND status = 'active' FOR UPDATE`,
|
||||||
|
[primaryMemberId],
|
||||||
|
);
|
||||||
|
const merged = await client.query<MemberRow>(
|
||||||
|
`SELECT * FROM club_members WHERE id = $1 AND status = 'active' FOR UPDATE`,
|
||||||
|
[mergedMemberId],
|
||||||
|
);
|
||||||
|
if (!primary.rows[0] || !merged.rows[0]) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw new Error('Uno de los socios no fue encontrado o no está activo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryRow = primary.rows[0];
|
||||||
|
const mergedRow = merged.rows[0];
|
||||||
|
|
||||||
|
// Transfer devices from merged to primary
|
||||||
|
await client.query(
|
||||||
|
`UPDATE club_devices SET member_id = $1 WHERE member_id = $2`,
|
||||||
|
[primaryMemberId, mergedMemberId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark merged member as merged and zero out balance
|
||||||
|
await client.query(
|
||||||
|
`UPDATE club_members
|
||||||
|
SET status = 'merged', user_id = $1, current_balance_cents = 0, updated_at = now()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[primaryMemberId, mergedMemberId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Credit merged balance to primary
|
||||||
|
const newBalance = Number(primaryRow.current_balance_cents) + Number(mergedRow.current_balance_cents);
|
||||||
|
const updatedPrimary = await client.query<MemberRow>(
|
||||||
|
`UPDATE club_members SET current_balance_cents = $2, updated_at = now()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[primaryMemberId, newBalance],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
if (!updatedPrimary.rows[0]) throw new Error('Club merge: primary member not found after update');
|
||||||
|
return {
|
||||||
|
primary: toMember(updatedPrimary.rows[0]),
|
||||||
|
merged: toMember({
|
||||||
|
...mergedRow,
|
||||||
|
status: 'merged' as const,
|
||||||
|
current_balance_cents: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Recovery codes ──────────────────────────────────────────────────────────
|
// ── Recovery codes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async generateRecoveryCodes(
|
async generateRecoveryCodes(
|
||||||
|
|||||||
34
work/artifacts/CLUB-005/architect.md
Normal file
34
work/artifacts/CLUB-005/architect.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Architect — CLUB-005
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Vincular socios anónimos a cuentas registradas de usuario, restaurar la tarjeta automáticamente al login y manejar fusión segura de membresías.
|
||||||
|
|
||||||
|
## Diseño
|
||||||
|
|
||||||
|
### 1. Restauración automática de tarjeta al login
|
||||||
|
El endpoint `GET /club/me` ya lo hace — comprueba `userId` primero, luego cae back a device token. No se necesita cambio.
|
||||||
|
|
||||||
|
### 2. Vincular miembro anónimo a cuenta registrada (`PATCH /club/me`)
|
||||||
|
- El frontend llama a este endpoint después de login/register cuando hay cookie `mdv_club`
|
||||||
|
- Requiere sesión (autenticado)
|
||||||
|
- Usa el device token de la cookie para encontrar el socio anónimo
|
||||||
|
- Actualiza `user_id` del socio anónimo al userId de la sesión
|
||||||
|
- Si el usuario ya tiene un socio vinculado → retorna `linked: false`, no sobreescribe
|
||||||
|
|
||||||
|
### 3. Fusión de membresías (`POST /club/merge`)
|
||||||
|
- Para cuando el usuario tiene un socio anónimo (device) Y un socio registrado (userId)
|
||||||
|
- El socio registrado es el primario (se conserva)
|
||||||
|
- El socio anónimo se marca como `merged`, su saldo se suma al primario
|
||||||
|
- Los dispositivos del anónimo se transfieren al primario
|
||||||
|
- Los recovery codes del anónimo expiran (no se transfieren)
|
||||||
|
|
||||||
|
### Seguridad
|
||||||
|
- `PATCH /club/me` requiere sesión — no se puede vincular sin estar autenticado
|
||||||
|
- `POST /club/merge` requiere sesión y solo usa el socio del usuario autenticado como primario
|
||||||
|
- La transacción de merge usa `FOR UPDATE` para evitar race conditions
|
||||||
|
|
||||||
|
### Endpoints nuevos
|
||||||
|
| Método | Ruta | Auth | Descripción |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| PATCH | `/club/me` | sesión | Vincular miembro anónimo a cuenta |
|
||||||
|
| POST | `/club/merge` | sesión | Fusionar miembro anónimo en el registrado |
|
||||||
17
work/artifacts/CLUB-005/documenter.md
Normal file
17
work/artifacts/CLUB-005/documenter.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# CLUB-005 — Documentation
|
||||||
|
|
||||||
|
## Endpoints added/modified
|
||||||
|
|
||||||
|
### PATCH /club/me
|
||||||
|
Link the anonymous Club member (identified by device token cookie) to the authenticated user account.
|
||||||
|
- **Auth**: session required
|
||||||
|
- **Response**: `{ member, linked: boolean, config }`
|
||||||
|
|
||||||
|
### POST /club/merge
|
||||||
|
Merge an anonymous member into the authenticated user's registered member.
|
||||||
|
- **Auth**: session required
|
||||||
|
- **Body**: `{ anonymousMemberId: uuid }`
|
||||||
|
- **Response**: `{ member, mergedId, config }`
|
||||||
|
- The anonymous member's balance is added to the registered member
|
||||||
|
- All devices are transferred to the registered member
|
||||||
|
- The anonymous member is marked as `merged`
|
||||||
19
work/artifacts/CLUB-005/implementer.md
Normal file
19
work/artifacts/CLUB-005/implementer.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementer — CLUB-005
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
Implementado link de socio anónimo a cuenta registrada y fusión de membresías.
|
||||||
|
|
||||||
|
## Archivos modificados/creados
|
||||||
|
- `src/modules/club/domain/club.ts` — tipos `LinkMemberResult`, `MergeMembersResult`
|
||||||
|
- `src/modules/club/domain/errors.ts` — `ClubMemberAlreadyLinkedError`, `ClubMergeConflictError`
|
||||||
|
- `src/modules/club/domain/ports.ts` — порты: `linkMemberToUser`, `mergeMembers`
|
||||||
|
- `src/modules/club/infrastructure/pg-club-repository.ts` — implementación de ambos métodos
|
||||||
|
- `src/modules/club/application/club-service.ts` — `linkMemberToUser()`, `mergeMembers()`
|
||||||
|
- `src/modules/club/api/club.routes.ts` — `PATCH /club/me` + `POST /club/merge`
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
- `cd project && npm run typecheck` ✅
|
||||||
|
- `cd project && npm run build` ✅
|
||||||
|
- `npx vitest run src/modules/club/tests/` ✅ (4 tests pass)
|
||||||
|
- `git diff --check` ✅
|
||||||
|
- `./scripts/verify.sh` ✅
|
||||||
13
work/artifacts/CLUB-005/leader-close.json
Normal file
13
work/artifacts/CLUB-005/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "CLUB-005",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "CLUB-005 cerrada: link a cuenta + merge de membresías implementados.",
|
||||||
|
"gates_summary": {
|
||||||
|
"reviewer": "APPROVED",
|
||||||
|
"security": "APPROVED",
|
||||||
|
"qa": "APPROVED"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-08-26T20:49:50Z"
|
||||||
|
}
|
||||||
16
work/artifacts/CLUB-005/qa.json
Normal file
16
work/artifacts/CLUB-005/qa.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "CLUB-005",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"qa_check": "qa",
|
||||||
|
"summary": "Build limpio. Tests pasan.",
|
||||||
|
"test_results": {
|
||||||
|
"automated": ["npm run typecheck ✅", "npm run build ✅", "npx vitest run src/modules/club/tests/ ✅"]
|
||||||
|
},
|
||||||
|
"manual_smoke_recommended": [
|
||||||
|
"Registrarse y verificar que PATCH /club/me vincula el miembro",
|
||||||
|
"Verificar que GET /club/me devuelve el miembro correcto tras login"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-26T20:49:40Z"
|
||||||
|
}
|
||||||
16
work/artifacts/CLUB-005/reviewer.json
Normal file
16
work/artifacts/CLUB-005/reviewer.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "CLUB-005",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Link y merge implementados correctamente con transacciones y FOR UPDATE.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "PATCH /club/me requiere sesión", "ok": true },
|
||||||
|
{ "item": "Merge usa FOR UPDATE + COMMIT", "ok": true },
|
||||||
|
{ "item": "TypeScript sin errores", "ok": true },
|
||||||
|
{ "item": "Tests club pasan", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"evidence": ["npm run typecheck", "npm run build", "npx vitest run src/modules/club/tests/"],
|
||||||
|
"timestamp": "2026-08-26T20:49:30Z"
|
||||||
|
}
|
||||||
14
work/artifacts/CLUB-005/security.json
Normal file
14
work/artifacts/CLUB-005/security.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "CLUB-005",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"security_check": "security",
|
||||||
|
"summary": "PATCH y POST requieren sesión autenticada. Merge usa transacciones con FOR UPDATE.",
|
||||||
|
"checks": {
|
||||||
|
"auth": "OK: PATCH /club/me y POST /club/merge requieren deps.authenticate (sesión)",
|
||||||
|
"race_condition": "OK: merge usa FOR UPDATE + COMMIT",
|
||||||
|
"no_overwrite": "OK: linkMemberToUser no sobreescribe userId existente"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-08-26T20:49:35Z"
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
"feature_id": "CHECKOUT-STOCK-RECHECK",
|
"feature_id": "CHECKOUT-STOCK-RECHECK",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "Implementar recovery codes: generate, list, recover",
|
"action": "Implementar link member to user y merge memberships",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "reviewer",
|
"next_agent": "reviewer",
|
||||||
"waiting_for": "build",
|
"waiting_for": "build",
|
||||||
"updated_at": "2026-08-26T18:43:37.576239Z",
|
"updated_at": "2026-08-26T18:47:48.659278Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-26T17:21:37Z",
|
"ts": "2026-08-26T17:21:37Z",
|
||||||
|
|||||||
Reference in New Issue
Block a user