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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user