fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX

Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent fe88f2084d
commit 4d5d2cd381
28 changed files with 2047 additions and 341 deletions
+62
View File
@@ -149,6 +149,68 @@ export function isTwoFactorVerificationGateFailure(status: number, message: stri
return /invalid verification code|verification code expired/i.test(message);
}
/** Shape returned by requestNewTwoFactorCode: the HTTP status (0 for a
* network error before any response), whether the mint succeeded, and a
* user-facing message extracted from the server body or a sensible fallback. */
export interface TwoFactorCodeRequestResult {
status: number;
ok: boolean;
message: string;
}
/**
* Mints a fresh 2FA verification code for the signed-in account via
* POST /api/user/2fa/code (RequireAuth + RequireNonGuest). The backend applies
* a per-user mint cooldown, so a too-fast re-request returns 429; production
* with no delivery channel configured fails closed with 503. A successful mint
* delivers the code through the build-dependent channel ([2FA] server log in
* dev/test builds), so this surfaces the server's `message` — never the code
* itself. The Authorization header is read from localStorage (authToken) —
* exactly where the auth store persists it — so this helper stays free of
* `$lib` imports and the pure-logic vitest suite can exercise it without a
* SvelteKit plugin resolving the `$lib` alias.
*/
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch('/api/user/2fa/code', { method: 'POST', headers });
if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message =
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
return { status: response.status, ok: true, message };
}
const body = await response.text();
return {
status: response.status,
ok: false,
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
};
} catch {
return { status: 0, ok: false, message: 'Network error requesting a new code' };
}
}
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
* endpoint bodies (429/503), kept inline so square.ts stays import-free for
* the vitest suite. */
function extractServerErrorMessage(body: string): string {
const trimmed = body.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as { error?: unknown; message?: unknown };
if (typeof parsed.error === 'string') return parsed.error;
if (typeof parsed.message === 'string') return parsed.message;
} catch {
// Not JSON — use the raw body below.
}
return trimmed;
}
/**
* Error code the booking-payment endpoint (POST /api/bookings/{id}/payment)
* returns with a 400 when a payment would exceed the booking's remaining