From a1436337413c475c44566c56806764372fbee665 Mon Sep 17 00:00:00 2001 From: Georgios Jason Efstathiou Date: Thu, 6 Aug 2026 22:15:54 +0300 Subject: [PATCH 1/5] Add the Quick checkpoint flow for rewards Adds a short identity check that users may be asked to complete before viewing or withdrawing rewards, plus a guard on /wave/rewards that sends them to it whenever the API says one is due. Without the guard those pages would break for anyone the API is holding back. The flow follows the same shape as phone verification: an intro step explaining what's being asked, the check itself via the Sumsub WebSDK, then a success step back to wherever the user came from. Copy leads with how quick it is and that withdrawal is available immediately afterwards, since this is meant to read as a speed bump rather than another verification. Once the check is submitted the page polls the API until it has a result. The API is the only authority on the outcome, and polling starts as soon as the SDK mounts rather than waiting on an SDK event, so an event we don't recognise can't leave someone stuck on a spinner. A failed check returns to the intro with retry wording; users who can't retry are pointed at support. The client parses only the response fields it needs and never reproduces any of the API's decision logic. Also adds a typed error to the API client so a check that falls due mid-session surfaces as something callers can act on rather than a generic 403. --- src/lib/utils/wave/call.ts | 25 +++ src/lib/utils/wave/liveness.ts | 47 ++++++ .../wave/(base-layout)/rewards/+layout.ts | 43 ++++++ .../wave/(flows)/checkpoint/+layout.svelte | 9 ++ .../wave/(flows)/checkpoint/+layout.ts | 18 +++ .../wave/(flows)/checkpoint/+page.svelte | 65 ++++++++ .../(pages)/wave/(flows)/checkpoint/+page.ts | 19 +++ .../(flows)/checkpoint/success/+page.svelte | 18 +++ .../wave/(flows)/checkpoint/success/+page.ts | 18 +++ .../(flows)/checkpoint/verify/+page.svelte | 142 ++++++++++++++++++ .../wave/(flows)/checkpoint/verify/+page.ts | 30 ++++ 11 files changed, 434 insertions(+) create mode 100644 src/lib/utils/wave/liveness.ts create mode 100644 src/routes/(pages)/wave/(base-layout)/rewards/+layout.ts create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/+layout.svelte create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/+layout.ts create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/+page.ts create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/success/+page.svelte create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/success/+page.ts create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte create mode 100644 src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.ts diff --git a/src/lib/utils/wave/call.ts b/src/lib/utils/wave/call.ts index fb10406a1..02123739b 100644 --- a/src/lib/utils/wave/call.ts +++ b/src/lib/utils/wave/call.ts @@ -22,6 +22,21 @@ export class AccountRestrictedError extends Error { } } +/** + * Thrown for 403 responses where the API is holding the request until the user + * completes a short identity check. + * + * Route guards normally catch this before the request is made, so seeing it + * means a check fell due mid-session. Callers should send the user to + * /wave/checkpoint rather than surfacing a generic error. + */ +export class LivenessCheckpointRequiredError extends Error { + constructor() { + super('A quick identity check is needed before you can continue.'); + this.name = 'LivenessCheckpointRequiredError'; + } +} + function isAccountSuspendedResponse(status: number, body: string): boolean { return status === 403 && body.includes('suspended'); } @@ -30,6 +45,12 @@ function isAccountRestrictedResponse(status: number, body: string): boolean { return status === 403 && body.includes('restricted'); } +// Matched on the machine-readable `code` the backend sets rather than the +// prose, which is free to change. +function isCheckpointRequiredResponse(status: number, body: string): boolean { + return status === 403 && body.includes('liveness_checkpoint_required'); +} + const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY_MS = 500; @@ -153,6 +174,10 @@ export async function authenticatedCall( throw new AccountRestrictedError(); } + if (isCheckpointRequiredResponse(res.status, errorText)) { + throw new LivenessCheckpointRequiredError(); + } + if (res.status === 401) { throw error(401, 'Unauthorized'); } diff --git a/src/lib/utils/wave/liveness.ts b/src/lib/utils/wave/liveness.ts new file mode 100644 index 000000000..3bb17d169 --- /dev/null +++ b/src/lib/utils/wave/liveness.ts @@ -0,0 +1,47 @@ +import z from 'zod'; +import { authenticatedCall } from './call'; +import parseRes from './utils/parse-res'; + +/** Areas that a checkpoint can be required for. */ +export const livenessCheckpointPurposes = ['grant_access'] as const; +export type LivenessCheckpointPurpose = (typeof livenessCheckpointPurposes)[number]; + +/** + * Only the fields the UI actually needs. The API decides entirely on its own + * whether a check is due; the client never reproduces that logic, and anything + * else the response carries is deliberately not picked up here. + */ +export const livenessCheckpointStatusSchema = z.object({ + purpose: z.enum(livenessCheckpointPurposes), + satisfied: z.boolean(), + challengeStatus: z.enum(['pending', 'approved', 'rejected', 'expired']).nullable(), + locked: z.boolean(), +}); + +export type LivenessCheckpointStatus = z.infer; + +export async function getLivenessCheckpointStatus(f = fetch, purpose: LivenessCheckpointPurpose) { + return parseRes( + livenessCheckpointStatusSchema, + await authenticatedCall(f, `/api/liveness-checkpoints/status?purpose=${purpose}`, { + method: 'GET', + }), + ); +} + +/** + * Starts (or resumes) a check and returns a SumSub token scoped to it. Throws + * if the user isn't currently allowed to start one. + */ +export async function startLivenessCheckpoint(f = fetch, purpose: LivenessCheckpointPurpose) { + return parseRes( + z.object({ + accessToken: z.string(), + checkpointId: z.uuid(), + }), + await authenticatedCall(f, `/api/liveness-checkpoints/session`, { + method: 'POST', + body: JSON.stringify({ purpose }), + }), + ); +} diff --git a/src/routes/(pages)/wave/(base-layout)/rewards/+layout.ts b/src/routes/(pages)/wave/(base-layout)/rewards/+layout.ts new file mode 100644 index 000000000..f89456bbd --- /dev/null +++ b/src/routes/(pages)/wave/(base-layout)/rewards/+layout.ts @@ -0,0 +1,43 @@ +import { getLivenessCheckpointStatus } from '$lib/utils/wave/liveness.js'; +import { redirect } from '@sveltejs/kit'; + +/** + * Sends the user to the checkpoint screen when the API says one is due. + * + * The API refuses the grants endpoints outright in that case, so without this + * guard the user would land on a broken page. Whether a check is actually due + * is entirely the API's call — this just asks and acts on the answer. + */ +export const load = async ({ parent, url, fetch, depends }) => { + depends('wave:liveness-checkpoint'); + + const { user } = await parent(); + + // Unauthenticated users are redirected to login by the child loads; there is + // no checkpoint to evaluate for them. + if (!user) return {}; + + try { + const checkpoint = await getLivenessCheckpointStatus(fetch, 'grant_access'); + + if (!checkpoint.satisfied) { + throw redirect( + 302, + `/wave/checkpoint?backTo=${encodeURIComponent(url.pathname + url.search)}`, + ); + } + + return { checkpoint }; + } catch (err) { + // Preserve our own redirect above. + if (err && typeof err === 'object' && 'status' in err && err.status === 302) { + throw err; + } + // Let the child loads handle auth — they own the login redirect and the + // backTo round trip. + if (err && typeof err === 'object' && 'status' in err && err.status === 401) { + return {}; + } + throw err; + } +}; diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/+layout.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/+layout.svelte new file mode 100644 index 000000000..d8e20eb42 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+layout.svelte @@ -0,0 +1,9 @@ + + + + +{@render children?.()} diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/+layout.ts b/src/routes/(pages)/wave/(flows)/checkpoint/+layout.ts new file mode 100644 index 000000000..e90765edb --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+layout.ts @@ -0,0 +1,18 @@ +import { getLivenessCheckpointStatus } from '$lib/utils/wave/liveness.js'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ parent, url, fetch, depends }) => { + depends('wave:liveness-checkpoint'); + + const { user } = await parent(); + + if (!user) { + throw redirect(302, `/wave/login?backTo=${encodeURIComponent(url.pathname + url.search)}`); + } + + const checkpoint = await getLivenessCheckpointStatus(fetch, 'grant_access'); + + return { + checkpoint, + }; +}; diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte new file mode 100644 index 000000000..2d4757d46 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte @@ -0,0 +1,65 @@ + + + + {#if checkpoint.locked} + + You've run out of identity checks for now. + Please get in touch with support and we'll help you sort it out. + + {#snippet actions()} + + {/snippet} + + {:else} + + It usually takes less than a minute, and you'll be able to withdraw your rewards immediately + afterwards. + + {#snippet actions()} + + {/snippet} + + {/if} + + {#snippet leftActions()} + + {/snippet} + + {#snippet actions()} + {#if !checkpoint.locked} + + {/if} + {/snippet} + diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/+page.ts b/src/routes/(pages)/wave/(flows)/checkpoint/+page.ts new file mode 100644 index 000000000..d4808bedd --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+page.ts @@ -0,0 +1,19 @@ +import { safeParseBackToParam } from '$lib/utils/safe-path'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ parent, url }) => { + const { checkpoint } = await parent(); + + const backTo = safeParseBackToParam(url) || '/wave/rewards'; + + // Nothing to ask for — either they're already through or the checkpoint + // doesn't apply to them. + if (checkpoint.satisfied) { + throw redirect(302, backTo); + } + + return { + checkpoint, + backTo, + }; +}; diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/success/+page.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/success/+page.svelte new file mode 100644 index 000000000..f3d6a3aa5 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/success/+page.svelte @@ -0,0 +1,18 @@ + + + + {#snippet actions()} + + {/snippet} + diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/success/+page.ts b/src/routes/(pages)/wave/(flows)/checkpoint/success/+page.ts new file mode 100644 index 000000000..3134afde7 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/success/+page.ts @@ -0,0 +1,18 @@ +import { safeParseBackToParam } from '$lib/utils/safe-path'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ parent, url }) => { + const { checkpoint } = await parent(); + + const backTo = safeParseBackToParam(url) || '/wave/rewards'; + + // Landing here without having passed means something went wrong on the way — + // send them back to the start rather than showing a success screen. + if (!checkpoint.satisfied) { + throw redirect(302, `/wave/checkpoint?backTo=${encodeURIComponent(backTo)}`); + } + + return { + backTo, + }; +}; diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte new file mode 100644 index 000000000..db982e46d --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte @@ -0,0 +1,142 @@ + + +{#if phase === 'checking'} + +
+ +
+
+{/if} + +
+
+
+ + diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.ts b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.ts new file mode 100644 index 000000000..2c654d244 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.ts @@ -0,0 +1,30 @@ +import { safeParseBackToParam } from '$lib/utils/safe-path'; +import { startLivenessCheckpoint } from '$lib/utils/wave/liveness.js'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ parent, url, fetch }) => { + const { checkpoint } = await parent(); + + const backTo = safeParseBackToParam(url) || '/wave/rewards'; + + if (checkpoint.satisfied) { + throw redirect(302, backTo); + } + + // Out of tries — the intro step explains what to do about it. + if (checkpoint.locked) { + throw redirect(302, `/wave/checkpoint?backTo=${encodeURIComponent(backTo)}`); + } + + // Starting here rather than on a button press in the component means the + // token is fetched server-side on first paint, so the SDK can mount as soon + // as the page does. Resumes an in-flight challenge instead of creating a + // second one, so a reload mid-flow is harmless. + const session = await startLivenessCheckpoint(fetch, 'grant_access'); + + return { + backTo, + sumsubToken: session.accessToken, + waveFullscreenFlow: true, + }; +}; From 43f102fc40b5c277d15445eaeb50a0cb4aad8eea Mon Sep 17 00:00:00 2001 From: Georgios Jason Efstathiou Date: Thu, 6 Aug 2026 23:19:30 +0300 Subject: [PATCH 2/5] Address review feedback on the checkpoint flow - Treat an 'expired' challenge as terminal in the verify page's poll loop. Nothing will ever resolve a superseded attempt, so the loop would have spun indefinitely; it now returns to the intro like a failed one does. - Correct the poll loop's comment, which read as though the first request fires on mount. The point being made was that the loop doesn't depend on SDK events, so say that instead. Left the intro's failure copy keyed on 'rejected' only, and noted why: 'expired' means abandoned or superseded rather than failed, so "that check didn't go through" would be the wrong thing to tell that user. --- .../(pages)/wave/(flows)/checkpoint/+page.svelte | 3 +++ .../wave/(flows)/checkpoint/verify/+page.svelte | 15 +++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte index 2d4757d46..55079fd53 100644 --- a/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte @@ -9,6 +9,9 @@ let { data } = $props(); let { checkpoint, backTo } = $derived(data); + // Only 'rejected' is a failure. An 'expired' attempt was abandoned or + // superseded by a newer one rather than failed, so telling that user "that + // check didn't go through" would be wrong — they get the first-time copy. let previousAttemptFailed = $derived(checkpoint.challengeStatus === 'rejected'); let description = $derived( diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte index db982e46d..351a59866 100644 --- a/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte +++ b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte @@ -30,9 +30,9 @@ * * The backend is the only authority here: it resolves the challenge from * SumSub's webhook, and falls back to asking SumSub directly if that webhook - * is slow or lost. Polling starts as soon as the SDK mounts rather than - * waiting for an SDK event, so an event we don't recognise can never leave - * the user stuck on a spinner. + * is slow or lost. The loop runs on a fixed interval from the moment the SDK + * mounts rather than being kicked off by an SDK event, so an event we don't + * recognise can never leave the user stuck on a spinner. */ async function poll() { if (stopped) return; @@ -47,7 +47,14 @@ return; } - if (status.challengeStatus === 'rejected' || status.locked) { + // 'expired' means this attempt was superseded (another tab started a new + // one), so nothing will ever resolve it. Terminal here too, otherwise the + // loop would spin indefinitely. + if ( + status.challengeStatus === 'rejected' || + status.challengeStatus === 'expired' || + status.locked + ) { stopped = true; await invalidate('wave:liveness-checkpoint'); await goto(`/wave/checkpoint?backTo=${encodeURIComponent(backTo)}`); From cd324d5f1d22e4d15a1b3c6cf604176ccae0a918 Mon Sep 17 00:00:00 2001 From: Georgios Jason Efstathiou Date: Tue, 11 Aug 2026 11:47:44 +0200 Subject: [PATCH 3/5] Fix navigation and stuck-spinner issues in the checkpoint flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invalidating before navigating re-ran the verify route's own loads, which started a second challenge (wiping the rejected state, so the user landed back on the first-time intro) and, on success, redirected through /wave/rewards before the success screen. Navigate with invalidateAll instead. The verify load starts a challenge, so the intro's button opts out of hover preloading — otherwise a mouse passing over it burns an attempt. Also stop hiding the SDK indefinitely: it re-presents its own retry UI in place, which was invisible behind the spinner, leaving the poll spinning forever. Reveal it again on SDK errors, and after a minute without a verdict. --- .../wave/(flows)/checkpoint/+page.svelte | 3 + .../(flows)/checkpoint/verify/+page.svelte | 76 +++++++++++++++++-- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte index 55079fd53..41b6e882a 100644 --- a/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte @@ -56,9 +56,12 @@ {#snippet actions()} {#if !checkpoint.locked} +