fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment

Three fresh reviews (money/security/dup-mod) cross-validated findings:
- MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL
- MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks
- DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed'
- HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert)
- LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval)
- Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID
- Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race
- SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence)

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent a8bf24ee23
commit a6a4683b74
24 changed files with 1338 additions and 384 deletions
@@ -14,6 +14,7 @@
submitPaymentWithRetry
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = {
id: string;
@@ -112,22 +113,16 @@
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
// surfaced whenever the gate is enforced — the operator relays the
// customer's code. Cash, card machine, and online (new-card nonce) payments
// are unaffected.
// are unaffected. Shared two-factor-code state (code, reveal, show/missing
// derivations, "Request a new code" handler) — see
// $lib/stores/twoFactorCode.svelte.ts. The admin always supplies the
// CUSTOMER's code — the admin's own 2FA flag is irrelevant to the backend
// gate, so `enabled` is always true.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// B6/B10: verification code for the customer's saved-card till charge,
// collected on the saved-card payment screen. Kept populated across retries
// so an invalid/expired code can be corrected without re-typing it. The
// admin always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate.
let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code — reveals the input even
// if the session user's flag is unset.
let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(
reveal2FACodeInput || (savedCardChargeRequires2FACode && paymentMethod === 'saved_card')
);
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
});
// The saved-card option is hidden outright unless a customer is selected
// AND has at least one currently-valid card on file.
@@ -327,7 +322,7 @@
body.user_saved_card_id = selectedSavedCardId;
// B6/B10: the backend requires the CARD OWNER's current 2FA
// verification code when the gate is enforced.
if (show2FACodeInput) body.verification_code = twoFactorCode;
if (twoFactor.showInput) body.verification_code = twoFactor.code;
} else if (paymentMethod === 'online_square') {
if (!onlineSquareCardInput) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -367,14 +362,14 @@
toast.success('Sale complete');
cart = [];
idempotencyKeys.clear();
twoFactorCode = '';
reveal2FACodeInput = false;
twoFactor.setCode('');
twoFactor.reveal = false;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed';
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the sale can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg;
toast.error(msg);
} finally {
@@ -775,7 +770,23 @@
<!-- B6/B10: saved-card till charges require the customer's
current 2FA verification code when the backend enforces
the gate. -->
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<Button
variant="outline"
size="sm"
class="w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
{/if}
@@ -798,7 +809,7 @@
loading={processing}
disabled={!canCharge ||
processing ||
missing2FACode ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
>
@@ -48,6 +48,7 @@
} from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
formatLocalDateTime,
getLondonTodayCalendarDate,
@@ -149,27 +150,16 @@
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification.
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// B6/B10: verification code for a saved-card deposit / new-card save. The
// backend requires the card owner's CURRENT one-time code when 2FA is
// enforced (delivered via the server log / email-SMS channel and relayed by
// the operator). Kept populated across retries so an invalid/expired code can
// be corrected without re-typing it.
let depositTwoFactorCode = $state('');
// Set true when a charge 403s for a missing code: the backend keys on the
// CARD OWNER, so even a session user whose own flag is unset must be able to
// enter the code.
let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(
reveal2FACodeInput ||
(savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard))
);
const missing2FACode = $derived(
show2FACodeInput && twoFactorEnabled && depositTwoFactorCode.trim() === ''
);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard)
});
const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -431,7 +421,7 @@
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: depositTwoFactorCode } : {})
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
};
paymentAttempted = true;
@@ -502,8 +492,8 @@
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
depositTwoFactorCode = '';
reveal2FACodeInput = false;
twoFactor.setCode('');
twoFactor.reveal = false;
overflowConfirm = null;
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
@@ -524,7 +514,7 @@
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the deposit can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
reveal2FACodeInput = true;
twoFactor.reveal = true;
}
// Pre-start overpayment guard on stale booking data: park the rejected
// request (body + amount) and surface the Confirm/Cancel prompt instead
@@ -2661,44 +2651,56 @@
/>
</div>
{/if}
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
<!-- B6/B10: saved-card deposits require the customer's
<!-- B6/B10: saved-card deposits require the customer's
current 2FA verification code when the backend
enforces the gate. -->
<div class="mb-6">
<TwoFactorCodeInput
bind:code={depositTwoFactorCode}
showInput={show2FACodeInput}
enabled={twoFactorEnabled}
/>
</div>
<div class="mb-6">
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid || missing2FACode}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
<p class="mt-4 text-center text-xs text-gray-500">
Secure payment powered by Square
@@ -7,6 +7,7 @@
import { parseWallClockDate } from '$lib/utils/timeSlots';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
@@ -17,7 +18,6 @@
isNonceStale,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
@@ -108,51 +108,15 @@
// B6/B10: saved-card tips (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification.
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// B6/B10: verification code for a saved-card tip / new-card save. The backend
// requires the card owner's CURRENT one-time code when 2FA is enforced
// (delivered via the server log / email-SMS channel and relayed by the
// operator). Kept populated across retries so an invalid/expired code can be
// corrected without re-typing it.
let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code: the backend keys on the
// CARD OWNER, so even a session user whose own flag is unset must be able to
// enter the code.
let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
);
const missing2FACode = $derived(
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
);
// POST /api/user/2fa/code mint state for the "Request a new code" button
// (session user = card owner, so a minted code authorizes their charge).
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
});
const isCardValid = $derived(cardSelectionValid);
@@ -335,7 +299,7 @@
...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
};
const response = await submitPaymentWithRetry(() =>
@@ -361,8 +325,8 @@
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
twoFactorCode = '';
reveal2FACodeInput = false;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Thank you for your tip!');
onSuccess?.();
} catch (err) {
@@ -379,7 +343,7 @@
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the tip can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
reveal2FACodeInput = true;
twoFactor.reveal = true;
}
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
@@ -539,18 +503,18 @@
<!-- B6/B10: saved-card tips require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactorCode}
showInput={show2FACodeInput}
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if show2FACodeInput && twoFactorEnabled}
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
@@ -569,7 +533,7 @@
<Button
class="w-full"
size="lg"
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || missing2FACode}
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing}
loading={paymentState === 'processing'}
onclick={submitTip}
>
@@ -12,6 +12,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
@@ -20,7 +21,6 @@
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
@@ -51,60 +51,21 @@
// B6/B10: saved-card charges (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification.
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
});
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
// B6/B10: the verification code for a saved-card charge / new-card save. The
// backend requires the card owner's CURRENT one-time code when 2FA is
// enforced (delivered via the server log / email-SMS channel and relayed by
// the operator). Kept populated across retries so an invalid/expired code can
// be corrected without re-typing it.
let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code: the backend keys on the
// CARD OWNER, so even a session user whose own flag is unset must be able to
// enter the code. Revealing the input makes the failure recoverable.
let reveal2FACodeInput = $state(false);
// Show the code input whenever the pending charge hits the backend's 2FA
// gate: charging a saved card OR saving the new card for reuse.
const show2FACodeInput = $derived(
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
);
const missing2FACode = $derived(
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
);
// POST /api/user/2fa/code mint state for the "Request a new code" button
// (session user = card owner, so a minted code authorizes their charge).
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
// Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern.
@@ -542,7 +503,7 @@
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: payIdempotencyKey
})
})
@@ -593,8 +554,8 @@
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
twoFactorCode = '';
reveal2FACodeInput = false;
twoFactor.setCode('');
twoFactor.reveal = false;
paymentResult = {
id: data.id,
amount: data.amount,
@@ -619,7 +580,7 @@
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
error = msg;
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
// A definitive charge failure consumes the nonce + SCA verification
@@ -1023,18 +984,18 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactorCode}
showInput={show2FACodeInput}
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if show2FACodeInput && twoFactorEnabled}
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
@@ -1084,7 +1045,7 @@
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled || missing2FACode}
disabled={payButtonDisabled || twoFactor.missing}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
@@ -1165,7 +1126,7 @@
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled || missing2FACode}
disabled={payButtonDisabled || twoFactor.missing}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid